Compare commits

..

23 Commits

Author SHA1 Message Date
Travis Abendshien 354c0a0afc fix(ui): increase spacing between color groups 2026-08-14 00:49:42 -07:00
Travis Abendshien dd00f4d4a0 fix: move add_colors() out of double session
The double session started causing a lock on the DB that prevented new colors from being added.
2026-08-14 00:22:08 -07:00
Travis Abendshien 1bfea60d7a fix(ui): remove blank action on suggest boxes 2026-08-13 22:47:17 -07:00
Travis Abendshien c015b1d4f2 fix(ui): shifting no longer resets opacity of added tags 2026-08-13 21:57:13 -07:00
Travis Abendshien 4b200a674e feat(ui): add hint icons to suggest boxes (#1475)
* feat(ui): add hint icons to suggest boxes

* docs: directly mention new icons
2026-08-13 20:22:52 -07:00
Travis Abendshien 507823bfcd feat(ui): use tab and shift+tab to navigate suggest boxes (#1474)
* fix(ui): correct suggest box content padding

* feat(ui): use tab and shift+tab to navigate suggest boxes

* fix: strip suggest box queries of whitespace

* fix(ui): fix shift opacity not targeting correct widget

* chore: cleanup comments and variable usage
2026-08-13 19:44:36 -07:00
Travis Abendshien 3def6c5239 feat(ui): translate vertical scrolling to horizontal in suggest boxes (#1473) 2026-08-13 19:44:21 -07:00
Travis Abendshien f04421fa93 feat: keep suggest boxes open by default (#1472) 2026-08-13 19:44:05 -07:00
Jann Stute b29e612e7f refactor: almost final migrations cleanup (#1456)
* fix: backup library before making any changes

* refactor: inline make_tables + minor cleanup

* refactor: remove unnecessary assurance

Bumping the auto increment value has been done since the original sql PR, so it doesn't need to be done on migrations.
See e5e7b8afc6.

* refactor: don't blindly create all tables in the beginning

The only table that has been added since DB version 6 (the earliest supported version), is the versions table in DB version 101.
This commit removes the "create all tables" statement, and instead creates the versions table in the 101 migration.
See 12e074b71d.

* refactor: don't require setting library_dir to create a backup

* refactor: move migrations to different file

* refactor: package each migration in a class

* fix: some syntax errors had slipped through

* fix: allow set_version to fail, but don't commit in that case

* refactor: condense imports

* fix: add override decorators

* refactor: move set_version to DBMigrations

* refactor: remove unnecessary assignment

* refactor: use _ instead of __

* fix: add missing field templates tables
2026-08-11 15:14:33 -07:00
Travis Abendshien 5852c59f1c docs: update changelog 2026-08-06 09:37:24 -07:00
Travis Abendshien 27fd1a4518 fix(docs): fix broken PEP 8 link 2026-08-05 16:15:10 -07:00
Travis Abendshien 8722c5f83e docs: update basic usage with tagging bar 2026-08-05 16:00:45 -07:00
Travis Abendshien f52e0fa568 fix: don't update media playback when adding tags from menu modal (fix #1457) 2026-08-03 15:44:45 -07:00
Travis Abendshien 4de9571d0b refactor: split ThumbRenderer class (#1462)
* refactor: split ThumbRenderer class

* refactor: consolidate embedded archive thumbnail functions

* fix: move jxl optional import to raster_image.py

* refactor: name and license consistency, misc fixes

* fix: remove file_renderer.py

* refactor: split Qt functions from thumb renderer

* refactor: cleanup vendored blender file

* refactor: rename ambiguous variables

* chore: implement review feedback

* chore: rename missed old variables
2026-08-03 14:33:54 -07:00
Xarvex ed0332b0e2 chore(nix/package): update inputs, use versioned chardet 2026-08-01 13:43:58 -05:00
Xarvex 13104381a4 chore(nix/shell): replace deprecated xorg package sets 2026-08-01 13:43:58 -05:00
Xarvex 7d2135c1f7 nit(nix/shell): use single packages attribute
See: https://github.com/TagStudioDev/TagStudio/pull/1415#issuecomment-5152755256
2026-08-01 13:43:56 -05:00
Travis Abendshien 6177befebb refactor(ui): apply new MVC guidelines to search panels (#1461)
* feat(ui): replace add tag modal with autocomplete search/create bar

* refactor: code cleanup and fixes

* refactor: RADICAL mvc refactor...

* feat: add setting to open edit window when creating tags

* refactor(ui): refactor preview panel into new MVC pattern

* refactor: remove controller suffix from preview_panel.py

* Revert "refactor: remove controller suffix from preview_panel.py"

This reverts commit c8ba9b15c2.

* feat(ui): add autocomplete search for field templates

* fix(ui): fix issues with tag and field widget appearances

* refactor(ui): use Qt's .layout() method instead of _layout references

* feat(ui): add underline indicator for search bar items

* refactor: remove view parameters

* refactor(ui): refactor PanelModal and PanelWidget into MVC Modal, ModalView, and ModalContent classes

* refactor(ui): refactor SearchPanel classes to use newer MVC pattern

* fix: remove deprecated methods

* chore: fixes after rebase
2026-07-27 12:43:11 -07:00
Travis Abendshien ff43c4a4bd refactor(ui): refactor PanelModal and PanelWidget into MVC Modal, ModalView, and ModalContent classes (#1454)
* feat(ui): replace add tag modal with autocomplete search/create bar

* refactor: code cleanup and fixes

* refactor: RADICAL mvc refactor...

* feat: add setting to open edit window when creating tags

* refactor(ui): refactor preview panel into new MVC pattern

* refactor: remove controller suffix from preview_panel.py

* refactor: remove unnecessary methods and properties

* Revert "refactor: remove controller suffix from preview_panel.py"

This reverts commit c8ba9b15c2.

* feat(ui): add autocomplete search for field templates

* fix(ui): fix issues with tag and field widget appearances

* refactor(ui): use Qt's .layout() method instead of _layout references

* refactor: remove view parameters

* refactor(ui): refactor PanelModal and PanelWidget into MVC Modal, ModalView, and ModalContent classes

* chore: fixes after rebase
2026-07-27 11:54:03 -07:00
Travis Abendshien ac4c28687e docs: update style guide (#1458)
* docs: update style guide

* docs: add driver argument guideline
2026-07-27 10:28:12 -07:00
Travis Abendshien 5557fe4f7c feat(ui/ux): replace "Add Tag/Field" modals with search+create bar(s) with autocomplete (#1451)
* feat(ui): replace add tag modal with autocomplete search/create bar

* fix(ui): restore esc to deselect entries behavior

* refactor: code cleanup and fixes

* refactor: RADICAL mvc refactor...

* feat: add setting to open edit window when creating tags

* feat(ui): add edit tag setting to context menu, tweak translations

* chore: remove unused TYPE_CHECKING imports

* chore: remove unused local import

* refactor(ui): refactor preview panel into new MVC pattern

* refactor: remove controller suffix from preview_panel.py

* refactor: remove unnecessary methods and properties

* Revert "refactor: remove controller suffix from preview_panel.py"

This reverts commit c8ba9b15c2.

* chore: cleanup comments

* feat(ui): add autocomplete search for field templates

* feat(ui): add shortcut for opening field template search bar

* fix(ui): update tag search bar when entry tags are updated

* fix(ui): fix issues with tag and field widget appearances

* refactor(ui): use Qt's .layout() method instead of _layout references

* feat(ui): add underline indicator for search bar items

* ui: misc UI fixes and tweaks

* refactor: remove view parameters

* docs: update docstrings
2026-07-27 10:27:54 -07:00
Weblate (bot) 2fdd1e4cb3 translations: update from Hosted Weblate (#1443)
* Translated using Weblate (Hungarian)

Currently translated at 100.0% (423 of 423 strings)

Co-authored-by: Szíjártó Levente Pál <szijartoleventepal@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/hu/
Translation: TagStudio/Application Strings

* Translated using Weblate (Spanish)

Currently translated at 100.0% (422 of 422 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (422 of 422 strings)

Co-authored-by: 2004milenadiaz-source <2004.milena.diaz@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Julen Arratibel Etxabe <jarratibeletxabe@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/es/
Translation: TagStudio/Application Strings

* Translated using Weblate (French)

Currently translated at 100.0% (422 of 422 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Med <45147847+kitsumed@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/fr/
Translation: TagStudio/Application Strings

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 79.3% (335 of 422 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: ??? <2807603108@qq.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/zh_Hans/
Translation: TagStudio/Application Strings

---------

Co-authored-by: Szíjártó Levente Pál <szijartoleventepal@gmail.com>
Co-authored-by: 2004milenadiaz-source <2004.milena.diaz@gmail.com>
Co-authored-by: Julen Arratibel Etxabe <jarratibeletxabe@gmail.com>
Co-authored-by: Med <45147847+kitsumed@users.noreply.github.com>
Co-authored-by: ??? <2807603108@qq.com>
2026-07-25 05:50:14 -07:00
Travis Abendshien 0d0c54e09f ci(docs): migrate from mkdocs to properdocs (#1459)
* ci(docs): migrate from mkdocs to properdocs

* ci(docs): remove extraneous env

---------

Co-authored-by: Xarvex <60973030+xarvex@users.noreply.github.com>
2026-07-25 02:32:46 -07:00
113 changed files with 4014 additions and 2524 deletions
+3 -5
View File
@@ -13,7 +13,7 @@ on:
- .github/actions/setup-python/action.yml
- .github/workflows/build_docs.yml
- docs/**
- mkdocs.yml
- properdocs.yml
workflow_dispatch:
concurrency:
@@ -50,7 +50,5 @@ jobs:
path: .cache
restore-keys: |
mkdocs-material-
- name: Run mkdocs
env:
DISABLE_MKDOCS_2_WARNING: 'true'
run: mkdocs gh-deploy --force
- name: Run properdocs
run: properdocs gh-deploy --force
+7 -1
View File
@@ -45,6 +45,12 @@ SPDX-FileCopyrightText = "(c) 2026 Boxicons"
SPDX-License-Identifier = "MIT"
[[annotations]]
path = ["src/tagstudio/resources/qt/images/dupe_file_stat.png"]
path = [
"src/tagstudio/resources/qt/images/dupe_file_stat.png",
"src/tagstudio/resources/qt/images/hint_field_add.png",
"src/tagstudio/resources/qt/images/hint_field_create.png",
"src/tagstudio/resources/qt/images/hint_tag_add.png",
"src/tagstudio/resources/qt/images/hint_tag_create.png",
]
SPDX-FileCopyrightText = "(c) github:google/material-design-icons Contributors"
SPDX-License-Identifier = "Apache-2.0"
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+52
View File
@@ -9,6 +9,58 @@ toc_depth: 2
# :material-script-text: Changelog
## 9.6.2 <small>August 6th, 2026</small>
### Added
- feat(thumbs): render `.ai` (Adobe Illustrator) thumbnails by @purpletennisball in #1453
### Changed
#### Tag & Field Search/Create Bars (by @CyanVoxel in #1451)
The tagging workflow has gotten a major overhaul! Clicking the "Add Tag" button or pressing <kbd>Ctrl</kbd>+<kbd>T</kbd> 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 <kbd>Enter</kbd>! 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% }
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:
- Open tagging bar clicking "Add Tag" or using <kbd>Ctrl</kbd>+<kbd>T</kbd>
- Open field bar clicking "Add Field" or using <kbd>Ctrl</kbd>+<kbd>L</kbd>
- When searching, the underlined tag/field template will be added on <kbd>Enter</kbd>
- With no search results, a new tag or field template will be created on <kbd>Enter</kbd> and added to the selection
- Holding <kbd>Shift</kbd> while pressing <kbd>Enter</kbd> will force create a new tag/field template, regardless of the search results
- An option to always open an Edit window for new tags after creation is available by right-clicking the search bar or by going to the Settings (**off** by default)
- An option to always open an Edit window for field content after applying a template is available by right-clicking the search bar or by going to the Settings (**on** by default)
- Field templates are _always_ edited after being created
- The classic tag search panel can be accessed from **File -> Add Tag to Selected** / <kbd>Shift</kbd>+<kbd>Ctrl</kbd>+<kbd>T</kbd>
#### Internal Changes
- refactor: split out sql migrations from library by @Computerdores in #1432
- refactor: remove dead folders table by @Computerdores in #1444
- ci(docs): migrate from mkdocs to properdocs by @CyanVoxel in #1459
- refactor(ui): refactor PanelModal and PanelWidget into MVC Modal, ModalView, and ModalContent classes by @CyanVoxel in #1454
- refactor(ui): apply new MVC guidelines to search panels by @CyanVoxel in #1461
- refactor: split ThumbRenderer class by @CyanVoxel in #1462
#### Translations
- **French** updated by @kitsumed
- **Hungarian** updated by @smileyhead
- **Chinese (Simplified Han Script)** updated by ???
- **Spanish** updated by @2004milenadiaz-source, @JulArr22
### Fixed
- fix: thumbnail rendering for older pxd files by @purpletennisball in #1441
- fix: correctly display duration statistic in preview panel by @ludvig-sandh in #1421
- fix: fix new aliases not saving on new tags by @CyanVoxel in #1455
- fix: don't update media playback when adding tags from menu modal by @CyanVoxel in f52e0fa56816ae43c4faf01b61d46ee8f49b206f
---
## 9.6.1 <small>July 9th, 2026</small>
### Added
+6 -16
View File
@@ -117,7 +117,7 @@ Licensing is now accomplished using the [REUSE](https://reuse.software/spec-3.3/
!!! tip "PR Scope"
If you're unsure where to stop the scope of your PR, ask yourself: _"If I broke this up, could any parts of it still be used by the project in the meantime?"_
### Workflow Checks
### :material-check-circle: Workflow Checks
When pushing your code, several automated [workflows](https://github.com/TagStudioDev/TagStudio/tree/main/.github/workflows) will check it against predefined tests and style checks. It's _highly recommended_ that you run these checks locally beforehand to avoid having to fight back-and-forth with the workflow checks inside your pull requests. These checks currently include:
@@ -126,7 +126,7 @@ When pushing your code, several automated [workflows](https://github.com/TagStud
- [Pytest](developing.md#pytest) tests
- REUSE [license compliance](#licenses)
### Runtime Requirements
## :material-timer-play: Runtime Requirements
Code must function on all of the supported operating systems and versions:
@@ -134,18 +134,8 @@ Code must function on all of the supported operating systems and versions:
- macOS 14.0+
- Common Linux distributions and versions
## :material-file-document: Documentation Guidelines
Final submitted code must **_NOT:_**
Documentation contributions include anything inside of the `docs/` folder as well as the `README.md`. Documentation inside the `docs/` folder is built and hosted on our static documentation site, [docs.tagstud.io](https://docs.tagstud.io/).
- Use "[dash-case / kebab-case](https://developer.mozilla.org/en-US/docs/Glossary/Kebab_case)" for file and folder names
- Follow the folder structure pattern
- Don't add images or other media with excessively large file sizes
- Provide alt text for embedded media
- Use "[Title Case](https://apastyle.apa.org/style-grammar-guidelines/capitalization/title-case)" for title capitalization
## :material-translate: Translation Guidelines
Translations are performed on the TagStudio [Weblate project](https://hosted.weblate.org/projects/tagstudio/).
_Translation guidelines coming soon._
- Contain superfluous or unnecessary logging statements
- Cause unreasonable slowdowns to the program outside of a progress-indicated task
- Cause undesirable visual glitches or artifacts on screen
+1 -1
View File
@@ -214,7 +214,7 @@ From there, Git will automatically run through the hooks during commit actions!
You can automatically enter this development shell, and keep your user shell, with a tool like [direnv](https://direnv.net/). Some reference `.envrc` files are provided in the repository at [`contrib`](https://github.com/TagStudioDev/TagStudio/tree/main/contrib).
Two currently available are for [Nix](#nixos) and [uv](#installing-with-uv), to use one:
Two currently available are for [Nix](#nix-nixos) and [uv](#installing-with-uv), to use one:
```sh
ln -s .envrc-$variant .envrc
+2
View File
@@ -10,6 +10,8 @@ icon: material/text-box
Fields are extra pieces of information you can add to [file entries](./entries.md), similar to how [tags](tags.md) are added to entries. Fields are useful for storing information that doesn't nessisarily need to be a tag, such as titles, comments, notes, specific dates or times, etc.
Unlike tags, fields are based on [templates](#templates) that contain pre-filled information such as the field type and title, and that information is _copied_ to fields when adding them to entries. Editing field information on entries **does not** modify the template it was created from.
To add a field to an entry, click the "Add Field" button in the preview panel. From there you can search and/or select a [field template](#field-templates) to choose from, or create a new one from the search bar. Alternatively you can create new field templates from **Edit -> Manage Field Templates**.
<figure markdown="span">
+2 -2
View File
@@ -98,7 +98,7 @@ A detailed specification written for the TagStudio tag and/or library format. In
- [x] Translations
- [ ] Search Bar Rework :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
- [ ] Improved Tag Autocomplete :material-chevron-triple-up:{ .priority-high title="High Priority" }
- [ ] Tags appear as widgets in search bar :material-chevron-triple-up:{ .priority-high title="High Priority" }
- [ ] Tags appear as widgets in search bar _(similar to new tag search/create bar)_ :material-chevron-triple-up:{ .priority-high title="High Priority" }
- [x] Unified Media Player
- [x] Auto-Hiding Player Controls
- [x] Play/Pause
@@ -116,7 +116,7 @@ A detailed specification written for the TagStudio tag and/or library format. In
- [x] Basic Support
- [ ] Full File Preview :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
- [ ] Syntax Highlighting :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.6.x]**
- [ ] Toggleable Persistent Tagging Panel :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.8.x]**
- [ ] Toggleable Persistent Tagging Panel :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
- [ ] Top Tags
- [ ] Recent Tags
- [ ] Tag Search
+188 -97
View File
@@ -6,116 +6,207 @@ icon: material/sign-text
<!-- SPDX-FileCopyrightText: (c) TagStudio Contributors -->
<!-- SPDX-License-Identifier: GPL-3.0-only -->
<!-- prettier-ignore -->
!!! abstract "Prerequisite Reading"
This guide assumes you've read the [Developing](developing.md) and [Contributing](contributing.md) pages first.
# :material-sign-text: Style Guide
## Formatting
## :material-script-text: General Principles
Most of the style guidelines can be checked, fixed, and enforced via Ruff. Older code may not be adhering to all of these guidelines, in which case _"do as I say, not as I do"..._
- Write **clear**, **concise**, and **modular** code.
- If the purpose of a peice of code is not obvious, a **short comment** should help explain it.
- Remember to follow the rest of the [contribution guidelines](contributing.md)!
- Do your best to write clear, concise, and modular code.
- This should include making methods private by default (e.g. `__method()`)
- Methods should only be protected (e.g. `_method()`) or public (e.g. `method()`) when needed and warranted
- Keep a maximum column width of no more than **100** characters.
- Code comments should be used to help describe sections of code that can't speak for themselves.
- Use [Google style](https://google.github.io/styleguide/pyguide.html#s3.8-comments-and-docstrings) docstrings for any classes and functions you add.
- If you're modifying an existing function that does _not_ have docstrings, you don't _have_ to add docstrings to it... but it would be pretty cool if you did ;)
- Imports should be ordered alphabetically.
- Lists of values should be ordered using their [natural sort order](https://en.wikipedia.org/wiki/Natural_sort_order).
- Some files have their methods ordered alphabetically as well (i.e. [`thumb_renderer`](https://github.com/TagStudioDev/TagStudio/blob/main/src/tagstudio/qt/widgets/thumb_renderer.py)). If you're working in a file and notice this, please try and keep to the pattern.
- When writing text for window titles or form titles, use "[Title Case](https://apastyle.apa.org/style-grammar-guidelines/capitalization/title-case)" capitalization. Your IDE may have a command to format this for you automatically, although some may incorrectly capitalize short prepositions. In a pinch you can use a website such as [capitalizemytitle.com](https://capitalizemytitle.com/) to check.
- If it wasn't mentioned above, then stick to [**PEP-8**](https://peps.python.org/pep-0008/)!
---
### Modules & Implementations
## :material-text-box-check: Formatting
- **Do not** modify legacy library code in the `src/core/library/json/` directory
- Avoid direct calls to `os`
- Use `Pathlib` library instead of `os.path`
- Use `platform.system()` instead of `os.name` and `sys.platform`
- Don't prepend local imports with `tagstudio`, stick to `src`
- Use the `logger` system instead of `print` statements
- Avoid nested f-strings
- Use HTML-like tags inside Qt widgets over stylesheets where possible
Final submitted code must **_NOT:_**
- Contain superfluous or unnecessary logging statements
- Cause unreasonable slowdowns to the program outside of a progress-indicated task
- Cause undesirable visual glitches or artifacts on screen
### Formatter Configs
Linting in Python files is mostly taken care of by [ruff](developing.md#ruff) and is based on the rules declared in `pyproject.toml` and `.editorconfig`.
TagStudio provides an [EditorConfig](https://editorconfig.org/#example-file) file ([`.editorconfig`](https://github.com/TagStudioDev/TagStudio/blob/main/.editorconfig)) along with a [Prettier](https://prettier.io/) config file ([`.prettierrc.toml`](https://github.com/TagStudioDev/TagStudio/blob/main/.prettierrc.toml)) for formatting files other than .py files (Markdown, JSON, YAML, HTML, CSS, etc.). If editing these types of files it's recommended that you use a formatter that supports EditorConfig or has its settings matched to the EditorConfig and Prettier configs. Lastly, please pay attention to the `prettier-ignore` flags in present in some files if you are not using Prettier, as formatting these sections will break formatting used elsewhere such as the [MkDocs site](https://docs.tagstud.io/).
## Qt
### :material-code-braces-box: Syntax Guidelines
As of writing this section, the QT part of the code base is quite unstructured and the View and Controller parts are completely intermixed[^1]. This makes maintenance, fixes and general understanding of the code base quite challenging, because the interesting parts you are looking for are entangled in a bunch of repetitive UI setup code. To address this we are aiming to more strictly separate the view and controller aspects of the QT frontend.
- Python files should always follow the [**PEP 8**](https://peps.python.org/pep-0008/) style guide conventions, unless specifically allowed otherwise.
- The most notable exception in our project is the line length limit of **100** characters, which is enforced via ruff.
- Internal Qt methods also use `camelCase` instead of `snake_case`, so overrides of those are commonly seen in the codebase.
- Classes and attributes considered to be "[private](https://docs.python.org/3/tutorial/classes.html#private-variables)" should be prepended with a **single underscore** (e.g. `_internal_method()`).
- If _functionally necessary_, an attribute name may be prepended with a double underscore to trigger "[name mangling](https://docs.python.org/3/reference/expressions.html#private-name-mangling)" (e.g. `__mangled_method()`).
- Classes and methods should contain [Google style](https://google.github.io/styleguide/pyguide.html#s3.8-comments-and-docstrings) docstrings _(this style is enforced via ruff)_.
- Lists and JSON keys should be ordered by their [natural sort order](https://en.wikipedia.org/wiki/Natural_sort_order) unless otherwise specified or readily indicated.
- Some files have some or all of their attributes sorted. Please respect any established patterns like these in files you modify.
The general structure of the QT code base should look like this:
---
## :material-filter-cog: Modules & Systems
### :fontawesome-brands-python: Python Modules
- Use `Pathlib` library instead of `os.path`
- Use `platform.system()` instead of `os.name` or `sys.platform`
- Avoid nested f-strings
### :material-tag: TagStudio Systems
- Translation keys can be accessed via bracket notation (e.g. `Translations["translation_key"]`) or with the `Translations.format()` method when a value needs to be passed to a placeholder in the translation.
- Avoid passing around the `QtDriver` class where possible. Instead, pass only the necessary components such as the `Library` and `GlobalSettings` instances.
- Use HTML-like tags inside strings over explicit stylesheets where possible. The `Style` class provides several handy methods for formatting text with these.
- Use the `format` method in the stylesheets class to format text headers.
---
## :material-folder-file: Project Layout
### :material-engine: Core <small>Backend</small>
Code that is integral to the core functionality of TagStudio and is UI-independent belongs under the `core/` directory. It's possible that some code that serves the UI can go here, as long as its purpose is to serve _any_ UI and is independent from Qt (e.g. file preview rendering).
```yaml title="Core Backend Directory Example"
core/
├── library/ # The TagStudio library system
│ │
│ ├── alchemy/ # Current SQLite backend w/ SQLAlchemy ORM
│ │
│ ├── json/ # Read-only legacy JSON library system, kept for migrations
│ │
│ ├── query_lang/ # The query parser
│ │
│ │ # Library files that do not involve the SQLAlchemy ORM
│ │ # NOTE: Future Non-SQLAlchemy library files will be placed here
│ ├── refresh.py
│ └── ...
└── utils/ # Utility classes and functions for the core
```
qt
├── controllers
│ ├── widgets
│ │ └── preview_panel_controller.py
│ └── main_window_controller.py
├── views
│ ├── widgets
│ │ └── preview_panel_view.py
│ └── main_window_view.py
├── ts_qt.py
└── mixed.py
```
In this structure there are the `views` and `controllers` sub-directories. They have the exact same structure and for every `<component>_view.py` there is a `<component>_controller.py` at the same location in the other subdirectory and vice versa.
Typically the classes should look like this:
```py
# my_cool_widget_view.py
class MyCoolWidgetView(QWidget):
def __init__(self):
super().__init__()
self.__button = QPushButton()
self.__color_dropdown = QComboBox()
# ...
self.__connect_callbacks()
def __connect_callbacks(self):
self.__button.clicked.connect(self._button_click_callback)
self.__color_dropdown.currentIndexChanged.connect(
lambda idx: self._color_dropdown_callback(self.__color_dropdown.itemData(idx))
)
def _button_click_callback(self):
raise NotImplementedError()
```
```py
# my_cool_widget_controller.py
class MyCoolWidget(MyCoolWidgetView):
def __init__(self):
super().__init__()
def _button_click_callback(self):
print("Button was clicked!")
def _color_dropdown_callback(self, color: Color):
print(f"The selected color is now: {color}")
```
Observe the following key aspects of this example:
- The Controller is just called `MyCoolWidget` instead of `MyCoolWidgetController` as it will be directly used by other code
- The UI elements are in private variables
- This enforces that the controller shouldn't directly access UI elements
- Instead the view should provide a protected API (e.g. `_get_color()`) for things like setting/getting the value of a dropdown, etc.
- Instead of `_get_color()` there could also be a `_color` method marked with `@property`
- The callback methods are already defined as protected methods with NotImplementedErrors
- Defines the interface the callbacks
- Enforces that UI events be handled
<!-- prettier-ignore -->
!!! tip
A good (non-exhaustive) rule of thumb is: If it requires a non-UI import, then it doesn't belong in the `*_view.py` file.
!!! danger "Read-Only Legacy Code"
**Do not modify** legacy library code in the `src/core/library/json/` directory!
[^1]: For an explanation of the Model-View-Controller (MVC) Model, checkout this article: [MVC Framework Introduction](https://www.geeksforgeeks.org/mvc-framework-introduction/).
---
### :material-button-cursor: App UI <small>Frontend</small>
The application UI code is stored in the `qt/` directory, and contains all code specific to the Qt frontend. Qt widgets are built using an [MVC](https://www.geeksforgeeks.org/software-engineering/mvc-framework-introduction/) pattern, which is described in-depth below:
#### MVC Pattern
- **Models** are usually just objects from the [library](#core-backend).
- The **controller** interacts with these, the _view_ does **not**.
- **Views** are Qt layout classes that **_only_** contain the **layout** and **styling** for one or more widgets.
- Class names are appended with `View`, and filenames appended with `_view`.
- Not to be used standalone, but as the layouts for one or more controllers.
- Some logic is acceptable in these classes if it serves to modularize the layout and allows controllers to influence how the layout is initialized.
- **Reusable Layouts**
- If a layout class is **_not meant_** to act as a view but instead be a generic layout, it belongs in the `views/layouts/` directory and the file should be appended with `_layout`.
- **Styling Classes**
- If a class is purely a source of reusable styling, it belongs in the `views/styles/` directory.
- **Controllers** are complete widgets or base classes for complete widgets.
- Controller files simply take on the name of the final widget they create.
- This also creates naming parity with other widgets that simply extend existing Qt widgets with additional logic.
```yaml title="Qt Frontend Directory Example"
qt/
├── controllers/ # Widgets implementing views or extending other widgets
│ ├── tag_suggest_box.py # Extends from `suggest_box.py`
│ ├── suggest_box.py # Implements `suggest_box_view.py`
│ ├── main_window.py
│ └── ...
├── mixed/ # Files yet to be refactored into controllers and views
├── views/ # Everything related to widget layouts and appearances
│ │
│ ├── layouts/ # Layouts meant to be reused on their own inside other layouts
│ │ ├── flow_layout.py
│ │ └── ...
│ │
│ ├── styles/ # Classes specific for styling
│ │ ├── palette.py
│ │ ├── stylesheets.py
│ │ └── ...
│ │
│ │ # Views (layouts) that get implemented by controllers (widgets)
│ ├── main_window_view.py
│ ├── suggest_box_view.py
│ └── ...
│ # Frontend classes that aren't related to widgets, like managers
├── resource_manager.py
├── cache_manager.py
├── ts_qt.py # Qt Driver
└── ...
```
<!-- prettier-ignore -->
!!! warning "Pre-MVC UI Code"
**Do not add** new files to the `qt/mixed/` directory! These files have yet to to be refactored per the current MVC style guidelines and the directory will be **removed** once those migrations have concluded.
Observe the following key aspects of the example below:
- The **view** extends from a Qt layout class, and the **controller** simply extends from QWidget.
- The **controller** is just called `MyCoolWidget` instead of `MyCoolWidgetController` as it will be directly used by other code.
- The **view's** widgets that are intended to be controlled by the controller are **public**, while the **controller's** methods are largely **private**.
<!-- prettier-ignore -->
!!! example "MVC-Separated Widget Example"
```py title="views/my_cool_widget_view.py"
class MyCoolWidgetView(QVBoxLayout):
def __init__(self):
super().__init__()
self.button = QPushButton()
self.color_dropdown = QComboBox()
self.addWidget(self.button)
self.addWidget(self.color_dropdown)
```
```py title="controllers/my_cool_widget.py"
class MyCoolWidget(QWidget):
def __init__(self):
super().__init__()
self.setLayout(MyCoolWidgetView())
self._connect_callbacks()
def _connect_callbacks(self):
self.layout().button.clicked.connect(self._button_click_callback)
self.layout().color_dropdown.currentIndexChanged.connect(
lambda idx: self._color_dropdown_callback(self.color_dropdown.itemData(idx)))
def _button_click_callback(self):
print("Button was clicked!")
def _color_dropdown_callback(self, color: Color):
print(f"The selected color is now: {color}")
```
<!-- prettier-ignore -->
!!! tip "Tip for Logic Placement"
A good rule of thumb is: If there's **conditional logic** after a widget has been created, it should probably go in a **controller**.
---
## :material-file-document: Documentation
Documentation contributions include anything inside the `docs/` folder as well as the `README.md`. Documentation inside the `docs/` folder is built and hosted on our static documentation site, [docs.tagstud.io](https://docs.tagstud.io/). Some files such as the `CHANGELOG.md`, `CONTRIBUTING.md`, and `STYLE.md` are symlinked in the repo root from the `docs/` folder.
- Use "[dash-case / kebab-case](https://developer.mozilla.org/en-US/docs/Glossary/Kebab_case)" for file and folder names
- Follow the folder structure pattern
- Don't add images or other media with excessively large file sizes
- Provide alt text for embedded media
- Use "[Title Case](https://apastyle.apa.org/style-grammar-guidelines/capitalization/title-case)" for title capitalization
---
## :material-translate: Translations
Translations are performed on the TagStudio [Weblate project](https://hosted.weblate.org/projects/tagstudio/).
- Do not change text inside placeholders
- Do not change the style tags inside translations
- Use the glossary for term definitions
+4
View File
@@ -73,6 +73,10 @@
border-color: #ffffff33;
}
img {
border-radius: 6px;
}
th,
td {
padding: 0.5em 1em 0.5em 1em !important;
+2 -4
View File
@@ -116,12 +116,10 @@ Due to the nature of how tags and Tag Felids operated prior to v9.5, the organiz
#### Is Hidden
<!-- prettier-ignore -->
!!! warning ""
**_Coming in version 9.6.x_**
When the "Is Hidden" property is checked, any file entries tagged with this tag will not show up in searches by default. This property comes by default with the built-in "Archived" tag.
---
## Tag Search Examples
The following are examples of how a set of given tags will respond to various search queries.
+75 -27
View File
@@ -17,47 +17,97 @@ To create or open a [library](libraries.md), go to **File -> Open/Create Library
TagStudio automatically scans for new or updated files when opening a library by default. Manually refresh by going to **File -> Refresh Directories** in the menu or by using <kbd>Ctrl</kbd>+<kbd>R</kbd> (<kbd>⌘ Command </kbd>+<kbd>R</kbd> on macOS).
<!-- prettier-ignore -->
!!! tip "TagStudio Libraries"
!!! abstract "TagStudio Libraries"
To learn more about how TagStudio libraries work and how to use them, visit the **[Libraries](libraries.md)** page.
## Adding Tags to File Entries
---
Access the "Add Tag" search box by either clicking on the "Add Tag" button at the bottom of the right sidebar, accessing the "Add Tags to Selected" option from the File menu, or by pressing <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>T</kbd>.
## :material-tag-text: Tagging
From here you can search for existing tags or create a new one if the one you're looking for doesn't exist. Click the "+" button next to any tags you want to the currently selected file entries. To quickly add the top result, press the <kbd>Enter</kbd>/<kbd>Return</kbd> key to add the topmost tag and reset the tag search. Press <kbd>Enter</kbd>/<kbd>Return</kbd> once more to close the dialog box. By using this method, you can quickly add various tags in quick succession just by using the keyboard!
With one or more file entries selected, you can **search for** or **create a new tag** by clicking the "Add Tag" button at the bottom of the preview panel or by pressing <kbd>Ctrl</kbd>+<kbd>T</kbd>.
To remove a tag from a file entry, hover over the tag in the preview panel and click on the "-" icon that appears.
This will open the the **"Tag Bar"**, a combination search/creation bar that is designed to allow you to quickly create and apply tags that don't exist yet, find and apply existing tags from your library, and verify if certain tags already exist and/or are applied to your selection.
## Adding Metadata to File Entries
Every step is keyboard-friendly, allowing for an efficient tagging "flow state" when it comes to creating and applying tags.
To add a metadata field to a file entry, start by clicking the "Add Field" button at the bottom of the preview panel. From the dropdown menu, select the type of metadata field youd like to add to the entry
<figure markdown="span">
![Empty File Entry](assets/tag_field_bars/add_buttons_normal.png){ width=80% }
<br>
![Empty File Entry](assets/tag_field_bars/tag_bar_empty.png){ width=80% }
<figcaption>Clicking "Add Tag" or pressing <kbd>Ctrl</kbd>+<kbd>T</kbd> replaces the "Add" buttons with a tag search bar. Pressing <kbd>Esc</kbd> (or <kbd>Enter</kbd> with a blank search) will close the bar and return the "Add" buttons.</figcaption>
</figure>
## Editing Metadata Fields
<!-- prettier-ignore -->
!!! note "Classic Tag Search Panel"
You can access the classic tag search panel by going to **File -> Add Tag to Selected** in the menu bar, or by pressing <kbd>Shift</kbd>+<kbd>Ctrl</kbd>+<kbd>T</kbd>. Note that this panel will change and move in the future, becoming part of a set of dockable side-panels for tagging that can stay open between making selections. See the [roadmap](./roadmap.md#uiux) for more info.
### Text Line / Text Box
### :material-tag-plus: Tag Bar: Creating Tags
Hover over the field and click the pencil icon. From there, add or edit text in the dialog box popup.
Type to search for an existing tag, or use that search term to quickly create and apply a new tag to your selection. Pressing <kbd>Enter</kbd> with **no results** will **create** :material-tag-plus-outline: a tag from your search query and apply it to the selected entries.
## Creating Tags
After applying a tag, the "Add Tag" button is now focused, allowing you to press <kbd>Enter</kbd> or <kbd>Space</kbd> to quickly reactivate the bar. Alternatively, you may edit your new tag by right-clicking the tag and selecting **"Edit"**.
Create a new tag by accessing the "New Tag" option from the Edit menu or by pressing <kbd>Ctrl</kbd>+<kbd>T</kbd>. In the tag creation panel, enter a tag name, optional shorthand name, optional tag aliases, optional parent tags, and an optional color.
Tags can be also viewed, created, edited, or deleted from the **Edit -> Manage Tags** window.
- The tag **name** is the base name of the tag. **_This does NOT have to be unique!_**
- The tag **shorthand** is a special type of alias that displays in situations where screen space is more valuable, notably with name disambiguation.
- **Aliases** are alternate names for a tag. These let you search for terms other than the exact tag name in order to find the tag again.
- **Parent Tags** are tags in which this tag can substitute for in searches. In other words, tags under this section are parents of this tag.
- Parent tags with the disambiguation check next to them will be used to help disambiguate tag names that may not be unique.
- For example: If you had a tag for "Freddy Fazbear", you might add "Five Nights at Freddy's" as one of the parent tags. If the disambiguation box is checked next to "Five Nights at Freddy's" parent tag, then the tag "Freddy Fazbear" will display as "Freddy Fazbear (Five Nights at Freddy's)". Furthermore, if the "Five Nights at Freddy's" tag has a shorthand like "FNAF", then the "Freddy Fazbear" tag will display as "Freddy Fazbear (FNAF)".
- The **color** option lets you select an optional color palette to use for your tag.
- The **"Is Category"** property lets you treat this tag as a category under which itself and any child tags inheriting from it will be sorted by inside the preview panel.
<figure markdown="span">
![Empty File Entry](assets/tag_field_bars/tag_bar_new.png){ width=80% }
<figcaption>A tag named "Cube" does not exist yet, and will be created and applied to the selection when <kbd>Enter</kbd> is pressed.</figcaption>
![Empty File Entry](assets/tag_field_bars/tag_button_highlighted.png){ width=80% }
<figcaption>The new tag "Cube" has been created and applied to our selection.</figcaption>
</figure>
### Tag Manager
<!-- prettier-ignore -->
!!! tip "Optionally Always Edit New Tags"
If you prefer opening an "Edit" window for tags after creating them and before they get applied to your selection, you can toggle this option on by right-clicking the search bar and clicking **"Edit After Creating a New Tag"**, or by visiting the same setting in the **Settings** window. By default, this option is **off.**
You can manage your library of tags by opening the "Tag Manager" panel from Edit -> "Manage Tags". From here you can create, search for, edit, and permanently delete any tags you've created in your library.
### :material-tag-search: Tag Bar: Searching for Tags
## Editing Tags
Pressing <kbd>Enter</kbd> with **one or more results** will **apply** :material-tag: the underlined tag to your selection (assuming it's not already on your selection).
To edit a tag, click on it inside the preview panel or right-click the tag and select "Edit Tag" from the context menu.
Tags that are already present on your entries will be greyed out and ignored when pressing <kbd>Enter</kbd>. This lets you visually confirm that a tag already exists and has been applied to the selection.
<figure markdown="span">
![Empty File Entry](assets/tag_field_bars/tag_bar_search_match.png){ width=80% }
<figcaption>On a new selected entry, typing "Cube" will find our existing tag.</figcaption>
![Empty File Entry](assets/tag_field_bars/tag_bar_search_match_greyed.png){ width=80% }
<figcaption>If the matched tag is already applied to the selected entries, it will be greyed out and ignored when pressing <kbd>Enter</kbd>.</figcaption>
</figure>
#### :material-tag-arrow-up: Force Create Tag
Holding <kbd>Shift</kbd> while pressing <kbd>Enter</kbd> will **force create** :material-tag-plus-outline: a tag from the search bar text, regardless of the search results. This is especially useful when you need to create a new tag with a name that partially overlaps with an existing tag's name.
<figure markdown="span">
![Empty File Entry](assets/tag_field_bars/tag_bar_shift_off.png){ width=80% }
<figcaption markdown="span">Pressing <kbd>Enter</kbd> will **apply** :material-tag: the underlined tag, "Cube".</figcaption>
![Empty File Entry](assets/tag_field_bars/tag_bar_shift_on.png){ width=80% }
<figcaption markdown="span">
<kbd>Shift</kbd>+<kbd>Enter</kbd> will **create** :material-tag-plus-outline: a new tag called "Cub".
<br>
Note the the greyed-out tag, showing it will not be applied.
</figcaption>
</figure>
---
## :material-text-box: Creating and Adding Fields
[Fields](./fields.md) are extra pieces of information you can add to file entries, such as titles, comments, notes, specific dates or times. Unlike tags, fields are based on [templates](./fields.md#field-templates) that contain pre-filled information such as the field type and title, and that information is _copied_ to fields when adding them to entries. Editing field information on entries **does not** modify the template it was created from.
Creating and adding fields to entries is extremely similar to how [tagging](#tagging) works, with a few notable differences:
- Click "Add Field" or press <kbd>Ctrl</kbd>+<kbd>L</kbd> to open the field bar.
- The same template can by applied to an entry any number of times.
- New field templates are automatically edited after being created, since they have a type that must be chosen before they can be used.
- This is separate from the option to automatically edit a field's _contents_ once a field has been added to an entry
- Editing a field's contents immediately after it's added to an entry is **enabled** by default, and can be toggled by right-clicking the search bar and clicking **"Edit After Adding a Field"**, or by visiting the same setting in the **Settings** window.
<figure markdown="span">
![Empty File Entry](assets/tag_field_bars/field_bar_search.png){ width=80% }
<figcaption>Searching for field templates inside the field bar.</figcaption>
</figure>
---
## Relinking Moved Files
@@ -71,9 +121,7 @@ Inevitably some of the files inside your library will be renamed, moved, or dele
!!! warning
If multiple matches for a moved file are found (matches are currently defined as files with a matching filename as the original), TagStudio will currently ignore the match groups. Adding a GUI for manual selection, as well as smarter automated relinking, are high priorities for future versions.
### Saving the Library
As of version 9.5, libraries are saved automatically as you go. To save a backup of your library, select File -> Save Library Backup from the menu bar.
---
## Launch Arguments
Generated
+6 -6
View File
@@ -7,11 +7,11 @@
]
},
"locked": {
"lastModified": 1778716662,
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
"lastModified": 1782949081,
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
"type": "github"
},
"original": {
@@ -22,11 +22,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1782723713,
"narHash": "sha256-oPXCU/SSUokcGaJREHibG1CBX3+s/W7orDWQOZDsEeQ=",
"lastModified": 1785571196,
"narHash": "sha256-KoTsyMQqnXQZq8deCEnu4QkyldkwH/bpMMhUcfMdGIw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "b5aa0fbd538984f6e3d201be0005b4463d8b09f8",
"rev": "148bab9c1c3c53136ecb44a6ea356a0ed5b39b06",
"type": "github"
},
"original": {
+1 -1
View File
@@ -97,7 +97,7 @@ python3Packages.buildPythonApplication {
with python3Packages;
[
audioop-lts
chardet
chardet_5
ffmpeg-python
humanfriendly
mutagen
+8 -10
View File
@@ -27,11 +27,11 @@ let
libdrm
libpulseaudio
libva
libx11
libxkbcommon
libxrandr
pipewire
qt6.qtwayland
xorg.libX11
xorg.libXrandr
]
);
@@ -73,19 +73,17 @@ let
};
in
pkgs.mkShellNoCC {
nativeBuildInputs = with pkgs; [
packages = [
python3Wrapped
]
++ (with pkgs; [
coreutils
ffmpeg-headless
ripgrep
uv
pyright
ruff
];
buildInputs = [
python3Wrapped
]
++ (with pkgs; [
ffmpeg-headless
ripgrep
]);
env = {
View File
+7 -3
View File
@@ -60,7 +60,11 @@ extra = [{ include-group = "pre-commit" }]
lint = [{ include-group = "pyright" }, { include-group = "ruff" }]
test = [{ include-group = "pytest" }]
mkdocs = ["mkdocs-material[imaging]>=9.7", "mkdocs-redirects~=1.2"]
mkdocs = [
"mkdocs-material[imaging]>=9.7",
"mkdocs-redirects~=1.2",
"properdocs",
]
pre-commit = ["pre-commit~=4.2"]
pyinstaller = ["Pyinstaller~=6.21"]
pyright = ["pyright~=1.1.409"]
@@ -88,7 +92,7 @@ filterwarnings = [
ignore = [
".venv/**",
"src/tagstudio/core/library/json/",
"src/tagstudio/qt/previews/vendored/pydub/",
"src/tagstudio/renderers/vendored/pydub/",
]
include = ["src/tagstudio", "tests"]
# Reference for the settings here: https://github.com/microsoft/pyright/blob/main/docs/configuration.md
@@ -117,7 +121,7 @@ ignore = ["D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107"]
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["D", "E402"]
"src/tagstudio/qt/previews/vendored/**" = ["B", "E", "N", "UP", "SIM115"]
"src/tagstudio/renderers/vendored/**" = ["B", "E", "N", "UP", "SIM115"]
[tool.ruff.lint.pydocstyle]
convention = "google"
@@ -1670,8 +1670,10 @@ class Library:
)
session.execute(update_tags_stmt)
session.commit()
else:
self.add_color(new_color_group)
return
# "if not existing_color", out of the session context
self.add_color(new_color_group)
def update_aliases(self, tag: Tag, aliases: Iterable[TagAlias], session: Session) -> bool:
"""Update TagAliases for a given Tag."""
+1
View File
@@ -305,6 +305,7 @@ class MediaCategories:
".nef",
".nrw",
".orf",
".r3d",
".raf",
".raw",
".rw2",
@@ -0,0 +1,84 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from typing import override
import structlog
from PySide6 import QtCore, QtGui
from PySide6.QtCore import QPoint, Signal
from PySide6.QtWidgets import (
QLineEdit,
QMenu,
QWidget,
)
from tagstudio.qt.views.stylesheets.stylesheets import (
autofill_scroll_top_focus_style,
autofill_scroll_top_style,
)
logger = structlog.get_logger(__name__)
class AutofillLineEdit(QLineEdit):
return_pressed = Signal()
shift_return_pressed = Signal()
holding_shift = Signal(bool)
index_updated = Signal(int)
def __init__(self, popup: QWidget) -> None:
super().__init__()
self._popup = popup
@override
def focusOutEvent(self, arg__1: QtGui.QFocusEvent) -> None:
self._popup.setStyleSheet(autofill_scroll_top_style("container"))
return super().focusOutEvent(arg__1)
@override
def focusInEvent(self, arg__1: QtGui.QFocusEvent) -> None:
self._popup.setStyleSheet(autofill_scroll_top_focus_style("container"))
return super().focusInEvent(arg__1)
@override
def event(self, arg__1: QtCore.QEvent) -> bool:
if arg__1.type() == QtCore.QEvent.Type.KeyPress:
assert isinstance(arg__1, QtGui.QKeyEvent)
if arg__1.key() == QtCore.Qt.Key.Key_Tab:
self.index_updated.emit(1)
return True
elif arg__1.key() == QtCore.Qt.Key.Key_Backtab:
self.index_updated.emit(-1)
return True
if arg__1.key() == QtCore.Qt.Key.Key_Shift:
self.holding_shift.emit(True) # noqa: FBT003
if arg__1.key() == QtCore.Qt.Key.Key_Escape:
self.setText("")
self.clearFocus()
elif (
arg__1.key() == QtCore.Qt.Key.Key_Enter or arg__1.key() == QtCore.Qt.Key.Key_Return
):
if arg__1.modifiers() == QtCore.Qt.KeyboardModifier.ShiftModifier:
self.shift_return_pressed.emit()
else:
self.return_pressed.emit()
return super().event(arg__1)
@override
def keyReleaseEvent(self, arg__1: QtGui.QKeyEvent) -> None:
if arg__1.key() == QtCore.Qt.Key.Key_Shift:
self.holding_shift.emit(False) # noqa: FBT003
return super().keyReleaseEvent(arg__1)
def show_action_menu(self, pos: QPoint) -> None:
"""Show a context menu of actions."""
menu = QMenu(self)
for action in self.actions():
# Filter out icon action(s)
if action.text():
menu.addAction(action)
menu.exec(self.mapToGlobal(pos)) # pyright: ignore[reportArgumentType]
@@ -73,8 +73,8 @@ class EditFieldTemplateModal(EditFieldTemplateModalView):
self.name_field.setStyleSheet(line_edit_style() if is_empty else "")
if self.panel_save_button is not None:
self.panel_save_button.setDisabled(is_empty)
if self.save_button is not None:
self.save_button.setDisabled(is_empty)
def __on_type_changed(self, index: int):
old_type = self.__field_type
@@ -0,0 +1,169 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from typing import override
from warnings import catch_warnings
import structlog
from PySide6.QtGui import QAction, Qt
from PySide6.QtWidgets import QWidget
from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
from tagstudio.core.library.alchemy.library import Library
from tagstudio.qt.controllers.edit_field_template_modal import EditFieldTemplateModal
from tagstudio.qt.controllers.field_template_widget_controller import FieldTemplateWidget
from tagstudio.qt.controllers.modal import Modal
from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.controllers.suggest_box import SuggestBox
from tagstudio.qt.controllers.underlined_widget import UnderlinedWidget
from tagstudio.qt.global_settings import GlobalSettings
from tagstudio.qt.translations import Translations
logger = structlog.get_logger(__name__)
class FieldSuggestBox(SuggestBox[BaseFieldTemplate]):
def __init__(self, library: Library, settings: GlobalSettings, placeholder_text: str = ""):
super().__init__(library, settings, placeholder_text)
# Context Menu Actions
edit_field_on_add_action = QAction(Translations["settings.edit_field_on_add"], self)
edit_field_on_add_action.setCheckable(True)
self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.addAction(edit_field_on_add_action)
self.layout().search_field.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.layout().search_field.addAction(edit_field_on_add_action)
edit_field_on_add_action.setChecked(self._settings.edit_field_on_add)
edit_field_on_add_action.triggered.connect(
lambda checked: self._toggle_edit_on_field_add(checked)
)
def _toggle_edit_on_field_add(self, checked: bool) -> None:
"""Toggle the setting for opening the edit window after adding a field."""
self._settings.edit_field_on_add = checked
self._settings.save()
@override
def _on_item_create(self) -> None:
"""Creates a new field template and adds it to the currently selected entries.
Optionally opens up an edit panel after creation and before adding to entries.
Populates name field using current search query.
"""
# NOTE: Unlike tags, creating new field templates will ALWAYS spawn an edit window
# since the user needs to decide what type of field it should be before it's created.
query: str = self.layout().search_field.text()
panel = EditFieldTemplateModal()
modal = Modal(
panel,
Translations["field_template.new"],
Translations["field_template.new"],
is_savable=True,
)
if query.strip():
panel.name_field.setText(query)
modal.saved.connect(lambda: self._create_item_from_modal(panel))
modal.show()
@override
def _on_item_edit(self, item: BaseFieldTemplate) -> None:
panel: EditFieldTemplateModal = EditFieldTemplateModal(item)
modal: Modal = Modal(panel, item.name, Translations["field_template.edit"], is_savable=True)
modal.saved.connect(lambda: self._edit_item(panel))
modal.show()
@override
def _on_item_chosen(self, item: BaseFieldTemplate) -> None:
self.item_chosen.emit(item)
self.done.emit("*")
@override
def _search_items(self, query: str) -> tuple[list[BaseFieldTemplate], list[BaseFieldTemplate]]:
if query != "":
return self._lib.search_field_templates(name=query, limit=0), []
else:
return ([], [])
@override
def _on_shift_held(self, held: bool) -> None:
if held:
self.set_hint_icon(self._rm.hint_field_create)
else:
self._update_hint_icon()
return super()._on_shift_held(held)
@override
def _update_hint_icon(self) -> None:
if self.layout().search_field.text() and len(self._search_results) > 0:
self.set_hint_icon(self._rm.hint_field_add)
elif self.layout().search_field.text():
self.set_hint_icon(self._rm.hint_field_create)
else:
self.set_hint_icon(None)
@override
def _set_item_widget(self, item: BaseFieldTemplate | None, index: int) -> None:
"""Set the field template of a field template widget at a specific index."""
underlined_widget: UnderlinedWidget = self._get_item_widget(index, self._lib)
field_template_widget = underlined_widget.widget
assert isinstance(field_template_widget, FieldTemplateWidget)
field_template_widget.has_remove = False
field_template_widget.set_field_template(item)
underlined_widget.setHidden(item is None)
if item is None:
return
# TODO: Add tabbing to different items, and use underline to indicate which will be added
underlined_widget.toggle_underline(index != 0)
# Disconnect previous callbacks
with catch_warnings(record=True):
field_template_widget.on_edit.disconnect()
field_template_widget.on_remove.disconnect()
field_template_widget.on_click.disconnect()
# Connect callbacks
field_template_widget.on_edit.connect(lambda item_=item: self._on_item_edit(item_))
field_template_widget.on_click.connect(
lambda checked=False, item_=item: self._on_item_chosen(item_)
)
@override
def _create_item_from_modal(self, edit_item_panel: ModalContent) -> None:
if isinstance(edit_item_panel, EditFieldTemplateModal):
template: BaseFieldTemplate = edit_item_panel.build_field_template()
self._lib.add_field_template(template)
self._on_item_chosen(template)
self._clear_search_query()
edit_item_panel.hide()
self._on_search_query_changed(self.layout().search_field.text())
@override
def _edit_item(self, edit_item_panel: ModalContent) -> None:
if not isinstance(edit_item_panel, EditFieldTemplateModal):
return
self._lib.update_field_template(
edit_item_panel.old_field_type, edit_item_panel.build_field_template()
)
self._update_items(self.layout().search_field.text())
@override
def _get_item_widget(self, index: int, library: Library | None) -> UnderlinedWidget:
"""Gets the item widget at a specific index."""
# Create any new item widgets needed up to the given index
if self.layout().content_layout.count() <= index:
while self.layout().content_layout.count() <= index:
field_template_widget = FieldTemplateWidget()
widget = UnderlinedWidget(field_template_widget)
widget.setHidden(True)
self.layout().content_layout.addWidget(widget)
widget_: QWidget = self.layout().content_layout.itemAt(index).widget()
assert isinstance(widget_, UnderlinedWidget)
return widget_
@@ -7,57 +7,40 @@ from warnings import catch_warnings
import structlog
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QMessageBox
from PySide6.QtWidgets import QMessageBox, QWidget
from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
from tagstudio.core.library.alchemy.library import Library
from tagstudio.qt.controllers.edit_field_template_modal import EditFieldTemplateModal
from tagstudio.qt.controllers.field_template_widget_controller import FieldTemplateWidget
from tagstudio.qt.controllers.modal import Modal
from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.controllers.search_panel_controller import SearchPanel
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.field_template_search_panel_view import FieldTemplateSearchPanelView
from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget
from tagstudio.qt.views.search_panel_view import SearchPanelView
logger = structlog.get_logger(__name__)
class FieldTemplateSearchModal(PanelModal):
def __init__(
self,
library: Library,
is_field_template_chooser: bool = True,
has_save: bool = False,
) -> None:
self.search_panel: FieldTemplateSearchPanel = FieldTemplateSearchPanel(
library,
is_field_template_chooser,
view=FieldTemplateSearchPanelView(is_field_template_chooser),
)
super().__init__(
self.search_panel,
Translations["field.add.plural"],
is_savable=has_save,
)
class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
field_template_chosen = Signal(object)
def __init__(
self,
library: Library,
is_field_template_chooser: bool = True,
view: FieldTemplateSearchPanelView | None = None,
is_chooser: bool = True,
view: SearchPanelView | None = None,
) -> None:
super().__init__(
view=view or FieldTemplateSearchPanelView(is_field_template_chooser),
view=view
or SearchPanelView(Translations["home.search_field_templates"], is_chooser=is_chooser),
exclude=[],
is_chooser=is_field_template_chooser,
is_chooser=is_chooser,
)
self.__lib = library
self._unlimited_limit_item_label = Translations["field_template.all_field_templates"]
self._create_and_add_button_label_key = "field_template.create_add"
self._create_and_add_button_key = "field_template.create_add"
@override
def _get_max_limit(self) -> int:
@@ -76,7 +59,7 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
logger.info("[FieldTemplateSearch] Create and Add Field Template", name=query)
panel: EditFieldTemplateModal = EditFieldTemplateModal()
modal: PanelModal = PanelModal(
modal: Modal = Modal(
panel,
Translations["field_template.new"],
Translations["field_template.new"],
@@ -93,7 +76,7 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
def on_item_edit(self, item: BaseFieldTemplate) -> None:
panel: EditFieldTemplateModal = EditFieldTemplateModal(item)
modal: PanelModal = PanelModal(
modal: Modal = Modal(
panel,
item.name,
Translations["field_template.edit"],
@@ -105,7 +88,7 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
@override
def _on_item_remove(self, item: BaseFieldTemplate) -> None:
if self.is_chooser:
if self._is_chooser:
return
message_box = QMessageBox(
@@ -141,7 +124,7 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
if item is None:
return
field_template_widget.has_remove = not self.is_chooser
field_template_widget.has_remove = not self._is_chooser
# Disconnect previous callbacks
with catch_warnings(record=True):
@@ -157,7 +140,7 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
)
@override
def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None:
def create_item(self, edit_item_panel: ModalContent, choose_item: bool = False) -> None:
if isinstance(edit_item_panel, EditFieldTemplateModal):
template: BaseFieldTemplate = edit_item_panel.build_field_template()
@@ -171,11 +154,25 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
self.on_search_query_changed(self.get_search_query())
@override
def edit_item(self, edit_item_panel: PanelWidget) -> None:
def edit_item(self, edit_item_panel: ModalContent) -> None:
if not isinstance(edit_item_panel, EditFieldTemplateModal):
return
self.__lib.update_field_template(
edit_item_panel.old_field_type, edit_item_panel.build_field_template()
)
self.update_items(self.search_field.text())
self.update_items(self.layout().search_field.text())
@override
def get_item_widget(self, index: int, library: Library | None) -> FieldTemplateWidget:
"""Gets the item widget at a specific index."""
# Create any new item widgets needed up to the given index
if self.layout().scroll_layout.count() <= index:
while self.layout().scroll_layout.count() <= index:
pad_field_template_widget = FieldTemplateWidget()
pad_field_template_widget.setHidden(True)
self.layout().scroll_layout.addWidget(pad_field_template_widget)
field_template_widget: QWidget = self.layout().scroll_layout.itemAt(index).widget()
assert isinstance(field_template_widget, FieldTemplateWidget)
return field_template_widget
@@ -0,0 +1,35 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from typing import override
import structlog
from PySide6 import QtCore, QtGui
from PySide6.QtWidgets import QScrollArea
logger = structlog.get_logger(__name__)
class HorizontalScrollArea(QScrollArea):
"""A QScrollArea that translates vertical scrolling to horizontal movement."""
@override
def wheelEvent(self, arg__1: QtGui.QWheelEvent) -> None:
angle_y = arg__1.angleDelta().y()
pixel_y = arg__1.pixelDelta().y()
if angle_y != 0 or pixel_y != 0:
translated_event = QtGui.QWheelEvent(
arg__1.position(),
arg__1.globalPosition(),
QtCore.QPoint(pixel_y * -1, 0),
QtCore.QPoint(angle_y * -1, 0),
arg__1.buttons(),
arg__1.modifiers(),
arg__1.phase(),
arg__1.inverted(),
)
arg__1.accept()
return super().wheelEvent(translated_event)
else:
return super().wheelEvent(arg__1)
+85
View File
@@ -0,0 +1,85 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import contextlib
from typing import Any, override
import structlog
from PySide6 import QtGui
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QWidget
from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.views.modal_view import ModalView
logger = structlog.get_logger(__name__)
class Modal(QWidget):
"""A generic modal window widget with common signals and styling."""
done = Signal()
saved = Signal()
saved_data = Signal(type(Any))
def __init__(
self,
content_widget: ModalContent,
title: str = "",
window_title: str | None = None,
is_savable: bool = False,
inline_title: bool = True,
):
super().__init__()
self.setWindowTitle(title if window_title is None else window_title)
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setLayout(
ModalView(
content_widget=content_widget,
title=title,
is_savable=is_savable,
inline_title=inline_title,
)
)
# [Done]
# - OR -
# [Cancel] [Save]
if not is_savable:
done_button = self.layout().content_widget.done_button
if done_button:
done_button.clicked.connect(self.hide)
done_button.clicked.connect(self.done.emit)
else:
cancel_button = self.layout().content_widget.cancel_button
if cancel_button:
cancel_button.clicked.connect(self.hide)
cancel_button.clicked.connect(content_widget.reset)
save_button = self.layout().content_widget.save_button
if save_button:
save_button.clicked.connect(self.hide)
save_button.clicked.connect(self.saved.emit)
save_button.clicked.connect(
lambda: self.saved_data.emit(content_widget.saved_data())
)
content_widget.parent_post_init()
@override
def closeEvent(self, event: QtGui.QCloseEvent) -> None:
with contextlib.suppress(AttributeError):
cancel_button = self.layout().content_widget.cancel_button
if cancel_button:
cancel_button.click()
with contextlib.suppress(AttributeError):
done_button = self.layout().content_widget.done_button
if done_button:
done_button.click()
event.accept()
@override
def layout(self) -> ModalView:
"""Return the typed layout for this widget."""
return super().layout() # pyright: ignore[reportReturnType]
@@ -0,0 +1,47 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from typing import Any, override
import structlog
from PySide6 import QtCore, QtGui
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QPushButton, QWidget
logger = structlog.get_logger(__name__)
class ModalContent(QWidget):
"""Base class for widgets that go inside a Modal widget."""
save_button: QPushButton | None = None
cancel_button: QPushButton | None = None
done_button: QPushButton | None = None
def __init__(self):
super().__init__()
def saved_data(self) -> Any: # pyright: ignore[reportExplicitAny]
return None
def reset(self) -> None:
pass
def parent_post_init(self) -> None:
pass
@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:
if self.save_button:
self.save_button.click()
elif self.done_button:
self.done_button.click()
else: # Other key presses
super().keyPressEvent(event)
@@ -1,65 +1,288 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import typing
from datetime import datetime as dt
from enum import IntEnum
from functools import partial
from pathlib import Path
from typing import override
from warnings import catch_warnings
from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
from tagstudio.core.library.alchemy.library import Library
import structlog
from PySide6 import QtCore
from PySide6.QtGui import QShortcut
from PySide6.QtWidgets import QWidget
from tagstudio.core.library.alchemy.fields import (
BaseField,
BaseFieldTemplate,
DatetimeField,
DatetimeFieldTemplate,
TextField,
TextFieldTemplate,
)
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
from tagstudio.qt.controllers.field_template_search_panel_controller import FieldTemplateSearchModal
from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal
from tagstudio.qt.translations import Translations
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.controllers.edit_text_controller import EditText
from tagstudio.qt.controllers.modal import Modal
from tagstudio.qt.mixed.datetime_picker import DatetimePicker
from tagstudio.qt.mixed.field_containers import FieldContainers
from tagstudio.qt.mixed.file_attributes import FileAttributeData
from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations
from tagstudio.qt.views.preview_panel_view import PreviewPanelView
if typing.TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
logger = structlog.get_logger(__name__)
class PreviewPanel(PreviewPanelView):
def __init__(self, library: Library, driver: "QtDriver") -> None:
super().__init__(library, driver)
self.__add_field_modal = FieldTemplateSearchModal(self.lib, is_field_template_chooser=True)
self.__add_tag_modal = TagSearchModal(
self.lib, title=Translations["tag.add.plural"], is_tag_chooser=True
class _ItemMode(IntEnum):
TAG = 1
FIELD = 2
class PreviewPanel(QWidget):
def __init__(self, driver: "QtDriver") -> None:
super().__init__()
self._driver = driver
self._lib = self._driver.lib
self._settings = self._driver.settings
self._selected: list[int]
self._current_stats: FileAttributeData | None = None
self._open_tag_search_action = QShortcut(
QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
QtCore.Qt.Key.Key_T,
),
self,
)
self._open_field_search_action = QShortcut(
QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
QtCore.Qt.Key.Key_L,
),
self,
)
self.__add_tag_modal.tsp.set_driver(driver)
self._thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning)
@typing.override
def _add_field_button_callback(self) -> None:
self.__add_field_modal.show()
self.setLayout(PreviewPanelView(driver=driver, pixel_ratio=self.devicePixelRatio()))
self._set_item_mode(None)
self._connect_callbacks()
@typing.override
def _add_tag_button_callback(self) -> None:
self.__add_tag_modal.show()
def _connect_callbacks(self) -> None:
# Tag Search
self.layout().add_tag_button.clicked.connect(lambda: self._set_item_mode(_ItemMode.TAG))
self._open_tag_search_action.activated.connect(self._open_tag_search_callback)
self.layout().tag_search_box.done.connect(partial(self._tag_added_callback))
self.layout().containers.on_tags_update.connect(self._update_added_callback)
# Field Search
self.layout().add_field_button.clicked.connect(lambda: self._set_item_mode(_ItemMode.FIELD))
self._open_field_search_action.activated.connect(self._open_field_search_callback)
self.layout().field_search_box.done.connect(partial(self._field_added_callback))
# Previews
self.layout().preview_thumb.stats_updated.connect(self._thumb_stats_updated_callback)
self.layout().preview_thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning)
def _set_item_mode(self, mode: _ItemMode | None):
def hide_and_disable_buttons():
self.layout().add_tag_button.setHidden(True)
self.layout().add_tag_button.setEnabled(False)
self.layout().add_field_button.setHidden(True)
self.layout().add_field_button.setEnabled(False)
def restore_buttons():
self.layout().add_tag_button.setHidden(False)
self.layout().add_tag_button.setEnabled(True)
self.layout().add_field_button.setHidden(False)
self.layout().add_field_button.setEnabled(True)
if mode == _ItemMode.TAG:
self.layout().tag_search_box.added = self.layout().containers.tags
self.layout().field_search_box.hide_and_reset()
self.layout().tag_search_box.setHidden(False)
hide_and_disable_buttons()
elif mode == _ItemMode.FIELD:
self.layout().tag_search_box.hide_and_reset()
self.layout().field_search_box.setHidden(False)
hide_and_disable_buttons()
else:
self.layout().tag_search_box.hide_and_reset()
self.layout().field_search_box.hide_and_reset()
restore_buttons()
def _open_tag_search_callback(self) -> None:
self.layout().add_tag_button.setFocus()
self.layout().add_tag_button.click()
def _open_field_search_callback(self) -> None:
self.layout().add_field_button.setFocus()
self.layout().add_field_button.click()
def _tag_added_callback(self, query: str):
if not query or not self._settings.keep_suggest_boxes_open:
self._set_item_mode(None)
self.layout().add_tag_button.setFocus()
else:
self._update_added_callback()
self.layout().tag_search_box.layout().search_field.setFocus()
def _field_added_callback(self, query: str):
if not query or not self._settings.keep_suggest_boxes_open:
self._set_item_mode(None)
self.layout().add_field_button.setFocus()
else:
self.layout().field_search_box.layout().search_field.setFocus()
def _update_added_callback(self):
self.layout().tag_search_box.added = self.layout().containers.tags
def _thumb_stats_updated_callback(self, filepath: Path, stats: FileAttributeData) -> None:
if len(self._selected) != 1:
return
if filepath != self.layout().preview_thumb.current_file:
return
if self._current_stats is None:
self._current_stats = FileAttributeData()
if stats.width is not None:
self._current_stats.width = stats.width
if stats.height is not None:
self._current_stats.height = stats.height
if stats.duration is not None:
self._current_stats.duration = stats.duration
self.layout().file_attrs.update_stats(filepath, self._current_stats)
@typing.override
def _set_selection_callback(self) -> None:
with catch_warnings(record=True):
self.__add_field_modal.search_panel.field_template_chosen.disconnect()
self.__add_tag_modal.tsp.item_chosen.disconnect()
self.layout().field_search_box.item_chosen.disconnect()
self.layout().tag_search_box.item_chosen.disconnect()
self.__add_field_modal.search_panel.field_template_chosen.connect(
self._add_field_to_selected
)
self.__add_tag_modal.tsp.item_chosen.connect(self._add_tag_to_selected)
self.layout().field_search_box.item_chosen.connect(self._add_field_to_selected)
self.layout().tag_search_box.item_chosen.connect(self._add_tag_to_selected)
def _add_field_to_selected(self, template: BaseFieldTemplate) -> None:
self._containers.add_field_to_selected(template)
self.layout().containers.add_field_to_selected(template)
# TODO: Allow editing of fields across multiple entries at once.
if len(self._selected) == 1:
self._containers.update_from_entry(self._selected[0])
if self._driver.settings.edit_field_on_add:
entry = unwrap(self._lib.get_entry_full(self._selected[0]))
entry_field = None
if isinstance(template, TextFieldTemplate):
entry_field = entry.text_fields[-1]
elif isinstance(template, DatetimeFieldTemplate):
entry_field = entry.datetime_fields[-1]
if entry_field is not None:
self._edit_field(entry.id, entry_field)
self.layout().containers.update_from_entry(self._selected[0])
def _edit_field(self, entry_id: int, field: BaseField) -> None:
# TODO: A lot of this code is similar to or straight up shared with FieldContainers.
# It's possible to reuse it later, after a FieldContainers refactor.
field_name_key: str = FIELD_TYPE_KEYS.get(field.class_name, "field_type.unknown")
if type(field) is TextField:
edit_modal = Modal(
EditText(field.name, field.value, field.is_multiline),
window_title=f"{Translations['field.edit']} ({Translations[field_name_key]})",
is_savable=True,
inline_title=False,
)
edit_modal.saved_data.connect(
partial(self.layout().containers.update_text_field_callback, field, entry_id)
)
edit_modal.show()
elif type(field) is DatetimeField:
edit_modal = Modal(
DatetimePicker(self._driver, field.name, field.value or dt.now()),
window_title=f"{Translations['field.edit']} ({Translations[field_name_key]})",
is_savable=True,
inline_title=False,
)
edit_modal.saved_data.connect(
partial(self.layout().containers.update_datetime_field_callback, field, entry_id)
)
edit_modal.show()
def _add_tag_to_selected(self, tag_id: int) -> None:
self._containers.add_tags_to_selected(tag_id)
self.layout().containers.add_tags_to_selected(tag_id)
if len(self._selected) == 1:
self._containers.update_from_entry(self._selected[0])
self.layout().containers.update_from_entry(self._selected[0])
def _toggle_ffmpeg_warning(self, enable_warning: bool = True) -> None:
if enable_warning and (not FfmpegStatus.which() or not FfprobeStatus.which()):
self._ffmpeg_warning_widget.show()
self.layout().warning_banner.show()
return
self._ffmpeg_warning_widget.hide()
self.layout().warning_banner.hide()
def set_selection(self, selected: list[int], update_preview: bool = True) -> None:
"""Render the panel widgets with the newest data from the Library.
Args:
selected (list[int]): List of the IDs of the selected entries.
update_preview (bool): Should the file preview be updated?
(Only works with one or more items selected)
"""
self._selected = selected
self._set_item_mode(None)
try:
# No Items Selected
if len(selected) == 0:
self.layout().preview_thumb.hide_preview()
self._current_stats = None
self.layout().file_attrs.update_stats()
self.layout().file_attrs.update_date_label()
self.layout().containers.hide_containers()
self.layout().add_tag_button.setEnabled(False)
self.layout().add_field_button.setEnabled(False)
# One Item Selected
elif len(selected) == 1:
entry_id = selected[0]
entry: Entry = unwrap(self._lib.get_entry(entry_id))
filepath: Path = unwrap(self._lib.library_dir) / entry.path
if filepath != self.layout().preview_thumb.current_file:
self._current_stats = None
if update_preview:
stats: FileAttributeData = self.layout().preview_thumb.display_file(filepath)
self._current_stats = stats
self.layout().file_attrs.update_stats(filepath, stats)
self.layout().file_attrs.update_date_label(filepath)
self.layout().containers.update_from_entry(entry_id)
self._set_selection_callback()
# Multiple Selected Items
elif len(selected) > 1:
# items: list[Entry] = [self.lib.get_entry_full(x) for x in self.driver.selected]
self.layout().preview_thumb.hide_preview() # TODO: Render mixed selection
self._current_stats = None
self.layout().file_attrs.update_multi_selection(len(selected))
self.layout().file_attrs.update_date_label()
self.layout().containers.hide_containers() # TODO: Allow for mixed editing
self._set_selection_callback()
except Exception as e:
logger.error("[Preview Panel] Error updating selection", error=e)
def stop_media_playback(self) -> None:
"""Stop any media playback in the preview panel."""
self.layout().preview_thumb.media_player.stop()
@property
def containers(self) -> FieldContainers:
return self.layout().containers
@override
def layout(self) -> PreviewPanelView:
return super().layout() # pyright: ignore[reportReturnType]
@@ -0,0 +1,27 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import typing
from typing import override
import structlog
from PySide6 import QtCore, QtGui
from PySide6.QtWidgets import QPushButton
if typing.TYPE_CHECKING:
pass
logger = structlog.get_logger(__name__)
class ReturnButton(QPushButton):
def __init__(self, *args, **kwargs) -> None: # pyright: ignore
super().__init__(*args, **kwargs)
@override
def keyPressEvent(self, arg__1: QtGui.QKeyEvent) -> None:
if self.hasFocus() and arg__1.key() in {QtCore.Qt.Key.Key_Enter, QtCore.Qt.Key.Key_Return}:
self.click()
super().keyPressEvent(arg__1)
@@ -8,11 +8,11 @@ import structlog
from PySide6 import QtCore, QtGui
from PySide6.QtCore import Signal
from PySide6.QtGui import QShowEvent
from PySide6.QtWidgets import QVBoxLayout, QWidget
from PySide6.QtWidgets import QWidget
from tagstudio.core.library.alchemy.library import Library
from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.panel_modal import PanelWidget
from tagstudio.qt.views.search_panel_view import SearchPanelView
logger = structlog.get_logger(__name__)
@@ -40,7 +40,7 @@ def _item_name(item: object) -> str:
raise AttributeError()
class SearchPanel[T](PanelWidget):
class SearchPanel[T](ModalContent):
item_chosen = Signal(int)
def __init__(
@@ -50,18 +50,18 @@ class SearchPanel[T](PanelWidget):
is_chooser: bool = True,
) -> None:
super().__init__()
self.view = view
self.is_chooser = is_chooser
self._layout = QVBoxLayout(self)
self._layout.setContentsMargins(0, 0, 0, 0)
self._layout.addWidget(self.view)
self.view.connect_callbacks(self)
self._driver: QtDriver | None = None
self.exclude: list[int] = exclude or []
self._is_chooser = is_chooser
self._create_and_add_button_in_layout = False
self._create_and_add_button_key: str = ""
# Items
self._excluded: list[int] = exclude or []
self._search_results: list[T] = []
# Limits
self._unlimited_limit_item_label: str = "All Items"
self.__limit_items: list[tuple[str, int]] = [
self._limit_items: list[tuple[str, int]] = [
("25", 25),
("50", 50),
("100", 100),
@@ -69,66 +69,102 @@ class SearchPanel[T](PanelWidget):
("500", 500),
(self._unlimited_limit_item_label, -1),
]
self.__default_limit_index: int = 0 # 25 Limit (Default)
self.__previous_limit_index: int = self.__default_limit_index
self._default_limit_index: int = 0 # 25 Limit (Default)
self._previous_limit_index: int = self._default_limit_index
self.view.set_limit_items(self.__limit_items)
self.view.set_limit_index(self.__default_limit_index)
self.setLayout(view)
self.set_limit_items(self._limit_items)
self.set_limit_index(self._default_limit_index)
self.setMinimumSize(300, 400)
self.connect_callbacks(self)
# Items
self._search_results: list[T] = []
def connect_callbacks(self, controller: "SearchPanel[Any]") -> None: # pyright: ignore[reportExplicitAny]
self.layout().limit_combobox.currentIndexChanged.connect(controller.on_limit_changed)
self.layout().search_field.textChanged.connect(controller.on_search_query_changed)
self.layout().search_field.returnPressed.connect(
lambda: controller.on_search_query_submitted(self.get_search_query())
)
self.layout().create_button.clicked.connect(controller.on_item_create)
self.layout().create_and_add_button.clicked.connect(
lambda: controller.on_item_create(add_to_entry=True)
)
self._create_and_add_button_label_key: str = ""
def set_limit_items(self, limit_items: list[tuple[str, int]]) -> None:
# Remove existing limit items
for i in reversed(range(self.layout().limit_combobox.count())):
self.layout().limit_combobox.removeItem(i)
@property
def search_field(self):
return self.view.search_field
# Add new limit items
self.layout().limit_combobox.addItems([limit_item[0] for limit_item in limit_items])
@property
def create_and_add_button(self):
return self.view.create_and_add_button
def get_limit_index(self) -> int:
return self.layout().limit_combobox.currentIndex()
def set_limit_index(self, index: int) -> None:
self.layout().limit_combobox.setCurrentIndex(index)
def focus_search_box(self, select_all: bool = False) -> None:
self.layout().search_field.setFocus()
if select_all:
self.layout().search_field.selectAll()
def get_search_query(self) -> str:
return self.view.get_search_query()
return self.layout().search_field.text()
def clear_search_query(self) -> None:
self.view.clear_search_query()
self.layout().search_field.setText("")
self.focus_search_box()
def get_item_widget(self, index: int, library: Library):
return self.view.get_item_widget(index, library)
# Item list
def scroll_to(self, position: int) -> None:
self.layout().scroll_area.verticalScrollBar().setValue(position)
def set_driver(self, driver: "QtDriver") -> None:
self._driver = driver
def add_create_and_add_button(self) -> None:
if self._create_and_add_button_in_layout:
return
self.layout().scroll_layout.addWidget(self.layout().create_and_add_button)
self.layout().create_and_add_button.show()
self._create_and_add_button_in_layout = True
def remove_create_and_add_button(self) -> None:
if not self._create_and_add_button_in_layout:
return
self.layout().scroll_layout.removeWidget(self.layout().create_and_add_button)
self.layout().create_and_add_button.hide()
self._create_and_add_button_in_layout = False
def get_item_widget(self, index: int, library: Library) -> Any: # pyright: ignore[reportExplicitAny]
return self.get_item_widget(index, library)
def on_limit_changed(self, index: int) -> None:
# Method was called outside the limit_combobox callback
if index != self.view.get_limit_index():
self.view.set_limit_index(index)
if index != self.get_limit_index():
self.set_limit_index(index)
if self.__previous_limit_index == index:
if self._previous_limit_index == index:
return
self.update_items(self.search_field.text())
self.update_items(self.layout().search_field.text())
def _get_limit(self) -> tuple[str, int]:
return self.__limit_items[self.view.get_limit_index()]
return self._limit_items[self.get_limit_index()]
def _get_previous_limit(self) -> tuple[str, int]:
return self.__limit_items[self.__previous_limit_index]
return self._limit_items[self._previous_limit_index]
def _get_max_limit(self) -> int:
raise NotImplementedError()
def on_search_query_changed(self, query: str) -> None:
self.create_and_add_button.setText(
Translations.format(self._create_and_add_button_label_key, query=query)
self.layout().create_and_add_button.setText(
Translations.format(self._create_and_add_button_key, query=query)
)
self.update_items(query)
def on_search_query_submitted(self, query: str) -> None:
# Focus search field if no query
if not query:
self.search_field.setFocus()
self.layout().search_field.setFocus()
parent: QWidget | None = self.parentWidget()
if parent is not None: # pyright: ignore[reportUnnecessaryComparison]
parent.hide()
@@ -137,7 +173,7 @@ class SearchPanel[T](PanelWidget):
# Create and add item if no search results
if len(self._search_results) <= 0:
self.on_item_create(add_to_entry=True)
elif self.is_chooser:
elif self._is_chooser:
self._on_item_chosen(self._search_results[0])
self.clear_search_query()
@@ -156,14 +192,14 @@ class SearchPanel[T](PanelWidget):
raise NotImplementedError()
def _is_excluded(self, item: T) -> bool:
return _item_id(item) in self.exclude
return _item_id(item) in self._excluded
def update_items(self, query: str | None = None) -> None:
"""Update the item list given a search query."""
logger.info("[SearchPanel] Updating items", limit=self._get_limit()[1])
# Remove the "Create & Add" button if one exists
self.view.remove_create_and_add_button()
self.remove_create_and_add_button()
# Get results for the search query
query_lower = "" if not query else query.lower()
@@ -205,11 +241,11 @@ class SearchPanel[T](PanelWidget):
item: T | None = all_results[i] if i < len(all_results) else None
self.set_item_widget(item=item, index=i)
self.__previous_limit_index = self.view.get_limit_index()
self._previous_limit_index = self.get_limit_index()
# Add back the "Create & Add" button
if query and query.strip():
self.view.add_create_and_add_button()
self.add_create_and_add_button()
def search_items(self, query: str) -> tuple[list[T], list[T]]: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
@@ -217,11 +253,16 @@ class SearchPanel[T](PanelWidget):
def set_item_widget(self, item: T | None, index: int) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
@override
def layout(self) -> SearchPanelView:
"""Return the typed layout for this widget."""
return super().layout() # pyright: ignore[reportReturnType]
@override
def showEvent(self, event: QShowEvent) -> None: # noqa N802
self.update_items()
self.view.scroll_to(0)
self.view.clear_search_query()
self.scroll_to(0)
self.clear_search_query()
return super().showEvent(event)
@override
@@ -229,13 +270,13 @@ class SearchPanel[T](PanelWidget):
# When Escape is pressed, focus back on the search box.
# If focus is already on the search box, close the modal.
if event.key() == QtCore.Qt.Key.Key_Escape:
if self.search_field.hasFocus():
if self.layout().search_field.hasFocus():
super().keyPressEvent(event)
else:
self.view.focus_search_box(select_all=True)
self.focus_search_box(select_all=True)
def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None: # pyright: ignore[reportUnusedParameter]
def create_item(self, edit_item_panel: ModalContent, choose_item: bool = False) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
def edit_item(self, edit_item_panel: PanelWidget) -> None: # pyright: ignore[reportUnusedParameter]
def edit_item(self, edit_item_panel: ModalContent) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
+298
View File
@@ -0,0 +1,298 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from functools import partial
from typing import Any, override
import structlog
from PIL import Image, ImageQt
from PySide6.QtCore import Signal
from PySide6.QtGui import QAction, QPixmap, QShowEvent, Qt
from PySide6.QtWidgets import QGraphicsOpacityEffect, QWidget
from tagstudio.core.library.alchemy.library import Library
from tagstudio.qt.controllers.autofill_line_edit import QtCore, QtGui
from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.controllers.underlined_widget import UnderlinedWidget
from tagstudio.qt.global_settings import GlobalSettings
from tagstudio.qt.helpers.color_overlay import auto_theme_overlay
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.stylesheets.stylesheets import (
autofill_line_edit_style,
autofill_line_edit_top_style,
)
from tagstudio.qt.views.suggest_box_view import SuggestBoxView
logger = structlog.get_logger(__name__)
def _item_id(item: object) -> int:
item_id: Any = getattr(item, "id") # noqa: B009 # pyright: ignore[reportExplicitAny]
if isinstance(item_id, int):
return item_id
else:
raise AttributeError()
def _item_name(item: object) -> str:
item_name: Any = getattr(item, "name") # noqa: B009 # pyright: ignore[reportExplicitAny]
if isinstance(item_name, str):
return item_name
else:
raise AttributeError()
class SuggestBox[T](QWidget):
item_chosen = Signal(object)
done = Signal(str) # Query
def __init__(
self, library: Library, settings: GlobalSettings, placeholder_text: str = ""
) -> None:
super().__init__()
self._lib = library
self._settings = settings
self._rm = ResourceManager()
self._limit = 25
self._is_shift_held = False
self._search_results: list[T] = []
self._selection_index = 0
self.added: list[int] = []
self.excluded: list[int] = []
self.setLayout(SuggestBoxView(placeholder_text))
self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self._connect_callbacks()
self._keep_box_open_action = QAction(Translations["settings.keep_suggest_boxes_open"], self)
self._keep_box_open_action.setCheckable(True)
self.addAction(self._keep_box_open_action)
self.layout().search_field.addAction(self._keep_box_open_action)
self._keep_box_open_action.triggered.connect(
lambda checked: self._toggle_keep_open(checked)
)
def _toggle_keep_open(self, checked: bool) -> None:
self._settings.keep_suggest_boxes_open = checked
self._settings.save()
def set_placeholder_text(self, text: str) -> None:
self.layout().search_field.setPlaceholderText(text)
def hide_and_reset(self) -> None:
self.hide()
self.layout().search_field.setDisabled(True)
self._on_shift_held(held=False)
def _connect_callbacks(self) -> None:
self.layout().search_field.textChanged.connect(self._on_search_query_changed)
self.layout().search_field.editingFinished.connect(self._editing_finished_callback)
self.layout().search_field.return_pressed.connect(
lambda: self._on_search_query_submitted(self.layout().search_field.text())
)
self.layout().search_field.shift_return_pressed.connect(
lambda: self._on_search_query_submitted(
self.layout().search_field.text(), always_create=True
)
)
self.layout().search_field.holding_shift.connect(partial(self._on_shift_held))
self.layout().search_field.index_updated.connect(partial(self._on_index_updated))
def set_hint_icon(self, icon: Image.Image | None) -> None:
if icon:
pixmap = QPixmap.fromImage(ImageQt.ImageQt(auto_theme_overlay(icon)))
self.layout().hint_icon_action.setIcon(pixmap)
else:
self.layout().hint_icon_action.setIcon(QPixmap())
def _on_shift_held(self, held: bool) -> None:
for i in range(0, self.layout().content_layout.count()):
underlined_widget = self.layout().content_layout.itemAt(i).widget()
assert isinstance(underlined_widget, UnderlinedWidget)
if held and i == self._selection_index:
self._is_shift_held = True
opacity_effect = QGraphicsOpacityEffect(self)
opacity_effect.setOpacity(0.3)
underlined_widget.widget.setGraphicsEffect(opacity_effect)
else:
self._is_shift_held = False
underlined_widget.widget.setGraphicsEffect(None) # pyright: ignore[reportArgumentType]
def _on_index_updated(self, delta: int) -> None:
# Initialize the widget count (non-hidden)
widget_count = 0
for i in range(0, self.layout().content_layout.count()):
widget = self.layout().content_layout.itemAt(i).widget()
if not widget.isHidden():
widget_count += 1
# Update the index
old_idx = self._selection_index
max_idx = widget_count - 1
if self._selection_index + delta < 0:
# Can't move further left
self._selection_index = 0
elif self._selection_index + delta > max_idx:
self._selection_index = max_idx
else:
self._selection_index = self._selection_index + delta
# Don't update the UI if there's no index change
if old_idx == self._selection_index:
return
# Draw the correct underline for the selected widget
for i in range(0, widget_count):
underlined_widget = self.layout().content_layout.itemAt(i).widget()
assert isinstance(underlined_widget, UnderlinedWidget)
if i == self._selection_index:
underlined_widget.toggle_underline(is_hidden=False)
self.layout().scroll_area.ensureWidgetVisible(
underlined_widget, xmargin=6, ymargin=0
)
else:
underlined_widget.toggle_underline(is_hidden=True)
def _clear_search_query(self) -> None:
self.layout().search_field.setText("")
def _get_item_widget(self, index: int, library: Library) -> Any: # pyright: ignore
raise NotImplementedError()
def _on_search_query_changed(self, query: str) -> None:
self._update_hint_icon()
self._update_items(query.strip())
def _on_search_query_submitted(self, query: str, always_create: bool = False) -> None:
# Focus search field if no query
if not query:
self.done.emit(query)
return
elif not self.isHidden():
self.layout().search_field.setFocus()
# Create and add item if no search results
if (len(self._search_results) <= 0) or always_create:
self._on_item_create()
else:
self._on_item_chosen(self._search_results[self._selection_index])
self._clear_search_query()
self._update_items()
def _on_item_create(self) -> None:
raise NotImplementedError()
def _on_item_edit(self, item: T) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
def _on_item_chosen(self, item: T) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
def _is_excluded(self, item: T) -> bool:
return _item_id(item) in self.excluded
def _update_hint_icon(self) -> None:
raise NotImplementedError()
def _update_items(self, query: str | None = None) -> None:
"""Update the item list given a search query."""
logger.info("[SearchPanel] Updating items", limit=self._limit)
self._selection_index = 0
if self.layout().content_layout.count() > 0:
self.layout().scroll_area.ensureWidgetVisible(
self.layout().content_layout.itemAt(0).widget(), xmargin=6, ymargin=0
)
# Get results for the search query
query_lower = "" if not query else query.lower()
search_results: tuple[list[T], list[T]] = self._search_items(query_lower)
# Sort and prioritize the results
direct_results = list({item for item in search_results[0] if not self._is_excluded(item)})
direct_results.sort(key=lambda item: _item_name(item).lower())
ancestor_results = list({item for item in search_results[1] if not self._is_excluded(item)})
ancestor_results.sort(key=lambda item: _item_name(item).lower())
raw_results = list(direct_results + ancestor_results)
priority_results: set[T] = set()
if query and query.strip():
for raw_item in raw_results:
if _item_name(raw_item).lower().startswith(query_lower):
priority_results.add(raw_item)
all_results: list[T] = sorted(list(priority_results), key=lambda i: len(_item_name(i))) + [
item for item in raw_results if item not in priority_results
]
# Target items already added to a selection and move them to the end of the list
already_added: list[T] = [i for i in all_results if _item_id(i) in self.added]
for item in already_added:
if item in all_results:
all_results.remove(item)
all_results = all_results + already_added
if self._limit > 0:
all_results = all_results[: self._limit]
self._search_results = all_results
logger.info("[SearchPanel] Search results", results=self._search_results)
for i in range(0, self._limit):
item: T | None = all_results[i] if i < len(all_results) else None
self._set_item_widget(item=item, index=i)
if self.layout().content_layout.isEmpty():
self.layout().scroll_area.setHidden(True)
self.layout().content_layout.setContentsMargins(0, 0, 0, 0)
self.layout().search_field.setStyleSheet(autofill_line_edit_style())
else:
self.layout().scroll_area.setHidden(False)
self.layout().content_layout.setContentsMargins(4, 6, 4, 6)
self.layout().search_field.setStyleSheet(autofill_line_edit_top_style())
def _search_items(self, query: str) -> tuple[list[T], list[T]]: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
def _set_item_widget(self, item: T | None, index: int) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
def _editing_finished_callback(self) -> None:
# Only trigger when the search field is clicked off of and there's no query.
# NOTE: The search field is already cleared by this point when pressing enter.
if self.layout().search_field.text() == "" and not self.layout().search_field.hasFocus():
self.done.emit("")
def _create_item_from_modal(self, edit_item_panel: ModalContent) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
def _edit_item(self, edit_item_panel: ModalContent) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
@override
def showEvent(self, event: QShowEvent) -> None:
self._update_items()
self._on_shift_held(held=False)
self.layout().search_field.setDisabled(False)
self._clear_search_query()
self._keep_box_open_action.setChecked(self._settings.keep_suggest_boxes_open)
return super().showEvent(event)
@override
def layout(self) -> SuggestBoxView:
return super().layout() # pyright: ignore[reportReturnType]
@override
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:
# When Escape is pressed, focus back on the search box.
if event.key() in {QtCore.Qt.Key.Key_Enter, QtCore.Qt.Key.Key_Return}:
self.done.emit("*")
elif event.key() == QtCore.Qt.Key.Key_Escape:
self.done.emit("")
@@ -12,8 +12,8 @@ from tagstudio.core.enums import TagClickActionOption
from tagstudio.core.library.alchemy.enums import BrowsingState
from tagstudio.core.library.alchemy.models import Tag
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.controllers.modal import Modal
from tagstudio.qt.mixed.build_tag import BuildTagPanel
from tagstudio.qt.views.panel_modal import PanelModal
from tagstudio.qt.views.tag_box_view import TagBoxWidgetView
if TYPE_CHECKING:
@@ -74,7 +74,7 @@ class TagBoxWidget(TagBoxWidgetView):
def _on_edit(self, tag: Tag) -> None:
build_tag_panel = BuildTagPanel(self.__driver.lib, tag=tag)
edit_modal = PanelModal(
edit_modal = Modal(
build_tag_panel,
self.__driver.lib.tag_display_name(tag),
"Edit Tag",
@@ -6,66 +6,44 @@ from typing import override
from warnings import catch_warnings
import structlog
from PySide6.QtWidgets import QMessageBox
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QMessageBox, QWidget
from tagstudio.core.constants import RESERVED_TAG_END, RESERVED_TAG_START
from tagstudio.core.library.alchemy.enums import BrowsingState
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Tag
from tagstudio.qt.controllers.modal import Modal
from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.controllers.search_panel_controller import SearchPanel
from tagstudio.qt.mixed.tag_widget import TagWidget
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget
from tagstudio.qt.views.tag_search_panel_view import TagSearchPanelView
from tagstudio.qt.views.search_panel_view import SearchPanelView
logger = structlog.get_logger(__name__)
class TagSearchModal(PanelModal):
tsp: "TagSearchPanel"
def __init__(
self,
library: Library,
title: str,
exclude: list[int] | None = None,
is_tag_chooser: bool = True,
has_save: bool = False,
):
self.tsp = TagSearchPanel(
library,
exclude,
is_tag_chooser,
view=TagSearchPanelView(is_tag_chooser),
)
super().__init__(
widget=self.tsp,
title=title,
is_savable=has_save,
)
class TagSearchPanel(SearchPanel[Tag]):
search_for_tag = Signal(int)
def __init__(
self,
library: Library,
exclude: list[int] | None = None,
is_tag_chooser: bool = True,
view: TagSearchPanelView | None = None,
is_chooser: bool = True,
view: SearchPanelView | None = None,
):
super().__init__(
view=view or TagSearchPanelView(is_tag_chooser),
view=view or SearchPanelView(Translations["home.search_tags"], is_chooser=is_chooser),
exclude=exclude,
is_chooser=is_tag_chooser,
is_chooser=is_chooser,
)
self.__lib = library
self._lib = library
self._unlimited_limit_item_label = Translations["tag.all_tags"]
self._create_and_add_button_label_key = "tag.create_add"
self._create_and_add_button_key = "tag.create_add"
@override
def _get_max_limit(self) -> int:
return len(self.__lib.tags)
return len(self._lib.tags)
@override
def on_item_create(self, add_to_entry: bool = False) -> None:
@@ -80,9 +58,8 @@ class TagSearchPanel(SearchPanel[Tag]):
from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports
query: str = self.get_search_query()
panel: BuildTagPanel = BuildTagPanel(self.__lib)
modal: PanelModal = PanelModal(
panel: BuildTagPanel = BuildTagPanel(self._lib)
modal: Modal = Modal(
panel,
Translations["tag.new"],
Translations["tag.add"] if add_to_entry else Translations["tag.new"],
@@ -100,10 +77,10 @@ class TagSearchPanel(SearchPanel[Tag]):
# TODO: Move this to a top-level import
from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports
edit_tag_panel: BuildTagPanel = BuildTagPanel(self.__lib, tag=item)
edit_tag_modal: PanelModal = PanelModal(
edit_tag_panel: BuildTagPanel = BuildTagPanel(self._lib, tag=item)
edit_tag_modal: Modal = Modal(
edit_tag_panel,
self.__lib.tag_display_name(item),
self._lib.tag_display_name(item),
Translations["tag.edit"],
is_savable=True,
)
@@ -112,7 +89,7 @@ class TagSearchPanel(SearchPanel[Tag]):
@override
def _on_item_remove(self, item: Tag) -> None:
if self.is_chooser:
if self._is_chooser:
return
if item.id in range(RESERVED_TAG_START, RESERVED_TAG_END):
@@ -121,7 +98,7 @@ class TagSearchPanel(SearchPanel[Tag]):
message_box = QMessageBox(
QMessageBox.Icon.Question,
Translations["tag.remove"],
Translations.format("tag.confirm_delete", tag_name=self.__lib.tag_display_name(item)),
Translations.format("tag.confirm_delete", tag_name=self._lib.tag_display_name(item)),
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel,
)
@@ -130,7 +107,7 @@ class TagSearchPanel(SearchPanel[Tag]):
if result != QMessageBox.StandardButton.Ok:
return
self.__lib.remove_tag(item.id)
self._lib.remove_tag(item.id)
self.update_items(self.get_search_query())
@override
@@ -139,12 +116,12 @@ class TagSearchPanel(SearchPanel[Tag]):
@override
def search_items(self, query: str) -> tuple[list[Tag], list[Tag]]:
return self.__lib.search_tags(name=query, limit=self._get_limit()[1])
return self._lib.search_tags(name=query, limit=self._get_limit()[1])
@override
def set_item_widget(self, item: Tag | None, index: int) -> None:
"""Set the tag of a tag widget at a specific index."""
tag_widget: TagWidget = self.get_item_widget(index, self.__lib)
tag_widget: TagWidget = self.get_item_widget(index, self._lib)
tag_widget.set_tag(item)
tag_widget.setHidden(item is None)
@@ -152,7 +129,7 @@ class TagSearchPanel(SearchPanel[Tag]):
return
assert item is not None
tag_widget.has_remove = not self.is_chooser and item.id not in range(
tag_widget.has_remove = not self._is_chooser and item.id not in range(
RESERVED_TAG_START, RESERVED_TAG_END
)
@@ -166,7 +143,7 @@ class TagSearchPanel(SearchPanel[Tag]):
# Connect callbacks
tag_widget.on_edit.connect(lambda edit_tag=item: self.on_item_edit(edit_tag))
tag_widget.on_remove.connect(lambda remove_tag=item: self._on_item_remove(remove_tag))
if self.is_chooser:
if self._is_chooser:
tag_widget.bg_button.clicked.connect(
lambda checked=False, tag=item: self._on_item_chosen(tag)
)
@@ -176,25 +153,19 @@ class TagSearchPanel(SearchPanel[Tag]):
)
# Connect search action
if self._driver is not None:
tag_widget.search_for_tag_action.triggered.connect(
lambda checked=False, tag_id=item.id: self.search_for_tag(tag_id)
)
tag_widget.search_for_tag_action.setEnabled(True)
else:
logger.warning(
"[TagSearchPanel] No driver was set for this TagSearchPanel. Was this on purpose?"
)
tag_widget.search_for_tag_action.setEnabled(False)
tag_widget.search_for_tag_action.triggered.connect(
lambda checked=False, tag_id=item.id: self.search_for_tag.emit(tag_id)
)
tag_widget.search_for_tag_action.setEnabled(True)
@override
def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None:
def create_item(self, edit_item_panel: ModalContent, choose_item: bool = False) -> None:
# TODO: Move this to a top-level import
from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports
if isinstance(edit_item_panel, BuildTagPanel):
tag: Tag = edit_item_panel.build_tag()
self.__lib.add_tag(
self._lib.add_tag(
tag, parent_ids=edit_item_panel.parent_ids, aliases=edit_item_panel.aliases
)
@@ -206,25 +177,32 @@ class TagSearchPanel(SearchPanel[Tag]):
self.on_search_query_changed(self.get_search_query())
@override
def edit_item(self, edit_item_panel: PanelWidget) -> None:
def edit_item(self, edit_item_panel: ModalContent) -> None:
# TODO: Move this to a top-level import
from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports
if not isinstance(edit_item_panel, BuildTagPanel):
return
self.__lib.update_tag(
self._lib.update_tag(
tag=edit_item_panel.build_tag(),
parent_ids=edit_item_panel.parent_ids,
aliases=edit_item_panel.aliases,
)
self.update_items(self.search_field.text())
self.update_items(self.layout().search_field.text())
def search_for_tag(self, tag_id: int) -> None:
if self._driver is None:
return
@override
def get_item_widget(self, index: int, library: Library | None) -> TagWidget:
"""Gets the item widget at a specific index."""
# Create any new item widgets needed up to the given index
if self.layout().scroll_layout.count() <= index:
while self.layout().scroll_layout.count() <= index:
pad_tag_widget = TagWidget(
tag=None, has_edit=True, has_remove=True, library=library
)
pad_tag_widget.setHidden(True)
self.layout().scroll_layout.addWidget(pad_tag_widget)
self._driver.main_window.search_field.setText(f"tag_id:{tag_id}")
self._driver.update_browsing_state(
BrowsingState.from_tag_id(tag_id, self._driver.browsing_history.current)
)
tag_widget: QWidget = self.layout().scroll_layout.itemAt(index).widget()
assert isinstance(tag_widget, TagWidget)
return tag_widget
@@ -0,0 +1,214 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from typing import override
from warnings import catch_warnings
import structlog
from PySide6.QtCore import Signal
from PySide6.QtGui import QAction
from PySide6.QtWidgets import QGraphicsOpacityEffect, QWidget
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Tag
from tagstudio.qt.controllers.modal import Modal
from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.controllers.suggest_box import SuggestBox
from tagstudio.qt.controllers.underlined_widget import UnderlinedWidget
from tagstudio.qt.global_settings import GlobalSettings
from tagstudio.qt.mixed.build_tag import BuildTagPanel
from tagstudio.qt.mixed.tag_widget import TagWidget
from tagstudio.qt.translations import Translations
logger = structlog.get_logger(__name__)
class TagSuggestBox(SuggestBox[Tag]):
search_for_tag = Signal(int)
def __init__(
self, library: Library, settings: GlobalSettings, placeholder_text: str = ""
) -> None:
super().__init__(library, settings, placeholder_text)
# Context Menu Actions
edit_tag_on_create_action = QAction(Translations["settings.edit_tag_on_create"], self)
edit_tag_on_create_action.setCheckable(True)
self.addAction(edit_tag_on_create_action)
self.layout().search_field.addAction(edit_tag_on_create_action)
edit_tag_on_create_action.setChecked(self._settings.edit_tag_on_create)
edit_tag_on_create_action.triggered.connect(
lambda checked: self._toggle_edit_on_tag_create(checked)
)
def _toggle_edit_on_tag_create(self, checked: bool) -> None:
"""Toggle the setting for opening the edit window after creating a tag."""
self._settings.edit_tag_on_create = checked
self._settings.save()
@override
def _on_item_create(self) -> None:
"""Creates a new tag and adds it to the currently selected entries.
Optionally opens up an edit panel after creation and before adding to entries.
Populates name field using current search query.
"""
query: str = self.layout().search_field.text()
if self._settings.edit_tag_on_create:
panel: BuildTagPanel = BuildTagPanel(self._lib)
modal: Modal = Modal(
panel, Translations["tag.new"], Translations["tag.new"], is_savable=True
)
if query.strip():
panel.name_field.setText(query)
modal.saved.connect(lambda: self._create_item_from_modal(panel))
modal.show()
else:
tag = Tag(name=query)
self._lib.add_tag(tag)
self._on_item_chosen(tag)
self._clear_search_query()
@override
def _on_item_edit(self, item: Tag) -> None:
edit_tag_panel: BuildTagPanel = BuildTagPanel(self._lib, tag=item)
edit_tag_modal: Modal = Modal(
edit_tag_panel,
self._lib.tag_display_name(item),
Translations["tag.edit"],
is_savable=True,
)
edit_tag_modal.saved.connect(lambda: self._edit_item(edit_tag_panel))
edit_tag_modal.show()
@override
def _on_item_chosen(self, item: Tag) -> None:
self.item_chosen.emit(item.id)
self._clear_search_query()
self.done.emit("*") # The query does not matter
@override
def _on_shift_held(self, held: bool) -> None:
if held:
self.set_hint_icon(self._rm.hint_tag_create)
else:
self._update_hint_icon()
# NOTE: This code is similar to the base method, but needs to reference the tag IDs for each
# widget and compare those against the self.added list.
for i in range(0, self.layout().content_layout.count()):
underlined_widget = self.layout().content_layout.itemAt(i).widget()
assert isinstance(underlined_widget, UnderlinedWidget)
if held and i == self._selection_index:
self._is_shift_held = True
opacity_effect = QGraphicsOpacityEffect(self)
opacity_effect.setOpacity(0.3)
underlined_widget.widget.setGraphicsEffect(opacity_effect)
else:
tag_widget = underlined_widget.widget
if not isinstance(tag_widget, TagWidget):
return
if not tag_widget.tag:
return
if tag_widget.tag.id not in self.added:
self._is_shift_held = False
underlined_widget.widget.setGraphicsEffect(None) # pyright: ignore[reportArgumentType]
@override
def _update_hint_icon(self) -> None:
if self.layout().search_field.text() and len(self._search_results) > 0:
self.set_hint_icon(self._rm.hint_tag_add)
elif self.layout().search_field.text():
self.set_hint_icon(self._rm.hint_tag_create)
else:
self.set_hint_icon(None)
@override
def _search_items(self, query: str) -> tuple[list[Tag], list[Tag]]:
if query != "":
return self._lib.search_tags(name=query, limit=0)
else:
return ([], [])
@override
def _set_item_widget(self, item: Tag | None, index: int) -> None:
"""Set the tag of a tag widget at a specific index."""
underlined_widget: UnderlinedWidget = self._get_item_widget(index, self._lib)
tag_widget = underlined_widget.widget
assert isinstance(tag_widget, TagWidget)
tag_widget.has_remove = False
tag_widget.set_tag(item)
underlined_widget.setHidden(item is None)
opacity_effect = QGraphicsOpacityEffect(self)
opacity_effect.setOpacity(0.3)
if item and item.id in self.added:
tag_widget.setGraphicsEffect(opacity_effect)
else:
tag_widget.setGraphicsEffect(None) # pyright: ignore[reportArgumentType]
if item is None:
return
# TODO: Add tabbing to different items, and use underline to indicate which will be added
underlined_widget.toggle_underline(index != 0)
# Disconnect previous callbacks
with catch_warnings(record=True):
tag_widget.on_edit.disconnect()
tag_widget.bg_button.clicked.disconnect()
tag_widget.search_for_tag_action.triggered.disconnect()
# Connect callbacks
tag_widget.on_edit.connect(lambda edit_tag=item: self._on_item_edit(edit_tag))
tag_widget.bg_button.clicked.connect(
lambda checked=False, tag=item: self._on_item_chosen(tag)
)
tag_widget.search_for_tag_action.triggered.connect(
lambda checked=False, tag_id=item.id: self.search_for_tag.emit(tag_id)
)
tag_widget.search_for_tag_action.setEnabled(True)
@override
def _create_item_from_modal(self, edit_item_panel: ModalContent) -> None:
if isinstance(edit_item_panel, BuildTagPanel):
tag: Tag = edit_item_panel.build_tag()
self._lib.add_tag(
tag, parent_ids=edit_item_panel.parent_ids, aliases=edit_item_panel.aliases
)
self._on_item_chosen(tag)
edit_item_panel.hide()
self._on_search_query_changed(self.layout().search_field.text())
@override
def _edit_item(self, edit_item_panel: ModalContent) -> None:
if not isinstance(edit_item_panel, BuildTagPanel):
return
self._lib.update_tag(
tag=edit_item_panel.build_tag(),
parent_ids=edit_item_panel.parent_ids,
aliases=edit_item_panel.aliases,
)
self._update_items(self.layout().search_field.text())
@override
def _get_item_widget(self, index: int, library: Library | None) -> UnderlinedWidget:
"""Gets the item widget at a specific index."""
# Create any new item widgets needed up to the given index
if self.layout().content_layout.count() <= index:
while self.layout().content_layout.count() <= index:
tag_widget = TagWidget(tag=None, has_edit=True, has_remove=True, library=library)
tag_widget.on_remove.connect(self._update_items)
widget = UnderlinedWidget(tag_widget)
widget.setHidden(True)
self.layout().content_layout.addWidget(widget)
widget_: QWidget = self.layout().content_layout.itemAt(index).widget()
assert isinstance(widget_, UnderlinedWidget)
return widget_
@@ -0,0 +1,26 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from typing import override
from PySide6.QtWidgets import QWidget
from tagstudio.qt.views.underlined_widget_view import UnderlinedWidgetView
class UnderlinedWidget(QWidget):
def __init__(self, widget: QWidget) -> None:
super().__init__()
self.setLayout(UnderlinedWidgetView(widget))
def toggle_underline(self, is_hidden: bool) -> None:
self.layout().underline.setHidden(is_hidden)
@property
def widget(self) -> QWidget:
return self.layout().itemAt(0).widget()
@override
def layout(self) -> UnderlinedWidgetView:
return super().layout() # pyright: ignore[reportReturnType]
+3
View File
@@ -74,6 +74,9 @@ class GlobalSettings(BaseModel):
infinite_scroll: bool = Field(default=True)
show_filepath: ShowFilepathOption = Field(default=ShowFilepathOption.DEFAULT)
tag_click_action: TagClickActionOption = Field(default=TagClickActionOption.DEFAULT)
edit_tag_on_create: bool = Field(default=False)
edit_field_on_add: bool = Field(default=True)
keep_suggest_boxes_open: bool = Field(default=True)
theme: Theme = Field(default=Theme.SYSTEM)
splash: Splash = Field(default=Splash.DEFAULT)
windows_start_command: bool = Field(default=False)
+2 -6
View File
@@ -6,7 +6,7 @@ from pathlib import Path
import ffmpeg
from tagstudio.qt.previews.vendored.probe import probe
from tagstudio.renderers.vendored.probe import probe
def is_readable_video(filepath: Path | str):
@@ -23,11 +23,7 @@ def is_readable_video(filepath: Path | str):
return False
for stream in result["streams"]:
# DRM check
if stream.get("codec_tag_string") in [
"drma",
"drms",
"drmi",
]:
if stream.get("codec_tag_string") in ["drma", "drms", "drmi"]:
return False
except ffmpeg.Error:
return False
-98
View File
@@ -1,98 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from typing import override
import structlog
from PySide6 import QtCore, QtGui
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QListWidget,
QListWidgetItem,
QPushButton,
QVBoxLayout,
QWidget,
)
from tagstudio.core.library.alchemy.library import Library
from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations
from tagstudio.qt.views.stylesheets.stylesheets import header
logger = structlog.get_logger(__name__)
# NOTE: This class doesn't inherit from PanelWidget? Seems like it predates that system?
class AddFieldModal(QWidget):
done = Signal(list)
def __init__(self, library: Library):
# [Done]
# - OR -
# [Cancel] [Save]
super().__init__()
self.lib = library
self.setWindowTitle(Translations["field.add"])
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setMinimumSize(400, 300)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 6, 6, 6)
self.title_widget = QLabel(header(Translations["field.add"], 3))
self.title_widget.setObjectName("fieldTitle")
self.title_widget.setWordWrap(True)
self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.list_widget = QListWidget()
self.button_container = QWidget()
self.button_layout = QHBoxLayout(self.button_container)
self.button_layout.setContentsMargins(6, 6, 6, 6)
self.button_layout.addStretch(1)
self.cancel_button = QPushButton(Translations["generic.cancel"])
self.cancel_button.clicked.connect(self.hide)
self.button_layout.addWidget(self.cancel_button)
self.save_button = QPushButton(Translations["generic.add"])
self.save_button.setDefault(True)
self.save_button.clicked.connect(self.hide)
self.save_button.clicked.connect(
lambda: (
# get userData for each selected item
self.done.emit(self.list_widget.selectedItems())
)
)
self.button_layout.addWidget(self.save_button)
self.root_layout.addWidget(self.title_widget)
self.root_layout.addWidget(self.list_widget)
self.root_layout.addStretch(1)
self.root_layout.addWidget(self.button_container)
@override
def show(self):
self.list_widget.clear()
for field_template in self.lib.field_templates:
field_name_key: str = FIELD_TYPE_KEYS.get(
field_template.class_name, "field_type.unknown"
)
item = QListWidgetItem(f"{field_template.name} ({Translations[field_name_key]})")
item.setData(Qt.ItemDataRole.UserRole, field_template)
self.list_widget.addItem(item)
self.list_widget.setFocus()
self.list_widget.setCurrentRow(0)
super().show()
@override
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
if event.key() == QtCore.Qt.Key.Key_Escape:
self.cancel_button.click()
elif event.key() in (QtCore.Qt.Key.Key_Enter, QtCore.Qt.Key.Key_Return):
self.save_button.click()
else: # Other key presses
pass
return super().keyPressEvent(event)
+4 -4
View File
@@ -24,10 +24,10 @@ from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.library.alchemy.library import Library, slugify
from tagstudio.core.library.alchemy.models import TagColorGroup
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.mixed.tag_color_preview import TagColorPreview
from tagstudio.qt.models.palette import ColorType, get_tag_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.panel_modal import PanelWidget
from tagstudio.qt.views.stylesheets.stylesheets import (
checkbox_style,
line_edit_style,
@@ -37,7 +37,7 @@ from tagstudio.qt.views.stylesheets.stylesheets import (
logger = structlog.get_logger(__name__)
class BuildColorPanel(PanelWidget):
class BuildColorPanel(ModalContent):
on_edit = Signal(TagColorGroup)
def __init__(self, library: Library, color_group: TagColorGroup):
@@ -254,8 +254,8 @@ class BuildColorPanel(PanelWidget):
self.slug_field.setText(slug)
self.update_preview_text()
if self.panel_save_button is not None:
self.panel_save_button.setDisabled(is_name_empty)
if self.save_button is not None:
self.save_button.setDisabled(is_name_empty)
def build_color(self) -> tuple[TagColorGroup, TagColorGroup]:
name = self.name_field.text()
+4 -4
View File
@@ -13,14 +13,14 @@ from PySide6.QtWidgets import QLabel, QLineEdit, QVBoxLayout, QWidget
from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX
from tagstudio.core.library.alchemy.library import Library, ReservedNamespaceError, slugify
from tagstudio.core.library.alchemy.models import Namespace
from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.panel_modal import PanelWidget
from tagstudio.qt.views.stylesheets.stylesheets import line_edit_style
logger = structlog.get_logger(__name__)
class BuildNamespacePanel(PanelWidget):
class BuildNamespacePanel(ModalContent):
on_edit = Signal(Namespace)
def __init__(self, library: Library, namespace: Namespace | None = None):
@@ -118,8 +118,8 @@ class BuildNamespacePanel(PanelWidget):
self.slug_field.setText(slug)
if self.panel_save_button is not None:
self.panel_save_button.setDisabled(is_name_empty)
if self.save_button is not None:
self.save_button.setDisabled(is_name_empty)
def no_collide(self, slug: str) -> str:
"""Return a slug name that's verified not to collide with other known namespace slugs."""
+27 -25
View File
@@ -28,12 +28,14 @@ from PySide6.QtWidgets import (
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Tag, TagAlias, TagColorGroup
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal
from tagstudio.qt.controllers.modal import Modal
from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchPanel
from tagstudio.qt.mixed.tag_color_preview import TagColorPreview
from tagstudio.qt.mixed.tag_color_selection import TagColorSelection
from tagstudio.qt.mixed.tag_widget import TagWidget
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget
from tagstudio.qt.views.search_panel_view import SearchPanelView
from tagstudio.qt.views.stylesheets.stylesheets import (
checkbox_style,
colored_radio_button_style,
@@ -73,12 +75,12 @@ class CustomTableItem(QLineEdit):
super().keyPressEvent(arg__1)
class BuildTagPanel(PanelWidget):
class BuildTagPanel(ModalContent):
on_edit = Signal(Tag)
def __init__(self, library: Library, tag: Tag | None = None) -> None:
super().__init__()
self.lib = library
self._lib = library
self.tag: Tag # NOTE: This gets set at the end of the init.
self.tag_color_namespace: str | None
self.tag_color_slug: str | None
@@ -153,7 +155,6 @@ class BuildTagPanel(PanelWidget):
self.parent_tags_title = QLabel(Translations["tag.parent_tags"])
self.parent_tags_layout.addWidget(self.parent_tags_title)
self.scroll_contents = QWidget()
self.parent_tags_scroll_layout = QVBoxLayout(self.scroll_contents)
self.parent_tags_scroll_layout.setContentsMargins(6, 6, 6, 0)
@@ -176,10 +177,11 @@ class BuildTagPanel(PanelWidget):
if tag is not None:
exclude_ids.append(tag.id)
self.add_tag_modal = TagSearchModal(
self.lib, title=Translations["tag.add.plural"], exclude=exclude_ids
)
self.add_tag_modal.tsp.item_chosen.connect(lambda x: self._add_parent_tag_callback(x))
tsp_view = SearchPanelView(placeholder_text=Translations["home.search_tags"])
tsp = TagSearchPanel(self._lib, exclude=exclude_ids, view=tsp_view)
self.add_tag_modal = Modal(tsp, title=Translations["tag.add.plural"])
tsp.item_chosen.connect(lambda x: self._add_parent_tag_callback(x))
self.parent_tags_add_button.clicked.connect(self.add_tag_modal.show)
# Color ----------------------------------------------------------------
@@ -194,14 +196,14 @@ class BuildTagPanel(PanelWidget):
self.color_button: TagColorPreview
try:
assert tag is not None
self.color_button = TagColorPreview(self.lib, tag.color)
self.color_button = TagColorPreview(self._lib, tag.color)
except Exception as e:
# TODO: Investigate why this happens during tests
logger.error("[BuildTag] Could not access Tag member attributes", error=e)
self.color_button = TagColorPreview(self.lib, None)
self.tag_color_selection = TagColorSelection(self.lib)
self.color_button = TagColorPreview(self._lib, None)
self.tag_color_selection = TagColorSelection(self._lib)
chose_tag_color_title = Translations["tag.choose_color"]
self.choose_color_modal = PanelModal(
self.choose_color_modal = Modal(
self.tag_color_selection, chose_tag_color_title, chose_tag_color_title
)
self.choose_color_modal.done.connect(
@@ -325,7 +327,7 @@ class BuildTagPanel(PanelWidget):
next_tab: QWidget = last_tab
for parent_id in self.parent_ids:
tag = self.lib.get_tag(parent_id)
tag = self._lib.get_tag(parent_id)
if not tag:
continue
is_disam = parent_id == self.disambiguation_id
@@ -363,7 +365,7 @@ class BuildTagPanel(PanelWidget):
text_color = get_tag_text_color(primary_color, highlight_color)
def update_parent_tag_callback(build_tag_panel: BuildTagPanel):
self.lib.update_tag(
self._lib.update_tag(
build_tag_panel.build_tag(),
parent_ids=set(build_tag_panel.parent_ids),
aliases=set(build_tag_panel.aliases),
@@ -371,10 +373,10 @@ class BuildTagPanel(PanelWidget):
self.set_parent_tags()
def on_parent_tag_edit(tag: Tag) -> None:
build_tag_panel = BuildTagPanel(self.lib, tag=tag)
edit_modal = PanelModal(
build_tag_panel = BuildTagPanel(self._lib, tag=tag)
edit_modal = Modal(
build_tag_panel,
self.lib.tag_display_name(tag),
self._lib.tag_display_name(tag),
"Edit Tag",
is_savable=True,
)
@@ -382,7 +384,7 @@ class BuildTagPanel(PanelWidget):
edit_modal.show()
# Add Tag Widget
tag_widget = TagWidget(tag, library=self.lib, has_edit=True, has_remove=True)
tag_widget = TagWidget(tag, library=self._lib, has_edit=True, has_remove=True)
tag_widget.on_remove.connect(lambda t=parent_id: self._remove_parent_tag_callback(t))
tag_widget.on_edit.connect(partial(on_parent_tag_edit, tag))
@@ -422,7 +424,7 @@ class BuildTagPanel(PanelWidget):
while self.aliases_table.rowCount() > 0:
self.aliases_table.removeRow(0)
last: QWidget | None = self.panel_save_button
last: QWidget | None = self.save_button
aliases = list(self.aliases)
alias_names = [a.name for a in aliases]
sorted_aliases = sorted(aliases, key=lambda x: alias_names[aliases.index(x)])
@@ -493,8 +495,8 @@ class BuildTagPanel(PanelWidget):
is_empty = not self.name_field.text().strip()
self.name_field.setStyleSheet(line_edit_style() if is_empty else "")
if self.panel_save_button is not None:
self.panel_save_button.setDisabled(is_empty)
if self.save_button is not None:
self.save_button.setDisabled(is_empty)
def build_tag(self) -> Tag:
tag = self.tag
@@ -515,8 +517,8 @@ class BuildTagPanel(PanelWidget):
self.setTabOrder(self.shorthand_field, self.aliases_add_button)
self.setTabOrder(self.aliases_add_button, self.parent_tags_add_button)
self.setTabOrder(self.parent_tags_add_button, self.color_button)
self.setTabOrder(self.color_button, unwrap(self.panel_cancel_button))
self.setTabOrder(unwrap(self.panel_cancel_button), unwrap(self.panel_save_button))
self.setTabOrder(unwrap(self.panel_save_button), self.aliases_table.cellWidget(0, 1))
self.setTabOrder(self.color_button, unwrap(self.cancel_button))
self.setTabOrder(unwrap(self.cancel_button), unwrap(self.save_button))
self.setTabOrder(unwrap(self.save_button), self.aliases_table.cellWidget(0, 1))
self.name_field.selectAll()
self.name_field.setFocus()
+2 -2
View File
@@ -12,12 +12,12 @@ from PySide6.QtWidgets import QMessageBox, QPushButton
from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX
from tagstudio.core.library.alchemy.models import TagColorGroup
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.controllers.modal import Modal
from tagstudio.qt.mixed.build_color import BuildColorPanel
from tagstudio.qt.mixed.field_widget import FieldWidget
from tagstudio.qt.mixed.tag_color_label import TagColorLabel
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.layouts.flow_layout import FlowLayout
from tagstudio.qt.views.panel_modal import PanelModal
from tagstudio.qt.views.stylesheets.stylesheets import add_button_style
if typing.TYPE_CHECKING:
@@ -102,7 +102,7 @@ class ColorBoxWidget(FieldWidget):
def edit_color(self, color_group: TagColorGroup):
build_color_panel = BuildColorPanel(self.lib, color_group)
self.edit_modal = PanelModal(
self.edit_modal = Modal(
build_color_panel,
"Edit Color",
is_savable=True,
+2 -2
View File
@@ -9,7 +9,7 @@ from typing import cast, override
from PySide6.QtCore import QDateTime
from PySide6.QtWidgets import QDateTimeEdit, QLineEdit, QVBoxLayout
from tagstudio.qt.views.panel_modal import PanelWidget
from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.views.stylesheets.stylesheets import title_line_edit_style
if typing.TYPE_CHECKING:
@@ -39,7 +39,7 @@ def qdtf2dtf(dtf: str) -> str:
return out
class DatetimePicker(PanelWidget):
class DatetimePicker(ModalContent):
def __init__(self, driver: "QtDriver", name: str, datetime: dt | str):
super().__init__()
self.setMinimumSize(300, 60)
+59 -53
View File
@@ -9,8 +9,7 @@ from functools import partial
from warnings import catch_warnings
import structlog
from PySide6.QtCore import Qt
from PySide6.QtGui import QGuiApplication
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
@@ -21,7 +20,6 @@ from PySide6.QtWidgets import (
QWidget,
)
from tagstudio.core.enums import Theme
from tagstudio.core.library.alchemy.fields import (
BaseField,
BaseFieldTemplate,
@@ -32,12 +30,13 @@ from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry, Tag
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.controllers.edit_text_controller import EditText
from tagstudio.qt.controllers.modal import Modal
from tagstudio.qt.controllers.tag_box_controller import TagBoxWidget
from tagstudio.qt.mixed.datetime_picker import DatetimePicker
from tagstudio.qt.mixed.field_widget import FieldContainer
from tagstudio.qt.mixed.text_field import TextContainerWidget
from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations
from tagstudio.qt.views.panel_modal import PanelModal
from tagstudio.qt.views.stylesheets.stylesheets import inset_container_style
if typing.TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
@@ -48,6 +47,8 @@ logger = structlog.get_logger(__name__)
class FieldContainers(QWidget):
"""Widget for the tag and field containers displayed inside the Preview Panel."""
on_tags_update = Signal()
def __init__(self, library: Library, driver: "QtDriver") -> None:
super().__init__()
@@ -58,18 +59,12 @@ class FieldContainers(QWidget):
self.common_fields: list = []
self.mixed_fields: list = []
self.cached_entries: list[Entry] = []
self.containers: list[FieldContainer] = []
self.panel_bg_color = (
Theme.COLOR_BG_DARK.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_BG_LIGHT.value
)
self._containers: list[FieldContainer] = []
self.scroll_layout = QVBoxLayout()
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_layout.setContentsMargins(3, 3, 3, 3)
self.scroll_layout.setSpacing(0)
self.scroll_layout.setSpacing(6)
scroll_container: QWidget = QWidget()
scroll_container.setObjectName("entryScrollContainer")
@@ -92,9 +87,7 @@ class FieldContainers(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.
self.scroll_area.setStyleSheet(
f"QWidget#entryScrollContainer{{background:{self.panel_bg_color};border-radius:6px;}}"
)
self.scroll_area.setStyleSheet(inset_container_style("entryScrollContainer"))
self.scroll_area.setWidget(scroll_container)
root_layout = QHBoxLayout(self)
@@ -137,8 +130,8 @@ class FieldContainers(QWidget):
self.write_field_container(index, field, is_mixed=False)
# Hide leftover container(s)
if len(self.containers) > container_len:
for i, c in enumerate(self.containers):
if len(self._containers) > container_len:
for i, c in enumerate(self._containers):
if i > (container_len - 1):
c.setHidden(True)
@@ -157,7 +150,7 @@ class FieldContainers(QWidget):
def hide_containers(self) -> None:
"""Hide all field and tag containers."""
for c in self.containers:
for c in self._containers:
c.setHidden(True)
def get_tag_categories(self, tags: set[Tag]) -> dict[Tag | None, set[Tag]]:
@@ -251,6 +244,27 @@ class FieldContainers(QWidget):
)
self.driver.add_tags_to_selected_callback(tag_ids)
def update_text_field_callback(
self, field: TextField, entry_id: int, content: dict[str, str | bool]
) -> None:
"""Callback called when a text field has updated data."""
self._update_text_field(
field, str(content["name"]), str(content["value"]), bool(content["is_multiline"])
)
self.update_from_entry(entry_id)
def update_datetime_field_callback(
self, field: DatetimeField, entry_id: int, content: dict[str, str]
) -> None:
"""Callback called when a datetime field has updated data."""
self.update_datetime_field(field, str(content["name"]), str(content["value"]))
self.update_from_entry(entry_id)
def remove_field_callback(self, field: BaseField, entry_id: int) -> None:
"""Callback called when a field needs to be removed from an entry."""
self._remove_field(field)
self.update_from_entry(entry_id)
def write_field_container(self, index: int, field: BaseField, is_mixed: bool = False) -> None:
"""Update/Create data for a field FieldContainer.
@@ -261,27 +275,6 @@ class FieldContainers(QWidget):
If True, field is not present in all selected items.
"""
def update_text_field_callback(
field: TextField, entry_id: int, content: dict[str, str | bool]
) -> None:
"""Callback called when a text field has updated data."""
self._update_text_field(
field, str(content["name"]), str(content["value"]), bool(content["is_multiline"])
)
self.update_from_entry(entry_id)
def update_datetime_field_callback(
field: DatetimeField, entry_id: int, content: dict[str, str]
) -> None:
"""Callback called when a datetime field has updated data."""
self.update_datetime_field(field, str(content["name"]), str(content["value"]))
self.update_from_entry(entry_id)
def remove_field_callback(field: BaseField, entry_id: int) -> None:
"""Callback called when a field needs to be removed from an entry."""
self._remove_field(field)
self.update_from_entry(entry_id)
def write_text_container(
container: FieldContainer, field: TextField, title: str, is_mixed: bool
):
@@ -298,21 +291,21 @@ class FieldContainers(QWidget):
container.set_inner_widget(inner_widget)
if not is_mixed:
edit_modal = PanelModal(
edit_modal = Modal(
EditText(field.name, field.value, field.is_multiline),
window_title=f"{Translations['field.edit']} ({Translations[field_name_key]})",
is_savable=True,
inline_title=False,
)
edit_modal.saved_data.connect(
partial(update_text_field_callback, field, self.top_entry_id)
partial(self.update_text_field_callback, field, self.top_entry_id)
)
container.set_edit_callback(edit_modal.show)
container.set_remove_callback(
lambda: self.remove_message_box(
prompt=self.remove_field_prompt(title),
callback=partial(remove_field_callback, field, self.top_entry_id),
callback=partial(self.remove_field_callback, field, self.top_entry_id),
)
)
@@ -336,21 +329,21 @@ class FieldContainers(QWidget):
container.set_inner_widget(inner_widget)
if not is_mixed:
edit_modal = PanelModal(
edit_modal = Modal(
DatetimePicker(self.driver, field.name, field.value or dt.now()),
window_title=f"{Translations['field.edit']} ({Translations[field_name_key]})",
is_savable=True,
inline_title=False,
)
edit_modal.saved_data.connect(
partial(update_datetime_field_callback, field, self.top_entry_id)
partial(self.update_datetime_field_callback, field, self.top_entry_id)
)
container.set_edit_callback(edit_modal.show)
container.set_remove_callback(
lambda: self.remove_message_box(
prompt=self.remove_field_prompt(field.name),
callback=partial(remove_field_callback, field, self.top_entry_id),
callback=partial(self.remove_field_callback, field, self.top_entry_id),
)
)
@@ -361,7 +354,7 @@ class FieldContainers(QWidget):
container.set_remove_callback(
lambda: self.remove_message_box(
prompt=self.remove_field_prompt(field.name),
callback=partial(remove_field_callback, field, self.top_entry_id),
callback=partial(self.remove_field_callback, field, self.top_entry_id),
)
)
@@ -373,12 +366,12 @@ class FieldContainers(QWidget):
)
# Create new containers if necessary
if len(self.containers) < (index + 1):
if len(self._containers) < (index + 1):
container = FieldContainer()
self.containers.append(container)
self._containers.append(container)
self.scroll_layout.addWidget(container)
else:
container = self.containers[index]
container = self._containers[index]
# Set field title
field_name_key: str = FIELD_TYPE_KEYS.get(field.class_name, "field_type.unknown")
@@ -407,12 +400,12 @@ class FieldContainers(QWidget):
If True, field is not present in all selected items.
"""
logger.info("[FieldContainers][write_tag_container]", index=index)
if len(self.containers) < (index + 1):
if len(self._containers) < (index + 1):
container = FieldContainer()
self.containers.append(container)
self._containers.append(container)
self.scroll_layout.addWidget(container)
else:
container = self.containers[index]
container = self._containers[index]
container.set_title(Translations["entries.tags"] if not category_tag else category_tag.name)
@@ -430,7 +423,10 @@ class FieldContainers(QWidget):
inner_widget.set_tags(tags)
inner_widget.on_update.connect(
lambda: self.update_from_entry(self.cached_entries[0].id, update_badges=True)
lambda: (
self.update_from_entry(self.cached_entries[0].id, update_badges=True),
self.on_tags_update.emit(),
)
)
else:
text = f"<i>{Translations['field.mixed_data']}</i>"
@@ -480,3 +476,13 @@ class FieldContainers(QWidget):
result = remove_mb.exec_()
if result == QMessageBox.ButtonRole.ActionRole.value:
callback()
@property
def tags(self) -> list[int]:
if len(self.cached_entries) <= 0:
return []
entry = self.cached_entries[0]
entry_ = self.lib.get_entry_full(entry.id, with_fields=False)
if not entry_:
return []
return [tag.id for tag in entry_.tags]
+6 -4
View File
@@ -34,7 +34,9 @@ class FieldContainer(QWidget):
self.copy_callback: Callable[[], None] | None = None
self.edit_callback: Callable[[], None] | None = None
self.remove_callback: Callable[[], None] | None = None
button_size = 24
button_size = 22
icon_margins = 4
icon_size = button_size - icon_margins
self.root_layout = QVBoxLayout(self)
self.root_layout.setObjectName("baseLayout")
@@ -42,7 +44,7 @@ class FieldContainer(QWidget):
self.inner_layout = QVBoxLayout()
self.inner_layout.setObjectName("innerLayout")
self.inner_layout.setContentsMargins(6, 0, 6, 6)
self.inner_layout.setContentsMargins(3, 0, 0, 3)
self.inner_layout.setSpacing(0)
self.field_container = QWidget()
self.field_container.setObjectName("fieldContainer")
@@ -82,7 +84,7 @@ class FieldContainer(QWidget):
self.edit_button.setMaximumSize(button_size, button_size)
self.edit_button.setFlat(True)
self.edit_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(FieldContainer.edit_icon)))
self.edit_button.setIconSize(QSize(20, 20))
self.edit_button.setIconSize(QSize(icon_size, icon_size))
self.edit_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.title_layout.addWidget(self.edit_button)
self.edit_button.setHidden(True)
@@ -93,7 +95,7 @@ class FieldContainer(QWidget):
self.remove_button.setMaximumSize(button_size, button_size)
self.remove_button.setFlat(True)
self.remove_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(FieldContainer.trash_icon)))
self.remove_button.setIconSize(QSize(20, 20))
self.remove_button.setIconSize(QSize(icon_size, icon_size))
self.remove_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.title_layout.addWidget(self.remove_button)
self.remove_button.setHidden(True)
+1 -1
View File
@@ -500,7 +500,7 @@ class ItemThumb(FlowWidget):
):
selected = self.driver._selected
if len(selected) == 1 and entry_id in selected:
self.driver.main_window.preview_panel.field_containers_widget.update_toggled_tag(
self.driver.main_window.preview_panel.containers.update_toggled_tag(
tag_id, toggle_value
)
+36 -6
View File
@@ -20,6 +20,8 @@ from PySide6.QtWidgets import (
)
from tagstudio.core.enums import ShowFilepathOption, TagClickActionOption
from tagstudio.qt.controllers.modal import Modal
from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.global_settings import (
DEFAULT_CACHED_THUMB_RES,
DEFAULT_THUMB_CACHE_SIZE,
@@ -30,7 +32,6 @@ from tagstudio.qt.global_settings import (
Theme,
)
from tagstudio.qt.translations import DEFAULT_TRANSLATION, LANGUAGES, Translations
from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget
if TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
@@ -38,7 +39,7 @@ if TYPE_CHECKING:
logger = structlog.get_logger(__name__)
class SettingsPanel(PanelWidget):
class SettingsPanel(ModalContent):
driver: "QtDriver"
filepath_option_map: dict[ShowFilepathOption, str] = {
@@ -216,6 +217,29 @@ class SettingsPanel(PanelWidget):
Translations["settings.tag_click_action.label"], self.tag_click_action_combobox
)
# Open Edit Window When Creating a Tag
self.edit_tag_on_create_checkbox = QCheckBox()
self.edit_tag_on_create_checkbox.setChecked(self.driver.settings.edit_tag_on_create)
form_layout.addRow(
Translations["settings.edit_tag_on_create"], self.edit_tag_on_create_checkbox
)
# Open Edit Window When Adding a Field
self.edit_field_on_add_checkbox = QCheckBox()
self.edit_field_on_add_checkbox.setChecked(self.driver.settings.edit_field_on_add)
form_layout.addRow(
Translations["settings.edit_field_on_add"], self.edit_field_on_add_checkbox
)
# Keep Tag/Field Suggest Boxes Open After Adding Items
self.keep_suggest_boxes_open_checkbox = QCheckBox()
self.keep_suggest_boxes_open_checkbox.setChecked(
self.driver.settings.keep_suggest_boxes_open
)
form_layout.addRow(
Translations["settings.keep_suggest_boxes_open"], self.keep_suggest_boxes_open_checkbox
)
# TODO: Implement Library Settings
def __build_library_settings(self): # pyright: ignore[reportUnusedFunction]
form_layout = QFormLayout(self.library_settings_container)
@@ -366,6 +390,9 @@ class SettingsPanel(PanelWidget):
"show_filepath": self.filepath_combobox.currentData(),
"theme": self.theme_combobox.currentData(),
"tag_click_action": self.tag_click_action_combobox.currentData(),
"edit_tag_on_create": self.edit_tag_on_create_checkbox.isChecked(),
"edit_field_on_add": self.edit_field_on_add_checkbox.isChecked(),
"keep_suggest_boxes_open": self.keep_suggest_boxes_open_checkbox.isChecked(),
"date_format": self.dateformat_combobox.currentData(),
"hour_format": self.hourformat_checkbox.isChecked(),
"zero_padding": self.zeropadding_checkbox.isChecked(),
@@ -388,6 +415,9 @@ class SettingsPanel(PanelWidget):
driver.settings.show_filepath = settings["show_filepath"]
driver.settings.theme = settings["theme"]
driver.settings.tag_click_action = settings["tag_click_action"]
driver.settings.edit_tag_on_create = settings["edit_tag_on_create"]
driver.settings.edit_field_on_add = settings["edit_field_on_add"]
driver.settings.keep_suggest_boxes_open = settings["keep_suggest_boxes_open"]
driver.settings.date_format = settings["date_format"]
driver.settings.hour_format = settings["hour_format"]
driver.settings.zero_padding = settings["zero_padding"]
@@ -409,15 +439,15 @@ class SettingsPanel(PanelWidget):
)
@classmethod
def build_modal(cls, driver: "QtDriver") -> PanelModal:
def build_modal(cls, driver: "QtDriver") -> Modal:
settings_panel = cls(driver)
modal = PanelModal(
widget=settings_panel,
modal = Modal(
content_widget=settings_panel,
window_title=Translations["settings.title"],
is_savable=True,
)
modal.saved.connect(lambda: settings_panel.update_settings(driver))
modal.title_widget.setVisible(False)
modal.layout().title_label.setVisible(False)
return modal
+6 -6
View File
@@ -23,11 +23,11 @@ from PySide6.QtWidgets import (
from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX
from tagstudio.core.enums import Theme
from tagstudio.qt.controllers.modal import Modal
from tagstudio.qt.mixed.build_namespace import BuildNamespacePanel
from tagstudio.qt.mixed.color_box import ColorBoxWidget
from tagstudio.qt.mixed.field_widget import FieldContainer
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.panel_modal import PanelModal
from tagstudio.qt.views.stylesheets.stylesheets import header
logger = structlog.get_logger(__name__)
@@ -38,7 +38,7 @@ if TYPE_CHECKING:
class TagColorManager(QWidget):
create_namespace_modal: PanelModal | None = None
create_namespace_modal: Modal | None = None
def __init__(
self,
@@ -68,7 +68,7 @@ class TagColorManager(QWidget):
self.scroll_layout = QVBoxLayout()
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_layout.setContentsMargins(3, 3, 3, 3)
self.scroll_layout.setSpacing(0)
self.scroll_layout.setSpacing(6)
scroll_container: QWidget = QWidget()
scroll_container.setObjectName("entryScrollContainer")
@@ -125,7 +125,7 @@ class TagColorManager(QWidget):
self.setup_color_groups(),
()
if len(self.driver.selected) < 1
else self.driver.main_window.preview_panel.field_containers_widget.update_from_entry( # noqa: E501
else self.driver.main_window.preview_panel.containers.update_from_entry( # noqa: E501
self.driver.selected[0], update_badges=False
),
)
@@ -142,7 +142,7 @@ class TagColorManager(QWidget):
self.setup_color_groups(),
()
if len(self.driver.selected) < 1
else self.driver.main_window.preview_panel.field_containers_widget.update_from_entry( # noqa: E501
else self.driver.main_window.preview_panel.containers.update_from_entry( # noqa: E501
self.driver.selected[0], update_badges=False
),
),
@@ -174,7 +174,7 @@ class TagColorManager(QWidget):
def create_namespace(self):
build_namespace_panel = BuildNamespacePanel(self.lib)
self.create_namespace_modal = PanelModal(
self.create_namespace_modal = Modal(
build_namespace_panel,
Translations["namespace.create.title"],
is_savable=True,
@@ -20,21 +20,22 @@ from PySide6.QtWidgets import (
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import TagColorGroup
from tagstudio.qt.mixed.tag_widget import (
get_tag_border_color,
get_tag_highlight_color,
get_tag_text_color,
)
from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.models.palette import ColorType, get_tag_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.layouts.flow_layout import FlowLayout
from tagstudio.qt.views.panel_modal import PanelWidget
from tagstudio.qt.views.stylesheets.stylesheets import color_swatch_style, header
from tagstudio.qt.views.stylesheets.stylesheets import (
color_swatch_style,
get_tag_border_color,
get_tag_highlight_color,
get_tag_text_color,
header,
)
logger = structlog.get_logger(__name__)
class TagColorSelection(PanelWidget):
class TagColorSelection(ModalContent):
def __init__(self, library: Library):
super().__init__()
self.lib = library
+6 -9
View File
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, override
import structlog
from PySide6.QtCore import QEvent, Qt, Signal
from PySide6.QtGui import QAction, QColor, QEnterEvent, QFontMetrics
from PySide6.QtWidgets import QHBoxLayout, QLineEdit, QPushButton, QVBoxLayout, QWidget
from PySide6.QtWidgets import QHBoxLayout, QLineEdit, QPushButton, QSizePolicy, QVBoxLayout, QWidget
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.library.alchemy.models import Tag
@@ -141,30 +141,27 @@ class TagWidget(QWidget):
self.search_for_tag_action = QAction(self)
self.search_for_tag_action.setText(Translations["tag.search_for_tag"])
self.bg_button.addAction(self.search_for_tag_action)
# add_to_search_action = QAction(self)
# add_to_search_action.setText(Translations.translate_formatted("tag.add_to_search"))
# self.bg_button.addAction(add_to_search_action)
self.inner_layout = QHBoxLayout()
self.inner_layout.setObjectName("innerLayout")
self.inner_layout.setContentsMargins(0, 0, 0, 0)
self.inner_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self._delete_button = QPushButton(self)
self._delete_button.setFlat(True)
self._delete_button.setText("")
self._delete_button.setHidden(True)
self._delete_button.setMinimumSize(22, 22)
self._delete_button.setMaximumSize(22, 22)
self._delete_button.setFixedSize(22, 22)
self._delete_button.clicked.connect(self.on_remove.emit)
self._delete_button.setHidden(True)
self.inner_layout.addWidget(self._delete_button)
self.inner_layout.addStretch(1)
self.bg_button.setLayout(self.inner_layout)
self.bg_button.setMinimumSize(44, 22)
self.bg_button.setMinimumHeight(22)
self.bg_button.setMaximumHeight(22)
self.bg_button.setFixedHeight(22)
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
self.base_layout.addWidget(self.bg_button)
+22
View File
@@ -7,12 +7,34 @@ from enum import IntEnum
from typing import Any
import structlog
from PySide6.QtGui import QPalette
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.utils.singleton import Singleton
logger = structlog.get_logger(__name__)
class Palette(metaclass=Singleton):
_palette: QPalette | None = None
_accent: str | None = None
@staticmethod
def set_palette(palette: QPalette) -> None:
Palette._palette = palette
@staticmethod
def accent() -> str:
if not Palette._palette:
logger.error("[Style] No QPalette set!")
return get_ui_color(ColorType.PRIMARY, UiColor.BLUE)
if not Palette._accent:
Palette._accent = (
f"rgba{QPalette.color(Palette._palette, QPalette.ColorRole.Accent).toTuple()}"
)
return Palette._accent
class ColorType(IntEnum):
PRIMARY = 0
TEXT = 1
File diff suppressed because it is too large Load Diff
+60
View File
@@ -0,0 +1,60 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from pathlib import Path
from PIL import ImageQt
from PySide6.QtCore import QObject, QSize, Signal
from PySide6.QtGui import QGuiApplication, QPixmap, Qt
from tagstudio.core.library.alchemy.library import Library
from tagstudio.qt.cache_manager import CacheManager
from tagstudio.qt.global_settings import GlobalSettings, Theme
from tagstudio.qt.previews.renderer import FileRenderer
class QtFileRenderer(QObject):
"""A Qt-specific wrapper for rendering image previews and thumbnails from files."""
updated = Signal(float, QPixmap, QSize, Path)
updated_ratio = Signal(float)
def __init__(self, library: Library, settings: GlobalSettings) -> None:
super().__init__()
self.renderer = FileRenderer(library, settings)
self.theme = (
Theme.DARK
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.LIGHT
)
def render(
self,
cache: CacheManager | None,
timestamp: float,
filepath: Path | str,
size: tuple[int, int],
pixel_ratio: float,
is_loading: bool = False,
is_thumb: bool = False,
):
image, size, timestamp = self.renderer.render(
cache=cache,
timestamp=timestamp,
filepath=filepath,
size=size,
dpi_scale=pixel_ratio,
theme=self.theme,
is_loading=is_loading,
is_thumb=is_thumb,
)
qim = ImageQt.ImageQt(image)
pixmap = QPixmap.fromImage(qim)
pixmap.setDevicePixelRatio(pixel_ratio)
self.updated_ratio.emit(image.size[0] / image.size[1])
if pixmap:
self.updated.emit(timestamp, pixmap, QSize(size[0], size[1]), filepath)
else:
self.updated.emit(timestamp, QPixmap(), QSize(*size), filepath)
+4
View File
@@ -36,6 +36,10 @@ class ResourceManager:
edit: Image.Image
file_generic: Image.Image
font: Image.Image
hint_field_add: Image.Image
hint_field_create: Image.Image
hint_tag_add: Image.Image
hint_tag_create: Image.Image
icon: Image.Image
ignored_stat: Image.Image
ignored: Image.Image
+16
View File
@@ -71,6 +71,22 @@
"mode": "pil",
"path": "qt/images/file_icons/font.png"
},
"hint_field_add": {
"mode": "pil",
"path": "qt/images/hint_field_add.png"
},
"hint_field_create": {
"mode": "pil",
"path": "qt/images/hint_field_create.png"
},
"hint_tag_add": {
"mode": "pil",
"path": "qt/images/hint_tag_add.png"
},
"hint_tag_create": {
"mode": "pil",
"path": "qt/images/hint_tag_create.png"
},
"icon": {
"mode": "pil",
"path": "icon.png"
+12 -3
View File
@@ -17,7 +17,7 @@ from tagstudio.core.library.alchemy.enums import ItemType
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.mixed.item_thumb import BadgeType, ItemThumb
from tagstudio.qt.previews.renderer import ThumbRenderer
from tagstudio.qt.qt_file_renderer import QtFileRenderer
if TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
@@ -44,7 +44,7 @@ class ThumbGridLayout(QLayout):
self._entry_items: dict[int, int] = {}
self._render_results: dict[Path, Any] = {}
self._renderer: ThumbRenderer = ThumbRenderer(self.driver)
self._renderer: QtFileRenderer = QtFileRenderer(self.driver.lib, self.driver.settings)
self._renderer.updated.connect(self._on_rendered)
self._render_cutoff: float = 0.0
@@ -77,6 +77,7 @@ class ThumbGridLayout(QLayout):
(
self._renderer.render,
(
self.driver.cache_manager,
self._render_cutoff,
Path(),
base_size,
@@ -300,7 +301,15 @@ class ThumbGridLayout(QLayout):
self.driver.thumb_job_queue.put(
(
self._renderer.render,
(timestamp, file_path, base_size, ratio, False, True),
(
self.driver.cache_manager,
timestamp,
file_path,
base_size,
ratio,
False,
True,
),
)
)
+58 -41
View File
@@ -51,8 +51,6 @@ from tagstudio.core.library.refresh import RefreshTracker
from tagstudio.core.media_types import MediaCategories
from tagstudio.core.query_lang.util import ParsingError
from tagstudio.core.ts_core import TagStudioCore
# This import has side-effect of importing PySide resources
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
from tagstudio.core.utils.module_status import ModuleStatus
from tagstudio.core.utils.ripgrep_status import RipgrepStatus
@@ -63,7 +61,8 @@ from tagstudio.qt.controllers.field_template_search_panel_controller import Fiel
from tagstudio.qt.controllers.fix_ignored_modal_controller import FixIgnoredEntriesModal
from tagstudio.qt.controllers.ignore_modal_controller import IgnoreModal
from tagstudio.qt.controllers.library_info_window_controller import LibraryInfoWindow
from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal
from tagstudio.qt.controllers.modal import Modal
from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchPanel
from tagstudio.qt.controllers.update_available_message_box import UpdateAvailableMessageBox
from tagstudio.qt.global_settings import DEFAULT_GLOBAL_SETTINGS_PATH, GlobalSettings, Theme
from tagstudio.qt.mixed.about_modal import AboutModal
@@ -77,16 +76,15 @@ from tagstudio.qt.mixed.migration_modal import JsonMigrationModal
from tagstudio.qt.mixed.progress_bar import ProgressWidget
from tagstudio.qt.mixed.settings_panel import SettingsPanel
from tagstudio.qt.mixed.tag_color_manager import TagColorManager
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
from tagstudio.qt.models.palette import ColorType, Palette, UiColor, get_ui_color
from tagstudio.qt.platform_strings import trash_term
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.translations import Translations
from tagstudio.qt.utils.custom_runnable import CustomRunnable
from tagstudio.qt.utils.file_deleter import delete_file
from tagstudio.qt.utils.function_iterator import FunctionIterator
from tagstudio.qt.views.field_template_search_panel_view import FieldTemplateSearchPanelView
from tagstudio.qt.views.main_window import MainWindow
from tagstudio.qt.views.panel_modal import PanelModal
from tagstudio.qt.views.search_panel_view import SearchPanelView
from tagstudio.qt.views.splash import SplashScreen
from tagstudio.qt.views.stylesheets.stylesheets import header
@@ -169,12 +167,12 @@ class QtDriver(DriverMixin, QObject):
favorite_updated = Signal(bool)
archived_updated = Signal(bool)
tag_manager_panel: PanelModal | None = None
tag_manager: Modal | None = None
color_manager_panel: TagColorManager | None = None
field_template_manager_panel: PanelModal | None = None
ignore_modal: PanelModal | None = None
add_tag_modal: PanelModal | None = None
add_field_modal: PanelModal | None = None
field_template_manager: Modal | None = None
ignore_modal: Modal | None = None
add_tag_modal: Modal | None = None
add_field_modal: Modal | None = None
folders_modal: FoldersToTagsModal
about_modal: AboutModal
unlinked_modal: FixUnlinkedEntriesModal
@@ -185,7 +183,7 @@ class QtDriver(DriverMixin, QObject):
applied_theme: Theme
lib: Library
cache_manager: CacheManager | None
cache_manager: CacheManager | None = None
browsing_history: History[BrowsingState]
@@ -274,7 +272,7 @@ class QtDriver(DriverMixin, QObject):
dir = QFileDialog.getExistingDirectory(
parent=None,
caption=Translations["window.title.open_create_library"],
dir="/",
dir=str(Path.home()),
options=QFileDialog.Option.ShowDirsOnly,
)
if dir not in (None, ""):
@@ -303,19 +301,28 @@ class QtDriver(DriverMixin, QObject):
elif self.settings.theme == Theme.LIGHT:
self.app.styleHints().setColorScheme(Qt.ColorScheme.Light)
pal: QPalette = self.app.palette()
# BUG: Changing the palette in any way here seems to affect the accent colors of certain
# widgets, like QLineEdit focused borders and QComboBox highlighted items and borders.
# Need to figure out the cause of this.
if (
platform.system() == "Darwin" or platform.system() == "Windows"
) and QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark:
pal: QPalette = self.app.palette()
pal.setColor(QPalette.ColorGroup.Normal, QPalette.ColorRole.Window, QColor("#1e1e1e"))
pal.setColor(QPalette.ColorGroup.Normal, QPalette.ColorRole.Button, QColor("#1e1e1e"))
pal.setColor(
QPalette.ColorGroup.Inactive, QPalette.ColorRole.ToolTipBase, QColor("#1e1e1e")
)
pal.setColor(
QPalette.ColorGroup.Inactive, QPalette.ColorRole.ToolTipText, QColor("#FFFFFF")
)
pal.setColor(QPalette.ColorGroup.Inactive, QPalette.ColorRole.Window, QColor("#232323"))
pal.setColor(QPalette.ColorGroup.Inactive, QPalette.ColorRole.Button, QColor("#232323"))
pal.setColor(
QPalette.ColorGroup.Inactive, QPalette.ColorRole.ButtonText, QColor("#666666")
)
self.app.setPalette(pal)
Palette.set_palette(pal)
self.app.setPalette(pal)
# Handle OS signals
self.setup_signals()
@@ -357,15 +364,15 @@ class QtDriver(DriverMixin, QObject):
if platform.system() != "Windows":
self.app.setDesktopFileName("tagstudio")
# Initialize the Tag Manager panel
self.tag_manager_panel = TagSearchModal(
self.lib,
title=Translations["tag_manager.title"],
is_tag_chooser=False,
tsp_view = SearchPanelView(
placeholder_text=Translations["home.search_tags"],
create_text=Translations["tag.create"],
is_chooser=False,
)
self.tag_manager_panel.tsp.set_driver(self)
self.tag_manager_panel.done.connect(
tsp = TagSearchPanel(self.lib, is_chooser=False, view=tsp_view)
tsp.search_for_tag.connect(self.search_for_tag_callback)
self.tag_manager = Modal(tsp, Translations["tag_manager.title"])
self.tag_manager.done.connect(
lambda checked=False: self.main_window.preview_panel.set_selection(
self.selected, update_preview=False
)
@@ -375,30 +382,33 @@ class QtDriver(DriverMixin, QObject):
self.color_manager_panel = TagColorManager(self)
# Initialize the Field Template Manager panel
self.field_template_manager_panel = PanelModal(
widget=FieldTemplateSearchPanel(
self.field_template_manager = Modal(
content_widget=FieldTemplateSearchPanel(
self.lib,
is_field_template_chooser=False,
view=FieldTemplateSearchPanelView(is_field_template_chooser=False),
is_chooser=False,
view=SearchPanelView(Translations["home.search_field_templates"], is_chooser=False),
),
title=Translations["field_template_manager.title"],
is_savable=False,
)
self.field_template_manager_panel.done.connect(
self.field_template_manager.done.connect(
lambda checked=False: self.main_window.preview_panel.set_selection(
self.selected, update_preview=False
)
)
# TODO: Remove/replace this with a suite of persistently visible tagging panels, including
# tag search. These will be docked on the left-hand side of the main window.
# Initialize the "Add Tag" panel
self.add_tag_modal = TagSearchModal(
self.lib, title=Translations["tag.add.plural"], is_tag_chooser=True
)
self.add_tag_modal.tsp.set_driver(self)
self.add_tag_modal.tsp.item_chosen.connect(
add_tag_tsp_view = SearchPanelView(placeholder_text=Translations["home.search_tags"])
add_tag_tsp = TagSearchPanel(self.lib, view=add_tag_tsp_view)
add_tag_tsp.search_for_tag.connect(self.search_for_tag_callback)
self.add_tag_modal = Modal(add_tag_tsp, Translations["tag.add.plural"])
add_tag_tsp.item_chosen.connect(
lambda chosen_tag: (
self.add_tags_to_selected_callback([chosen_tag]),
self.main_window.preview_panel.set_selection(self.selected),
self.main_window.preview_panel.set_selection(self.selected, update_preview=False),
)
)
@@ -479,14 +489,14 @@ class QtDriver(DriverMixin, QObject):
lambda f="": self.delete_files_callback(f)
)
self.main_window.menu_bar.tag_manager_action.triggered.connect(self.tag_manager_panel.show)
self.main_window.menu_bar.tag_manager_action.triggered.connect(self.tag_manager.show)
self.main_window.menu_bar.color_manager_action.triggered.connect(
self.color_manager_panel.show
)
self.main_window.menu_bar.field_template_manager_action.triggered.connect(
self.field_template_manager_panel.show
self.field_template_manager.show
)
# endregion
@@ -627,8 +637,10 @@ class QtDriver(DriverMixin, QObject):
if path_result.success and path_result.library_path:
self.open_library(path_result.library_path)
self.check_for_update()
self.main_window.search_field.setFocus()
self.app.exec()
self.check_for_update()
self.shutdown()
def show_error_message(self, error_name: str, error_desc: str | None = None):
@@ -724,7 +736,7 @@ class QtDriver(DriverMixin, QObject):
self.ignore_modal = None
panel = IgnoreModal(self.lib)
self.ignore_modal = PanelModal(
self.ignore_modal = Modal(
panel,
Translations["menu.edit.ignore_files"],
is_savable=True,
@@ -862,7 +874,7 @@ class QtDriver(DriverMixin, QObject):
def add_tag_action_callback(self):
panel = BuildTagPanel(self.lib)
self.modal = PanelModal(
self.modal = Modal(
panel,
Translations["tag.new"],
Translations["tag.create"],
@@ -881,6 +893,11 @@ class QtDriver(DriverMixin, QObject):
)
self.modal.show()
def search_for_tag_callback(self, tag_id: int) -> None:
"""Callback to search for a given Tag ID."""
self.main_window.search_field.setText(f"tag_id:{tag_id}")
self.update_browsing_state(BrowsingState.from_tag_id(tag_id, self.browsing_history.current))
def select_all_action_callback(self):
"""Set the selection to all visible items."""
self.select_all()
@@ -954,7 +971,7 @@ class QtDriver(DriverMixin, QObject):
for i, tup in enumerate(pending):
e_id, f = tup
if (origin_path == f) or (not origin_path):
self.main_window.preview_panel.preview_thumb.media_player.stop()
self.main_window.preview_panel.stop_media_playback()
msg = Translations.format(
"status.deleting_file", i=i, count=len(pending), path=f
@@ -15,14 +15,14 @@ from PySide6.QtWidgets import (
)
from tagstudio.qt.controllers.clickable_label import ClickableLabel
from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.panel_modal import PanelWidget
from tagstudio.qt.views.stylesheets.stylesheets import checkbox_style
logger = structlog.get_logger(__name__)
class EditFieldTemplateModalView(PanelWidget):
class EditFieldTemplateModalView(ModalContent):
def __init__(self) -> None:
super().__init__()
+2 -2
View File
@@ -14,12 +14,12 @@ from PySide6.QtWidgets import (
)
from tagstudio.qt.controllers.clickable_label import ClickableLabel
from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.panel_modal import PanelWidget
from tagstudio.qt.views.stylesheets.stylesheets import checkbox_style, title_line_edit_style
class EditTextView(PanelWidget):
class EditTextView(ModalContent):
def __init__(self):
super().__init__()
self.setMinimumSize(480, 240)
@@ -1,33 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from typing import override
from PySide6.QtWidgets import QWidget
from tagstudio.core.library.alchemy.library import Library
from tagstudio.qt.controllers.field_template_widget_controller import FieldTemplateWidget
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.search_panel_view import SearchPanelView
class FieldTemplateSearchPanelView(SearchPanelView):
def __init__(self, is_field_template_chooser: bool) -> None:
super().__init__(is_field_template_chooser)
self.search_field.setPlaceholderText(Translations["home.search_field_templates"])
self.create_button.setText(Translations["field_template.create"])
@override
def get_item_widget(self, index: int, library: Library | None) -> FieldTemplateWidget:
"""Gets the item widget at a specific index."""
# Create any new item widgets needed up to the given index
if self._scroll_layout.count() <= index:
while self._scroll_layout.count() <= index:
pad_field_template_widget = FieldTemplateWidget()
pad_field_template_widget.setHidden(True)
self._scroll_layout.addWidget(pad_field_template_widget)
field_template_widget: QWidget = self._scroll_layout.itemAt(index).widget()
assert isinstance(field_template_widget, FieldTemplateWidget)
return field_template_widget
@@ -2,8 +2,8 @@
# SPDX-License-Identifier: GPL-3.0-only
from PySide6.QtCore import Signal
from PySide6.QtGui import QColor
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout, QWidget
from PySide6.QtGui import QColor, Qt
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QSizePolicy, QVBoxLayout, QWidget
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.qt.models.palette import ColorType, get_tag_color
@@ -12,8 +12,10 @@ from tagstudio.qt.views.stylesheets.stylesheets import (
get_tag_highlight_color,
get_tag_text_color,
list_button_style,
tag_remove_button_style,
)
# TODO: These colors and logic should be moved to and reworked in the stylesheets file.
primary_color: QColor = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
border_color: QColor = get_tag_border_color(primary_color)
highlight_color: QColor = get_tag_highlight_color(primary_color)
@@ -30,7 +32,6 @@ class FieldTemplateWidgetView(QWidget):
self.__root_layout = QVBoxLayout(self)
self.__root_layout.setObjectName("root_layout")
self.__root_layout.setContentsMargins(0, 0, 0, 0)
# Background button
@@ -47,6 +48,8 @@ class FieldTemplateWidgetView(QWidget):
self.__inner_layout.setObjectName("inner_layout")
self._bg_button.setLayout(self.__inner_layout)
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
self.__inner_layout.setContentsMargins(0, 0, 0, 0)
# Remove button
@@ -56,9 +59,12 @@ class FieldTemplateWidgetView(QWidget):
self._delete_button.setHidden(True)
self._delete_button.setMinimumSize(22, 22)
self._delete_button.setMaximumSize(22, 22)
self._delete_button.setStyleSheet(
tag_remove_button_style(primary_color, text_color, border_color, highlight_color)
)
self.__inner_layout.addWidget(self._delete_button)
self.__inner_layout.addStretch(1)
self.__inner_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.__connect_callbacks()
+2 -2
View File
@@ -14,13 +14,13 @@ from PySide6.QtWidgets import (
from tagstudio.core.constants import IGNORE_NAME
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Tag
from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.panel_modal import PanelWidget
logger = structlog.get_logger(__name__)
class IgnoreModalView(PanelWidget):
class IgnoreModalView(ModalContent):
on_edit = Signal(Tag)
def __init__(self, library: Library) -> None:
+14 -6
View File
@@ -5,10 +5,11 @@
import typing
from collections.abc import Callable
from pathlib import Path
from typing import override
import structlog
from PIL import Image, ImageQt
from PySide6 import QtCore
from PySide6 import QtCore, QtGui
from PySide6.QtCore import QMetaObject, QSize, QStringListModel, Qt
from PySide6.QtGui import QAction, QPixmap
from PySide6.QtWidgets import (
@@ -182,10 +183,10 @@ class MainMenuBar(QMenuBar):
self.new_tag_action.setShortcut(
QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
QtCore.Qt.Key.Key_T,
QtCore.Qt.Key.Key_N,
)
)
self.new_tag_action.setToolTip("Ctrl+T")
self.new_tag_action.setToolTip("Ctrl+N")
self.new_tag_action.setEnabled(False)
self.edit_menu.addAction(self.new_tag_action)
@@ -220,9 +221,8 @@ class MainMenuBar(QMenuBar):
# Clear Selection
self.clear_select_action = QAction(Translations["select.clear"], self)
self.clear_select_action.setShortcut(QtCore.Qt.Key.Key_Escape)
self.clear_select_action.setToolTip("Esc")
self.clear_select_action.setEnabled(False)
self.clear_select_action.setToolTip("Esc")
self.edit_menu.addAction(self.clear_select_action)
# Copy Fields
@@ -700,10 +700,12 @@ class MainWindow(QMainWindow):
self.content_splitter.addWidget(self.entry_list_container)
def setup_preview_panel(self, driver: "QtDriver"):
self.preview_panel = PreviewPanel(driver.lib, driver)
self.preview_panel = PreviewPanel(driver)
self.content_splitter.addWidget(self.preview_panel)
def setup_status_bar(self):
# BUG: Clicking the status bar does not count as losing focus on other widgets
# (for example, the "Add Tag" line edit). Can this be fixed?
self.status_bar = QStatusBar(self)
self.status_bar.setObjectName("status_bar")
status_bar_size_policy = QSizePolicy(
@@ -746,3 +748,9 @@ class MainWindow(QMainWindow):
def show_hidden_entries(self) -> bool:
"""Whether to show entries tagged with hidden tags."""
return self.show_hidden_entries_checkbox.isChecked()
@override
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:
if event.key() == QtCore.Qt.Key.Key_Escape:
self.menu_bar.clear_select_action.trigger()
return super().keyPressEvent(event)
+64
View File
@@ -0,0 +1,64 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import structlog
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.stylesheets.stylesheets import header
logger = structlog.get_logger(__name__)
class ModalView(QVBoxLayout):
"""A generic reusable modal panel widget."""
def __init__(
self,
content_widget: ModalContent,
title: str = "",
is_savable: bool = False,
inline_title: bool = True,
):
super().__init__()
self.content_widget = content_widget
self.setContentsMargins(6, 6 if inline_title else 12, 6, 6)
self.button_container = QWidget()
self.button_layout = QHBoxLayout(self.button_container)
self.button_layout.setContentsMargins(6, 6, 6, 6)
self.button_layout.addStretch(1)
# [Done]
# - OR -
# [Cancel] [Save]
if not is_savable:
done_button = QPushButton(Translations["generic.done"])
done_button.setAutoDefault(True)
self.content_widget.done_button = done_button
self.button_layout.addWidget(done_button)
else:
cancel_button = QPushButton(Translations["generic.cancel"])
self.content_widget.cancel_button = cancel_button
self.button_layout.addWidget(cancel_button)
save_button = QPushButton(Translations["generic.save"])
save_button.setAutoDefault(True)
self.content_widget.save_button = save_button
self.button_layout.addWidget(save_button)
if inline_title:
self.title_label = QLabel()
self.title_label.setObjectName("fieldTitle")
self.title_label.setWordWrap(True)
self.title_label.setText(header(title, 3))
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()
-127
View File
@@ -1,127 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import contextlib
from typing import Any, override
import structlog
from PySide6 import QtCore, QtGui
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
from tagstudio.qt.translations import Translations
logger = structlog.get_logger(__name__)
class PanelModal(QWidget):
"""A generic reusable modal panel widget."""
done = Signal()
saved = Signal()
saved_data = Signal(type(Any))
def __init__(
self,
widget: "PanelWidget",
title: str = "",
window_title: str | None = None,
is_savable: bool = False,
inline_title: bool = True,
):
# [Done]
# - OR -
# [Cancel] [Save]
super().__init__()
self.widget = widget
self.setWindowTitle(title if window_title is None else window_title)
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 0 if inline_title else 12, 6, 6)
self.button_container = QWidget()
self.button_layout = QHBoxLayout(self.button_container)
self.button_layout.setContentsMargins(6, 6, 6, 6)
self.button_layout.addStretch(1)
if not is_savable:
self.done_button = QPushButton(Translations["generic.done"])
self.done_button.setAutoDefault(True)
self.done_button.clicked.connect(self.hide)
self.done_button.clicked.connect(self.done.emit)
self.widget.panel_done_button = self.done_button
self.button_layout.addWidget(self.done_button)
else:
self.cancel_button = QPushButton(Translations["generic.cancel"])
self.cancel_button.clicked.connect(self.hide)
self.cancel_button.clicked.connect(widget.reset)
self.widget.panel_cancel_button = self.cancel_button
self.button_layout.addWidget(self.cancel_button)
self.save_button = QPushButton(Translations["generic.save"])
self.save_button.setAutoDefault(True)
self.save_button.clicked.connect(self.hide)
self.save_button.clicked.connect(self.saved.emit)
self.save_button.clicked.connect(lambda: self.saved_data.emit(widget.saved_data()))
self.widget.panel_save_button = self.save_button
self.button_layout.addWidget(self.save_button)
if inline_title:
self.title_widget = QLabel()
self.title_widget.setObjectName("fieldTitle")
self.title_widget.setWordWrap(True)
self.title_widget.setStyleSheet("font-weight:bold;font-size:14px;padding-top:6px")
self.title_widget.setText(title)
self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.root_layout.addWidget(self.title_widget)
self.root_layout.addWidget(widget)
widget.parent_modal = self
self.root_layout.setStretch(1, 2)
self.root_layout.addWidget(self.button_container)
widget.parent_post_init()
@override
def closeEvent(self, event: QtGui.QCloseEvent) -> None:
with contextlib.suppress(AttributeError):
self.cancel_button.click()
with contextlib.suppress(AttributeError):
self.done_button.click()
event.accept()
class PanelWidget(QWidget):
"""Used for widgets that go in a modal panel, ex. for editing or searching."""
parent_modal: PanelModal | None = None
panel_save_button: QPushButton | None = None
panel_cancel_button: QPushButton | None = None
panel_done_button: QPushButton | None = None
def __init__(self):
super().__init__()
def saved_data(self) -> Any: # pyright: ignore[reportExplicitAny]
return None
def reset(self) -> None:
pass
def parent_post_init(self) -> None:
pass
@override
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:
if event.key() == QtCore.Qt.Key.Key_Escape:
if self.panel_cancel_button:
self.panel_cancel_button.click()
elif self.panel_done_button:
self.panel_done_button.click()
elif event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter:
if self.panel_save_button:
self.panel_save_button.click()
elif self.panel_done_button:
self.panel_done_button.click()
else: # Other key presses
super().keyPressEvent(event)
+55 -173
View File
@@ -3,29 +3,20 @@
import math
import traceback
import typing
from pathlib import Path
import structlog
from PySide6.QtCore import Qt
from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QPushButton,
QSplitter,
QVBoxLayout,
QWidget,
)
from PySide6.QtWidgets import QHBoxLayout, QLabel, QSplitter, QVBoxLayout, QWidget
from tagstudio.core.constants import FFMPEG_HELP_URL
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.controllers.field_suggest_box import FieldSuggestBox
from tagstudio.qt.controllers.preview_thumb_controller import PreviewThumb
from tagstudio.qt.controllers.return_button import ReturnButton
from tagstudio.qt.controllers.tag_suggest_box import TagSuggestBox
from tagstudio.qt.mixed.field_containers import FieldContainers
from tagstudio.qt.mixed.file_attributes import FileAttributeData, FileAttributes
from tagstudio.qt.mixed.file_attributes import FileAttributes
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.stylesheets.stylesheets import button_style, preview_warning_style
@@ -36,33 +27,40 @@ if typing.TYPE_CHECKING:
logger = structlog.get_logger(__name__)
class PreviewPanelView(QWidget):
lib: Library
_selected: list[int]
def __init__(self, library: Library, driver: "QtDriver") -> None:
class PreviewPanelView(QVBoxLayout):
def __init__(self, driver: "QtDriver", pixel_ratio: float) -> None:
super().__init__()
self.lib = library
self.setContentsMargins(0, 0, 0, 0)
self.setSpacing(6)
rm = ResourceManager()
self._thumb = PreviewThumb(self.lib, driver)
self._file_attrs = FileAttributes(self.lib, driver)
self._containers = FieldContainers(
self.lib, driver
) # TODO: this should be name mangled, but is still needed on the controller side atm
self.__current_stats: FileAttributeData | None = None
# Search/Create Boxes
def ph_text(key: str) -> str:
return " ".join([Translations[key], Translations["home.search.how_to_exit"]])
self.field_search_box = FieldSuggestBox(
driver.lib, driver.settings, ph_text("home.search_or_create_fields")
)
self.tag_search_box = TagSuggestBox(
driver.lib, driver.settings, ph_text("home.search_or_create_tags")
)
self.preview_thumb = PreviewThumb(driver.lib, driver)
self.file_attrs = FileAttributes(driver.lib, driver)
self.containers = FieldContainers(driver.lib, driver)
# Visual Preview
preview_section = QWidget()
preview_layout = QVBoxLayout(preview_section)
preview_layout.setContentsMargins(0, 0, 0, 0)
preview_layout.setSpacing(6)
self._ffmpeg_warning_widget = QWidget()
self._ffmpeg_warning_widget.setObjectName("ffmpeg_widget")
ffmpeg_warning_layout = QHBoxLayout(self._ffmpeg_warning_widget)
# Warning Banner (Missing FFmpeg, etc.)
self.warning_banner = QWidget()
self.warning_banner.setObjectName("ffmpeg_widget")
ffmpeg_warning_layout = QHBoxLayout(self.warning_banner)
ffmpeg_warning_layout.setContentsMargins(3, 3, 3, 3)
self._ffmpeg_warning_widget.setStyleSheet(preview_warning_style())
self.warning_banner.setStyleSheet(preview_warning_style())
ffmpeg_warning_label = QLabel(
Translations.format(
"preview.missing_module.multimedia",
@@ -75,16 +73,16 @@ class PreviewPanelView(QWidget):
)
warning_icon = QLabel()
warning_icon_pixmap = rm.alert.scaled(
math.floor(20 * self.devicePixelRatio()), math.floor(20 * self.devicePixelRatio())
math.floor(20 * pixel_ratio), math.floor(20 * pixel_ratio)
)
warning_icon_pixmap.setDevicePixelRatio(self.devicePixelRatio())
warning_icon_pixmap.setDevicePixelRatio(pixel_ratio)
warning_icon.setPixmap(warning_icon_pixmap)
ffmpeg_warning_layout.addWidget(warning_icon)
ffmpeg_warning_layout.addWidget(ffmpeg_warning_label)
ffmpeg_warning_layout.setStretch(1, 1)
self.warning_banner.hide()
self._ffmpeg_warning_widget.hide()
# File Information
info_section = QWidget()
info_layout = QVBoxLayout(info_section)
info_layout.setContentsMargins(0, 0, 0, 0)
@@ -94,154 +92,38 @@ class PreviewPanelView(QWidget):
splitter.setOrientation(Qt.Orientation.Vertical)
splitter.setHandleWidth(12)
# Add Tag/Field Buttons
add_buttons_container = QWidget()
add_buttons_layout = QHBoxLayout(add_buttons_container)
add_buttons_layout.setContentsMargins(0, 0, 0, 0)
add_buttons_layout.setSpacing(6)
self.__add_tag_button = QPushButton(Translations["tag.add"])
self.__add_tag_button.setEnabled(False)
self.__add_tag_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.__add_tag_button.setMinimumHeight(28)
self.__add_tag_button.setStyleSheet(button_style())
self.add_tag_button = ReturnButton(Translations["tag.add"])
self.add_tag_button.setEnabled(False)
self.add_tag_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.add_tag_button.setMinimumHeight(30)
self.add_tag_button.setStyleSheet(button_style())
self.__add_field_button = QPushButton(Translations["field.add"])
self.__add_field_button.setEnabled(False)
self.__add_field_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.__add_field_button.setMinimumHeight(28)
self.__add_field_button.setStyleSheet(button_style())
self.add_field_button = ReturnButton(Translations["field.add"])
self.add_field_button.setEnabled(False)
self.add_field_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.add_field_button.setMinimumHeight(30)
self.add_field_button.setStyleSheet(button_style())
add_buttons_layout.addWidget(self.__add_tag_button)
add_buttons_layout.addWidget(self.__add_field_button)
add_buttons_layout.addWidget(self.add_tag_button)
add_buttons_layout.addWidget(self.add_field_button)
add_buttons_layout.addWidget(self.tag_search_box)
add_buttons_layout.addWidget(self.field_search_box)
preview_layout.addWidget(self._thumb)
info_layout.addWidget(self._ffmpeg_warning_widget)
info_layout.addWidget(self._file_attrs)
info_layout.addWidget(self._containers)
# Finalize Layout
preview_layout.addWidget(self.preview_thumb)
info_layout.addWidget(self.warning_banner)
info_layout.addWidget(self.file_attrs)
info_layout.addWidget(self.containers)
splitter.addWidget(preview_section)
splitter.addWidget(info_section)
splitter.setStretchFactor(1, 2)
root_layout = QVBoxLayout(self)
root_layout.setContentsMargins(0, 0, 0, 0)
root_layout.addWidget(splitter)
root_layout.addWidget(add_buttons_container)
self.__connect_callbacks()
def __connect_callbacks(self) -> None:
self.__add_field_button.clicked.connect(self._add_field_button_callback)
self.__add_tag_button.clicked.connect(self._add_tag_button_callback)
self._thumb.stats_updated.connect(self.__thumb_stats_updated_callback)
def _add_field_button_callback(self) -> None:
raise NotImplementedError()
def _add_tag_button_callback(self) -> None:
raise NotImplementedError()
def __thumb_stats_updated_callback(self, filepath: Path, stats: FileAttributeData) -> None:
if len(self._selected) != 1:
return
if filepath != self._thumb.current_file:
return
if self.__current_stats is None:
self.__current_stats = FileAttributeData()
if stats.width is not None:
self.__current_stats.width = stats.width
if stats.height is not None:
self.__current_stats.height = stats.height
if stats.duration is not None:
self.__current_stats.duration = stats.duration
self._file_attrs.update_stats(filepath, self.__current_stats)
def _set_selection_callback(self) -> None:
raise NotImplementedError()
def set_selection(self, selected: list[int], update_preview: bool = True) -> None:
"""Render the panel widgets with the newest data from the Library.
Args:
selected (list[int]): List of the IDs of the selected entries.
update_preview (bool): Should the file preview be updated?
(Only works with one or more items selected)
"""
self._selected = selected
try:
# No Items Selected
if len(selected) == 0:
self._thumb.hide_preview()
self.__current_stats = None
self._file_attrs.update_stats()
self._file_attrs.update_date_label()
self._containers.hide_containers()
self.add_buttons_enabled = False
# One Item Selected
elif len(selected) == 1:
entry_id = selected[0]
entry: Entry = unwrap(self.lib.get_entry(entry_id))
filepath: Path = unwrap(self.lib.library_dir) / entry.path
if filepath != self._thumb.current_file:
self.__current_stats = None
if update_preview:
stats: FileAttributeData = self._thumb.display_file(filepath)
self.__current_stats = stats
self._file_attrs.update_stats(filepath, stats)
self._file_attrs.update_date_label(filepath)
self._containers.update_from_entry(entry_id)
self._set_selection_callback()
self.add_buttons_enabled = True
# Multiple Selected Items
elif len(selected) > 1:
# items: list[Entry] = [self.lib.get_entry_full(x) for x in self.driver.selected]
self._thumb.hide_preview() # TODO: Render mixed selection
self.__current_stats = None
self._file_attrs.update_multi_selection(len(selected))
self._file_attrs.update_date_label()
self._containers.hide_containers() # TODO: Allow for mixed editing
self._set_selection_callback()
self.add_buttons_enabled = True
except Exception as e:
logger.error("[Preview Panel] Error updating selection", error=e)
traceback.print_exc()
@property
def add_buttons_enabled(self) -> bool: # needed for the tests
field = self.__add_field_button.isEnabled()
tag = self.__add_tag_button.isEnabled()
assert field == tag
return field
@add_buttons_enabled.setter
def add_buttons_enabled(self, enabled: bool) -> None:
self.__add_field_button.setEnabled(enabled)
self.__add_tag_button.setEnabled(enabled)
@property
def _file_attributes_widget(self) -> FileAttributes: # needed for the tests
"""Getter for the file attributes widget."""
return self._file_attrs
@property
def field_containers_widget(self) -> FieldContainers: # needed for the tests
"""Getter for the field containers widget."""
return self._containers
@property
def preview_thumb(self) -> PreviewThumb:
return self._thumb
self.addWidget(splitter)
self.addWidget(add_buttons_container)
+6 -3
View File
@@ -17,7 +17,7 @@ from tagstudio.core.media_types import MediaType
from tagstudio.qt.mixed.file_attributes import FileAttributeData
from tagstudio.qt.mixed.media_player import MediaPlayer
from tagstudio.qt.platform_strings import open_file_str, trash_term
from tagstudio.qt.previews.renderer import ThumbRenderer
from tagstudio.qt.qt_file_renderer import QtFileRenderer
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.stylesheets.rounded_pixmap_style import RoundedPixmapStyle
@@ -45,6 +45,7 @@ class PreviewThumbView(QWidget):
def __init__(self, library: Library, driver: "QtDriver") -> None:
super().__init__()
self._driver = driver
self.__img_button_size = (266, 266)
self.__image_ratio = 1.0
@@ -109,7 +110,7 @@ class PreviewThumbView(QWidget):
self.__media_player_page = QWidget()
self.__stacked_page_setup(self.__media_player_page, self.__media_player)
self.__thumb_renderer = ThumbRenderer(driver)
self.__thumb_renderer = QtFileRenderer(driver.lib, driver.settings)
self.__thumb_renderer.updated.connect(self.__thumb_renderer_updated_callback)
self.__thumb_renderer.updated_ratio.connect(self.__thumb_renderer_updated_ratio_callback)
@@ -160,6 +161,7 @@ class PreviewThumbView(QWidget):
layout.addWidget(widget)
layout.setAlignment(widget, Qt.AlignmentFlag.AlignCenter)
layout.setContentsMargins(0, 0, 0, 0)
widget.setCursor(Qt.CursorShape.PointingHandCursor)
page.setLayout(layout)
def __update_image_size(self, size: tuple[int, int]) -> None:
@@ -232,12 +234,13 @@ class PreviewThumbView(QWidget):
math.ceil(self.__img_button_size[1] * THUMB_SIZE_FACTOR),
)
# TODO: Make driver update the cache manager reference here instead of passing the driver.
self.__thumb_renderer.render(
self._driver.cache_manager,
time.time(),
filepath,
self.__rendered_res,
self.devicePixelRatio(),
update_on_ratio_change=True,
)
def __update_media_player(self, filepath: Path) -> None:
+32 -111
View File
@@ -1,7 +1,7 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import (
@@ -16,144 +16,65 @@ from PySide6.QtWidgets import (
QWidget,
)
from tagstudio.core.library.alchemy.library import Library
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.panel_modal import PanelWidget
from tagstudio.qt.views.stylesheets.stylesheets import list_button_style
if TYPE_CHECKING:
from tagstudio.qt.controllers.search_panel_controller import SearchPanel
pass
class SearchPanelView(PanelWidget):
def __init__(self, is_chooser: bool) -> None:
class SearchPanelView(QVBoxLayout):
def __init__(
self, placeholder_text: str, create_text: str = "", is_chooser: bool = True
) -> None:
self.is_chooser: bool = is_chooser
super().__init__()
self.__root_layout = QVBoxLayout(self)
self.__root_layout.setContentsMargins(6, 0, 6, 0)
self.setMinimumSize(300, 400)
self.setContentsMargins(6, 0, 6, 0)
# Limit container
self.__limit_container = QWidget()
self.__root_layout.addWidget(self.__limit_container)
self.__limit_layout = QHBoxLayout(self.__limit_container)
self.__limit_layout.setContentsMargins(0, 0, 0, 0)
self.__limit_layout.setSpacing(12)
self.__limit_layout.addStretch(1)
self.__limit_title = QLabel(Translations["home.search.view_limit"])
self.__limit_layout.addWidget(self.__limit_title)
self.limit_container = QWidget()
self.limit_layout = QHBoxLayout(self.limit_container)
self.limit_layout.setContentsMargins(0, 0, 0, 0)
self.limit_layout.setSpacing(12)
self.limit_layout.addStretch(1)
self.limit_title = QLabel(Translations["home.search.view_limit"])
self.limit_layout.addWidget(self.limit_title)
self.addWidget(self.limit_container)
# Limit dropdown
self.limit_combobox = QComboBox()
self.__limit_layout.addWidget(self.limit_combobox)
self.__limit_layout.addStretch(1)
self.limit_layout.addWidget(self.limit_combobox)
self.limit_layout.addStretch(1)
self.limit_combobox.setEditable(False)
# Search field
self.search_field = QLineEdit()
self.search_field.setPlaceholderText(placeholder_text)
self.search_field.setObjectName("search_field")
self.__root_layout.addWidget(self.search_field)
self.search_field.setMinimumSize(QSize(0, 32))
self.addWidget(self.search_field)
# Scroll area
self.__scroll_contents = QWidget()
self._scroll_layout = QVBoxLayout(self.__scroll_contents)
self._scroll_layout.setContentsMargins(6, 0, 6, 0)
self._scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.__scroll_area = QScrollArea()
self.__scroll_area.setWidget(self.__scroll_contents)
self.__root_layout.addWidget(self.__scroll_area)
self.__scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
self.__scroll_area.setWidgetResizable(True)
self.__scroll_area.setFrameShadow(QFrame.Shadow.Plain)
self.__scroll_area.setFrameShape(QFrame.Shape.NoFrame)
self.scroll_contents = QWidget()
self.scroll_layout = QVBoxLayout(self.scroll_contents)
self.scroll_layout.setContentsMargins(6, 0, 6, 0)
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_area = QScrollArea()
self.scroll_area.setWidget(self.scroll_contents)
self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
self.addWidget(self.scroll_area)
# Create button
self.create_button = QPushButton("")
self.create_button = QPushButton(create_text)
if not self.is_chooser:
self.__root_layout.addWidget(self.create_button)
self.addWidget(self.create_button)
# Create and add button
self.create_and_add_button_in_layout: bool = False
self.create_and_add_button = QPushButton()
self.create_and_add_button.setFlat(True)
self.create_and_add_button.setMinimumSize(22, 22)
self.create_and_add_button.setStyleSheet(list_button_style(border_style="dashed"))
@property
def scroll_layout(self) -> QVBoxLayout:
return self._scroll_layout
@property
def scroll_area(self) -> QScrollArea:
return self.__scroll_area
def connect_callbacks(self, controller: "SearchPanel[Any]") -> None: # pyright: ignore[reportExplicitAny]
self.limit_combobox.currentIndexChanged.connect(controller.on_limit_changed)
self.search_field.textChanged.connect(controller.on_search_query_changed)
self.search_field.returnPressed.connect(
lambda: controller.on_search_query_submitted(self.get_search_query())
)
self.create_button.clicked.connect(controller.on_item_create)
self.create_and_add_button.clicked.connect(
lambda: controller.on_item_create(add_to_entry=True)
)
def set_limit_items(self, limit_items: list[tuple[str, int]]) -> None:
# Remove existing limit items
for i in reversed(range(self.limit_combobox.count())):
self.limit_combobox.removeItem(i)
# Add new limit items
self.limit_combobox.addItems([limit_item[0] for limit_item in limit_items])
def get_limit_index(self) -> int:
return self.limit_combobox.currentIndex()
def set_limit_index(self, index: int) -> None:
self.limit_combobox.setCurrentIndex(index)
def focus_search_box(self, select_all: bool = False) -> None:
self.search_field.setFocus()
if select_all:
self.search_field.selectAll()
def get_search_query(self) -> str:
return self.search_field.text()
def clear_search_query(self) -> None:
self.search_field.setText("")
self.focus_search_box()
# Item list
def scroll_to(self, position: int) -> None:
self.__scroll_area.verticalScrollBar().setValue(position)
def get_item_widget(self, index: int, library: Library | None) -> Any: # pyright: ignore[reportUnusedParameter, reportExplicitAny]
raise NotImplementedError()
def add_create_and_add_button(self) -> None:
if self.create_and_add_button_in_layout:
return
self._scroll_layout.addWidget(self.create_and_add_button)
self.create_and_add_button.show()
self.create_and_add_button_in_layout = True
def remove_create_and_add_button(self) -> None:
if not self.create_and_add_button_in_layout:
return
self._scroll_layout.removeWidget(self.create_and_add_button)
self.create_and_add_button.hide()
self.create_and_add_button_in_layout = False
+166 -11
View File
@@ -8,7 +8,7 @@ from PySide6.QtGui import QColor, QGuiApplication
from tagstudio.core.enums import Theme
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.library.alchemy.models import Tag
from tagstudio.qt.models.palette import ColorType, UiColor, get_tag_color, get_ui_color
from tagstudio.qt.models.palette import ColorType, Palette, UiColor, get_tag_color, get_ui_color
# TODO: There's plenty of good opportunities here to consolidate similar styles.
# Work should be done to more closely use Qt's theming systems rather than override them.
@@ -53,18 +53,29 @@ def button_style() -> str:
border-radius: 6px;
font-weight: 500;
text-align: center;
padding: 0px 12px;
}}
QPushButton::hover{{
background-color: {Theme.COLOR_HOVER.value};
border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)};
border-style: solid;
border-width: 2px;
border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)};
padding: 0px 8px;
}}
QPushButton::pressed{{
background-color: {Theme.COLOR_PRESSED.value};
border-color: {get_ui_color(ColorType.LIGHT_ACCENT, UiColor.THEME_DARK)};
outline: none;
background-color: palette(light);
border-style: solid;
border-width: 2px;
border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)};
padding: 0px 8px;
}}
QPushButton::focus{{
outline: none;
border: solid;
border-width: 2px;
border-color: {Palette.accent()};
padding: 0px 8px;
}}
QPushButton::disabled{{
background-color: {Theme.COLOR_DISABLED_BG.value};
@@ -72,6 +83,40 @@ def button_style() -> str:
"""
def line_edit_style_main() -> str:
"""Style used for common QLineEdits."""
bg_color = (
Theme.COLOR_BG_DARK.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_BG_LIGHT.value
)
return f"""
QLineEdit{{
background: {bg_color};
border-radius: 6px;
font-weight: 500;
text-align: center;
padding: 0px 4px;
}}
QLineEdit::hover{{
border-style: solid;
border-width: 2px;
border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)};
padding: 0px 2px;
}}
QLineEdit::focus{{
border-style: solid;
border-width: 2px;
border-color: {Palette.accent()};
padding: 0px 2px;
}}
QLineEdit::disabled{{
background-color: {Theme.COLOR_DISABLED_BG.value};
}}
"""
def checkbox_style() -> str:
"""Style used for QCheckBoxes."""
primary_color = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
@@ -229,6 +274,7 @@ def line_edit_style() -> str:
def list_button_style(
color: QColor | None = None,
border_style: str = "solid",
italic: bool = False,
) -> str:
"""Style used for special QPushButtons found in lists."""
if color is None:
@@ -243,6 +289,7 @@ def list_button_style(
background: rgba{color.toTuple()};
color: rgba{text_color.toTuple()};
font-weight: 600;
{"font: italic;" if italic else ""}
border-color: rgba{border_color.toTuple()};
border-radius: 6px;
border-style: {border_style};
@@ -310,9 +357,9 @@ def tag_style(
border-radius: 6px;
border-style: {border_style};
border-width: 2px;
font-size: 13px;
padding-right: 4px;
padding-left: 4px;
font-size: 13px
}}
QPushButton::hover{{
border-color: rgba{highlight_color.toTuple()};
@@ -323,12 +370,9 @@ def tag_style(
border-color: rgba{primary_color.toTuple()};
}}
QPushButton::focus{{
padding-right: 0px;
padding-left: 0px;
outline-style: solid;
outline-width: 1px;
outline-radius: 4px;
outline-color: rgba{text_color.toTuple()};
outline: none;
border-width: 3px;
border-color: rgba{text_color.toTuple()};
}}
"""
@@ -366,6 +410,13 @@ def tag_remove_button_style(
"""
def widget_underline_style() -> str:
return f"""
background: {Palette.accent()};
border-radius: 2px;
"""
def title_line_edit_style() -> str:
"""Used to mimic an H3-like header style inside a QLineEdit."""
return """
@@ -374,6 +425,110 @@ 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
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_BG_LIGHT.value
)
return f"""
QWidget{"#" + object_name if object_name else ""}{{
background: {bg_color};
border-radius: 6px;
}}
"""
# TODO: Combine the autofill styles into one method?
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
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_BG_LIGHT.value
)
return f"""
QWidget{"#" + object_name if object_name else ""}{{
background: {bg_color};
border-top-left-radius: 6px;
border-top-right-radius: 6px;
border: none;
}}
"""
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
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_BG_LIGHT.value
)
return f"""
QWidget{"#" + object_name if object_name else ""}{{
background: {bg_color};
border-top-left-radius: 6px;
border-top-right-radius: 6px;
border: solid;
border-width: 2px 2px 0px 2px;
border-color: {Palette.accent()};
}}
"""
def autofill_line_edit_style() -> str:
"""Used for QLineEdits."""
bg_color = (
Theme.COLOR_BG_DARK.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_BG_LIGHT.value
)
return f"""
QLineEdit{{
background: {bg_color};
border-radius: 6px;
padding: 3px 6px;
}}
QLineEdit::focus{{
padding: 4px 4px;
border: solid;
border-width: 2px;
border-color: {Palette.accent()};
}}
"""
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
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_BG_LIGHT.value
)
return f"""
QLineEdit{{
background: {bg_color};
border-top-left-radius: 0px;
border-top-right-radius: 0px;
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
padding: 0px 0px 2px 6px;
}}
QLineEdit::focus{{
padding: 4px 4px;
border: solid;
border-width: 0px 2px 2px 2px;
border-color: {Palette.accent()};
}}
"""
def preview_warning_style() -> str:
return f"""
QWidget#ffmpeg_widget {{
@@ -0,0 +1,80 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import structlog
from PySide6.QtCore import Qt
from PySide6.QtGui import QPixmap
from PySide6.QtWidgets import QFrame, QHBoxLayout, QSizePolicy, QVBoxLayout, QWidget
from tagstudio.qt.controllers.autofill_line_edit import AutofillLineEdit
from tagstudio.qt.controllers.horizontal_scroll_area import HorizontalScrollArea
from tagstudio.qt.views.stylesheets.stylesheets import (
autofill_line_edit_style,
autofill_scroll_top_style,
)
logger = structlog.get_logger(__name__)
class SuggestBoxView(QVBoxLayout):
def __init__(self, placeholder_text: str = "") -> None:
super().__init__()
self.setContentsMargins(0, 0, 0, 0)
self.setSpacing(0)
scroll_area_style = """
QScrollArea{
background: transparent;
}
QScrollArea > QWidget > QWidget{
background: transparent;
}
"""
# Autocomplete ScrollArea
contents = QWidget()
self.content_layout = QHBoxLayout(contents)
self.content_layout.setSpacing(6)
self.content_layout.setAlignment(Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignLeft)
self.content_layout.setContentsMargins(0, 0, 0, 0)
scroll_area_container = QWidget()
scroll_area_container.setObjectName("container")
scroll_area_container_layout = QHBoxLayout(scroll_area_container)
scroll_area_container_layout.setContentsMargins(0, 0, 0, 0)
scroll_area_container_layout.setSpacing(0)
scroll_area_container.setStyleSheet(autofill_scroll_top_style("container"))
self.scroll_area = HorizontalScrollArea()
self.scroll_area.setStyleSheet(scroll_area_style)
self.scroll_area.setViewportMargins(2, 0, 2, 0)
scroll_area_container_layout.addWidget(self.scroll_area)
self.scroll_area.setWidget(contents)
search_bar_height = 28
underline_padding = 7
self.scroll_area.setMaximumHeight(search_bar_height + underline_padding)
self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.scroll_area.verticalScrollBar().setEnabled(False)
self.scroll_area.setContentsMargins(0, 0, 0, 0)
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
self.scroll_area.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
# Search Field
self.search_field = AutofillLineEdit(scroll_area_container)
self.search_field.setStyleSheet(autofill_line_edit_style())
self.search_field.setObjectName("search_field")
self.search_field.setMinimumHeight(search_bar_height)
self.search_field.setPlaceholderText(placeholder_text)
self.hint_icon_action = self.search_field.addAction(
QPixmap(), AutofillLineEdit.ActionPosition.TrailingPosition
)
self.scroll_area.setFocusProxy(self.search_field)
self.search_field.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.search_field.customContextMenuRequested.connect(self.search_field.show_action_menu)
# Finalize Layout
self.addWidget(scroll_area_container)
self.addWidget(self.search_field)
-5
View File
@@ -39,17 +39,12 @@ class TagBoxWidgetView(FieldWidget):
for tag in tags_:
tag_widget = TagWidget(tag, library=self.__lib, has_edit=True, has_remove=True)
tag_widget.on_click.connect(lambda t=tag: self._on_click(t))
tag_widget.on_remove.connect(lambda t=tag: self._on_remove(t))
tag_widget.on_edit.connect(lambda t=tag: self._on_edit(t))
tag_widget.search_for_tag_action.triggered.connect(
lambda checked=False, t=tag: self._on_search(t)
)
self.__root_layout.addWidget(tag_widget)
def _on_click(self, tag: Tag) -> None:
@@ -1,32 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from PySide6.QtWidgets import QWidget
from tagstudio.core.library.alchemy.library import Library
from tagstudio.qt.mixed.tag_widget import TagWidget
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.search_panel_view import SearchPanelView
class TagSearchPanelView(SearchPanelView):
def __init__(self, is_tag_chooser: bool) -> None:
super().__init__(is_tag_chooser)
self.search_field.setPlaceholderText(Translations["home.search_tags"])
self.create_button.setText(Translations["tag.create"])
def get_item_widget(self, index: int, library: Library | None) -> TagWidget:
"""Gets the item widget at a specific index."""
# Create any new item widgets needed up to the given index
if self._scroll_layout.count() <= index:
while self._scroll_layout.count() <= index:
pad_tag_widget = TagWidget(
tag=None, has_edit=True, has_remove=True, library=library
)
pad_tag_widget.setHidden(True)
self._scroll_layout.addWidget(pad_tag_widget)
tag_widget: QWidget = self._scroll_layout.itemAt(index).widget()
assert isinstance(tag_widget, TagWidget)
return tag_widget
@@ -0,0 +1,27 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QPushButton, QVBoxLayout, QWidget
from tagstudio.qt.views.stylesheets.stylesheets import widget_underline_style
class UnderlinedWidgetView(QVBoxLayout):
def __init__(self, widget: QWidget) -> None:
super().__init__()
self.setContentsMargins(0, 0, 0, 0)
self.setSpacing(3)
self.setAlignment(Qt.AlignmentFlag.AlignTop)
# HACK: I don't know why I can't just use a QFrame for the outline.
# The styling and sizing only seems to work if it's something like a QPushButton.
self.underline = QPushButton()
self.underline.setFlat(True)
self.underline.setDisabled(True)
self.underline.setMaximumHeight(4)
self.underline.setStyleSheet(widget_underline_style())
self.addWidget(widget)
self.addWidget(self.underline)
+164
View File
@@ -0,0 +1,164 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import tarfile
import zipfile
from io import BytesIO
from pathlib import Path
from typing import Literal
import py7zr
import py7zr.io
import rarfile
import structlog
from PIL import Image
from tagstudio.core.media_types import MediaCategories
from tagstudio.core.utils.types import unwrap
from tagstudio.renderers.raster_image import image_from_bytes
logger = structlog.get_logger(__name__)
type Archive = zipfile.ZipFile | rarfile.RarFile | SevenZipFile | TarFile
class SevenZipFile(py7zr.SevenZipFile):
"""Wrapper around py7zr.SevenZipFile to mimic zipfile.ZipFile's API."""
def __init__(self, filepath: Path, mode: Literal["r"]) -> None:
super().__init__(filepath, mode)
def read(self, name: str) -> bytes:
# SevenZipFile must be reset after every extraction
# See https://py7zr.readthedocs.io/en/stable/api.html#py7zr.SevenZipFile.extract
self.reset()
factory = py7zr.io.BytesIOFactory(limit=10485760) # 10 MiB
self.extract(targets=[name], factory=factory)
return factory.get(name).read()
class TarFile:
"""Wrapper around tarfile.TarFile to mimic zipfile.ZipFile's API."""
def __init__(self, filepath: Path, mode: Literal["r"]) -> None:
self.tar: tarfile.TarFile
self.filepath = filepath
self.mode: Literal["r"] = mode
def namelist(self) -> list[str]:
return self.tar.getnames()
def read(self, name: str) -> bytes:
return unwrap(self.tar.extractfile(name)).read()
def __enter__(self) -> "TarFile":
self.tar = tarfile.open(name=self.filepath, mode=self.mode).__enter__()
return self
def __exit__(self, *args) -> None: # pyright: ignore[reportUnknownParameterType, reportMissingParameterType]
self.tar.__exit__(*args)
def open_archive(filepath: Path, ext: str = "") -> Archive:
"""Open an archive with its corresponding archiver.
Args:
filepath (Path): The path to the archive.
ext (str): The file extension.
Returns:
Archive: The opened archive.
"""
archiver: type[Archive] = zipfile.ZipFile
if ext in {".7z", ".cb7", ".s7z"}:
archiver = SevenZipFile
elif ext in {".cbr", ".rar"}:
archiver = rarfile.RarFile
elif ext in {".cbt", ".tar", ".tgz"}:
archiver = TarFile
return archiver(filepath, "r")
def first_image_in_archive(archive: Archive) -> Image.Image | None:
"""Find and extract the first renderable image in the archive.
Args:
archive (Archive): The current archive.
Returns:
Image: The first renderable image in the archive.
"""
for file_name in archive.namelist(): # pyright: ignore[reportUnknownVariableType]
ext = Path(file_name).suffix
if MediaCategories.IMAGE_RASTER_TYPES.contains(ext):
image_data = archive.read(file_name) # pyright: ignore[reportUnknownVariableType]
return image_from_bytes(BytesIO(image_data))
return 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.
Args:
filepath (Path): The path to the archive.
image_names: (list[Path] | list[str] | None): List of embedded image names to search for.
ext (str): The file extension. Used to help determine more specific archive type.
Returns:
Image: The first image found in the archive.
"""
try:
with open_archive(filepath, ext) 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)
for image_name in image_names:
if image_name in archive.namelist():
file_data = archive.read(str(image_name)) # pyright: ignore[reportUnknownVariableType]
return image_from_bytes(BytesIO(file_data))
# If no images were found with the given names, fallback to the first image found.
if not image_names:
return first_image_in_archive(archive)
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return None
def apple_embedded_thumb(filepath: Path) -> Image.Image | None:
"""Extract and render an apple embedded thumbnail (iWork, Apple Creative Studio)."""
image_names: list[str] = [
"preview.jpg",
"QuickLook/Preview.heic",
"QuickLook/Thumbnail.jpg",
"QuickLook/Thumbnail.heic",
"QuickLook/Thumbnail.webp",
"QuickLook/Icon.webp",
]
return archive_thumb(filepath, image_names)
def krita_thumb(filepath: Path) -> Image.Image | None:
"""Extract and render a thumbnail for a Krita file."""
image_names = ["preview.png"]
return archive_thumb(filepath, image_names)
def open_doc_thumb(filepath: Path) -> Image.Image | None:
"""Extract and render a thumbnail for an OpenDocument file."""
image_names = ["Thumbnails/thumbnail.png"]
return archive_thumb(filepath, image_names)
def powerpoint_thumb(filepath: Path) -> Image.Image | None:
"""Extract and render a thumbnail for a Microsoft PowerPoint file."""
image_names = ["docProps/thumbnail.jpeg"]
return archive_thumb(filepath, image_names)
+149
View File
@@ -0,0 +1,149 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import math
from io import BytesIO
from pathlib import Path
from warnings import catch_warnings
import numpy as np
import structlog
from mutagen import flac, id3, mp4
from mutagen._util import MutagenError
from PIL import Image, ImageDraw
from tagstudio.renderers.vendored.pydub.audio_segment import (
_AudioSegment as AudioSegment, # pyright: ignore[reportPrivateUsage]
)
logger = structlog.get_logger(__name__)
def audio_album_thumb(filepath: Path, ext: str) -> Image.Image | None:
"""Return an album cover thumb from an audio file if a cover is present.
Args:
filepath (Path): The path of the file.
ext (str): The file extension (with leading ".").
"""
image: Image.Image | None = None
try:
if not filepath.is_file():
raise FileNotFoundError
artwork = None
if ext in [".mp3"]:
id3_tags: id3.ID3 = id3.ID3(filepath)
id3_covers: list = id3_tags.getall("APIC") # pyright: ignore[reportUnknownVariableType]
if id3_covers:
artwork = Image.open(BytesIO(id3_covers[0].data))
elif ext in [".flac"]:
flac_tags: flac.FLAC = flac.FLAC(filepath)
flac_covers: list = flac_tags.pictures # pyright: ignore[reportUnknownVariableType]
if flac_covers:
artwork = Image.open(BytesIO(flac_covers[0].data))
elif ext in [".mp4", ".m4a", ".aac"]:
mp4_tags: mp4.MP4 = mp4.MP4(filepath)
mp4_covers: list | None = mp4_tags.get("covr") # pyright: ignore[reportUnknownVariableType]
if mp4_covers:
artwork = Image.open(BytesIO(mp4_covers[0]))
if artwork:
image = artwork
except (
FileNotFoundError,
id3.ID3NoHeaderError, # pyright: ignore[reportPrivateImportUsage]
mp4.MP4MetadataError,
mp4.MP4StreamInfoError,
MutagenError,
) as e:
logger.error("Couldn't read album artwork", path=filepath, error=type(e).__name__)
return image
def audio_waveform_thumb(
filepath: Path, ext: str, size: int, pixel_ratio: float
) -> Image.Image | None:
"""Render a waveform image from an audio file.
Args:
filepath (Path): The path of the file.
ext (str): The file extension (with leading ".").
size (tuple[int,int]): The size of the thumbnail.
pixel_ratio (float): The screen pixel ratio.
"""
# BASE_SCALE used for drawing on a larger image and resampling down
# to provide an antialiased effect.
base_scale: int = 2
samples_per_bar: int = 3
size_scaled: int = size * base_scale
allow_small_min: bool = False
im: Image.Image | None = None
try:
bar_count: int = min(math.floor((size // pixel_ratio) / 5), 64)
audio = AudioSegment.from_file(filepath, ext[1:]) # pyright: ignore[reportUnknownVariableType]
data = np.frombuffer(buffer=audio._data, dtype=np.int16)
data_indices = np.linspace(1, len(data), num=bar_count * samples_per_bar)
bar_margin: float = ((size_scaled / (bar_count * 3)) * base_scale) / 2
line_width: float = ((size_scaled - bar_margin) / (bar_count * 3)) * base_scale
bar_height: float = (size_scaled) - (size_scaled // bar_margin)
count: int = 0
maximum_item: int = 0
max_array: list[int] = []
highest_line: int = 0
for i in range(-1, len(data_indices)):
d = data[math.ceil(data_indices[i]) - 1]
if count < samples_per_bar:
count = count + 1
with catch_warnings(record=True):
if abs(d) > maximum_item:
maximum_item = int(abs(d))
else:
max_array.append(maximum_item)
if maximum_item > highest_line:
highest_line = maximum_item
maximum_item = 0
count = 1
line_ratio = max(highest_line / bar_height, 1)
im = Image.new("RGB", (size_scaled, size_scaled), color="#000000")
draw = ImageDraw.Draw(im)
current_x = bar_margin
for item in max_array:
item_height = item / line_ratio
# If small minimums are not allowed, raise all values
# smaller than the line width to the same value.
if not allow_small_min:
item_height = max(item_height, line_width)
current_y = (bar_height - item_height + (size_scaled // bar_margin)) // 2
draw.rounded_rectangle(
(
current_x,
current_y,
(current_x + line_width),
(current_y + item_height),
),
radius=100 * base_scale,
fill=("#FF0000"),
outline=("#FFFF00"),
width=max(math.ceil(line_width / 6), base_scale),
)
current_x = current_x + line_width + bar_margin
im.resize((size, size), Image.Resampling.BILINEAR)
except Exception as e:
logger.error("Couldn't render waveform", path=filepath.name, error=type(e).__name__)
return im
+41
View File
@@ -0,0 +1,41 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from pathlib import Path
import structlog
from PIL import Image
from PySide6.QtCore import Qt
from PySide6.QtGui import QGuiApplication
from tagstudio.renderers.vendored.blender_thumbnailer import blend_thumb
logger = structlog.get_logger(__name__)
def blender_thumb(filepath: Path) -> Image.Image | None:
"""Get an emended thumbnail from a Blender file, if a thumbnail is present.
Args:
filepath (Path): The path of the file.
"""
bg_color: str = (
"#1e1e1e"
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else "#FFFFFF"
)
im: Image.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.paste(blend_image, mask=blend_image.getchannel(3))
im = bg
else:
logger.info(
f"[ThumbRenderer][BLENDER][INFO] {filepath.name} "
"Doesn't have an embedded thumbnail."
)
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
+41
View File
@@ -0,0 +1,41 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import sqlite3
from io import BytesIO
from pathlib import Path
import structlog
from PIL import Image
logger = structlog.get_logger(__name__)
def clip_studio_thumb(filepath: Path) -> Image.Image | None:
"""Extract the thumbnail from the SQLite database embedded in a .clip file.
Args:
filepath (Path): The path of the .clip file.
Returns:
Image: The embedded thumbnail, if extractable.
"""
im: Image.Image | None = None
try:
with open(filepath, "rb") as f:
blob = f.read()
sqlite_index = blob.find(b"SQLite format 3")
if sqlite_index == -1:
return im
with sqlite3.connect(":memory:") as conn:
conn.deserialize(blob[sqlite_index:])
thumbnail = conn.execute("SELECT ImageData FROM CanvasPreview").fetchone()
if thumbnail:
im = Image.open(BytesIO(thumbnail[0]))
conn.close()
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
+73
View File
@@ -0,0 +1,73 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import xml.etree.ElementTree as ET
from io import BytesIO
from pathlib import Path
from xml.etree.ElementTree import Element
import structlog
from PIL import Image
from tagstudio.core.media_types import MediaCategories
from tagstudio.core.utils.types import unwrap
from tagstudio.renderers.archive import Archive, first_image_in_archive, open_archive
from tagstudio.renderers.raster_image import image_from_bytes
logger = structlog.get_logger(__name__)
def epub_thumb(filepath: Path, ext: str) -> Image.Image | None:
"""Extracts the cover specified by ComicInfo.xml or first image found in the ePub file.
Args:
filepath (Path): The path to the ePub file.
ext (str): The file extension.
Returns:
Image: The cover specified in ComicInfo.xml,
the first image found in the ePub file, or None by default.
"""
im: Image.Image | None = None
try:
with open_archive(filepath, ext) 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")
if not im:
im = _cover_from_comic_info(archive, comic_info, "InnerCover")
if not im:
im = first_image_in_archive(archive)
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
def _cover_from_comic_info(
archive: Archive, comic_info: Element, cover_type: str
) -> Image.Image | None:
"""Extract the cover specified in ComicInfo.xml.
Args:
archive (Archive): The current ePub file.
comic_info (Element): The parsed ComicInfo.xml.
cover_type (str): The type of cover to load.
Returns:
Image: The cover specified in ComicInfo.xml.
"""
im: Image.Image | None = None
cover = comic_info.find(f"./*Page[@Type='{cover_type}']")
if cover is not None:
pages = [f for f in archive.namelist() if f != "ComicInfo.xml"] # pyright: ignore[reportUnknownVariableType]
page_name = pages[int(unwrap(cover.get("Image")))] # pyright: ignore[reportUnknownVariableType]
ext = Path(page_name).suffix
if MediaCategories.IMAGE_RASTER_TYPES.contains(ext):
image_data = archive.read(page_name) # pyright: ignore[reportUnknownVariableType]
im = image_from_bytes(BytesIO(image_data))
return im
+108
View File
@@ -0,0 +1,108 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import math
from pathlib import Path
from typing import cast
import numpy as np
import structlog
from PIL import Image, ImageDraw, ImageFont
from tagstudio.core.constants import FONT_SAMPLE_SIZES, FONT_SAMPLE_TEXT
from tagstudio.qt.helpers.color_overlay import auto_theme_overlay
from tagstudio.qt.helpers.text_wrapper import wrap_full_text
logger = structlog.get_logger(__name__)
def font_small_thumb(filepath: Path, size: int) -> Image.Image | None:
"""Render a small font preview ("Aa") thumbnail from a font file.
Args:
filepath (Path): The path of the file.
size (tuple[int,int]): The size of the thumbnail.
"""
im: Image.Image | None = None
try:
bg = Image.new("RGB", (size, size), color="#000000")
raw = Image.new("RGB", (size * 3, size * 3), color="#000000")
draw = ImageDraw.Draw(raw)
font = ImageFont.truetype(filepath, size=size)
# NOTE: While a stroke effect is desired, the text
# method only allows for outer strokes, which looks
# a bit weird when rendering fonts.
draw.text(
(size // 8, size // 8),
"Aa",
font=font,
fill="#FF0000",
# stroke_width=math.ceil(size / 96),
# stroke_fill="#FFFF00",
)
# NOTE: Change to getchannel(1) if using an outline.
data = np.asarray(raw.getchannel(0))
m, n = data.shape[:2]
col: np.ndarray = cast(np.ndarray, data.any(0))
row: np.ndarray = cast(np.ndarray, data.any(1))
cropped_data = np.asarray(raw)[
row.argmax() : m - row[::-1].argmax(),
col.argmax() : n - col[::-1].argmax(),
]
cropped_im: Image.Image = Image.fromarray(cropped_data, "RGB")
margin: int = math.ceil(size // 16)
orig_x, orig_y = cropped_im.size
new_x, new_y = (size, size)
if orig_x > orig_y:
new_x = size
new_y = math.ceil(size * (orig_y / orig_x))
elif orig_y > orig_x:
new_y = size
new_x = math.ceil(size * (orig_x / orig_y))
cropped_im = cropped_im.resize(
size=(new_x - (margin * 2), new_y - (margin * 2)),
resample=Image.Resampling.BILINEAR,
)
bg.paste(
cropped_im,
box=(margin, margin + ((size - new_y) // 2)),
)
im = bg
except OSError as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
def font_full_preview(filepath: Path, size: int) -> Image.Image | None:
"""Render a large font preview ("Alphabet") thumbnail from a font file.
Args:
filepath (Path): The path of the file.
size (tuple[int,int]): The size of the thumbnail.
"""
# Scale the sample font sizes to the preview image
# resolution,assuming the sizes are tuned for 256px.
im: Image.Image | None = None
try:
scaled_sizes: list[int] = [math.floor(x * (size / 256)) for x in FONT_SAMPLE_SIZES]
bg = Image.new("RGBA", (size, size), color="#00000000")
draw = ImageDraw.Draw(bg)
lines_of_padding = 2
y_offset = 0.0
for font_size in scaled_sizes:
font = ImageFont.truetype(filepath, size=font_size)
text_wrapped: str = wrap_full_text(FONT_SAMPLE_TEXT, font=font, width=size, draw=draw)
draw.multiline_text((0, y_offset), text_wrapped, font=font)
y_offset += (len(text_wrapped.split("\n")) + lines_of_padding) * draw.textbbox(
(0, 0), "A", font=font
)[-1]
im = auto_theme_overlay(bg, use_alpha=False)
except OSError as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
+58
View File
@@ -0,0 +1,58 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import os
import struct
import xml.etree.ElementTree as ET
import zlib
from pathlib import Path
import structlog
from PIL import Image
from tagstudio.core.utils.types import unwrap
logger = structlog.get_logger(__name__)
def medibang_paint_thumb(filepath: Path) -> Image.Image | None:
"""Extract the thumbnail from a .mdp file.
Args:
filepath (Path): The path of the .mdp file.
Returns:
Image: The embedded thumbnail.
"""
im: Image.Image | None = None
try:
with open(filepath, "rb") as f:
magic = struct.unpack("<7sx", f.read(8))[0]
if magic != b"mdipack":
return im
bin_header = struct.unpack("<LLL", f.read(12))
xml_header = ET.fromstring(f.read(bin_header[1]))
mdibin_count = len(xml_header.findall("./*Layer")) + 1
for _ in range(mdibin_count):
pac_header = struct.unpack("<3sxLLLL48s64s", f.read(132))
if not pac_header[6].startswith(b"thumb"):
f.seek(pac_header[3], os.SEEK_CUR)
continue
thumb_element = unwrap(xml_header.find("Thumb"))
dimensions = (
int(unwrap(thumb_element.get("width"))),
int(unwrap(thumb_element.get("height"))),
)
thumb_blob = f.read(pac_header[3])
if pac_header[2] == 1:
thumb_blob = zlib.decompress(thumb_blob, bufsize=pac_header[4])
im = Image.frombytes("RGBA", dimensions, thumb_blob, "raw", "BGRA")
break
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
+50
View File
@@ -0,0 +1,50 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import base64
import struct
import xml.etree.ElementTree as ET
from io import BytesIO
from pathlib import Path
import structlog
from PIL import Image
logger = structlog.get_logger(__name__)
def paint_dot_net_thumb(filepath: Path) -> Image.Image | None:
"""Extract the base64-encoded thumbnail from a .pdn file header.
Args:
filepath (Path): The path of the .pdn file.
Returns:
Image: the decoded PNG thumbnail or None by default.
"""
im: Image.Image | None = None
with open(filepath, "rb") as f:
try:
# First 4 bytes are the magic number
if f.read(4) != b"PDN3":
return im
# Header length is a little-endian 24-bit int
header_size = struct.unpack("<i", f.read(3) + b"\x00")[0]
thumb_element = ET.fromstring(f.read(header_size)).find("./*thumb")
if thumb_element is None:
return im
encoded_png = thumb_element.get("png")
if encoded_png:
decoded_png = base64.b64decode(encoded_png)
im = Image.open(BytesIO(decoded_png))
if im.mode == "RGBA":
new_bg = Image.new("RGB", im.size, color="#1e1e1e")
new_bg.paste(im, mask=im.getchannel(3))
im = new_bg
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
+66
View File
@@ -0,0 +1,66 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# NOTE: This file contains Qt imports because Qt is used as the vector renderer in this case.
# This is NOT considered part of the Qt frontend, but is technically tangled with the Qt import.
from io import BytesIO
from pathlib import Path
import structlog
from PIL import Image
from PySide6.QtCore import QBuffer, QFile, QFileDevice, QIODeviceBase, QSizeF
from PySide6.QtGui import QImage
from PySide6.QtPdf import QPdfDocument, QPdfDocumentRenderOptions
from tagstudio.qt.helpers.image_effects import replace_transparent_pixels
logger = structlog.get_logger(__name__)
def pdf_thumb(filepath: Path, size: int, ext: str) -> Image.Image | None:
"""Render a thumbnail for a PDF or Adobe Illustrator file.
filepath (Path): The path of the file.
size (int): The size of the icon.
ext (str): The file extension.
"""
im: Image.Image | None = None
file: QFile = QFile(filepath)
success: bool = file.open(QIODeviceBase.OpenModeFlag.ReadOnly, QFileDevice.Permission.ReadUser)
if not success:
logger.error("Couldn't render thumbnail", filepath=filepath)
return im
document: QPdfDocument = QPdfDocument()
document.load(file)
file.close()
# Transform page_size in points to pixels with proper aspect ratio
page_size: QSizeF = document.pagePointSize(0)
ratio_hw: float = page_size.height() / page_size.width()
if ratio_hw >= 1:
page_size *= size / page_size.height()
else:
page_size *= size / page_size.width()
# Enlarge image for anti-aliasing
scale_factor = 2.5 if ext in {".pdf"} else 1
page_size *= scale_factor
# Render image with no anti-aliasing for speed
render_options: QPdfDocumentRenderOptions = QPdfDocumentRenderOptions()
render_options.setRenderFlags(
QPdfDocumentRenderOptions.RenderFlag.TextAliased
| QPdfDocumentRenderOptions.RenderFlag.ImageAliased
| QPdfDocumentRenderOptions.RenderFlag.PathAliased
)
# Convert QImage to PIL Image
q_image: QImage = document.render(0, page_size.toSize(), render_options)
buffer: QBuffer = QBuffer()
buffer.open(QBuffer.OpenModeFlag.ReadWrite)
try:
q_image.save(buffer, "PNG") # pyright: ignore
im = Image.open(BytesIO(buffer.buffer().data()))
finally:
buffer.close()
# Replace transparent pixels with white (otherwise Background defaults to transparent)
return replace_transparent_pixels(im)
+126
View File
@@ -0,0 +1,126 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import os
from io import BytesIO
from pathlib import Path
import cv2
import numpy as np
import rawpy
import structlog
from PIL import Image, ImageOps, UnidentifiedImageError
from PIL.Image import DecompressionBombError
from pillow_heif import register_heif_opener # pyright: ignore[reportUnknownVariableType]
from rawpy import (
LibRawFileUnsupportedError, # pyright: ignore[reportPrivateImportUsage]
LibRawIOError, # pyright: ignore[reportPrivateImportUsage]
)
from tagstudio.core.utils.types import unwrap
logger = structlog.get_logger(__name__)
try:
import pillow_jxl # noqa: F401 # pyright: ignore
except ImportError as e:
logger.error('[ThumbRenderer] Could not import the "pillow_jxl" module', error=e)
register_heif_opener()
os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1"
def raster_image_thumb(filepath: Path) -> Image.Image | None:
"""Render a thumbnail for a standard image type.
Args:
filepath (Path): The path of the file.
"""
im: Image.Image | None = None
try:
with filepath.open("rb") as file:
im = image_from_bytes(BytesIO(file.read()))
except (
FileNotFoundError,
UnidentifiedImageError,
DecompressionBombError,
NotImplementedError,
) as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
def exr_image_thumb(filepath: Path) -> Image.Image | None:
"""Render a thumbnail for a EXR image type.
Args:
filepath (Path): The path of the file.
"""
im: Image.Image | None = None
try:
# Load the EXR data to an array and rotate the color space from BGRA -> RGBA
raw_array = cv2.imread(str(filepath), cv2.IMREAD_UNCHANGED)
assert raw_array is not None
raw_array[..., :3] = raw_array[..., 2::-1]
# Correct the gamma of the raw array
gamma = 2.2
array_gamma = np.power(np.clip(raw_array, 0, 1), 1 / gamma)
array = (array_gamma * 255).astype(np.uint8)
im = Image.fromarray(array, mode="RGBA")
# Paste solid background
if im.mode == "RGBA":
new_bg = Image.new("RGB", im.size, color="#1e1e1e")
new_bg.paste(im, mask=im.getchannel(3))
im = new_bg
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
def raw_image_thumb(filepath: Path) -> Image.Image | None:
"""Render a thumbnail for a RAW image type.
Args:
filepath (Path): The path of the file.
"""
im: Image.Image | None = None
try:
with rawpy.imread(str(filepath)) as raw:
rgb = raw.postprocess(use_camera_wb=True)
im = Image.frombytes(
"RGB",
(rgb.shape[1], rgb.shape[0]),
rgb,
decoder_name="raw",
)
except (
DecompressionBombError,
LibRawIOError,
LibRawFileUnsupportedError,
) as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
def image_from_bytes(image_data: BytesIO) -> Image.Image:
"""Load a raster image and add a background if it's transparent.
Args:
image_data (BytesIO): The binary image data.
Returns:
Image.Image: The loaded raster image, with a background if needed.
"""
im: Image.Image = Image.open(image_data)
if im.mode != "RGB" and im.mode != "RGBA":
im = im.convert(mode="RGBA")
if im.mode == "RGBA":
new_bg = Image.new("RGB", im.size, color="#1e1e1e")
new_bg.paste(im, mask=im.getchannel(3))
im = new_bg
return unwrap(ImageOps.exif_transpose(im))
+30
View File
@@ -0,0 +1,30 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from pathlib import Path
import srctools
import structlog
from PIL import Image
logger = structlog.get_logger(__name__)
def vtf_thumb(filepath: Path) -> Image.Image | None:
"""Extract and render a thumbnail for VTF (Valve Texture Format) images.
Uses the srctools library for reading VTF files.
Args:
filepath (Path): The path of the file.
"""
im: Image.Image | None = None
try:
with open(filepath, "rb") as f:
vtf = srctools.VTF.read(f)
im = vtf.get(frame=0).to_PIL()
except (ValueError, FileNotFoundError) as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
+55
View File
@@ -0,0 +1,55 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from pathlib import Path
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 tagstudio.core.utils.encoding import detect_char_encoding
logger = structlog.get_logger(__name__)
def text_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 = (
"#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"
)
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__)
return im
+56
View File
@@ -0,0 +1,56 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# NOTE: This file contains Qt imports because Qt is used as the vector renderer in this case.
# This is NOT considered part of the Qt frontend, but is technically tangled with the Qt import.
from io import BytesIO
from pathlib import Path
import structlog
from PIL import (
Image,
UnidentifiedImageError,
)
from PySide6.QtCore import QBuffer, Qt
from PySide6.QtGui import QImage, QPainter
from PySide6.QtSvg import QSvgRenderer
logger = structlog.get_logger(__name__)
def vector_image_thumb(filepath: Path, size: int) -> Image.Image:
"""Render a thumbnail for a vector image, such as SVG.
Args:
filepath (Path): The path of the file.
size (tuple[int,int]): The size of the thumbnail.
"""
im: Image.Image | None = None
# Create an image to draw the svg to and a painter to do the drawing
q_image: QImage = QImage(size, size, QImage.Format.Format_ARGB32)
q_image.fill("#1e1e1e")
# Create an svg renderer, then render to the painter
svg: QSvgRenderer = QSvgRenderer(str(filepath))
if not svg.isValid():
raise UnidentifiedImageError
painter: QPainter = QPainter(q_image)
svg.setAspectRatioMode(Qt.AspectRatioMode.KeepAspectRatio)
svg.render(painter)
painter.end()
# Write the image to a buffer as png
buffer: QBuffer = QBuffer()
buffer.open(QBuffer.OpenModeFlag.ReadWrite)
q_image.save(buffer, "PNG") # pyright: ignore[reportCallIssue, reportArgumentType]
# Load the image from the buffer
im = Image.new("RGB", (size, size), color="#1e1e1e")
im.paste(Image.open(BytesIO(buffer.data().data())))
im = im.convert(mode="RGB")
buffer.close()
return im
@@ -1,33 +1,29 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: (c) 2017 Blender Foundation
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# <pep8 compliant>
## This file is a modified script that gets the thumbnail data stored in a blend file
"""Extract an embedded thumbnail from a Blender file."""
import gzip
import os
import struct
from io import BufferedReader
from pathlib import Path
from PIL import Image, ImageOps
def blend_extract_thumb(path) -> tuple[bytes | None, int, int]:
def blend_extract_thumb(path: Path | str) -> tuple[bytes | None, int, int]:
rend = b"REND"
test = b"TEST"
blendfile: BufferedReader | gzip.GzipFile = open(path, "rb") # noqa: SIM115
blendfile: BufferedReader | gzip.GzipFile = open(path, "rb")
head = blendfile.read(12)
if head[0:2] == b"\x1f\x8b": # gzip magic
blendfile.close()
blendfile = gzip.GzipFile("", "rb", 0, open(path, "rb")) # noqa: SIM115
blendfile = gzip.GzipFile("", "rb", 0, open(path, "rb"))
head = blendfile.read(12)
if not head.startswith(b"BLENDER"):
@@ -82,7 +78,7 @@ def blend_extract_thumb(path) -> tuple[bytes | None, int, int]:
return image_buffer, x, y
def blend_thumb(file_in) -> Image.Image | None:
def blend_thumb(file_in: Path | str) -> Image.Image | None:
buf, width, height = blend_extract_thumb(file_in)
if buf is None:
return None
@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: (c) 2022 Karl Kroening (kkroening)
# SPDX-FileCopyrightText: (c) 2022 Karl Kroening (kkroening)
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# Vendored from ffmpeg-python and ffmpeg-python PR#790 by amamic1803
@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: (c) 2022 James Robert (jiaaro)
# SPDX-FileCopyrightText: (c) 2022 James Robert (jiaaro), http://jiaaro.com
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# Vendored from pydub
@@ -43,7 +43,7 @@ from pydub.utils import (
)
from tagstudio.core.utils.silent_subprocess import silent_popen
from tagstudio.qt.previews.vendored.pydub.utils import _mediainfo_json
from tagstudio.renderers.vendored.pydub.utils import _mediainfo_json
basestring = str
xrange = range
@@ -256,7 +256,7 @@ class _AudioSegment:
self.sample_width = 4
self.frame_width = self.channels * self.sample_width
super(_AudioSegment, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
@property
def raw_data(self):
@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: (c) 2011 James Robert, http://jiaaro.com
# SPDX-FileCopyrightText: (c) 2011 James Robert (jiaaro), http://jiaaro.com
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# Vendored from pydub
+55
View File
@@ -0,0 +1,55 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import math
from pathlib import Path
import cv2
import structlog
from cv2.typing import MatLike
from PIL import Image, UnidentifiedImageError
from PIL.Image import DecompressionBombError
from tagstudio.qt.helpers.file_tester import is_readable_video
logger = structlog.get_logger(__name__)
def video_thumb(filepath: Path) -> Image.Image | None:
"""Render a thumbnail for a video file.
Args:
filepath (Path): The path of the file.
"""
im: Image.Image | None = None
frame: MatLike | None = None
try:
if is_readable_video(filepath):
video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG)
# TODO: Move this check to is_readable_video()
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,
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2),
)
# NOTE: 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.
max_frame_seek: int = 10
for i in range(
0,
min(max_frame_seek, math.floor(video.get(cv2.CAP_PROP_FRAME_COUNT))),
):
success, frame = video.read()
if not success:
video.set(cv2.CAP_PROP_POS_FRAMES, i)
else:
break
if frame is not None:
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
im = Image.fromarray(frame)
except (UnidentifiedImageError, cv2.error, DecompressionBombError, OSError) as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Some files were not shown because too many files have changed in this diff Show More