diff --git a/LICENSES/CC-BY-SA-4.0.txt b/LICENSES/CC-BY-SA-4.0.txt
deleted file mode 100644
index 2d58298e6e..0000000000
--- a/LICENSES/CC-BY-SA-4.0.txt
+++ /dev/null
@@ -1,428 +0,0 @@
-Attribution-ShareAlike 4.0 International
-
-=======================================================================
-
-Creative Commons Corporation ("Creative Commons") is not a law firm and
-does not provide legal services or legal advice. Distribution of
-Creative Commons public licenses does not create a lawyer-client or
-other relationship. Creative Commons makes its licenses and related
-information available on an "as-is" basis. Creative Commons gives no
-warranties regarding its licenses, any material licensed under their
-terms and conditions, or any related information. Creative Commons
-disclaims all liability for damages resulting from their use to the
-fullest extent possible.
-
-Using Creative Commons Public Licenses
-
-Creative Commons public licenses provide a standard set of terms and
-conditions that creators and other rights holders may use to share
-original works of authorship and other material subject to copyright
-and certain other rights specified in the public license below. The
-following considerations are for informational purposes only, are not
-exhaustive, and do not form part of our licenses.
-
- Considerations for licensors: Our public licenses are
- intended for use by those authorized to give the public
- permission to use material in ways otherwise restricted by
- copyright and certain other rights. Our licenses are
- irrevocable. Licensors should read and understand the terms
- and conditions of the license they choose before applying it.
- Licensors should also secure all rights necessary before
- applying our licenses so that the public can reuse the
- material as expected. Licensors should clearly mark any
- material not subject to the license. This includes other CC-
- licensed material, or material used under an exception or
- limitation to copyright. More considerations for licensors:
- wiki.creativecommons.org/Considerations_for_licensors
-
- Considerations for the public: By using one of our public
- licenses, a licensor grants the public permission to use the
- licensed material under specified terms and conditions. If
- the licensor's permission is not necessary for any reason--for
- example, because of any applicable exception or limitation to
- copyright--then that use is not regulated by the license. Our
- licenses grant only permissions under copyright and certain
- other rights that a licensor has authority to grant. Use of
- the licensed material may still be restricted for other
- reasons, including because others have copyright or other
- rights in the material. A licensor may make special requests,
- such as asking that all changes be marked or described.
- Although not required by our licenses, you are encouraged to
- respect those requests where reasonable. More considerations
- for the public:
- wiki.creativecommons.org/Considerations_for_licensees
-
-=======================================================================
-
-Creative Commons Attribution-ShareAlike 4.0 International Public
-License
-
-By exercising the Licensed Rights (defined below), You accept and agree
-to be bound by the terms and conditions of this Creative Commons
-Attribution-ShareAlike 4.0 International Public License ("Public
-License"). To the extent this Public License may be interpreted as a
-contract, You are granted the Licensed Rights in consideration of Your
-acceptance of these terms and conditions, and the Licensor grants You
-such rights in consideration of benefits the Licensor receives from
-making the Licensed Material available under these terms and
-conditions.
-
-
-Section 1 -- Definitions.
-
- a. Adapted Material means material subject to Copyright and Similar
- Rights that is derived from or based upon the Licensed Material
- and in which the Licensed Material is translated, altered,
- arranged, transformed, or otherwise modified in a manner requiring
- permission under the Copyright and Similar Rights held by the
- Licensor. For purposes of this Public License, where the Licensed
- Material is a musical work, performance, or sound recording,
- Adapted Material is always produced where the Licensed Material is
- synched in timed relation with a moving image.
-
- b. Adapter's License means the license You apply to Your Copyright
- and Similar Rights in Your contributions to Adapted Material in
- accordance with the terms and conditions of this Public License.
-
- c. BY-SA Compatible License means a license listed at
- creativecommons.org/compatiblelicenses, approved by Creative
- Commons as essentially the equivalent of this Public License.
-
- d. Copyright and Similar Rights means copyright and/or similar rights
- closely related to copyright including, without limitation,
- performance, broadcast, sound recording, and Sui Generis Database
- Rights, without regard to how the rights are labeled or
- categorized. For purposes of this Public License, the rights
- specified in Section 2(b)(1)-(2) are not Copyright and Similar
- Rights.
-
- e. Effective Technological Measures means those measures that, in the
- absence of proper authority, may not be circumvented under laws
- fulfilling obligations under Article 11 of the WIPO Copyright
- Treaty adopted on December 20, 1996, and/or similar international
- agreements.
-
- f. Exceptions and Limitations means fair use, fair dealing, and/or
- any other exception or limitation to Copyright and Similar Rights
- that applies to Your use of the Licensed Material.
-
- g. License Elements means the license attributes listed in the name
- of a Creative Commons Public License. The License Elements of this
- Public License are Attribution and ShareAlike.
-
- h. Licensed Material means the artistic or literary work, database,
- or other material to which the Licensor applied this Public
- License.
-
- i. Licensed Rights means the rights granted to You subject to the
- terms and conditions of this Public License, which are limited to
- all Copyright and Similar Rights that apply to Your use of the
- Licensed Material and that the Licensor has authority to license.
-
- j. Licensor means the individual(s) or entity(ies) granting rights
- under this Public License.
-
- k. Share means to provide material to the public by any means or
- process that requires permission under the Licensed Rights, such
- as reproduction, public display, public performance, distribution,
- dissemination, communication, or importation, and to make material
- available to the public including in ways that members of the
- public may access the material from a place and at a time
- individually chosen by them.
-
- l. Sui Generis Database Rights means rights other than copyright
- resulting from Directive 96/9/EC of the European Parliament and of
- the Council of 11 March 1996 on the legal protection of databases,
- as amended and/or succeeded, as well as other essentially
- equivalent rights anywhere in the world.
-
- m. You means the individual or entity exercising the Licensed Rights
- under this Public License. Your has a corresponding meaning.
-
-
-Section 2 -- Scope.
-
- a. License grant.
-
- 1. Subject to the terms and conditions of this Public License,
- the Licensor hereby grants You a worldwide, royalty-free,
- non-sublicensable, non-exclusive, irrevocable license to
- exercise the Licensed Rights in the Licensed Material to:
-
- a. reproduce and Share the Licensed Material, in whole or
- in part; and
-
- b. produce, reproduce, and Share Adapted Material.
-
- 2. Exceptions and Limitations. For the avoidance of doubt, where
- Exceptions and Limitations apply to Your use, this Public
- License does not apply, and You do not need to comply with
- its terms and conditions.
-
- 3. Term. The term of this Public License is specified in Section
- 6(a).
-
- 4. Media and formats; technical modifications allowed. The
- Licensor authorizes You to exercise the Licensed Rights in
- all media and formats whether now known or hereafter created,
- and to make technical modifications necessary to do so. The
- Licensor waives and/or agrees not to assert any right or
- authority to forbid You from making technical modifications
- necessary to exercise the Licensed Rights, including
- technical modifications necessary to circumvent Effective
- Technological Measures. For purposes of this Public License,
- simply making modifications authorized by this Section 2(a)
- (4) never produces Adapted Material.
-
- 5. Downstream recipients.
-
- a. Offer from the Licensor -- Licensed Material. Every
- recipient of the Licensed Material automatically
- receives an offer from the Licensor to exercise the
- Licensed Rights under the terms and conditions of this
- Public License.
-
- b. Additional offer from the Licensor -- Adapted Material.
- Every recipient of Adapted Material from You
- automatically receives an offer from the Licensor to
- exercise the Licensed Rights in the Adapted Material
- under the conditions of the Adapter's License You apply.
-
- c. No downstream restrictions. You may not offer or impose
- any additional or different terms or conditions on, or
- apply any Effective Technological Measures to, the
- Licensed Material if doing so restricts exercise of the
- Licensed Rights by any recipient of the Licensed
- Material.
-
- 6. No endorsement. Nothing in this Public License constitutes or
- may be construed as permission to assert or imply that You
- are, or that Your use of the Licensed Material is, connected
- with, or sponsored, endorsed, or granted official status by,
- the Licensor or others designated to receive attribution as
- provided in Section 3(a)(1)(A)(i).
-
- b. Other rights.
-
- 1. Moral rights, such as the right of integrity, are not
- licensed under this Public License, nor are publicity,
- privacy, and/or other similar personality rights; however, to
- the extent possible, the Licensor waives and/or agrees not to
- assert any such rights held by the Licensor to the limited
- extent necessary to allow You to exercise the Licensed
- Rights, but not otherwise.
-
- 2. Patent and trademark rights are not licensed under this
- Public License.
-
- 3. To the extent possible, the Licensor waives any right to
- collect royalties from You for the exercise of the Licensed
- Rights, whether directly or through a collecting society
- under any voluntary or waivable statutory or compulsory
- licensing scheme. In all other cases the Licensor expressly
- reserves any right to collect such royalties.
-
-
-Section 3 -- License Conditions.
-
-Your exercise of the Licensed Rights is expressly made subject to the
-following conditions.
-
- a. Attribution.
-
- 1. If You Share the Licensed Material (including in modified
- form), You must:
-
- a. retain the following if it is supplied by the Licensor
- with the Licensed Material:
-
- i. identification of the creator(s) of the Licensed
- Material and any others designated to receive
- attribution, in any reasonable manner requested by
- the Licensor (including by pseudonym if
- designated);
-
- ii. a copyright notice;
-
- iii. a notice that refers to this Public License;
-
- iv. a notice that refers to the disclaimer of
- warranties;
-
- v. a URI or hyperlink to the Licensed Material to the
- extent reasonably practicable;
-
- b. indicate if You modified the Licensed Material and
- retain an indication of any previous modifications; and
-
- c. indicate the Licensed Material is licensed under this
- Public License, and include the text of, or the URI or
- hyperlink to, this Public License.
-
- 2. You may satisfy the conditions in Section 3(a)(1) in any
- reasonable manner based on the medium, means, and context in
- which You Share the Licensed Material. For example, it may be
- reasonable to satisfy the conditions by providing a URI or
- hyperlink to a resource that includes the required
- information.
-
- 3. If requested by the Licensor, You must remove any of the
- information required by Section 3(a)(1)(A) to the extent
- reasonably practicable.
-
- b. ShareAlike.
-
- In addition to the conditions in Section 3(a), if You Share
- Adapted Material You produce, the following conditions also apply.
-
- 1. The Adapter's License You apply must be a Creative Commons
- license with the same License Elements, this version or
- later, or a BY-SA Compatible License.
-
- 2. You must include the text of, or the URI or hyperlink to, the
- Adapter's License You apply. You may satisfy this condition
- in any reasonable manner based on the medium, means, and
- context in which You Share Adapted Material.
-
- 3. You may not offer or impose any additional or different terms
- or conditions on, or apply any Effective Technological
- Measures to, Adapted Material that restrict exercise of the
- rights granted under the Adapter's License You apply.
-
-
-Section 4 -- Sui Generis Database Rights.
-
-Where the Licensed Rights include Sui Generis Database Rights that
-apply to Your use of the Licensed Material:
-
- a. for the avoidance of doubt, Section 2(a)(1) grants You the right
- to extract, reuse, reproduce, and Share all or a substantial
- portion of the contents of the database;
-
- b. if You include all or a substantial portion of the database
- contents in a database in which You have Sui Generis Database
- Rights, then the database in which You have Sui Generis Database
- Rights (but not its individual contents) is Adapted Material,
- including for purposes of Section 3(b); and
-
- c. You must comply with the conditions in Section 3(a) if You Share
- all or a substantial portion of the contents of the database.
-
-For the avoidance of doubt, this Section 4 supplements and does not
-replace Your obligations under this Public License where the Licensed
-Rights include other Copyright and Similar Rights.
-
-
-Section 5 -- Disclaimer of Warranties and Limitation of Liability.
-
- a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
- EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
- AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
- ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
- IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
- WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
- PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
- ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
- KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
- ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
-
- b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
- TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
- NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
- INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
- COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
- USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
- ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
- DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
- IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
-
- c. The disclaimer of warranties and limitation of liability provided
- above shall be interpreted in a manner that, to the extent
- possible, most closely approximates an absolute disclaimer and
- waiver of all liability.
-
-
-Section 6 -- Term and Termination.
-
- a. This Public License applies for the term of the Copyright and
- Similar Rights licensed here. However, if You fail to comply with
- this Public License, then Your rights under this Public License
- terminate automatically.
-
- b. Where Your right to use the Licensed Material has terminated under
- Section 6(a), it reinstates:
-
- 1. automatically as of the date the violation is cured, provided
- it is cured within 30 days of Your discovery of the
- violation; or
-
- 2. upon express reinstatement by the Licensor.
-
- For the avoidance of doubt, this Section 6(b) does not affect any
- right the Licensor may have to seek remedies for Your violations
- of this Public License.
-
- c. For the avoidance of doubt, the Licensor may also offer the
- Licensed Material under separate terms or conditions or stop
- distributing the Licensed Material at any time; however, doing so
- will not terminate this Public License.
-
- d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
- License.
-
-
-Section 7 -- Other Terms and Conditions.
-
- a. The Licensor shall not be bound by any additional or different
- terms or conditions communicated by You unless expressly agreed.
-
- b. Any arrangements, understandings, or agreements regarding the
- Licensed Material not stated herein are separate from and
- independent of the terms and conditions of this Public License.
-
-
-Section 8 -- Interpretation.
-
- a. For the avoidance of doubt, this Public License does not, and
- shall not be interpreted to, reduce, limit, restrict, or impose
- conditions on any use of the Licensed Material that could lawfully
- be made without permission under this Public License.
-
- b. To the extent possible, if any provision of this Public License is
- deemed unenforceable, it shall be automatically reformed to the
- minimum extent necessary to make it enforceable. If the provision
- cannot be reformed, it shall be severed from this Public License
- without affecting the enforceability of the remaining terms and
- conditions.
-
- c. No term or condition of this Public License will be waived and no
- failure to comply consented to unless expressly agreed to by the
- Licensor.
-
- d. Nothing in this Public License constitutes or may be interpreted
- as a limitation upon, or waiver of, any privileges and immunities
- that apply to the Licensor or You, including from the legal
- processes of any jurisdiction or authority.
-
-
-=======================================================================
-
-Creative Commons is not a party to its public
-licenses. Notwithstanding, Creative Commons may elect to apply one of
-its public licenses to material it publishes and in those instances
-will be considered the “Licensor.” The text of the Creative Commons
-public licenses is dedicated to the public domain under the CC0 Public
-Domain Dedication. Except for the limited purpose of indicating that
-material is shared under a Creative Commons public license or as
-otherwise permitted by the Creative Commons policies published at
-creativecommons.org/policies, Creative Commons does not authorize the
-use of the trademark "Creative Commons" or any other trademark or logo
-of Creative Commons without its prior written consent including,
-without limitation, in connection with any unauthorized modifications
-to any of its public licenses or any other arrangements,
-understandings, or agreements concerning use of licensed material. For
-the avoidance of doubt, this paragraph does not form part of the
-public licenses.
-
-Creative Commons may be contacted at creativecommons.org.
-
diff --git a/README.md b/README.md
index 57b2d3b94e..40ccdafead 100644
--- a/README.md
+++ b/README.md
@@ -69,7 +69,7 @@ Tags currently consist of the following attributes:
- **Parent Tags**: Other tags in which this tag inherits from. In practice, this means that this tag can be substituted in searches for any listed parent tags.
- Parent tags checked with the "disambiguation" checkbox 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)".
-- **Is Category**: A property that when checked, treats this tag as a category in the preview panel.
+- **Is Category**: A property that when checked, treats this tag as a category in the Inspector panel.
Fields, like tags, are additional pieces of custom metadata that you can add to your file entries. Fields currently have several hardcoded names (e.g. "Title", "Author", "Series") but custom field names are planned for an upcoming update.
@@ -108,7 +108,7 @@ You can manage your library of tags from opening the "Tag Manager" panel from Ed
### Editing Tags
-To edit a tag, click on it inside the preview panel or right-click the tag and select "Edit Tag" from the context menu.
+To edit a tag, click on it inside the Inspector or right-click the tag and select "Edit Tag" from the context menu.
### Adding Tags to File Entries
@@ -116,11 +116,11 @@ Access the "Add Tag" search box by either clicking on the "Add Tag" button at th
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 Enter/Return key to add the top-most tag and reset the tag search. Press Enter/Return 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!
-To remove a tag from a file entry, hover over the tag in the preview panel and click on the "-" icon that appears.
+To remove a tag from a file entry, hover over the tag in the Inspector and click on the "-" icon that appears.
### Adding Fields to File Entries
-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 you’d like to add to the entry
+To add a metadata field to a file entry, start by clicking the "Add Field" button at the bottom of the Inspector. From the dropdown menu, select the type of metadata field you’d like to add to the entry
### Editing Fields
diff --git a/docs/entries.md b/docs/entries.md
index 7b11939ff8..fd5d072093 100644
--- a/docs/entries.md
+++ b/docs/entries.md
@@ -16,11 +16,11 @@ File entry data is stored within the `ts_library.sqlite` file inside each librar
## Appearance
-File entries appear as thumbnails inside the grid display. The inspector shows a more detailed preview of the file, along with extra file stats and all attached TagStudio tags and fields.
+File entries appear as thumbnails inside the grid display. The Inspector panel shows a more detailed preview of the file, along with extra file stats and all attached TagStudio tags and fields.
## Unlinked Entries
-If the file that an entry is referencing has been moved, renamed, or deleted on disk, then TagStudio will display its unlinked status with a red chain-link icon instead of its thumbnail image. Certain uncached stats such as the file size and image dimensions will also be unavailable to see in the inspector.
+If the file that an entry is referencing has been moved, renamed, or deleted on disk, then TagStudio will display its unlinked status with a red chain-link icon instead of its thumbnail image. Certain uncached stats such as the file size and image dimensions will also be unavailable to see in the Inspector.
To fix file entries that have become unlinked, select the "Fix Unlinked Entries" option from the Tools menu. From there, refresh the unlinked entry count and choose whether to search and relink you files, and/or delete the file entries from your library. This will NOT delete or modify any files on disk.
diff --git a/docs/fields.md b/docs/fields.md
index 9f3e45727d..70eca99fdc 100644
--- a/docs/fields.md
+++ b/docs/fields.md
@@ -12,7 +12,7 @@ Fields are extra pieces of information you can add to [file entries](./entries.m
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 at the bottom of the inspector. 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**.
+To add a field to an entry, click the "Add Field" button at the bottom of the Inspector 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**.

diff --git a/docs/preview-support.md b/docs/preview-support.md
index df64c56db7..9d9315467b 100644
--- a/docs/preview-support.md
+++ b/docs/preview-support.md
@@ -12,7 +12,7 @@ TagStudio offers built-in preview and thumbnail support for a wide variety of fi
### :material-image-outline: Images
-Images will generate thumbnails the first time they are viewed or since the last time they were modified. Thumbnails are used in the grid view, but not in the Preview Panel. Animated images will play in the Preview Panel.
+Images will generate thumbnails the first time they are viewed or since the last time they were modified. Thumbnails are used in the grid view, but not in the Inspector panel. Animated images will play in the Inspector.
| Filetype | Extensions | Animation |
| -------------------- | -------------------------------------------------- | :---------------------------------: |
@@ -47,7 +47,7 @@ Images will generate thumbnails the first time they are viewed or since the last
### :material-movie-open: Videos
-Video thumbnails will default to the closest viable frame from the middle of the video. Both thumbnail generation and video playback in the Preview Panel requires [FFmpeg](install.md#optional-dependencies) installed on your system.
+Video thumbnails will default to the closest viable frame from the middle of the video. Both thumbnail generation and video playback in the Inspector requires [FFmpeg](install.md#optional-dependencies) installed on your system.
| Filetype | Extensions | Dependencies |
| --------------------- | ----------------------- | :----------: |
@@ -65,7 +65,7 @@ Video thumbnails will default to the closest viable frame from the middle of the
### :material-sine-wave: Audio
-Audio thumbnails will default to embedded cover art (if any) and fallback to generated waveform thumbnails. Audio file playback is supported in the Preview Panel if you have [FFmpeg](install.md#optional-dependencies) installed on your system. Audio waveforms are currently not cached.
+Audio thumbnails will default to embedded cover art (if any) and fallback to generated waveform thumbnails. Audio file playback is supported in the Inspector if you have [FFmpeg](install.md#optional-dependencies) installed on your system. Audio waveforms are currently not cached.
| Filetype | Extensions | Dependencies |
| ------------------- | ------------------------ | :----------: |
@@ -103,7 +103,7 @@ Preview support for office documents or well-known project file formats varies b
### :material-archive: Archives
-Archive thumbnails will display the first image from the archive within the Preview Panel.
+Archive thumbnails will display the first image from the archive within the Inspector.
| Filetype | Extensions |
| -------- | -------------- |
@@ -129,7 +129,7 @@ Archive thumbnails will display the first image from the archive within the Prev
### :material-format-font: Fonts
-Font thumbnails will use a "Aa" example preview of the font, with a full alphanumeric of the font available in the Preview Panel.
+Font thumbnails will use a "Aa" example preview of the font, with a full alphanumeric of the font available in the Inspector.
| Filetype | Extensions |
| -------------------- | ----------------- |
@@ -143,7 +143,7 @@ Font thumbnails will use a "Aa" example preview of the font, with a full alphanu
!!! info "Plain Text Support"
TagStudio supports the *vast* majority of files considered to be "[plain text](https://en.wikipedia.org/wiki/Plain_text)". If an extension or format is not listed here, odds are it's still supported anyway.
-Text files render the first 256 bytes of text information to an image preview for thumbnails and the Preview Panel. Improved thumbnails, full scrollable text, and syntax highlighting are on our [roadmap](roadmap.md#uiux) for future features.
+Text files render the first 256 bytes of text information to an image preview for thumbnails and the Inspector. Improved thumbnails, full scrollable text, and syntax highlighting are on our [roadmap](roadmap.md#uiux) for future features.
| Filetype | Extensions | Syntax Highlighting |
| ---------- | --------------------------------------------- | :--------------------------------: |
diff --git a/docs/roadmap.md b/docs/roadmap.md
index b1a0db9785..fcc1409569 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -203,7 +203,7 @@ Discrete library objects representing [attributes](Ctrl+T.
+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 Inspector panel or by pressing Ctrl+T.
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.
diff --git a/src/tagstudio/previews/file_renderer.py b/src/tagstudio/previews/file_renderer.py
index 73086bb9aa..04916c2bda 100644
--- a/src/tagstudio/previews/file_renderer.py
+++ b/src/tagstudio/previews/file_renderer.py
@@ -96,9 +96,9 @@ def __init__(self, library: Library, settings: AppSettings) -> None:
self.settings = settings
# Cached thumbnail elements.
- # Key: Size + Pixel Ratio Tuple + Radius Scale
- # (Ex. (512, 512, 1.25, 4))
- self.thumb_masks: dict[tuple[int, int, float, float], Image.Image] = {}
+ # Key: Size + Pixel Ratio Tuple
+ # (Ex. (512, 512, 1.25))
+ self.thumb_masks: dict[tuple[int, int, float], Image.Image] = {}
self.raised_edges: dict[tuple[int, int, float], tuple[Image.Image, Image.Image]] = {}
# Key: ("name", UiColor, 512, 512, 1.25)
@@ -129,27 +129,19 @@ def _get_resource_id(self, url: Path) -> str:
return "file_generic"
# NOTE: This method will be replaced with frontend specific decorations (Qt painting)
- def _get_mask(
- self, size: tuple[int, int], pixel_ratio: float, scale_radius: bool = False
- ) -> Image.Image:
- """Return a thumbnail mask given a size, pixel ratio, and radius scaling option.
+ def _get_mask(self, size: tuple[int, int], pixel_ratio: float) -> Image.Image:
+ """Return a thumbnail mask given a size and pixel ratio.
If one is not already cached, a new one will be rendered.
Args:
size (tuple[int, int]): The size of the graphic.
pixel_ratio (float): The screen pixel ratio.
- scale_radius (bool): Option to scale the radius up (Used for Preview Panel).
"""
- thumb_scale: int = 512
- radius_scale: float = 1
- if scale_radius:
- radius_scale = max(size[0], size[1]) / thumb_scale
-
- item: Image.Image | None = self.thumb_masks.get((*size, pixel_ratio, radius_scale))
+ item: Image.Image | None = self.thumb_masks.get((*size, pixel_ratio))
if not item:
- item = self._render_mask(size, pixel_ratio, radius_scale)
- self.thumb_masks[(*size, pixel_ratio, radius_scale)] = item
+ item = self._render_mask(size, pixel_ratio)
+ self.thumb_masks[(*size, pixel_ratio)] = item
return item
# NOTE: This method will be replaced with frontend specific decorations (Qt painting)
@@ -215,15 +207,12 @@ def _get_icon(
return item
# NOTE: This method will be replaced with frontend specific decorations (Qt painting)
- def _render_mask(
- self, size: tuple[int, int], pixel_ratio: float, radius_scale: float = 1
- ) -> Image.Image:
+ def _render_mask(self, size: tuple[int, int], pixel_ratio: float) -> Image.Image:
"""Render a thumbnail mask graphic.
Args:
size (tuple[int,int]): The size of the graphic.
pixel_ratio (float): The screen pixel ratio.
- radius_scale (float): The scale factor of the border radius (Used by Preview Panel).
"""
smooth_factor: int = 2
radius_factor: int = 8
@@ -236,7 +225,7 @@ def _render_mask(
draw = ImageDraw.Draw(im)
draw.rounded_rectangle(
(0, 0) + tuple([d - 1 for d in im.size]),
- radius=math.ceil(radius_factor * smooth_factor * pixel_ratio * radius_scale),
+ radius=math.ceil(radius_factor * smooth_factor * pixel_ratio),
fill="white",
)
im = im.resize(
@@ -686,11 +675,7 @@ def fetch_cached_image(file_name: Path):
if not filepath.exists() or filepath.is_dir()
else render_default((512, 512), 2)
)
- render_mask_and_edge = False
- mask = self._get_mask(image.size, dpi_scale, scale_radius=True)
- bg = Image.new("RGBA", image.size, (0, 0, 0, 0))
- bg.paste(image, mask=mask.getchannel(0))
- image = bg
+ image = image.convert("RGBA")
# If the image couldn't be rendered, use a default media image.
if not image:
diff --git a/src/tagstudio/qt/controllers/banner.py b/src/tagstudio/qt/controllers/banner.py
index 073277f9b7..8b1bb9f07b 100644
--- a/src/tagstudio/qt/controllers/banner.py
+++ b/src/tagstudio/qt/controllers/banner.py
@@ -22,7 +22,8 @@
from tagstudio.qt.views.banner_view import BannerView
from tagstudio.qt.views.styles.stylesheets import (
- BANNER_CORNER_RADIUS,
+ PAD,
+ RADIUS,
banner_notice_bg_color,
banner_notice_style,
banner_progress_bg_color,
@@ -54,7 +55,7 @@ def paintEvent(self, event: QPaintEvent) -> None:
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
path = QPainterPath()
- path.addRoundedRect(QRectF(self.rect()), BANNER_CORNER_RADIUS, BANNER_CORNER_RADIUS)
+ path.addRoundedRect(QRectF(self.rect()), RADIUS, RADIUS)
painter.fillPath(path, self._bg_color)
painter.end()
@@ -66,8 +67,7 @@ class Banner(QWidget):
"""
CONTENT_HEIGHT = 36
- GAP = 6
- HEIGHT = CONTENT_HEIGHT + GAP
+ HEIGHT = CONTENT_HEIGHT + PAD
ANIMATION_MS = 250
COLOR_ANIMATION_MS = 250
MIN_VISIBLE_MS = 3000
@@ -81,7 +81,7 @@ def __init__(self, parent: QWidget | None = None):
self.setMaximumHeight(0)
outer_layout = QVBoxLayout(self)
- outer_layout.setContentsMargins(0, 0, 0, self.GAP)
+ outer_layout.setContentsMargins(0, 0, 0, PAD)
outer_layout.setSpacing(0)
self._background = _BannerBackground(self)
@@ -104,7 +104,7 @@ def __init__(self, parent: QWidget | None = None):
self._progress_bg_color = banner_progress_bg_color()
self._background.setStyleSheet(self._notice_style)
self._background.set_bg_color(self._notice_bg_color)
- self.view.progress_bar.set_corner_radius(BANNER_CORNER_RADIUS)
+ self.view.progress_bar.set_corner_radius(RADIUS)
self.view.progress_bar.set_chunk_color(banner_progress_chunk_color())
self._card_color_anim = QVariantAnimation(self)
diff --git a/src/tagstudio/qt/controllers/inspector.py b/src/tagstudio/qt/controllers/inspector.py
index a360c18d5c..a9bfefd291 100644
--- a/src/tagstudio/qt/controllers/inspector.py
+++ b/src/tagstudio/qt/controllers/inspector.py
@@ -28,8 +28,8 @@
from tagstudio.i18n.translations import Translations, field_type_name
from tagstudio.qt.controllers.edit_text import EditText
from tagstudio.qt.controllers.modal import Modal
+from tagstudio.qt.controllers.tiles.tile_list import TileList
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.views.inspector_view import InspectorView
@@ -186,8 +186,8 @@ def _add_field_to_selected(self, template: BaseFieldTemplate) -> None:
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.
+ # TODO: A lot of this code is similar to or straight up shared with TileList.
+ # It's possible to reuse it later, after a TileList refactor.
field_type = field_type_name(field.class_name)
if type(field) is TextField:
@@ -274,14 +274,14 @@ def set_selection(self, selected: list[int], update_preview: bool = True) -> Non
self._set_selection_callback()
except Exception as e:
- logger.error("[Preview Panel] Error updating selection", error=e)
+ logger.error("[Inspector] Error updating selection", error=e)
def stop_media_playback(self) -> None:
"""Stop any media playback in the inspector."""
self.layout().preview_thumb.media_player.stop()
@property
- def containers(self) -> FieldContainers:
+ def containers(self) -> TileList:
return self.layout().containers
@override
diff --git a/src/tagstudio/qt/controllers/main_window.py b/src/tagstudio/qt/controllers/main_window.py
index dd75ee33ef..46c50bd58d 100644
--- a/src/tagstudio/qt/controllers/main_window.py
+++ b/src/tagstudio/qt/controllers/main_window.py
@@ -31,7 +31,6 @@
QSizePolicy,
QSpacerItem,
QSplitter,
- QStatusBar,
QVBoxLayout,
QWidget,
)
@@ -42,13 +41,13 @@
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.banner_stack import BannerStack
from tagstudio.qt.controllers.inspector import Inspector
+from tagstudio.qt.controllers.pagination import Pagination
from tagstudio.qt.helpers.mnemonics import assign_mnemonics
from tagstudio.qt.mixed.landing import LandingWidget
-from tagstudio.qt.mixed.pagination import Pagination
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.views.layouts.thumb_grid_layout import ThumbGridLayout
from tagstudio.qt.views.styles.color_overlay import auto_theme_overlay
-from tagstudio.qt.views.styles.stylesheets import checkbox_style
+from tagstudio.qt.views.styles.stylesheets import HALF_PAD, PAD, WIN_PAD, checkbox_style
# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:
@@ -479,6 +478,7 @@ def __init__(self, driver: QtDriver, parent: QWidget | None = None) -> None:
# initialized in setup_extra_input_bar
self.extra_input_layout: QHBoxLayout
+ self.results_label: QLabel
self.sorting_mode_combobox: QComboBox
self.sorting_direction_combobox: QComboBox
self.thumb_size_combobox: QComboBox
@@ -486,6 +486,8 @@ def __init__(self, driver: QtDriver, parent: QWidget | None = None) -> None:
# initialized in setup_content
self.content_layout: QHBoxLayout
self.content_splitter: QSplitter
+ self.central_content: QWidget
+ self.central_content_layout: QVBoxLayout
# initialized in setup_entry_list
self.entry_list_container: QWidget
@@ -497,20 +499,18 @@ def __init__(self, driver: QtDriver, parent: QWidget | None = None) -> None:
self.landing_widget: LandingWidget
self.pagination: Pagination
- # initialized in setup_preview_panel
- self.preview_panel: Inspector
+ # initialized in setup_inspector
+ self.inspector: Inspector
# endregion
if not self.objectName():
self.setObjectName("MainWindow")
- self.resize(1316, 740)
+ self.resize(1280, 720)
self.setup_menu_bar()
self.setup_central_widget(driver)
- self.setup_status_bar()
-
QMetaObject.connectSlotsByName(self)
# NOTE: These are old attempts to allow for a translucent/acrylic
@@ -540,6 +540,7 @@ def setup_central_widget(self, driver: QtDriver):
self.central_widget.setObjectName("central_widget")
self.central_layout = QGridLayout(self.central_widget)
self.central_layout.setObjectName("central_layout")
+ self.central_layout.setContentsMargins(0, WIN_PAD, 0, 0)
self.setup_search_bar()
self.setup_extra_input_bar()
@@ -551,6 +552,7 @@ def setup_search_bar(self):
self.search_bar_layout = QHBoxLayout()
self.search_bar_layout.setObjectName("search_bar_layout")
self.search_bar_layout.setSizeConstraint(QLayout.SizeConstraint.SetMinimumSize)
+ self.search_bar_layout.setContentsMargins(WIN_PAD, 0, 0, 0)
self.back_button = QPushButton(self.central_widget)
back_icon: Image.Image = self.rm.bxs_left_arrow
@@ -588,19 +590,26 @@ def setup_search_bar(self):
self.search_button.setMinimumSize(QSize(0, 32))
self.search_bar_layout.addWidget(self.search_button)
- self.central_layout.addLayout(self.search_bar_layout, 3, 0, 1, 1)
-
def setup_extra_input_bar(self):
"""Sets up inputs for sorting settings and thumbnail size."""
self.extra_input_layout = QHBoxLayout()
self.extra_input_layout.setObjectName("extra_input_layout")
+ self.extra_input_layout.setContentsMargins(WIN_PAD, 0, 0, 0)
+
+ self.results_label = QLabel("")
+ self.results_label.setObjectName("results_label")
+ self.extra_input_layout.addWidget(self.results_label)
+
+ self.extra_input_layout.addItem(
+ QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
+ )
## Show hidden entries checkbox
self.show_hidden_entries_widget = QWidget()
self.show_hidden_entries_layout = QHBoxLayout(self.show_hidden_entries_widget)
self.show_hidden_entries_layout.setStretch(1, 1)
self.show_hidden_entries_layout.setContentsMargins(0, 0, 0, 0)
- self.show_hidden_entries_layout.setSpacing(6)
+ self.show_hidden_entries_layout.setSpacing(PAD)
self.show_hidden_entries_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.show_hidden_entries_title = QLabel(Translations["home.show_hidden_entries"])
self.show_hidden_entries_checkbox = QCheckBox()
@@ -614,11 +623,6 @@ def setup_extra_input_bar(self):
self.extra_input_layout.addWidget(self.show_hidden_entries_widget)
- ## Spacer
- self.extra_input_layout.addItem(
- QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
- )
-
## Sorting Mode Dropdown
self.sorting_mode_combobox = QComboBox(self.central_widget)
self.sorting_mode_combobox.setObjectName("sorting_mode_combobox")
@@ -655,28 +659,39 @@ def setup_extra_input_bar(self):
self.thumb_size_combobox.addItem(size[0], size[1])
self.thumb_size_combobox.setCurrentIndex(2) # Default: Medium
- self.central_layout.addLayout(self.extra_input_layout, 5, 0, 1, 1)
-
def setup_content(self, driver: QtDriver):
self.content_layout = QHBoxLayout()
self.content_layout.setObjectName("content_layout")
+ self.content_layout.setContentsMargins(0, 0, 0, 0)
self.content_splitter = QSplitter()
self.content_splitter.setObjectName("content_splitter")
- self.content_splitter.setHandleWidth(12)
+ self.content_splitter.setHandleWidth(PAD + HALF_PAD)
+
+ self.central_content = QWidget()
+ self.central_content.setObjectName("central_content")
+ self.central_content_layout = QVBoxLayout(self.central_content)
+ self.central_content_layout.setObjectName("central_content_layout")
+ self.central_content_layout.setContentsMargins(0, 0, 0, 0)
+ self.central_content_layout.setSpacing(PAD)
+ self.central_content_layout.addLayout(self.search_bar_layout)
+ self.central_content_layout.addLayout(self.extra_input_layout)
self.setup_entry_list(driver)
- self.setup_preview_panel(driver)
+ self.content_splitter.addWidget(self.central_content)
+
+ self.setup_inspector(driver)
self.content_splitter.setStretchFactor(0, 1)
self.content_layout.addWidget(self.content_splitter)
- self.central_layout.addLayout(self.content_layout, 10, 0, 1, 1)
+ self.central_layout.addLayout(self.content_layout, 0, 0, 1, 1)
def setup_entry_list(self, driver: QtDriver):
self.entry_list_container = QWidget()
self.entry_list_layout = QVBoxLayout(self.entry_list_container)
self.entry_list_layout.setSpacing(0)
+ self.entry_list_layout.setContentsMargins(WIN_PAD, 0, 0, 0)
self.entry_scroll_area = QScrollArea()
self.entry_scroll_area.setObjectName("entry_scroll_area")
@@ -691,8 +706,10 @@ def setup_entry_list(self, driver: QtDriver):
self.thumb_grid = QWidget()
self.thumb_grid.setObjectName("thumb_grid")
- self.thumb_layout = ThumbGridLayout(driver, self.entry_scroll_area)
- self.thumb_layout.setSpacing(min(self.thumb_size // 10, 12))
+ # Padding so floating pagination bar doesn't block bottom of scroll contents
+ self.thumb_layout = ThumbGridLayout(
+ driver, self.entry_scroll_area, bottom_padding=Pagination.HEIGHT
+ )
self.thumb_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.thumb_grid.setLayout(self.thumb_layout)
self.entry_scroll_area.setWidget(self.thumb_grid)
@@ -701,33 +718,14 @@ def setup_entry_list(self, driver: QtDriver):
self.entry_list_layout.addWidget(self.banners)
self.entry_list_layout.addWidget(self.entry_scroll_area)
-
self.landing_widget = LandingWidget(driver, self.devicePixelRatio())
self.entry_list_layout.addWidget(self.landing_widget)
+ self.pagination = Pagination(self.entry_list_container)
+ self.central_content_layout.addWidget(self.entry_list_container)
- self.pagination = Pagination()
- self.entry_list_layout.addWidget(self.pagination)
-
- self.content_splitter.addWidget(self.entry_list_container)
-
- def setup_preview_panel(self, driver: QtDriver):
- self.preview_panel = Inspector(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(
- QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum
- )
- status_bar_size_policy.setHorizontalStretch(0)
- status_bar_size_policy.setVerticalStretch(0)
- status_bar_size_policy.setHeightForWidth(self.status_bar.sizePolicy().hasHeightForWidth())
- self.status_bar.setSizePolicy(status_bar_size_policy)
- self.status_bar.setSizeGripEnabled(False)
- self.setStatusBar(self.status_bar)
+ def setup_inspector(self, driver: QtDriver):
+ self.inspector = Inspector(driver)
+ self.content_splitter.addWidget(self.inspector)
# endregion
diff --git a/src/tagstudio/qt/controllers/pagination.py b/src/tagstudio/qt/controllers/pagination.py
new file mode 100644
index 0000000000..7dd133b8a3
--- /dev/null
+++ b/src/tagstudio/qt/controllers/pagination.py
@@ -0,0 +1,211 @@
+# 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 QEvent, QObject, Qt, Signal
+from PySide6.QtWidgets import QHBoxLayout, QPushButton, QWidget
+
+from tagstudio.qt.views.pagination_view import PaginationView
+from tagstudio.qt.views.styles.stylesheets import pagination_style
+
+logger = structlog.get_logger(__name__)
+
+
+class Pagination(QWidget):
+ HEIGHT = 36
+ _RIGHT_MARGIN = 6
+ _SCROLLBAR_WIDTH = 14
+
+ index = Signal(int)
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.setHidden(True)
+ self.setObjectName("pagination")
+ self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground)
+ self.setFixedHeight(self.HEIGHT)
+ self.setStyleSheet(pagination_style())
+
+ self.setLayout(PaginationView())
+ self._connect_callbacks()
+
+ if parent is not None:
+ parent.installEventFilter(self)
+ self._sync_geometry()
+
+ @override
+ def layout(self) -> PaginationView:
+ return super().layout() # pyright: ignore[reportReturnType]
+
+ def _connect_callbacks(self) -> None:
+ self.layout().current_page_field.returnPressed.connect(
+ lambda: self._goto_page(int(self.layout().current_page_field.text()) - 1)
+ )
+
+ def update_buttons(self, page_count: int, index: int, emit: bool = True):
+ if index < 0:
+ raise ValueError("Negative index detected")
+
+ view = self.layout()
+ for i in range(0, 10):
+ if button := self._get_button_at(view.start_buffer_layout, i):
+ button.setHidden(True)
+ if button := self._get_button_at(view.end_buffer_layout, i):
+ button.setHidden(True)
+
+ end_page = page_count - 1
+ # Hide everything if there are only one or less pages.
+ if page_count <= 1:
+ self.setHidden(True)
+
+ # Enable/Disable Next + Prev Buttons
+ elif page_count > 1:
+ if index == 0:
+ view.prev_button.setDisabled(True)
+ else:
+ self._assign_click(view.prev_button, index - 1)
+ view.prev_button.setDisabled(False)
+
+ if index == end_page:
+ view.next_button.setDisabled(True)
+ else:
+ self._assign_click(view.next_button, index + 1)
+ view.next_button.setDisabled(False)
+
+ # Set Ellipses Sizes
+ if 8 <= page_count <= 11:
+ end_scale = max(1, page_count - index - 6)
+ start_scale = max(1, index - 5)
+ elif page_count > 11:
+ end_scale = max(1, 7 - index)
+ start_scale = max(1, (7 - (end_page - index)))
+ else:
+ end_scale, start_scale = 1, 1
+
+ if page_count >= 8:
+ end_size = view.BUTTON_SIZE.width() * end_scale + (3 * (end_scale - 1))
+ start_size = view.BUTTON_SIZE.width() * start_scale + (3 * (start_scale - 1))
+ view.end_ellipses.setMinimumWidth(end_size)
+ view.end_ellipses.setMaximumWidth(end_size)
+ view.start_ellipses.setMinimumWidth(start_size)
+ view.start_ellipses.setMaximumWidth(start_size)
+
+ # Enable/Disable Ellipses
+ if index <= view.BUFFER_PAGE_COUNT + 1:
+ view.start_ellipses.setHidden(True)
+ else:
+ view.start_ellipses.setHidden(False)
+ self._assign_click(view.start_button, 0)
+ if index >= (page_count - view.BUFFER_PAGE_COUNT - 2):
+ view.end_ellipses.setHidden(True)
+ else:
+ view.end_ellipses.setHidden(False)
+
+ # Hide/Unhide Start + End Buttons
+ if index != 0:
+ view.start_button.setText("1")
+ self._assign_click(view.start_button, 0)
+ view.start_button.setHidden(False)
+ else:
+ view.start_button.setHidden(True)
+ if index != page_count - 1:
+ view.end_button.setText(str(page_count))
+ self._assign_click(view.end_button, page_count - 1)
+ view.end_button.setHidden(False)
+ else:
+ view.end_button.setHidden(True)
+
+ if index == 0 or index == 1:
+ view.start_buffer_container.setHidden(True)
+ else:
+ view.start_buffer_container.setHidden(False)
+
+ if index == page_count - 1 or index == page_count - 2:
+ view.end_buffer_container.setHidden(True)
+ else:
+ view.end_buffer_container.setHidden(False)
+
+ # Current Field and Buffer Pages
+ for i in range(0, page_count):
+ # Set Field
+ if i == index:
+ if button := self._get_button_at(view.start_buffer_layout, i):
+ button.setHidden(True)
+ if button := self._get_button_at(view.end_buffer_layout, i):
+ button.setHidden(True)
+ view.current_page_field.setText(str(i + 1))
+
+ start_offset = max(0, index - view.BUFFER_PAGE_COUNT * 2)
+ end_offset = min(page_count - 1, index)
+ if i < index:
+ if (i != 0) and i >= index - view.BUFFER_PAGE_COUNT:
+ if button := self._get_button_at(
+ view.start_buffer_layout, i - start_offset
+ ):
+ button.setHidden(False)
+ button.setText(str(i + 1))
+ self._assign_click(button, i)
+ else:
+ if button := self._get_button_at(view.start_buffer_layout, i):
+ button.setHidden(True)
+ if button := self._get_button_at(view.end_buffer_layout, i):
+ button.setHidden(True)
+ elif i > index:
+ if i != page_count - 1 and i <= index + view.BUFFER_PAGE_COUNT:
+ if button := self._get_button_at(view.end_buffer_layout, i - end_offset):
+ button.setHidden(False)
+ button.setText(str(i + 1))
+ self._assign_click(button, i)
+ else:
+ if button := self._get_button_at(view.end_buffer_layout, i):
+ button.setHidden(True)
+ for j in range(0, view.BUFFER_PAGE_COUNT):
+ if button := self._get_button_at(
+ view.end_buffer_layout, i - end_offset + j
+ ):
+ button.setHidden(True)
+
+ if button := self._get_button_at(view.start_buffer_layout, i - 1):
+ button.setHidden(True)
+
+ self.setHidden(False)
+ self._sync_geometry()
+ self.raise_()
+
+ view.validator.setTop(page_count)
+ if emit:
+ self.index.emit(index)
+
+ def _goto_page(self, index: int):
+ self.update_buttons(self.layout().validator.top(), index)
+
+ def _assign_click(self, button: QPushButton, index: int):
+ with catch_warnings(record=True):
+ button.clicked.disconnect()
+ button.clicked.connect(lambda checked=False, i=index: self._goto_page(i))
+
+ @staticmethod
+ def _get_button_at(layout: QHBoxLayout, index: int) -> QPushButton | None:
+ """Safely return a button in a layout at a given index, if it exists."""
+ item = layout.itemAt(index)
+ widget = item.widget() if item else None
+ return widget if isinstance(widget, QPushButton) else None
+
+ @override
+ def eventFilter(self, watched: QObject, event: QEvent) -> bool:
+ if watched is self.parentWidget() and event.type() == QEvent.Type.Resize:
+ self._sync_geometry()
+ return super().eventFilter(watched, event)
+
+ def _sync_geometry(self):
+ parent = self.parentWidget()
+ if parent is None:
+ return
+ # Stays out of the way of the entry view scrollbar
+ right_inset = max(self._RIGHT_MARGIN, self._SCROLLBAR_WIDTH)
+ width = max(0, parent.width() - right_inset)
+ self.setGeometry(0, parent.height() - self.HEIGHT, width, self.HEIGHT)
diff --git a/src/tagstudio/qt/controllers/preview_thumb.py b/src/tagstudio/qt/controllers/preview_thumb.py
index 25b5b29a86..0f4fc9a5ed 100644
--- a/src/tagstudio/qt/controllers/preview_thumb.py
+++ b/src/tagstudio/qt/controllers/preview_thumb.py
@@ -3,6 +3,9 @@
import io
+import math
+import time
+from enum import Enum, auto
from pathlib import Path
from typing import TYPE_CHECKING, override
@@ -11,16 +14,21 @@
import structlog
from PIL import Image, UnidentifiedImageError
from PIL.Image import DecompressionBombError
-from PySide6.QtCore import QSize
+from PySide6.QtCore import QBuffer, QByteArray, QRectF, QSize, Qt, Signal
+from PySide6.QtGui import QBrush, QMovie, QPainter, QPixmap, QResizeEvent
+from PySide6.QtWidgets import QWidget
from rawpy import LibRawFileUnsupportedError, LibRawIOError # pyright: ignore
-from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.media_types import MediaTypes
from tagstudio.core.query_lang.file_groups import SEARCH
+from tagstudio.core.utils.types import unwrap
from tagstudio.previews.video_tester import is_readable_video
from tagstudio.qt.mixed.file_attributes import FileAttributeData
+from tagstudio.qt.mixed.media_player import MediaPlayer
+from tagstudio.qt.qt_file_renderer import QtFileRenderer
from tagstudio.qt.utils.file_opener import open_file
from tagstudio.qt.views.preview_thumb_view import PreviewThumbView
+from tagstudio.qt.views.styles.stylesheets import RADIUS
if TYPE_CHECKING:
from tagstudio.qt.qt_driver import QtDriver
@@ -28,15 +36,329 @@
logger = structlog.get_logger(__name__)
Image.MAX_IMAGE_PIXELS = None
+_DEFAULT_PREVIEW_SIZE = (272, 272)
+_THUMB_SIZE_FACTOR = 2
-# TODO: Use newer MVC style guidelines
-class PreviewThumb(PreviewThumbView):
- def __init__(self, library: Library, driver: QtDriver):
- super().__init__(library, driver)
- self.__driver: QtDriver = driver
+class _PreviewType(Enum):
+ """Enum for which of the Inspector's stacked pages should be shown for a file."""
- def __get_image_stats(self, filepath: Path) -> FileAttributeData:
+ ANIMATED = auto()
+ AUDIO = auto()
+ IMAGE = auto()
+ TEXT = auto()
+ VIDEO = auto()
+
+
+class PreviewThumb(QWidget):
+ """The file preview thumbnail widget."""
+
+ check_ffmpeg = Signal(bool)
+ stats_updated = Signal(Path, FileAttributeData)
+
+ def __init__(self, driver: QtDriver):
+ super().__init__()
+ self._driver = driver
+ self._thumb_renderer = QtFileRenderer(driver.lib, driver.settings)
+
+ self._current_file: Path | None = None
+ self._gif_buffer: QBuffer = QBuffer()
+ self._gif_size: QSize = QSize()
+ self._image_ratio: float = 1.0
+ self._preview_size: tuple[int, int] = _DEFAULT_PREVIEW_SIZE
+ self._rendered_res: tuple[int, int] = (0, 0)
+ self._should_render_on_resize: bool = False
+ self._source_pixmap: QPixmap = QPixmap()
+
+ self.setMinimumSize(*self._preview_size)
+ self.setLayout(PreviewThumbView(driver))
+ self._connect_callbacks()
+
+ self.hide_preview()
+
+ @override
+ def layout(self) -> PreviewThumbView:
+ return super().layout() # pyright: ignore[reportReturnType]
+
+ def _connect_callbacks(self) -> None:
+ view = self.layout()
+ view.open_file_action.triggered.connect(self._open_file_action_callback)
+ view.open_explorer_action.triggered.connect(self._open_explorer_action_callback)
+ view.delete_action.triggered.connect(self._delete_action_callback)
+ view.button_wrapper.clicked.connect(self._button_wrapper_callback)
+
+ # QMediaPlayer loads duration asynchronously after setSource().
+ view.media_player.player.durationChanged.connect(
+ self._media_player_duration_changed_callback
+ )
+ # Need to watch for this to resize the player appropriately.
+ view.media_player.player.hasVideoChanged.connect(self._media_player_video_changed_callback)
+
+ self._thumb_renderer.updated.connect(self._thumb_renderer_updated_callback)
+ self._thumb_renderer.updated_ratio.connect(self._thumb_renderer_updated_ratio_callback)
+
+ def _open_file_action_callback(self) -> None:
+ if self._current_file:
+ open_file(
+ self._current_file,
+ windows_start_command=self._driver.settings.windows_start_command,
+ )
+
+ def _open_explorer_action_callback(self) -> None:
+ if self._current_file:
+ open_file(self._current_file, file_manager=True)
+
+ def _delete_action_callback(self) -> None:
+ if self._current_file:
+ self._driver.delete_files_callback(self._current_file)
+
+ def _button_wrapper_callback(self) -> None:
+ if self._current_file:
+ open_file(
+ self._current_file,
+ windows_start_command=self._driver.settings.windows_start_command,
+ )
+
+ def _media_player_video_changed_callback(self) -> None:
+ self._update_image_size((self.size().width(), self.size().height()))
+
+ def _media_player_duration_changed_callback(self, duration_ms: int) -> None:
+ filepath = self.layout().media_player.filepath
+ if filepath is None or duration_ms <= 0:
+ return
+
+ self.stats_updated.emit(
+ filepath,
+ FileAttributeData(duration=duration_ms // 1000),
+ )
+
+ def _thumb_renderer_updated_callback(
+ self, _timestamp: float, img: QPixmap, _size: QSize, _path: Path
+ ) -> None:
+ self._source_pixmap = img
+ self._update_image_size((self.size().width(), self.size().height()), refresh_icon=True)
+
+ def _update_icon(self) -> None:
+ button = self.layout().button_wrapper
+ if self._source_pixmap.isNull():
+ button.setIcon(self._source_pixmap)
+ return
+
+ ratio = self.devicePixelRatio()
+ scaled = self._source_pixmap.scaled(
+ button.iconSize() * ratio,
+ Qt.AspectRatioMode.IgnoreAspectRatio,
+ Qt.TransformationMode.SmoothTransformation,
+ )
+ scaled.setDevicePixelRatio(1)
+ pixmap = QPixmap(scaled.size())
+ pixmap.fill(Qt.GlobalColor.transparent)
+ painter = QPainter(pixmap)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
+ painter.setPen(Qt.PenStyle.NoPen)
+ painter.setBrush(QBrush(scaled))
+ painter.drawRoundedRect(QRectF(pixmap.rect()), RADIUS * ratio, RADIUS * ratio)
+ painter.end()
+ pixmap.setDevicePixelRatio(ratio)
+ button.setIcon(pixmap)
+
+ def _thumb_renderer_updated_ratio_callback(self, ratio: float) -> None:
+ self._image_ratio = ratio
+
+ def _update_image_size(self, size: tuple[int, int], refresh_icon: bool = False) -> None:
+ view = self.layout()
+ scaled_width: float = size[0]
+ scaled_height: float = size[1]
+ # Landscape
+ if self._image_ratio > 1:
+ scaled_height = size[0] * (1 / self._image_ratio)
+ # Portrait
+ elif self._image_ratio <= 1:
+ scaled_width = size[1] * self._image_ratio
+
+ if scaled_width > size[0]:
+ scaled_height = scaled_height * (size[0] / scaled_width)
+ scaled_width = size[0]
+ elif scaled_height > size[1]:
+ scaled_width = scaled_width * (size[1] / scaled_height)
+ scaled_height = size[1]
+
+ scaled_size = QSize(int(scaled_width), int(scaled_height))
+
+ self._preview_size = (int(scaled_width), int(scaled_height))
+ view.button_wrapper.setMaximumSize(scaled_size)
+ view.button_wrapper.setMinimumSize(scaled_size)
+ if refresh_icon or view.button_wrapper.iconSize() != scaled_size:
+ view.button_wrapper.setIconSize(scaled_size)
+ self._update_icon()
+ view.preview_gif.setMaximumSize(scaled_size)
+ view.preview_gif.setMinimumSize(scaled_size)
+
+ view.media_player.setMaximumSize(scaled_size)
+ view.media_player.setMinimumSize(scaled_size)
+
+ for page in (view.preview_img_page, view.preview_gif_page, view.media_player_page):
+ unwrap(page.layout()).activate()
+
+ movie = view.preview_gif.movie()
+ if movie:
+ gif_max = max(self._gif_size.width(), self._gif_size.height())
+ display_max = max(scaled_size.width(), scaled_size.height()) * self.devicePixelRatio()
+ is_upscaling = gif_max < display_max
+ movie.setScaledSize(QSize() if is_upscaling else scaled_size)
+
+ def _switch_preview(self, preview: _PreviewType | None) -> None:
+ view = self.layout()
+ if preview in [_PreviewType.AUDIO, _PreviewType.VIDEO]:
+ view.media_player.show()
+ view.setCurrentWidget(view.media_player_page)
+ view.media_player_page.raise_()
+ self.check_ffmpeg.emit(True) # noqa: FBT003
+ else:
+ view.media_player.stop()
+ view.media_player.hide()
+ self.check_ffmpeg.emit(False) # noqa: FBT003
+
+ if preview in [_PreviewType.IMAGE, _PreviewType.AUDIO]:
+ view.button_wrapper.show()
+ current_page = (
+ view.preview_img_page if preview == _PreviewType.IMAGE else view.media_player_page
+ )
+ view.setCurrentWidget(current_page)
+ current_page.raise_()
+ else:
+ view.button_wrapper.hide()
+
+ if preview == _PreviewType.ANIMATED:
+ view.preview_gif.show()
+ view.setCurrentWidget(view.preview_gif_page)
+ view.preview_gif_page.raise_()
+ else:
+ if view.preview_gif.movie():
+ view.preview_gif.movie().stop()
+ self._gif_buffer.close()
+ view.preview_gif.hide()
+
+ def _render_thumb(self, filepath: Path) -> None:
+ self._should_render_on_resize = True
+
+ self._rendered_res = (
+ math.ceil(self._preview_size[0] * _THUMB_SIZE_FACTOR),
+ math.ceil(self._preview_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(),
+ )
+
+ def _update_media_player(self, filepath: Path) -> None:
+ """Display either audio or video."""
+ self.layout().media_player.play(filepath)
+
+ def _display_video(self, filepath: Path, size: QSize | None) -> FileAttributeData:
+ self._should_render_on_resize = False
+
+ self._switch_preview(_PreviewType.VIDEO)
+ self._update_media_player(filepath)
+ stats = FileAttributeData()
+
+ if size is not None:
+ stats.width = size.width()
+ stats.height = size.height()
+
+ self._image_ratio = stats.width / stats.height
+ self.resizeEvent(
+ QResizeEvent(
+ QSize(stats.width, stats.height),
+ QSize(stats.width, stats.height),
+ )
+ )
+
+ return stats
+
+ def _display_audio(self, filepath: Path) -> FileAttributeData:
+ self._switch_preview(_PreviewType.AUDIO)
+ self._render_thumb(filepath)
+ self._update_media_player(filepath)
+ return FileAttributeData()
+
+ def _display_gif(self, gif_data: bytes, size: tuple[int, int]) -> FileAttributeData | None:
+ """Update the animated image preview from a filepath."""
+ self._should_render_on_resize = False
+
+ view = self.layout()
+ stats = FileAttributeData()
+
+ # Ensure that any movie and buffer from previous animations are cleared.
+ if view.preview_gif.movie():
+ view.preview_gif.movie().stop()
+ self._gif_buffer.close()
+
+ stats.width = size[0]
+ stats.height = size[1]
+
+ self._image_ratio = stats.width / stats.height
+ self._gif_size = QSize(*size)
+
+ self._gif_buffer.setData(gif_data)
+ movie = QMovie(self._gif_buffer, QByteArray())
+ view.preview_gif.setMovie(movie)
+
+ # If the animation only has 1 frame, it isn't animated and shouldn't be treated as such
+ if movie.frameCount() <= 1:
+ return None
+
+ # The animation has more than 1 frame, continue displaying it as an animation
+ self._switch_preview(_PreviewType.ANIMATED)
+ self.resizeEvent(
+ QResizeEvent(
+ QSize(stats.width, stats.height),
+ QSize(stats.width, stats.height),
+ )
+ )
+ movie.start()
+ stats.duration = movie.frameCount() // 60
+
+ return stats
+
+ def _display_image(self, filepath: Path):
+ """Renders the given file as an image, no matter its media type."""
+ self._switch_preview(_PreviewType.IMAGE)
+ self._render_thumb(filepath)
+
+ def hide_preview(self) -> None:
+ """Completely hide the file preview."""
+ self._switch_preview(None)
+ self._current_file = None
+ self._should_render_on_resize = False
+
+ @override
+ def resizeEvent(self, event: QResizeEvent) -> None:
+ self._update_image_size((self.size().width(), self.size().height()))
+
+ if (
+ self._current_file is not None
+ and self._should_render_on_resize
+ and self._rendered_res < self._preview_size
+ ):
+ self._render_thumb(self._current_file)
+
+ return super().resizeEvent(event)
+
+ @property
+ def media_player(self) -> MediaPlayer:
+ return self.layout().media_player
+
+ @property
+ def current_file(self) -> Path | None:
+ return self._current_file
+
+ def _get_image_stats(self, filepath: Path) -> FileAttributeData:
"""Get width and height of an image as dict."""
stats = FileAttributeData()
ext = filepath.suffix.lower()
@@ -73,13 +395,12 @@ def __get_image_stats(self, filepath: Path) -> FileAttributeData:
return stats
- def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None:
+ def _get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None:
"""Loads an animated image and returns gif data and size, if successful."""
ext = filepath.suffix.lower()
try:
image: Image.Image = Image.open(filepath)
-
if ext == ".apng":
image_bytes_io = io.BytesIO()
image.save(
@@ -102,7 +423,7 @@ def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None
logger.error("[PreviewThumb] Could not load animated image", filepath=filepath, error=e)
return None
- def __get_video_res(self, filepath: str) -> tuple[bool, QSize]:
+ def _get_video_res(self, filepath: str) -> tuple[bool, QSize]:
video = cv2.VideoCapture(filepath, cv2.CAP_FFMPEG)
success, frame = video.read()
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
@@ -112,14 +433,13 @@ def __get_video_res(self, filepath: str) -> tuple[bool, QSize]:
def display_file(self, filepath: Path) -> FileAttributeData:
"""Render a single file preview."""
self._current_file = filepath
-
ext = filepath.suffix.lower()
# Video
if MediaTypes.contains("video", ext, SEARCH) and is_readable_video(filepath):
size: QSize | None = None
try:
- success, size = self.__get_video_res(str(filepath))
+ success, size = self._get_video_res(str(filepath))
if not success:
size = None
except cv2.error as e:
@@ -131,40 +451,14 @@ def display_file(self, filepath: Path) -> FileAttributeData:
return self._display_audio(filepath)
# Animated Images
elif MediaTypes.contains("image.animated", ext, SEARCH):
- if (ret := self.__get_gif_data(filepath)) and (
+ if (ret := self._get_gif_data(filepath)) and (
stats := self._display_gif(ret[0], ret[1])
) is not None:
return stats
else:
self._display_image(filepath)
- return self.__get_image_stats(filepath)
+ return self._get_image_stats(filepath)
# Other Types (Including Images)
else:
self._display_image(filepath)
- return self.__get_image_stats(filepath)
-
- @override
- def _open_file_action_callback(self):
- if self._current_file:
- open_file(
- self._current_file,
- windows_start_command=self.__driver.settings.windows_start_command,
- )
-
- @override
- def _open_explorer_action_callback(self):
- if self._current_file:
- open_file(self._current_file, file_manager=True)
-
- @override
- def _delete_action_callback(self):
- if self._current_file:
- self.__driver.delete_files_callback(self._current_file)
-
- @override
- def _button_wrapper_callback(self):
- if self._current_file:
- open_file(
- self._current_file,
- windows_start_command=self.__driver.settings.windows_start_command,
- )
+ return self._get_image_stats(filepath)
diff --git a/src/tagstudio/qt/controllers/tag_box.py b/src/tagstudio/qt/controllers/tag_box.py
deleted file mode 100644
index 395ff2d596..0000000000
--- a/src/tagstudio/qt/controllers/tag_box.py
+++ /dev/null
@@ -1,102 +0,0 @@
-# SPDX-FileCopyrightText: (c) TagStudio Contributors
-# SPDX-License-Identifier: GPL-3.0-only
-
-
-from functools import partial
-from typing import TYPE_CHECKING, override
-
-import structlog
-from PySide6.QtCore import Signal
-
-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.i18n.translations import Translations
-from tagstudio.qt.controllers.edit_tag_panel import EditTagPanel
-from tagstudio.qt.controllers.modal import Modal
-from tagstudio.qt.views.tag_box_view import TagBoxWidgetView
-
-if TYPE_CHECKING:
- from tagstudio.qt.qt_driver import QtDriver
-
-logger = structlog.get_logger(__name__)
-
-
-# TODO: Use newer MVC style guidelines
-class TagBoxWidget(TagBoxWidgetView):
- on_update = Signal()
-
- __entries: list[int] = []
-
- def __init__(self, title: str, driver: QtDriver):
- super().__init__(title, driver)
- self.__driver = driver
-
- def set_entries(self, entries: list[int]) -> None:
- self.__entries = entries
-
- @override
- def _on_click(self, tag: Tag) -> None:
- match self.__driver.settings.tag_click_action:
- case TagClickActionOption.OPEN_EDIT:
- self._on_edit(tag)
- case TagClickActionOption.SET_SEARCH:
- self.__driver.update_browsing_state(
- BrowsingState.from_tag_id(tag.id, self.__driver.browsing_history.current)
- )
- case TagClickActionOption.ADD_TO_SEARCH:
- # NOTE: modifying the ast and then setting that would be nicer
- # than this string manipulation, but also much more complex,
- # due to needing to implement a visitor that turns an AST to a string
- # So if that exists when you read this, change the following accordingly.
- current = self.__driver.browsing_history.current
- suffix = unwrap(
- BrowsingState.from_tag_id(tag.id, self.__driver.browsing_history.current).query
- )
- self.__driver.update_browsing_state(
- current.with_search_query(
- f"{current.query} {suffix}" if current.query else suffix
- )
- )
-
- @override
- def _on_remove(self, tag: Tag) -> None:
- logger.info(
- "[TagBoxWidget] remove_tag",
- selected=self.__entries,
- )
-
- for entry_id in self.__entries:
- self.__driver.lib.remove_tags_from_entries(entry_id, tag.id)
-
- self.on_update.emit()
-
- @override
- def _on_edit(self, tag: Tag) -> None:
- edit_tag_panel = EditTagPanel(self.__driver.lib, tag=tag)
-
- edit_modal = Modal(
- edit_tag_panel,
- self.__driver.lib.tag_display_name(tag),
- Translations["tag.edit"],
- is_savable=True,
- )
- edit_modal.saved.connect(partial(self._update_tag_callback, edit_tag_panel))
- edit_modal.show()
-
- def _update_tag_callback(self, edit_tag_panel: EditTagPanel):
- self.__driver.lib.update_tag(
- edit_tag_panel.build_tag(),
- parent_ids=set(edit_tag_panel.parent_ids),
- aliases=set(edit_tag_panel.aliases),
- exclusion_ids=set(edit_tag_panel.exclusion_ids),
- )
- self.on_update.emit()
-
- @override
- def _on_search(self, tag: Tag) -> None:
- 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)
- )
diff --git a/src/tagstudio/qt/mixed/color_box.py b/src/tagstudio/qt/controllers/tiles/color_data.py
similarity index 83%
rename from src/tagstudio/qt/mixed/color_box.py
rename to src/tagstudio/qt/controllers/tiles/color_data.py
index ebc8211795..b232adf226 100644
--- a/src/tagstudio/qt/mixed/color_box.py
+++ b/src/tagstudio/qt/controllers/tiles/color_data.py
@@ -4,6 +4,7 @@
import typing
from collections.abc import Iterable
+from typing import override
import structlog
from PySide6.QtCore import Qt, Signal
@@ -11,14 +12,13 @@
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.i18n.translations import Translations
from tagstudio.qt.controllers.capsule import Capsule
from tagstudio.qt.controllers.modal import Modal
+from tagstudio.qt.controllers.tiles.tile_data import TileData
from tagstudio.qt.mixed.build_color import BuildColorPanel
-from tagstudio.qt.mixed.field_widget import FieldWidget
-from tagstudio.qt.views.layouts.flow_layout import FlowLayout
from tagstudio.qt.views.styles.stylesheets import add_button_style
+from tagstudio.qt.views.tiles.color_data_view import ColorDataView
if typing.TYPE_CHECKING:
from tagstudio.core.library.alchemy.library import Library
@@ -26,8 +26,9 @@
logger = structlog.get_logger(__name__)
-# TODO: Split to use MVC guidelines.
-class ColorBoxWidget(FieldWidget):
+class ColorData(TileData):
+ """An inner widget for color names that goes in a Tile widget."""
+
updated = Signal()
def __init__(
@@ -41,16 +42,15 @@ def __init__(
self.lib: Library = library
title = "" if not self.lib.engine else self.lib.get_namespace_name(group)
- super().__init__(title)
-
- self.setObjectName("colorBox")
- self.base_layout = FlowLayout()
- self.base_layout.enable_grid_optimizations(value=True)
- self.base_layout.setContentsMargins(0, 0, 0, 0)
- self.setLayout(self.base_layout)
+ super().__init__(title, ColorDataView())
+ self.setObjectName("color_data")
self.set_colors(self.colors)
+ @override
+ def layout(self) -> ColorDataView:
+ return super().layout() # pyright: ignore[reportReturnType]
+
def set_colors(self, colors: Iterable[TagColorGroup]):
colors_ = sorted(
list(colors), key=lambda color: self.lib.get_namespace_name(color.namespace)
@@ -59,8 +59,10 @@ def set_colors(self, colors: Iterable[TagColorGroup]):
max_width = 60
capsules: list[Capsule] = []
- while self.base_layout.itemAt(0):
- unwrap(self.base_layout.takeAt(0)).widget().deleteLater() # pyright: ignore[reportOptionalMemberAccess]
+ layout = self.layout()
+ while item := layout.takeAt(0):
+ if widget := item.widget():
+ widget.deleteLater()
for color in colors_:
capsule = Capsule(has_edit=is_mutable, has_remove=is_mutable)
@@ -77,7 +79,7 @@ def set_colors(self, colors: Iterable[TagColorGroup]):
capsule.setCursor(Qt.CursorShape.ArrowCursor)
capsules.append(capsule)
- self.base_layout.addWidget(capsule)
+ self.layout().addWidget(capsule)
for capsule in capsules:
capsule.setFixedWidth(max_width)
@@ -99,7 +101,7 @@ def set_colors(self, colors: Iterable[TagColorGroup]):
)
)
)
- self.base_layout.addWidget(add_button)
+ self.layout().addWidget(add_button)
def edit_color(self, color_group: TagColorGroup):
build_color_panel = BuildColorPanel(self.lib, color_group)
@@ -133,6 +135,6 @@ def delete_color(self, color_group: TagColorGroup):
if result != QMessageBox.ButtonRole.ActionRole.value:
return
- logger.info("[ColorBoxWidget] Removing color", color=color_group)
+ logger.info("[ColorData] Removing color", color=color_group)
self.lib.delete_color(color_group)
self.updated.emit()
diff --git a/src/tagstudio/qt/controllers/tiles/tag_data.py b/src/tagstudio/qt/controllers/tiles/tag_data.py
new file mode 100644
index 0000000000..295697e5c9
--- /dev/null
+++ b/src/tagstudio/qt/controllers/tiles/tag_data.py
@@ -0,0 +1,125 @@
+# SPDX-FileCopyrightText: (c) TagStudio Contributors
+# SPDX-License-Identifier: GPL-3.0-only
+
+
+from collections.abc import Iterable
+from functools import partial
+from typing import TYPE_CHECKING, override
+
+import structlog
+from PySide6.QtCore import Signal
+
+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.i18n.translations import Translations
+from tagstudio.qt.controllers.capsule import Capsule
+from tagstudio.qt.controllers.edit_tag_panel import EditTagPanel
+from tagstudio.qt.controllers.modal import Modal
+from tagstudio.qt.controllers.tiles.tile_data import TileData
+from tagstudio.qt.views.tiles.tag_data_view import TagDataView
+
+if TYPE_CHECKING:
+ from tagstudio.qt.qt_driver import QtDriver
+
+logger = structlog.get_logger(__name__)
+
+
+class TagData(TileData):
+ """An inner widget for tags that go in a Tile widget."""
+
+ on_update = Signal()
+
+ def __init__(self, title: str, driver: QtDriver):
+ self._driver = driver
+ self._lib = driver.lib
+ self._entries: list[int] = []
+ super().__init__(title, TagDataView())
+ self.setObjectName("tag_data")
+
+ @override
+ def layout(self) -> TagDataView:
+ return super().layout() # pyright: ignore[reportReturnType]
+
+ def set_entries(self, entries: list[int]) -> None:
+ self._entries = entries
+
+ def set_tags(self, tags: Iterable[Tag]) -> None:
+ tags_ = sorted(list(tags), key=lambda tag: self._lib.tag_display_name(tag))
+ logger.info("[TagData] Tags:", tags=tags)
+
+ layout = self.layout()
+ while item := layout.takeAt(0):
+ if widget := item.widget():
+ widget.deleteLater()
+
+ for tag in tags_:
+ capsule = Capsule(
+ has_edit=True, has_remove=True, search_label=Translations["tag.search_for_tag"]
+ )
+ capsule.set_text(self._lib.tag_display_name(tag))
+ capsule.set_color_group(tag.color)
+ capsule.on_click.connect(lambda t=tag: self._on_click(t))
+ capsule.on_remove.connect(lambda t=tag: self._on_remove(t))
+ capsule.on_edit.connect(lambda t=tag: self._on_edit(t))
+ capsule.on_search.connect(lambda t=tag: self._on_search(t))
+ layout.addWidget(capsule)
+
+ def _on_click(self, tag: Tag) -> None:
+ match self._driver.settings.tag_click_action:
+ case TagClickActionOption.OPEN_EDIT:
+ self._on_edit(tag)
+ case TagClickActionOption.SET_SEARCH:
+ self._driver.update_browsing_state(
+ BrowsingState.from_tag_id(tag.id, self._driver.browsing_history.current)
+ )
+ case TagClickActionOption.ADD_TO_SEARCH:
+ # NOTE: modifying the ast and then setting that would be nicer
+ # than this string manipulation, but also much more complex,
+ # due to needing to implement a visitor that turns an AST to a string
+ # So if that exists when you read this, change the following accordingly.
+ current = self._driver.browsing_history.current
+ suffix = unwrap(
+ BrowsingState.from_tag_id(tag.id, self._driver.browsing_history.current).query
+ )
+ self._driver.update_browsing_state(
+ current.with_search_query(
+ f"{current.query} {suffix}" if current.query else suffix
+ )
+ )
+
+ def _on_remove(self, tag: Tag) -> None:
+ logger.info("[TagData] remove_tag", selected=self._entries)
+
+ for entry_id in self._entries:
+ self._driver.lib.remove_tags_from_entries(entry_id, tag.id)
+
+ self.on_update.emit()
+
+ def _on_edit(self, tag: Tag) -> None:
+ edit_tag_panel = EditTagPanel(self._driver.lib, tag=tag)
+
+ edit_modal = Modal(
+ edit_tag_panel,
+ self._driver.lib.tag_display_name(tag),
+ Translations["tag.edit"],
+ is_savable=True,
+ )
+ edit_modal.saved.connect(partial(self._update_tag_callback, edit_tag_panel))
+ edit_modal.show()
+
+ def _update_tag_callback(self, edit_tag_panel: EditTagPanel):
+ self._driver.lib.update_tag(
+ edit_tag_panel.build_tag(),
+ parent_ids=set(edit_tag_panel.parent_ids),
+ aliases=set(edit_tag_panel.aliases),
+ exclusion_ids=set(edit_tag_panel.exclusion_ids),
+ )
+ self.on_update.emit()
+
+ def _on_search(self, tag: Tag) -> None:
+ 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)
+ )
diff --git a/src/tagstudio/qt/controllers/tiles/text_data.py b/src/tagstudio/qt/controllers/tiles/text_data.py
new file mode 100644
index 0000000000..89ac9e090b
--- /dev/null
+++ b/src/tagstudio/qt/controllers/tiles/text_data.py
@@ -0,0 +1,40 @@
+# SPDX-FileCopyrightText: (c) TagStudio Contributors
+# SPDX-License-Identifier: GPL-3.0-only
+
+
+import re
+from typing import override
+
+from tagstudio.qt.controllers.tiles.tile_data import TileData
+from tagstudio.qt.views.tiles.text_data_view import TextDataView
+
+
+class TextData(TileData):
+ """An inner widget for any text that goes in a Tile widget.
+
+ Can include text fields, date fields, etc.
+ """
+
+ def __init__(self, title: str, text: str) -> None:
+ super().__init__(title, TextDataView())
+ self.setObjectName("text_data")
+ self.set_text(text)
+
+ @override
+ def layout(self) -> TextDataView:
+ return super().layout() # pyright: ignore[reportReturnType]
+
+ def set_text(self, text: str):
+ text = linkify(text)
+ self.layout().text_label.setText(text)
+
+
+# Regex from https://stackoverflow.com/a/6041965
+def linkify(text: str):
+ url_pattern = r"(http|ftp|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:\/~+#\-*]*[\w@?^=%&\/~+#\-*])" # noqa: E501
+ return re.sub(
+ url_pattern,
+ lambda url: f'{url.group(0)}',
+ text,
+ flags=re.IGNORECASE,
+ )
diff --git a/src/tagstudio/qt/controllers/tiles/tile.py b/src/tagstudio/qt/controllers/tiles/tile.py
new file mode 100644
index 0000000000..080d067e57
--- /dev/null
+++ b/src/tagstudio/qt/controllers/tiles/tile.py
@@ -0,0 +1,104 @@
+# SPDX-FileCopyrightText: (c) TagStudio Contributors
+# SPDX-License-Identifier: GPL-3.0-only
+
+
+from collections.abc import Callable
+from typing import override
+from warnings import catch_warnings
+
+from PySide6.QtCore import QEvent, Qt
+from PySide6.QtGui import QEnterEvent, QResizeEvent
+from PySide6.QtWidgets import QWidget
+
+from tagstudio.core.utils.types import unwrap
+from tagstudio.qt.views.styles.stylesheets import container_style, header
+from tagstudio.qt.views.tiles.tile_view import TileView
+
+
+class Tile(QWidget):
+ """Wraps a title, action buttons, and data such as text or tags inside a single widget."""
+
+ def __init__(self, title: str = "TILE") -> None:
+ super().__init__()
+ self.setObjectName("tile")
+ self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground)
+ self.copy_callback: Callable[[], None] | None = None
+ self.edit_callback: Callable[[], None] | None = None
+ self.remove_callback: Callable[[], None] | None = None
+
+ self.setLayout(TileView())
+ self.set_title(title)
+ self.setStyleSheet(container_style())
+
+ @override
+ def layout(self) -> TileView:
+ return super().layout() # pyright: ignore[reportReturnType]
+
+ def set_copy_callback(self, callback: Callable[[], None] | None = None) -> None:
+ with catch_warnings(record=True):
+ self.layout().copy_button.clicked.disconnect()
+
+ self.copy_callback = callback
+ if callback:
+ self.layout().copy_button.clicked.connect(callback)
+
+ def set_edit_callback(self, callback: Callable[[], None] | None = None) -> None:
+ with catch_warnings(record=True):
+ self.layout().edit_button.clicked.disconnect()
+
+ self.edit_callback = callback
+ if callback:
+ self.layout().edit_button.clicked.connect(callback)
+
+ def set_remove_callback(self, callback: Callable[[], None] | None = None) -> None:
+ with catch_warnings(record=True):
+ self.layout().remove_button.clicked.disconnect()
+
+ self.remove_callback = callback
+ if callback:
+ self.layout().remove_button.clicked.connect(callback)
+
+ def set_inner_widget(self, widget: QWidget) -> None:
+ if item := self.layout().data_layout.itemAt(0):
+ old: QWidget = unwrap(item.widget())
+ self.layout().data_layout.removeWidget(old)
+ old.deleteLater()
+
+ self.layout().data_layout.addWidget(widget)
+
+ def get_inner_widget(self) -> QWidget | None:
+ if item := self.layout().data_layout.itemAt(0):
+ return item.widget()
+ return None
+
+ def set_title(self, title: str) -> None:
+ self.title = header(title, 4)
+ self.layout().title_widget.setText(self.title)
+
+ @override
+ def enterEvent(self, event: QEnterEvent) -> None:
+ # NOTE: You could pass the hover event to the inner widget if needed.
+ layout = self.layout()
+ if self.copy_callback:
+ layout.copy_button.setHidden(False)
+ if self.edit_callback:
+ layout.edit_button.setHidden(False)
+ if self.remove_callback:
+ layout.remove_button.setHidden(False)
+ return super().enterEvent(event)
+
+ @override
+ def leaveEvent(self, event: QEvent) -> None:
+ layout = self.layout()
+ if self.copy_callback:
+ layout.copy_button.setHidden(True)
+ if self.edit_callback:
+ layout.edit_button.setHidden(True)
+ if self.remove_callback:
+ layout.remove_button.setHidden(True)
+ return super().leaveEvent(event)
+
+ @override
+ def resizeEvent(self, event: QResizeEvent) -> None:
+ self.layout().title_widget.setFixedWidth(int(event.size().width() // 1.5))
+ return super().resizeEvent(event)
diff --git a/src/tagstudio/qt/controllers/tiles/tile_data.py b/src/tagstudio/qt/controllers/tiles/tile_data.py
new file mode 100644
index 0000000000..9198859a9a
--- /dev/null
+++ b/src/tagstudio/qt/controllers/tiles/tile_data.py
@@ -0,0 +1,14 @@
+# SPDX-FileCopyrightText: (c) TagStudio Contributors
+# SPDX-License-Identifier: GPL-3.0-only
+
+
+from PySide6.QtWidgets import QLayout, QWidget
+
+
+class TileData(QWidget):
+ """A base class for widgets that go in a Tile widget."""
+
+ def __init__(self, title: str, view: QLayout) -> None:
+ super().__init__()
+ self.title: str = title
+ self.setLayout(view)
diff --git a/src/tagstudio/qt/mixed/field_containers.py b/src/tagstudio/qt/controllers/tiles/tile_list.py
similarity index 73%
rename from src/tagstudio/qt/mixed/field_containers.py
rename to src/tagstudio/qt/controllers/tiles/tile_list.py
index 4f648535a1..ff3b792026 100644
--- a/src/tagstudio/qt/mixed/field_containers.py
+++ b/src/tagstudio/qt/controllers/tiles/tile_list.py
@@ -6,19 +6,11 @@
from collections.abc import Callable
from datetime import datetime as dt
from functools import partial
-from warnings import catch_warnings
+from typing import override
import structlog
-from PySide6.QtCore import Qt, Signal
-from PySide6.QtWidgets import (
- QFrame,
- QHBoxLayout,
- QMessageBox,
- QScrollArea,
- QSizePolicy,
- QVBoxLayout,
- QWidget,
-)
+from PySide6.QtCore import Signal
+from PySide6.QtWidgets import QMessageBox, QWidget
from tagstudio.core.library.alchemy.fields import (
BaseField,
@@ -32,11 +24,11 @@
from tagstudio.i18n.translations import Translations, field_type_name
from tagstudio.qt.controllers.edit_text import EditText
from tagstudio.qt.controllers.modal import Modal
-from tagstudio.qt.controllers.tag_box import TagBoxWidget
+from tagstudio.qt.controllers.tiles.tag_data import TagData
+from tagstudio.qt.controllers.tiles.text_data import TextData
+from tagstudio.qt.controllers.tiles.tile import Tile
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.views.styles.stylesheets import inset_container_style
+from tagstudio.qt.views.tiles.tile_list_view import TileListView
if typing.TYPE_CHECKING:
from tagstudio.qt.qt_driver import QtDriver
@@ -44,9 +36,8 @@
logger = structlog.get_logger(__name__)
-# TODO: Split to use MVC guidelines.
-class FieldContainers(QWidget):
- """Widget for the tag and field containers displayed inside the Preview Panel."""
+class TileList(QWidget):
+ """A styled list of Tile widgets."""
on_tags_update = Signal()
@@ -55,45 +46,16 @@ def __init__(self, library: Library, driver: QtDriver) -> None:
self.lib = library
self.driver: QtDriver = driver
- self.initialized = False
- self.is_open: bool = False
- self.common_fields: list = []
- self.mixed_fields: list = []
self.cached_entries: list[Entry] = []
- 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(6)
-
- scroll_container: QWidget = QWidget()
- scroll_container.setObjectName("entryScrollContainer")
- scroll_container.setLayout(self.scroll_layout)
-
- info_section = QWidget()
- info_layout = QVBoxLayout(info_section)
- info_layout.setContentsMargins(0, 0, 0, 0)
- info_layout.setSpacing(0)
-
- self.scroll_area = QScrollArea()
- self.scroll_area.setObjectName("entryScrollArea")
- self.scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
- self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
- self.scroll_area.setWidgetResizable(True)
- self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
- self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
-
- # NOTE: I would rather have this style applied to the scroll_area
- # 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(inset_container_style("entryScrollContainer"))
- self.scroll_area.setWidget(scroll_container)
-
- root_layout = QHBoxLayout(self)
- root_layout.setContentsMargins(0, 0, 0, 0)
- root_layout.addWidget(self.scroll_area)
+ self._tiles: list[Tile] = []
+
+ # TODO: Reimplement mixed entry editing
+
+ self.setLayout(TileListView())
+
+ @override
+ def layout(self) -> TileListView:
+ return super().layout() # pyright: ignore[reportReturnType]
@property
def top_entry_id(self) -> int:
@@ -102,7 +64,7 @@ def top_entry_id(self) -> int:
def update_from_entry(self, entry_id: int, update_badges: bool = True) -> None:
"""Update tags and fields from a single Entry source."""
- logger.warning("[FieldContainers] Updating Selection", entry_id=entry_id)
+ logger.warning("[TileList] Updating Selection", entry_id=entry_id)
entry = unwrap(self.lib.get_entry_full(entry_id))
self.cached_entries = [entry]
@@ -118,9 +80,7 @@ def update_granular(
if entry_tags:
categories = self.get_tag_categories(entry_tags)
for cat, tags in sorted(categories.items(), key=lambda kv: (kv[0] is None, kv)):
- self.write_tag_container(
- container_index, tags=tags, category_tag=cat, is_mixed=False
- )
+ self.write_tag_tile(container_index, tags=tags, category_tag=cat, is_mixed=False)
container_index += 1
container_len += 1
if update_badges:
@@ -128,11 +88,11 @@ def update_granular(
# Write field container(s)
for index, field in enumerate(entry_fields, start=container_index):
- self.write_field_container(index, field, is_mixed=False)
+ self.write_field_tile(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._tiles) > container_len:
+ for i, c in enumerate(self._tiles):
if i > (container_len - 1):
c.setHidden(True)
@@ -151,7 +111,7 @@ def update_toggled_tag(self, tag_id: int, toggle_value: bool) -> None:
def hide_containers(self) -> None:
"""Hide all field and tag containers."""
- for c in self._containers:
+ for c in self._tiles:
c.setHidden(True)
def get_tag_categories(self, tags: set[Tag]) -> dict[Tag | None, set[Tag]]:
@@ -215,7 +175,7 @@ def add_field_to_selected(
assert isinstance(field_templates, list)
logger.info(
- "[FieldContainers][add_field_to_selected]",
+ "[TileList][add_field_to_selected]",
selected=self.driver.selected,
fields=[
(field_template.class_name, field_template.id) for field_template in field_templates
@@ -225,7 +185,7 @@ def add_field_to_selected(
for entry_id in self.driver.selected:
for field_template in field_templates:
logger.info(
- "[FieldContainers][add_field_to_selected] Adding field",
+ "[TileList][add_field_to_selected] Adding field",
name=field_template.name,
type=field_template.class_name,
)
@@ -239,7 +199,7 @@ def add_tags_to_selected(self, tag_ids: int | list[int]) -> None:
if isinstance(tag_ids, int):
tag_ids = [tag_ids]
logger.info(
- "[FieldContainers][add_tags_to_selected]",
+ "[TileList][add_tags_to_selected]",
selected=self.driver.selected,
tag_ids=tag_ids,
)
@@ -266,8 +226,8 @@ def remove_field_callback(self, field: BaseField, entry_id: int) -> None:
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.
+ def write_field_tile(self, index: int, field: BaseField, is_mixed: bool = False) -> None:
+ """Update/Create data for a field Tile.
Args:
index(int): The container index.
@@ -276,9 +236,7 @@ def write_field_container(self, index: int, field: BaseField, is_mixed: bool = F
If True, field is not present in all selected items.
"""
- def write_text_container(
- container: FieldContainer, field: TextField, title: str, is_mixed: bool
- ):
+ def write_text_tile(container: Tile, field: TextField, title: str, is_mixed: bool):
container.set_title(field.name)
# Normalize line endings in any text content.
@@ -288,7 +246,7 @@ def write_text_container(
else:
text = f"{Translations['field.mixed_data']}"
- inner_widget = TextContainerWidget(title, text)
+ inner_widget = TextData(title, text)
container.set_inner_widget(inner_widget)
if not is_mixed:
@@ -311,7 +269,7 @@ def write_text_container(
)
def write_datetime_container(
- container: FieldContainer, field: DatetimeField, title: str, is_mixed: bool
+ container: Tile, field: DatetimeField, title: str, is_mixed: bool
):
container.set_title(field.name)
@@ -326,7 +284,7 @@ def write_datetime_container(
else:
text = f"{Translations['field.mixed_data']}"
- inner_widget = TextContainerWidget(title, text)
+ inner_widget = TextData(title, text)
container.set_inner_widget(inner_widget)
if not is_mixed:
@@ -350,7 +308,7 @@ def write_datetime_container(
def write_unknown_container():
container.set_title(field.name)
- inner_widget = TextContainerWidget(title, field.name)
+ inner_widget = TextData(title, field.name)
container.set_inner_widget(inner_widget)
container.set_remove_callback(
lambda: self.remove_message_box(
@@ -360,19 +318,19 @@ def write_unknown_container():
)
logger.info(
- "[FieldContainers][write_container]",
+ "[TileList][write_container]",
index=index,
name=field.name,
type=field.class_name,
)
# Create new containers if necessary
- if len(self._containers) < (index + 1):
- container = FieldContainer()
- self._containers.append(container)
- self.scroll_layout.addWidget(container)
+ if len(self._tiles) < (index + 1):
+ container = Tile()
+ self._tiles.append(container)
+ self.layout().scroll_layout.addWidget(container)
else:
- container = self._containers[index]
+ container = self._tiles[index]
# Set field title
field_type = field_type_name(field.class_name)
@@ -380,7 +338,7 @@ def write_unknown_container():
# Write containers
if type(field) is TextField:
- write_text_container(container, field, title, is_mixed)
+ write_text_tile(container, field, title, is_mixed)
elif type(field) is DatetimeField:
write_datetime_container(container, field, title, is_mixed)
else:
@@ -388,10 +346,10 @@ def write_unknown_container():
container.setHidden(False)
- def write_tag_container(
+ def write_tag_tile(
self, index: int, tags: set[Tag], category_tag: Tag | None = None, is_mixed: bool = False
) -> None:
- """Update/Create tag data for a tag FieldContainer.
+ """Update/Create tag data for a tag Tile.
Args:
index(int): The container index.
@@ -400,38 +358,33 @@ def write_tag_container(
is_mixed(bool): Relevant when multiple items are selected.
If True, field is not present in all selected items.
"""
- logger.info("[FieldContainers][write_tag_container]", index=index)
- if len(self._containers) < (index + 1):
- container = FieldContainer()
- self._containers.append(container)
- self.scroll_layout.addWidget(container)
+ logger.info("[TileList][write_tag_tile]", index=index)
+ if len(self._tiles) < (index + 1):
+ container = Tile()
+ self._tiles.append(container)
+ self.layout().scroll_layout.addWidget(container)
else:
- container = self._containers[index]
+ container = self._tiles[index]
container.set_title(Translations["entries.tags"] if not category_tag else category_tag.name)
if not is_mixed:
inner_widget = container.get_inner_widget()
- if isinstance(inner_widget, TagBoxWidget):
- with catch_warnings(record=True):
- inner_widget.on_update.disconnect()
-
- else:
- inner_widget = TagBoxWidget(Translations["entries.tags"], self.driver)
+ if not isinstance(inner_widget, TagData):
+ inner_widget = TagData(Translations["entries.tags"], self.driver)
container.set_inner_widget(inner_widget)
+ inner_widget.on_update.connect(
+ lambda: (
+ self.update_from_entry(self.cached_entries[0].id, update_badges=True),
+ self.on_tags_update.emit(),
+ )
+ )
inner_widget.set_entries([e.id for e in self.cached_entries])
inner_widget.set_tags(tags)
-
- inner_widget.on_update.connect(
- lambda: (
- self.update_from_entry(self.cached_entries[0].id, update_badges=True),
- self.on_tags_update.emit(),
- )
- )
else:
text = f"{Translations['field.mixed_data']}"
- inner_widget = TextContainerWidget("Mixed Tags", text) # NOTE: Unlocalized but unused
+ inner_widget = TextData("Mixed Tags", text) # NOTE: Unlocalized but unused
container.set_inner_widget(inner_widget)
container.set_edit_callback()
@@ -441,7 +394,7 @@ def write_tag_container(
def _remove_field(self, field: BaseField) -> None:
"""Remove a field from all selected Entries."""
logger.info(
- "[FieldContainers] Removing Field",
+ "[TileList] Removing Field",
field=field,
selected=[x.path for x in self.cached_entries],
)
@@ -470,9 +423,9 @@ def remove_message_box(self, prompt: str, callback: Callable[..., None]) -> None
remove_mb.setWindowTitle(Translations["Remove Field"])
remove_mb.setIcon(QMessageBox.Icon.Warning)
cancel_button = remove_mb.addButton(
- Translations["generic.cancel_alt"], QMessageBox.ButtonRole.DestructiveRole
+ Translations["generic.cancel_alt"], QMessageBox.ButtonRole.RejectRole
)
- remove_mb.addButton("&Remove", QMessageBox.ButtonRole.RejectRole)
+ remove_mb.addButton("&Remove", QMessageBox.ButtonRole.DestructiveRole)
remove_mb.setEscapeButton(cancel_button)
result = remove_mb.exec_()
if result == QMessageBox.ButtonRole.ActionRole.value:
diff --git a/src/tagstudio/qt/mixed/field_widget.py b/src/tagstudio/qt/mixed/field_widget.py
deleted file mode 100644
index 4b69967813..0000000000
--- a/src/tagstudio/qt/mixed/field_widget.py
+++ /dev/null
@@ -1,189 +0,0 @@
-# SPDX-FileCopyrightText: (c) TagStudio Contributors
-# SPDX-License-Identifier: GPL-3.0-only
-
-
-from collections.abc import Callable
-from typing import override
-from warnings import catch_warnings
-
-import structlog
-from PIL import ImageQt
-from PySide6.QtCore import QEvent, QSize, Qt
-from PySide6.QtGui import QEnterEvent, QPixmap, QResizeEvent
-from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
-
-from tagstudio.core.utils.types import unwrap
-from tagstudio.qt.resource_manager import ResourceManager
-from tagstudio.qt.views.styles.color_overlay import auto_theme_overlay
-from tagstudio.qt.views.styles.stylesheets import container_style, header
-
-logger = structlog.get_logger(__name__)
-
-
-# TODO: Split to use MVC guidelines.
-class FieldContainer(QWidget):
- rm: ResourceManager = ResourceManager()
- copy_icon = auto_theme_overlay(rm.copy, inverse=True)
- edit_icon = auto_theme_overlay(rm.edit, inverse=True)
- trash_icon = auto_theme_overlay(rm.trash, inverse=True)
-
- # TODO: There should be a global button theme somewhere.
-
- def __init__(self, title: str = "Field", inline: bool = True) -> None:
- super().__init__()
- self.setObjectName("fieldContainer")
- self.title: str = title
- self.copy_callback: Callable[[], None] | None = None
- self.edit_callback: Callable[[], None] | None = None
- self.remove_callback: Callable[[], None] | None = None
- button_size = 22
- icon_margins = 4
- icon_size = button_size - icon_margins
-
- self.root_layout = QVBoxLayout(self)
- self.root_layout.setObjectName("baseLayout")
- self.root_layout.setContentsMargins(0, 0, 0, 0)
-
- self.inner_layout = QVBoxLayout()
- self.inner_layout.setObjectName("innerLayout")
- self.inner_layout.setContentsMargins(3, 0, 0, 3)
- self.inner_layout.setSpacing(0)
- self.field_container = QWidget()
- self.field_container.setObjectName("fieldContainer")
- self.field_container.setLayout(self.inner_layout)
- self.root_layout.addWidget(self.field_container)
-
- self.title_container = QWidget()
- self.title_layout = QHBoxLayout(self.title_container)
- self.title_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
- self.title_layout.setObjectName("fieldLayout")
- self.title_layout.setContentsMargins(0, 0, 0, 0)
- self.title_layout.setSpacing(0)
- self.inner_layout.addWidget(self.title_container)
-
- self.title_widget = QLabel()
- self.title_widget.setMinimumHeight(button_size)
- self.title_widget.setObjectName("fieldTitle")
- self.title_widget.setWordWrap(True)
- self.title_widget.setText(title)
- self.title_layout.addWidget(self.title_widget)
- self.title_layout.addStretch(2)
-
- self.copy_button = QPushButton()
- self.copy_button.setObjectName("copyButton")
- self.copy_button.setMinimumSize(button_size, button_size)
- self.copy_button.setMaximumSize(button_size, button_size)
- self.copy_button.setFlat(True)
- self.copy_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(FieldContainer.copy_icon)))
- self.copy_button.setIconSize(QSize(20, 20))
- self.copy_button.setCursor(Qt.CursorShape.PointingHandCursor)
- self.title_layout.addWidget(self.copy_button)
- self.copy_button.setHidden(True)
-
- self.edit_button = QPushButton()
- self.edit_button.setObjectName("editButton")
- self.edit_button.setMinimumSize(button_size, button_size)
- 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(icon_size, icon_size))
- self.edit_button.setCursor(Qt.CursorShape.PointingHandCursor)
- self.title_layout.addWidget(self.edit_button)
- self.edit_button.setHidden(True)
-
- self.remove_button = QPushButton()
- self.remove_button.setObjectName("removeButton")
- self.remove_button.setMinimumSize(button_size, button_size)
- 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(icon_size, icon_size))
- self.remove_button.setCursor(Qt.CursorShape.PointingHandCursor)
- self.title_layout.addWidget(self.remove_button)
- self.remove_button.setHidden(True)
-
- self.field = QWidget()
- self.field.setObjectName("field")
- self.field_layout = QHBoxLayout()
- self.field_layout.setObjectName("fieldLayout")
- self.field_layout.setContentsMargins(0, 0, 0, 0)
- self.field.setLayout(self.field_layout)
- self.inner_layout.addWidget(self.field)
-
- self.set_title(title)
- self.setStyleSheet(container_style())
-
- def set_copy_callback(self, callback: Callable[[], None] | None = None) -> None:
- with catch_warnings(record=True):
- self.copy_button.clicked.disconnect()
-
- self.copy_callback = callback
- if callback:
- self.copy_button.clicked.connect(callback)
-
- def set_edit_callback(self, callback: Callable[[], None] | None = None) -> None:
- with catch_warnings(record=True):
- self.edit_button.clicked.disconnect()
-
- self.edit_callback = callback
- if callback:
- self.edit_button.clicked.connect(callback)
-
- def set_remove_callback(self, callback: Callable[[], None] | None = None) -> None:
- with catch_warnings(record=True):
- self.remove_button.clicked.disconnect()
-
- self.remove_callback = callback
- if callback:
- self.remove_button.clicked.connect(callback)
-
- def set_inner_widget(self, widget: FieldWidget) -> None:
- item = self.field_layout.itemAt(0)
- if item:
- old: QWidget = unwrap(item.widget())
- self.field_layout.removeWidget(old)
- old.deleteLater()
-
- self.field_layout.addWidget(widget)
-
- def get_inner_widget(self) -> QWidget | None:
- item = self.field_layout.itemAt(0)
- if item:
- return item.widget()
- return None
-
- def set_title(self, title: str) -> None:
- self.title = header(title, 4)
- self.title_widget.setText(self.title)
-
- @override
- def enterEvent(self, event: QEnterEvent) -> None:
- # NOTE: You could pass the hover event to the FieldWidget if needed.
- if self.copy_callback:
- self.copy_button.setHidden(False)
- if self.edit_callback:
- self.edit_button.setHidden(False)
- if self.remove_callback:
- self.remove_button.setHidden(False)
- return super().enterEvent(event)
-
- @override
- def leaveEvent(self, event: QEvent) -> None:
- if self.copy_callback:
- self.copy_button.setHidden(True)
- if self.edit_callback:
- self.edit_button.setHidden(True)
- if self.remove_callback:
- self.remove_button.setHidden(True)
- return super().leaveEvent(event)
-
- @override
- def resizeEvent(self, event: QResizeEvent) -> None:
- self.title_widget.setFixedWidth(int(event.size().width() // 1.5))
- return super().resizeEvent(event)
-
-
-class FieldWidget(QWidget):
- def __init__(self, title: str) -> None:
- super().__init__()
- self.title: str = title
diff --git a/src/tagstudio/qt/mixed/folders_to_tags.py b/src/tagstudio/qt/mixed/folders_to_tags.py
index 0a3677fd5f..f2e6914c1a 100644
--- a/src/tagstudio/qt/mixed/folders_to_tags.py
+++ b/src/tagstudio/qt/mixed/folders_to_tags.py
@@ -227,9 +227,7 @@ def __init__(self, library: Library, driver: QtDriver):
def on_apply(self):
folders_to_tags(self.library)
self.close()
- self.driver.main_window.preview_panel.set_selection(
- self.driver.selected, update_preview=False
- )
+ self.driver.main_window.inspector.set_selection(self.driver.selected, update_preview=False)
@override
def showEvent(self, event: QtGui.QShowEvent):
diff --git a/src/tagstudio/qt/mixed/item_thumb.py b/src/tagstudio/qt/mixed/item_thumb.py
index f789d77985..a93157af42 100644
--- a/src/tagstudio/qt/mixed/item_thumb.py
+++ b/src/tagstudio/qt/mixed/item_thumb.py
@@ -498,9 +498,7 @@ def toggle_item_tag(
):
selected = self.driver._selected
if len(selected) == 1 and entry_id in selected:
- self.driver.main_window.preview_panel.containers.update_toggled_tag(
- tag_id, toggle_value
- )
+ self.driver.main_window.inspector.containers.update_toggled_tag(tag_id, toggle_value)
@override
def mouseMoveEvent(self, event: QMouseEvent) -> None:
diff --git a/src/tagstudio/qt/mixed/media_player.py b/src/tagstudio/qt/mixed/media_player.py
index a8029a311d..1439fb6594 100644
--- a/src/tagstudio/qt/mixed/media_player.py
+++ b/src/tagstudio/qt/mixed/media_player.py
@@ -8,18 +8,16 @@
from typing import override
import structlog
-from PIL import Image, ImageDraw, ImageQt
+from PIL import ImageQt
from PySide6.QtCore import QEvent, QObject, QRectF, QSize, Qt, QUrl, QVariantAnimation
from PySide6.QtGui import (
QAction,
- QBitmap,
QBrush,
QColor,
QLinearGradient,
QMouseEvent,
QPen,
QPixmap,
- QRegion,
QResizeEvent,
)
from PySide6.QtMultimedia import QAudioOutput, QMediaDevices, QMediaPlayer
@@ -264,16 +262,6 @@ def toggle_loop(self) -> None:
self.driver.settings.save()
self.player.setLoops(-1 if self.driver.settings.loop else 1)
- def apply_rounded_corners(self) -> None:
- """Apply a rounded corner effect to the video player."""
- width: int = int(max(self.contentsRect().size().width(), 0))
- height: int = int(max(self.contentsRect().size().height(), 0))
- mask = Image.new("RGBA", (width, height), (0, 0, 0, 255))
- draw = ImageDraw.Draw(mask)
- draw.rounded_rectangle((0, 0) + (width, height), radius=8, fill=(0, 0, 0, 0))
- final_mask: QPixmap = mask.getchannel("A").toqpixmap() # pyright: ignore[reportUnknownVariableType]
- self.setMask(QRegion(QBitmap(final_mask)))
-
def set_tint_opacity(self, opacity: int) -> None:
"""Set the opacity of the video player's tint.
@@ -474,7 +462,6 @@ def _update_controls(self, size: QSize) -> None:
self.centerOn(self.video_preview)
self.tint.setRect(0, 0, self.size().width(), self.size().height())
- self.apply_rounded_corners()
@override
def resizeEvent(self, event: QResizeEvent) -> None:
diff --git a/src/tagstudio/qt/mixed/mirror_entries_modal.py b/src/tagstudio/qt/mixed/mirror_entries_modal.py
index 3ec9ea6115..75980138eb 100644
--- a/src/tagstudio/qt/mixed/mirror_entries_modal.py
+++ b/src/tagstudio/qt/mixed/mirror_entries_modal.py
@@ -87,7 +87,7 @@ def displayed_text(x):
pw.from_iterable_function(
self.mirror_entries_runnable,
displayed_text,
- lambda s=self.driver.selected: self.driver.main_window.preview_panel.set_selection(s),
+ lambda s=self.driver.selected: self.driver.main_window.inspector.set_selection(s),
self.done.emit,
)
diff --git a/src/tagstudio/qt/mixed/pagination.py b/src/tagstudio/qt/mixed/pagination.py
deleted file mode 100644
index 78cf7316d3..0000000000
--- a/src/tagstudio/qt/mixed/pagination.py
+++ /dev/null
@@ -1,298 +0,0 @@
-# SPDX-FileCopyrightText: (c) TagStudio Contributors
-# SPDX-License-Identifier: GPL-3.0-only
-
-# pyright: reportOptionalMemberAccess=false
-
-from typing import cast, override
-from warnings import catch_warnings
-
-from PIL import Image, ImageQt
-from PySide6.QtCore import QSize, Signal
-from PySide6.QtGui import QIntValidator, QPixmap
-from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QPushButton, QSizePolicy, QWidget
-
-from tagstudio.qt.resource_manager import ResourceManager
-from tagstudio.qt.views.styles.color_overlay import auto_theme_overlay
-
-
-# TODO: Split to use MVC guidelines.
-class Pagination(QWidget):
- """Widget containing controls for navigating between pages of items."""
-
- index = Signal(int)
-
- def __init__(self, parent: QWidget | None = None) -> None:
- super().__init__(parent=parent)
- self.rm = ResourceManager()
- self.page_count: int = 0
- self.current_page_index: int = 0
- self.buffer_page_count: int = 4
- self.button_size = QSize(32, 24)
-
- # ------------ UI EXAMPLE --------------
- # [<] [1]...[3][4] [5] [6][7]...[42] [>]
- # ^^^^ <-- 2 Buffer Pages
- # Center Page Number is Editable Text
- # --------------------------------------
-
- # [----------- ROOT LAYOUT ------------]
- self.setHidden(True)
- self.root_layout = QHBoxLayout(self)
- self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred)
- self.root_layout.setContentsMargins(0, 6, 0, 0)
- self.root_layout.setSpacing(3)
-
- # [<] ----------------------------------
- self.prev_button = QPushButton()
- prev_icon: Image.Image = self.rm.bxs_left_arrow
- prev_icon = auto_theme_overlay(prev_icon, use_alpha=False)
- self.prev_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(prev_icon)))
- self.prev_button.setIconSize(QSize(12, 12))
- self.prev_button.setMinimumSize(self.button_size)
- self.prev_button.setMaximumSize(self.button_size)
-
- # --- [1] ------------------------------
- self.start_button = QPushButton()
- self.start_button.setMinimumSize(self.button_size)
- self.start_button.setMaximumSize(self.button_size)
-
- # ------ ... ---------------------------
- self.start_ellipses = QLabel()
- self.start_ellipses.setMinimumSize(self.button_size)
- self.start_ellipses.setMaximumSize(self.button_size)
- self.start_ellipses.setText(". . .")
-
- # --------- [3][4] ---------------------
- self.start_buffer_container = QWidget()
- self.start_buffer_layout = QHBoxLayout(self.start_buffer_container)
- self.start_buffer_layout.setContentsMargins(0, 0, 0, 0)
- self.start_buffer_layout.setSpacing(3)
-
- # ---------------- [5] -----------------
- self.current_page_field = QLineEdit()
- self.current_page_field.setMinimumSize(self.button_size)
- self.current_page_field.setMaximumSize(self.button_size)
- self.validator = Validator(1, self.page_count)
- self.current_page_field.setValidator(self.validator)
- self.current_page_field.returnPressed.connect(
- lambda: self._goto_page(int(self.current_page_field.text()) - 1)
- )
-
- # -------------------- [6][7] ----------
- self.end_buffer_container = QWidget()
- self.end_buffer_layout = QHBoxLayout(self.end_buffer_container)
- self.end_buffer_layout.setContentsMargins(0, 0, 0, 0)
- self.end_buffer_layout.setSpacing(3)
-
- # -------------------------- ... -------
- self.end_ellipses = QLabel()
- self.end_ellipses.setMinimumSize(self.button_size)
- self.end_ellipses.setMaximumSize(self.button_size)
- self.end_ellipses.setText(". . .")
-
- # ----------------------------- [42] ---
- self.end_button = QPushButton()
- self.end_button.setMinimumSize(self.button_size)
- self.end_button.setMaximumSize(self.button_size)
-
- # ---------------------------------- [>]
- self.next_button = QPushButton()
- next_icon: Image.Image = self.rm.bxs_right_arrow
- next_icon = auto_theme_overlay(next_icon, use_alpha=False)
- self.next_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(next_icon)))
- self.next_button.setIconSize(QSize(12, 12))
- self.next_button.setMinimumSize(self.button_size)
- self.next_button.setMaximumSize(self.button_size)
-
- # Add Widgets to Root Layout
- self.root_layout.addStretch(1)
- self.root_layout.addWidget(self.prev_button)
- self.root_layout.addWidget(self.start_button)
- self.root_layout.addWidget(self.start_ellipses)
- self.root_layout.addWidget(self.start_buffer_container)
- self.root_layout.addWidget(self.current_page_field)
- self.root_layout.addWidget(self.end_buffer_container)
- self.root_layout.addWidget(self.end_ellipses)
- self.root_layout.addWidget(self.end_button)
- self.root_layout.addWidget(self.next_button)
- self.root_layout.addStretch(1)
-
- self._populate_buffer_buttons()
-
- def update_buttons(self, page_count: int, index: int, emit: bool = True):
- # Guard
- if index < 0:
- raise ValueError("Negative index detected")
-
- for i in range(0, 10):
- if self.start_buffer_layout.itemAt(i):
- self.start_buffer_layout.itemAt(i).widget().setHidden(True)
- if self.end_buffer_layout.itemAt(i):
- self.end_buffer_layout.itemAt(i).widget().setHidden(True)
-
- end_page = page_count - 1
- if page_count <= 1:
- # Hide everything if there are only one or less pages.
- # [-------------- HIDDEN --------------]
- self.setHidden(True)
-
- elif page_count > 1:
- # Enable/Disable Next+Prev Buttons
- if index == 0:
- self.prev_button.setDisabled(True)
- else:
- self._assign_click(self.prev_button, index - 1)
- self.prev_button.setDisabled(False)
-
- if index == end_page:
- self.next_button.setDisabled(True)
- else:
- self._assign_click(self.next_button, index + 1)
- self.next_button.setDisabled(False)
-
- # Set Ellipses Sizes
- if 8 <= page_count <= 11:
- end_scale = max(1, page_count - index - 6)
- srt_scale = max(1, index - 5)
- elif page_count > 11:
- end_scale = max(1, 7 - index)
- srt_scale = max(1, (7 - (end_page - index)))
-
- if page_count >= 8:
- end_size = self.button_size.width() * end_scale + (3 * (end_scale - 1)) # pyright: ignore[reportPossiblyUnboundVariable]
- srt_size = self.button_size.width() * srt_scale + (3 * (srt_scale - 1)) # pyright: ignore[reportPossiblyUnboundVariable]
- self.end_ellipses.setMinimumWidth(end_size)
- self.end_ellipses.setMaximumWidth(end_size)
- self.start_ellipses.setMinimumWidth(srt_size)
- self.start_ellipses.setMaximumWidth(srt_size)
-
- # Enable/Disable Ellipses
- if index <= self.buffer_page_count + 1:
- self.start_ellipses.setHidden(True)
- else:
- self.start_ellipses.setHidden(False)
- self._assign_click(self.start_button, 0)
- if index >= (page_count - self.buffer_page_count - 2):
- self.end_ellipses.setHidden(True)
- else:
- self.end_ellipses.setHidden(False)
-
- # Hide/Unhide Start+End Buttons
- if index != 0:
- self.start_button.setText("1")
- self._assign_click(self.start_button, 0)
- self.start_button.setHidden(False)
- else:
- self.start_button.setHidden(True)
- if index != page_count - 1:
- self.end_button.setText(str(page_count))
- self._assign_click(self.end_button, page_count - 1)
- self.end_button.setHidden(False)
- else:
- self.end_button.setHidden(True)
-
- if index == 0 or index == 1:
- self.start_buffer_container.setHidden(True)
- else:
- self.start_buffer_container.setHidden(False)
-
- if index == page_count - 1 or index == page_count - 2:
- self.end_buffer_container.setHidden(True)
- else:
- self.end_buffer_container.setHidden(False)
-
- # Current Field and Buffer Pages
- sbc = 0
- for i in range(0, page_count):
- # Set Field
- if i == index:
- if self.start_buffer_layout.itemAt(i):
- self.start_buffer_layout.itemAt(i).widget().setHidden(True)
- if self.end_buffer_layout.itemAt(i):
- self.end_buffer_layout.itemAt(i).widget().setHidden(True)
- sbc += 1
- self.current_page_field.setText(str(i + 1))
-
- start_offset = max(0, (index - 4) - 4)
- end_offset = min(page_count - 1, (index + 4) - 4)
- if i < index:
- if (i != 0) and i >= index - 4:
- self.start_buffer_layout.itemAt(i - start_offset).widget().setHidden(False)
- self.start_buffer_layout.itemAt(i - start_offset).widget().setText( # pyright: ignore[reportAttributeAccessIssue]
- str(i + 1)
- )
- self._assign_click(
- cast(
- QPushButton,
- self.start_buffer_layout.itemAt(i - start_offset).widget(),
- ),
- i,
- )
- sbc += 1
- else:
- if self.start_buffer_layout.itemAt(i):
- self.start_buffer_layout.itemAt(i).widget().setHidden(True)
- if self.end_buffer_layout.itemAt(i):
- self.end_buffer_layout.itemAt(i).widget().setHidden(True)
- elif i > index:
- if i != page_count - 1 and i <= index + 4:
- self.end_buffer_layout.itemAt(i - end_offset).widget().setHidden(False)
- self.end_buffer_layout.itemAt(i - end_offset).widget().setText(str(i + 1)) # pyright: ignore[reportAttributeAccessIssue]
- self._assign_click(
- cast(
- QPushButton,
- self.end_buffer_layout.itemAt(i - end_offset).widget(),
- ),
- i,
- )
- else:
- if self.end_buffer_layout.itemAt(i):
- self.end_buffer_layout.itemAt(i).widget().setHidden(True)
- for j in range(0, self.buffer_page_count):
- if self.end_buffer_layout.itemAt(i - end_offset + j):
- self.end_buffer_layout.itemAt(
- i - end_offset + j
- ).widget().setHidden(True)
-
- if self.start_buffer_layout.itemAt(i - 1):
- self.start_buffer_layout.itemAt(i - 1).widget().setHidden(True)
-
- self.setHidden(False)
-
- self.validator.setTop(page_count)
- if emit:
- self.index.emit(index)
- self.current_page_index = index
- self.page_count = page_count
-
- def _goto_page(self, index: int):
- self.update_buttons(self.page_count, index)
-
- def _assign_click(self, button: QPushButton, index: int):
- with catch_warnings(record=True):
- button.clicked.disconnect()
- button.clicked.connect(lambda checked=False, i=index: self._goto_page(i))
-
- def _populate_buffer_buttons(self):
- for _ in range(max(self.buffer_page_count * 2, 5)):
- button = QPushButton()
- button.setMinimumSize(self.button_size)
- button.setMaximumSize(self.button_size)
- button.setHidden(True)
- self.start_buffer_layout.addWidget(button)
-
- end_button = QPushButton()
- end_button.setMinimumSize(self.button_size)
- end_button.setMaximumSize(self.button_size)
- end_button.setHidden(True)
- self.end_buffer_layout.addWidget(end_button)
-
-
-class Validator(QIntValidator):
- def __init__(self, bottom: int, top: int) -> None:
- super().__init__(bottom, top)
-
- @override
- def fixup(self, input: str) -> str:
- input = input.strip("0")
- return super().fixup(str(self.top()) if input else "1")
diff --git a/src/tagstudio/qt/mixed/settings_panel.py b/src/tagstudio/qt/mixed/settings_panel.py
index b78d1d3a48..26ffa079af 100644
--- a/src/tagstudio/qt/mixed/settings_panel.py
+++ b/src/tagstudio/qt/mixed/settings_panel.py
@@ -461,7 +461,7 @@ def update_settings(self, driver: QtDriver):
# Apply changes
# Show File Path
driver.update_recent_lib_menu()
- driver.main_window.preview_panel.set_selection(self.driver.selected)
+ driver.main_window.inspector.set_selection(self.driver.selected)
library_directory = driver.lib.library_dir
if settings["show_filepath"] == ShowFilepathOption.SHOW_FULL_PATHS:
display_path = library_directory or ""
diff --git a/src/tagstudio/qt/mixed/tag_color_manager.py b/src/tagstudio/qt/mixed/tag_color_manager.py
index 639859baf9..19ef50aea7 100644
--- a/src/tagstudio/qt/mixed/tag_color_manager.py
+++ b/src/tagstudio/qt/mixed/tag_color_manager.py
@@ -26,9 +26,9 @@
from tagstudio.core.utils.types import unwrap
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.modal import Modal
+from tagstudio.qt.controllers.tiles.color_data import ColorData
+from tagstudio.qt.controllers.tiles.tile import Tile
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.views.styles.stylesheets import header
logger = structlog.get_logger(__name__)
@@ -119,22 +119,22 @@ def setup_color_groups(self):
for group, colors in self.driver.lib.tag_color_groups.items():
if not group.startswith(RESERVED_NAMESPACE_PREFIX):
all_default = False
- color_box = ColorBoxWidget(group, colors, self.driver.lib)
- color_box.updated.connect(
+ color_data = ColorData(group, colors, self.driver.lib)
+ color_data.updated.connect(
lambda: (
self.reset(),
self.setup_color_groups(),
()
if len(self.driver.selected) < 1
- else self.driver.main_window.preview_panel.containers.update_from_entry( # noqa: E501
+ else self.driver.main_window.inspector.containers.update_from_entry( # noqa: E501
self.driver.selected[0], update_badges=False
),
)
)
- field_container = FieldContainer(self.driver.lib.get_namespace_name(group))
- field_container.set_inner_widget(color_box)
+ tile = Tile(self.driver.lib.get_namespace_name(group))
+ tile.set_inner_widget(color_data)
if not group.startswith(RESERVED_NAMESPACE_PREFIX):
- field_container.set_remove_callback(
+ tile.set_remove_callback(
lambda checked=False, g=group: self.delete_namespace_dialog(
prompt=Translations["color.namespace.delete.prompt"],
callback=lambda namespace=g: (
@@ -143,14 +143,14 @@ def setup_color_groups(self):
self.setup_color_groups(),
()
if len(self.driver.selected) < 1
- else self.driver.main_window.preview_panel.containers.update_from_entry( # noqa: E501
+ else self.driver.main_window.inspector.containers.update_from_entry( # noqa: E501
self.driver.selected[0], update_badges=False
),
),
)
)
- self.scroll_layout.addWidget(field_container)
+ self.scroll_layout.addWidget(tile)
if all_default:
ns_container = QWidget()
diff --git a/src/tagstudio/qt/mixed/text_field.py b/src/tagstudio/qt/mixed/text_field.py
deleted file mode 100644
index 683bfadb3d..0000000000
--- a/src/tagstudio/qt/mixed/text_field.py
+++ /dev/null
@@ -1,43 +0,0 @@
-# SPDX-FileCopyrightText: (c) TagStudio Contributors
-# SPDX-License-Identifier: GPL-3.0-only
-
-
-import re
-
-from PySide6.QtCore import Qt
-from PySide6.QtWidgets import QHBoxLayout, QLabel
-
-from tagstudio.qt.mixed.field_widget import FieldWidget
-
-
-# TODO: Split to use MVC guidelines.
-class TextContainerWidget(FieldWidget):
- def __init__(self, title: str, text: str) -> None:
- super().__init__(title)
- self.setObjectName("textBox")
- self.base_layout = QHBoxLayout()
- self.base_layout.setContentsMargins(0, 0, 0, 0)
- self.setLayout(self.base_layout)
- self.text_label = QLabel()
- self.text_label.setStyleSheet("font-size: 12px")
- self.text_label.setWordWrap(True)
- self.text_label.setTextFormat(Qt.TextFormat.MarkdownText)
- self.text_label.setOpenExternalLinks(True)
- self.text_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
- self.base_layout.addWidget(self.text_label)
- self.set_text(text)
-
- def set_text(self, text: str):
- text = linkify(text)
- self.text_label.setText(text)
-
-
-# Regex from https://stackoverflow.com/a/6041965
-def linkify(text: str):
- url_pattern = r"(http|ftp|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:\/~+#\-*]*[\w@?^=%&\/~+#\-*])" # noqa: E501
- return re.sub(
- url_pattern,
- lambda url: f'{url.group(0)}',
- text,
- flags=re.IGNORECASE,
- )
diff --git a/src/tagstudio/qt/qt_driver.py b/src/tagstudio/qt/qt_driver.py
index df9bd5db0d..f3f812e8d9 100644
--- a/src/tagstudio/qt/qt_driver.py
+++ b/src/tagstudio/qt/qt_driver.py
@@ -390,7 +390,7 @@ def start(self) -> None:
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(
+ lambda checked=False: self.main_window.inspector.set_selection(
self.selected, update_preview=False
)
)
@@ -409,7 +409,7 @@ def start(self) -> None:
is_savable=False,
)
self.field_template_manager.done.connect(
- lambda checked=False: self.main_window.preview_panel.set_selection(
+ lambda checked=False: self.main_window.inspector.set_selection(
self.selected, update_preview=False
)
)
@@ -425,7 +425,7 @@ def start(self) -> None:
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, update_preview=False),
+ self.main_window.inspector.set_selection(self.selected, update_preview=False),
)
)
@@ -670,7 +670,6 @@ def on_visible_changed(entry_id: int | None):
self.shutdown()
def show_error_message(self, error_name: str, error_desc: str | None = None):
- self.main_window.status_bar.showMessage(error_name, Qt.AlignmentFlag.AlignLeft)
self.main_window.landing_widget.set_status_label(error_name)
self.main_window.setWindowTitle(f"{self.base_title} - {error_name}")
@@ -706,7 +705,7 @@ def _update_browsing_state():
.with_show_hidden_entries(self.main_window.show_hidden_entries)
)
except ParsingError as e:
- self.main_window.status_bar.showMessage(
+ self.main_window.results_label.setText(
f"{Translations['status.results.invalid_syntax']} "
f'"{self.main_window.search_field.text()}"'
)
@@ -813,16 +812,14 @@ def close_library(self, is_shutdown: bool = False):
if (sync_banner := self._sync_banner) is not None:
sync_banner.hide_banner(force=True)
- self.main_window.status_bar.showMessage(Translations["status.library_closing"])
- start_time = time.time()
-
if not is_shutdown:
self.cached_values.remove(AppCacheItems.LAST_LIBRARY)
self.cached_values.sync()
# Reset library state
- self.main_window.preview_panel.set_selection(self.selected)
+ self.main_window.inspector.set_selection(self.selected)
self.main_window.search_field.setText("")
+ self.main_window.results_label.setText("")
scrollbar: QScrollArea = self.main_window.entry_scroll_area
scrollbar.verticalScrollBar().setValue(0)
self.__reset_navigation()
@@ -848,7 +845,7 @@ def close_library(self, is_shutdown: bool = False):
self.library_info_window.close()
self.main_window.thumb_layout.set_entries([])
- self.main_window.preview_panel.set_selection(self.selected)
+ self.main_window.inspector.set_selection(self.selected)
self.main_window.toggle_landing_page(enabled=True)
self.main_window.landing_widget.animate_logo_in()
self.main_window.pagination.setHidden(True)
@@ -876,26 +873,9 @@ def close_library(self, is_shutdown: bool = False):
if self.main_window.menu_bar.add_tag_to_selected_action:
self.main_window.menu_bar.add_tag_to_selected_action.setEnabled(False)
- end_time = time.time()
- self.main_window.status_bar.showMessage(
- Translations.format(
- "status.library_closed", time_span=format_timespan(end_time - start_time)
- )
- )
-
def backup_library(self):
logger.info("Backing Up Library...")
- self.main_window.status_bar.showMessage(Translations["status.library_backup_in_progress"])
- start_time = time.time()
- target_path = Library.save_library_backup_to_disk(unwrap(self.lib.library_dir))
- end_time = time.time()
- self.main_window.status_bar.showMessage(
- Translations.format(
- "status.library_backup_success",
- path=target_path,
- time_span=format_timespan(end_time - start_time),
- )
- )
+ Library.save_library_backup_to_disk(unwrap(self.lib.library_dir))
def emit_badge_signals(self, tag_ids: list[int] | set[int], emit_on_absent: bool = True):
"""Emit any connected signals for updating badge icons."""
@@ -944,7 +924,7 @@ def select_all_action_callback(self):
self.set_clipboard_menu_viability()
self.set_select_actions_visibility()
- self.main_window.preview_panel.set_selection(self.selected, update_preview=False)
+ self.main_window.inspector.set_selection(self.selected, update_preview=False)
def select_inverse_action_callback(self):
"""Invert the selection of all visible items."""
@@ -953,14 +933,14 @@ def select_inverse_action_callback(self):
self.set_clipboard_menu_viability()
self.set_select_actions_visibility()
- self.main_window.preview_panel.set_selection(self.selected, update_preview=False)
+ self.main_window.inspector.set_selection(self.selected, update_preview=False)
def clear_select_action_callback(self):
self.clear_selected()
self.set_select_actions_visibility()
self.set_clipboard_menu_viability()
- self.main_window.preview_panel.set_selection(self.selected)
+ self.main_window.inspector.set_selection(self.selected)
def add_tags_to_selected_callback(self, tag_ids: list[int]):
selected: list[int] = self.selected
@@ -983,7 +963,6 @@ def delete_files_callback(self, origin_path: str | Path, origin_id: int | None =
"""
entry: Entry | None = None
pending: list[tuple[int | None, Path]] = []
- deleted_count: int = 0
selected = self.selected
library_dir = unwrap(self.lib.library_dir)
@@ -1007,37 +986,17 @@ def delete_files_callback(self, origin_path: str | Path, origin_id: int | None =
return_code == QMessageBox.ButtonRole.DestructiveRole.value
and return_code != QMessageBox.ButtonRole.ActionRole.value
):
- for i, tup in enumerate(pending):
- e_id, f = tup
+ for e_id, f in pending:
if (origin_path == f) or (not origin_path):
- self.main_window.preview_panel.stop_media_playback()
-
- msg = Translations.format(
- "status.deleting_file", i=i, count=len(pending), path=f
- )
- self.main_window.status_bar.showMessage(msg)
- self.main_window.status_bar.repaint()
+ self.main_window.inspector.stop_media_playback()
if e_id is not None:
self.lib.remove_entries([e_id])
- if delete_file(library_dir / f):
- deleted_count += 1
+ delete_file(library_dir / f)
self.clear_select_action_callback()
self.update_browsing_state()
- if deleted_count > 0 and deleted_count != len(pending):
- msg = Translations.format("status.deleted_partial_warning", count=deleted_count)
- else:
- index = min(deleted_count, 2)
- msg = (
- Translations["status.deleted_none"],
- Translations["status.deleted_file_singular"],
- Translations.format("status.deleted_file_plural", count=deleted_count),
- )[index]
- self.main_window.status_bar.showMessage(msg)
- self.main_window.status_bar.repaint()
-
def delete_file_confirmation(self, count: int, filename: Path | None = None) -> int:
"""A confirmation dialogue box for deleting files.
@@ -1217,7 +1176,7 @@ def _is_sync_stale(self, session_id: int) -> bool:
def _on_new_files_banner_refresh(self):
self.update_browsing_state()
- self.main_window.preview_panel.set_selection(self.selected, update_preview=True)
+ self.main_window.inspector.set_selection(self.selected, update_preview=True)
# If there are still unlinked entries after the automatic relinking step, show a notice.
sync_banner = self._sync_banner
if sync_banner is None:
@@ -1343,9 +1302,6 @@ def sorting_mode_callback(self):
def thumb_size_callback(self, size: int):
"""Perform actions needed when the thumbnail size selection is changed."""
- spacing_divisor: int = 10
- min_spacing: int = 12
-
self.update_thumbs()
blank_icon: QIcon = QIcon()
for it in self.main_window.thumb_layout._item_thumbs:
@@ -1355,9 +1311,6 @@ def thumb_size_callback(self, size: int):
it.setFixedSize(self.main_window.thumb_size, self.main_window.thumb_size)
it.thumb_button.thumb_size = (self.main_window.thumb_size, self.main_window.thumb_size)
it.set_filename_visibility(it.show_filename_label)
- self.main_window.thumb_layout.setSpacing(
- min(self.main_window.thumb_size // spacing_divisor, min_spacing)
- )
def show_hidden_entries_callback(self):
logger.info("Show Hidden Entries Changed", exclude=self.main_window.show_hidden_entries)
@@ -1428,7 +1381,7 @@ def paste_fields_action_callback(self):
if TAG_FAVORITE in self.copy_buffer["tags"]:
self.update_badges({BadgeType.FAVORITE: True}, origin_id=0, add_tags=False)
else:
- self.main_window.preview_panel.set_selection(self.selected)
+ self.main_window.inspector.set_selection(self.selected)
def toggle_item_selection(self, item_id: int, append: bool, bridge: bool):
"""Toggle the selection of an item in the Thumbnail Grid.
@@ -1457,7 +1410,7 @@ def toggle_item_selection(self, item_id: int, append: bool, bridge: bool):
self.set_clipboard_menu_viability()
self.set_select_actions_visibility()
- self.main_window.preview_panel.set_selection(self.selected)
+ self.main_window.inspector.set_selection(self.selected)
def set_clipboard_menu_viability(self):
if len(self.selected) == 1:
@@ -1625,10 +1578,6 @@ def update_browsing_state(self, state: BrowsingState | None = None) -> None:
self.main_window.search_field.setText(self.browsing_history.current.query or "")
- # inform user about running search
- self.main_window.status_bar.showMessage(Translations["status.library_search_query"])
- self.main_window.status_bar.repaint()
-
# search the library
start_time = time.time()
Ignore.get_patterns(self.lib.library_dir, include_global=True)
@@ -1637,11 +1586,14 @@ def update_browsing_state(self, state: BrowsingState | None = None) -> None:
end_time = time.time()
# inform user about completed search
- self.main_window.status_bar.showMessage(
+ time_span = f"({format_timespan(end_time - start_time).title()})"
+ count = f"{results.total_count:,}"
+ self.main_window.results_label.setText(
Translations.format(
- "status.results_found",
- count=results.total_count,
- time_span=format_timespan(end_time - start_time),
+ # Show "X Results" on query, "X Items" on none
+ "status.results_found" if state and state.query else "status.items",
+ count=count,
+ time_span=time_span,
)
)
@@ -1790,7 +1742,6 @@ def open_library(self, path: Path) -> None:
)
message = Translations.format("splash.opening_library", library_path=library_dir_display)
self.main_window.landing_widget.set_status_label(message)
- self.main_window.status_bar.showMessage(message, 3)
self.main_window.repaint()
if self.lib.library_dir:
@@ -1892,7 +1843,7 @@ def _init_library(self, path: Path, open_status: OpenLibraryResult):
self.main_window.menu_bar.folders_to_tags_action.setEnabled(True)
self.main_window.menu_bar.library_info_action.setEnabled(True)
- self.main_window.preview_panel.set_selection(self.selected)
+ self.main_window.inspector.set_selection(self.selected)
# page (re)rendering, extract eventually
initial_state = BrowsingState(
diff --git a/src/tagstudio/qt/views/edit_field_template_modal_view.py b/src/tagstudio/qt/views/edit_field_template_modal_view.py
index b28b8b72ab..3ae9ba80dd 100644
--- a/src/tagstudio/qt/views/edit_field_template_modal_view.py
+++ b/src/tagstudio/qt/views/edit_field_template_modal_view.py
@@ -17,7 +17,7 @@
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.clickable_label import ClickableLabel
from tagstudio.qt.controllers.modal_content import ModalContent
-from tagstudio.qt.views.styles.stylesheets import checkbox_style
+from tagstudio.qt.views.styles.stylesheets import PAD, checkbox_style
logger = structlog.get_logger(__name__)
@@ -29,7 +29,7 @@ def __init__(self) -> None:
# Layout Init
self.setMinimumSize(460, 200)
self.root_layout = QVBoxLayout(self)
- self.root_layout.setContentsMargins(6, 0, 6, 0)
+ self.root_layout.setContentsMargins(PAD, 0, PAD, 0)
self.root_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
# Field Name
@@ -64,14 +64,14 @@ def __init__(self) -> None:
self._text_field_attributes_layout = QHBoxLayout(self._text_field_attributes_widget)
self._text_field_attributes_layout.setStretch(1, 1)
self._text_field_attributes_layout.setContentsMargins(0, 0, 0, 0)
- self._text_field_attributes_layout.setSpacing(6)
+ self._text_field_attributes_layout.setSpacing(PAD)
# Is Multiline
self._multiline_widget = QWidget()
self._multiline_layout = QHBoxLayout(self._multiline_widget)
self._multiline_layout.setStretch(1, 1)
self._multiline_layout.setContentsMargins(0, 0, 0, 0)
- self._multiline_layout.setSpacing(6)
+ self._multiline_layout.setSpacing(PAD)
self._multiline_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self._multiline_title = ClickableLabel(Translations["field.text.is_multiline"])
self._multiline_checkbox = QCheckBox()
diff --git a/src/tagstudio/qt/views/edit_text_view.py b/src/tagstudio/qt/views/edit_text_view.py
index 7bd4328bbc..6795932389 100644
--- a/src/tagstudio/qt/views/edit_text_view.py
+++ b/src/tagstudio/qt/views/edit_text_view.py
@@ -16,7 +16,7 @@
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.clickable_label import ClickableLabel
from tagstudio.qt.controllers.modal_content import ModalContent
-from tagstudio.qt.views.styles.stylesheets import checkbox_style, title_line_edit_style
+from tagstudio.qt.views.styles.stylesheets import PAD, checkbox_style, title_line_edit_style
class EditTextView(ModalContent):
@@ -24,7 +24,7 @@ def __init__(self):
super().__init__()
self.setMinimumSize(480, 240)
self.root_layout = QVBoxLayout(self)
- self.root_layout.setContentsMargins(6, 0, 6, 0)
+ self.root_layout.setContentsMargins(PAD, 0, PAD, 0)
self.name_field = QLineEdit()
self.name_field.setStyleSheet(title_line_edit_style())
diff --git a/src/tagstudio/qt/views/fix_ignored_modal_view.py b/src/tagstudio/qt/views/fix_ignored_modal_view.py
index 1e9d92242f..03adabb4bd 100644
--- a/src/tagstudio/qt/views/fix_ignored_modal_view.py
+++ b/src/tagstudio/qt/views/fix_ignored_modal_view.py
@@ -10,8 +10,8 @@
from tagstudio.core.library.alchemy.library import Library
from tagstudio.i18n.translations import Translations
+from tagstudio.qt.views.styles.stylesheets import PAD
-# Only import for type checking/autocompletion, will not be imported at runtime.
if TYPE_CHECKING:
from tagstudio.qt.qt_driver import QtDriver
@@ -27,7 +27,7 @@ def __init__(self, library: Library, driver: QtDriver):
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setMinimumSize(400, 300)
self.root_layout = QVBoxLayout(self)
- self.root_layout.setContentsMargins(6, 6, 6, 6)
+ self.root_layout.setContentsMargins(PAD, PAD, PAD, PAD)
self.ignored_desc_widget = QLabel(Translations["entries.ignored.description"])
self.ignored_desc_widget.setObjectName("ignoredDescriptionLabel")
@@ -43,7 +43,7 @@ def __init__(self, library: Library, driver: QtDriver):
self.button_container = QWidget()
self.button_layout = QHBoxLayout(self.button_container)
- self.button_layout.setContentsMargins(6, 6, 6, 6)
+ self.button_layout.setContentsMargins(PAD, PAD, PAD, PAD)
self.button_layout.addStretch(1)
self.done_button = QPushButton(Translations["generic.done_alt"])
@@ -59,9 +59,9 @@ def __init__(self, library: Library, driver: QtDriver):
self.root_layout.addWidget(self.button_container)
@override
- def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
+ def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:
if event.key() == QtCore.Qt.Key.Key_Escape:
self.done_button.click()
- else: # Other key presses
+ else:
pass
return super().keyPressEvent(event)
diff --git a/src/tagstudio/qt/views/ignore_modal_view.py b/src/tagstudio/qt/views/ignore_modal_view.py
index d00d0d4492..2dad0e690b 100644
--- a/src/tagstudio/qt/views/ignore_modal_view.py
+++ b/src/tagstudio/qt/views/ignore_modal_view.py
@@ -16,6 +16,7 @@
from tagstudio.core.library.alchemy.models import Tag
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.modal_content import ModalContent
+from tagstudio.qt.views.styles.stylesheets import PAD
logger = structlog.get_logger(__name__)
@@ -30,7 +31,7 @@ def __init__(self, library: Library) -> None:
self.setMinimumSize(640, 460)
self.root_layout = QVBoxLayout(self)
- self.root_layout.setContentsMargins(6, 0, 6, 0)
+ self.root_layout.setContentsMargins(PAD, 0, PAD, 0)
self.root_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.text_edit = QPlainTextEdit()
@@ -44,6 +45,6 @@ def __init__(self, library: Library) -> None:
Translations.format("ignore.open_file", ts_ignore=IGNORE_NAME)
)
- # Add Widgets to Layout ================================================
+ # Finalize Layout
self.root_layout.addWidget(self.text_edit)
self.root_layout.addWidget(self.open_button)
diff --git a/src/tagstudio/qt/views/inspector_view.py b/src/tagstudio/qt/views/inspector_view.py
index 3195d0e262..91bb95d43d 100644
--- a/src/tagstudio/qt/views/inspector_view.py
+++ b/src/tagstudio/qt/views/inspector_view.py
@@ -16,10 +16,16 @@
from tagstudio.qt.controllers.preview_thumb 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.controllers.tiles.tile_list import TileList
from tagstudio.qt.mixed.file_attributes import FileAttributes
from tagstudio.qt.resource_manager import ResourceManager
-from tagstudio.qt.views.styles.stylesheets import button_style, preview_warning_style
+from tagstudio.qt.views.styles.stylesheets import (
+ HALF_PAD,
+ PAD,
+ WIN_PAD,
+ button_style,
+ preview_warning_style,
+)
if typing.TYPE_CHECKING:
from tagstudio.qt.qt_driver import QtDriver
@@ -30,8 +36,8 @@
class InspectorView(QVBoxLayout):
def __init__(self, driver: QtDriver, pixel_ratio: float) -> None:
super().__init__()
- self.setContentsMargins(0, 0, 0, 0)
- self.setSpacing(6)
+ self.setContentsMargins(0, 0, WIN_PAD, WIN_PAD)
+ self.setSpacing(PAD)
rm = ResourceManager()
# Search/Create Boxes
@@ -45,21 +51,21 @@ def ph_text(key: str) -> str:
driver.lib, driver.settings, ph_text("home.search_or_create_tags")
)
- self.preview_thumb = PreviewThumb(driver.lib, driver)
+ self.preview_thumb = PreviewThumb(driver)
self.file_attrs = FileAttributes(driver.lib, driver)
- self.containers = FieldContainers(driver.lib, driver)
+ self.containers = TileList(driver.lib, driver)
# Visual Preview
preview_section = QWidget()
preview_layout = QVBoxLayout(preview_section)
preview_layout.setContentsMargins(0, 0, 0, 0)
- preview_layout.setSpacing(6)
+ preview_layout.setSpacing(PAD)
# 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)
+ ffmpeg_warning_layout.setContentsMargins(HALF_PAD, HALF_PAD, HALF_PAD, HALF_PAD)
self.warning_banner.setStyleSheet(preview_warning_style())
ffmpeg_warning_label = QLabel(
Translations.format(
@@ -86,17 +92,17 @@ def ph_text(key: str) -> str:
info_section = QWidget()
info_layout = QVBoxLayout(info_section)
info_layout.setContentsMargins(0, 0, 0, 0)
- info_layout.setSpacing(6)
+ info_layout.setSpacing(PAD)
splitter = QSplitter()
splitter.setOrientation(Qt.Orientation.Vertical)
- splitter.setHandleWidth(12)
+ splitter.setHandleWidth(PAD * 2)
# 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)
+ add_buttons_layout.setSpacing(PAD)
self.add_tag_button = ReturnButton(Translations["tag.add"])
self.add_tag_button.setEnabled(False)
diff --git a/src/tagstudio/qt/views/layouts/thumb_grid_layout.py b/src/tagstudio/qt/views/layouts/thumb_grid_layout.py
index dc1f69191e..9754bdacce 100644
--- a/src/tagstudio/qt/views/layouts/thumb_grid_layout.py
+++ b/src/tagstudio/qt/views/layouts/thumb_grid_layout.py
@@ -24,13 +24,18 @@
class ThumbGridLayout(QLayout):
+ SPACING = 9
+
# Id of first visible entry
visible_changed = Signal(int)
- def __init__(self, driver: QtDriver, scroll_area: QScrollArea) -> None:
+ def __init__(self, driver: QtDriver, scroll_area: QScrollArea, bottom_padding: int = 0) -> None:
super().__init__(None)
+ self.setContentsMargins(0, 0, 0, 0)
+ self.setSpacing(self.SPACING)
self.driver: QtDriver = driver
self.scroll_area: QScrollArea = scroll_area
+ self._bottom_padding: int = bottom_padding
self._item_thumbs: list[ItemThumb] = []
self._items: list[QLayoutItem] = []
@@ -183,8 +188,8 @@ def heightForWidth(self, arg__1: int) -> int:
width = arg__1
per_row, _, height_offset = self._size(width)
if per_row == 0:
- return height_offset
- return math.ceil(len(self._entry_ids) / per_row) * height_offset
+ return height_offset + self._bottom_padding
+ return math.ceil(len(self._entry_ids) / per_row) * height_offset + self._bottom_padding
@override
def setGeometry(self, arg__1: QRect) -> None:
diff --git a/src/tagstudio/qt/views/library_info_window_view.py b/src/tagstudio/qt/views/library_info_window_view.py
index 623066eddc..c6de871b21 100644
--- a/src/tagstudio/qt/views/library_info_window_view.py
+++ b/src/tagstudio/qt/views/library_info_window_view.py
@@ -24,7 +24,7 @@
from tagstudio.i18n.platform_strings import open_file_str
from tagstudio.i18n.translations import Translations
from tagstudio.qt.views.styles.color_overlay import auto_theme_overlay
-from tagstudio.qt.views.styles.stylesheets import header
+from tagstudio.qt.views.styles.stylesheets import PAD, header
# Only import for type checking/autocompletion, will not be imported at runtime.
if TYPE_CHECKING:
@@ -42,7 +42,7 @@ def __init__(self, library: Library, driver: QtDriver):
self.setWindowTitle("Library Information")
self.setMinimumSize(800, 480)
self.root_layout = QVBoxLayout(self)
- self.root_layout.setContentsMargins(6, 6, 6, 6)
+ self.root_layout.setContentsMargins(PAD, PAD, PAD, PAD)
row_height: int = 22
icon_margin: int = 4
@@ -58,7 +58,7 @@ def __init__(self, library: Library, driver: QtDriver):
self.body_widget = QWidget()
self.body_layout = QHBoxLayout(self.body_widget)
self.body_layout.setContentsMargins(0, 0, 0, 0)
- self.body_layout.setSpacing(6)
+ self.body_layout.setSpacing(PAD)
# Statistics -----------------------------------------------------------
self.stats_widget = QWidget()
@@ -440,7 +440,7 @@ def __init__(self, library: Library, driver: QtDriver):
# Details --------------------------------------------------------------
self.details_container = QWidget()
self.details_layout = QHBoxLayout(self.details_container)
- self.details_layout.setContentsMargins(6, 0, 6, 0)
+ self.details_layout.setContentsMargins(PAD, 0, PAD, 0)
opacity_effect_details = QGraphicsOpacityEffect(self)
opacity_effect_details.setOpacity(0.5)
@@ -451,7 +451,7 @@ def __init__(self, library: Library, driver: QtDriver):
# Buttons --------------------------------------------------------------
self.button_container = QWidget()
self.button_layout = QHBoxLayout(self.button_container)
- self.button_layout.setContentsMargins(6, 6, 6, 6)
+ self.button_layout.setContentsMargins(PAD, PAD, PAD, PAD)
self.button_layout.addStretch(1)
self.close_button = QPushButton(Translations["generic.close"])
diff --git a/src/tagstudio/qt/views/modal_view.py b/src/tagstudio/qt/views/modal_view.py
index cf921189e3..026a06ce99 100644
--- a/src/tagstudio/qt/views/modal_view.py
+++ b/src/tagstudio/qt/views/modal_view.py
@@ -8,7 +8,7 @@
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.modal_content import ModalContent
-from tagstudio.qt.views.styles.stylesheets import header
+from tagstudio.qt.views.styles.stylesheets import PAD, header
logger = structlog.get_logger(__name__)
@@ -26,11 +26,11 @@ def __init__(
):
super().__init__()
self.content_widget = content_widget
- self.setContentsMargins(6, 6 if inline_title else 12, 6, 6)
+ self.setContentsMargins(PAD, PAD if inline_title else PAD * 2, PAD, PAD)
self.button_container = QWidget()
self.button_layout = QHBoxLayout(self.button_container)
- self.button_layout.setContentsMargins(6, 6, 6, 6)
+ self.button_layout.setContentsMargins(PAD, PAD, PAD, PAD)
self.button_layout.addStretch(1)
# [Done]
diff --git a/src/tagstudio/qt/views/pagination_view.py b/src/tagstudio/qt/views/pagination_view.py
new file mode 100644
index 0000000000..0f6c668c6f
--- /dev/null
+++ b/src/tagstudio/qt/views/pagination_view.py
@@ -0,0 +1,121 @@
+# SPDX-FileCopyrightText: (c) TagStudio Contributors
+# SPDX-License-Identifier: GPL-3.0-only
+
+
+from typing import override
+
+from PIL import Image, ImageQt
+from PySide6.QtCore import QSize
+from PySide6.QtGui import QIntValidator, QPixmap, Qt
+from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QPushButton, QWidget
+
+from tagstudio.qt.resource_manager import ResourceManager
+from tagstudio.qt.views.styles.color_overlay import auto_theme_overlay
+from tagstudio.qt.views.styles.stylesheets import HALF_PAD, PAD
+
+
+class PageValidator(QIntValidator):
+ @override
+ def fixup(self, input: str) -> str:
+ input = input.strip("0")
+ return super().fixup(str(self.top()) if input else "1")
+
+
+class PaginationView(QHBoxLayout):
+ # NOTE: UI Example:
+ # [<] [1]...[3][4] [5] [6][7]...[42] [>]
+ # ^^^^ <-- 2 Buffer Pages
+ # Center Page Number is Editable Text
+
+ BUTTON_SIZE = QSize(32, 24)
+ BUFFER_PAGE_COUNT = 4
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.setContentsMargins(0, PAD, 0, PAD)
+ self.setSpacing(HALF_PAD)
+ _rm = ResourceManager()
+
+ # [<] ----------------------------------
+ self.prev_button = QPushButton()
+ prev_icon: Image.Image = auto_theme_overlay(_rm.bxs_left_arrow, use_alpha=False)
+ self.prev_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(prev_icon)))
+ self.prev_button.setIconSize(QSize(12, 12))
+ self.prev_button.setMinimumSize(self.BUTTON_SIZE)
+ self.prev_button.setMaximumSize(self.BUTTON_SIZE)
+
+ # --- [1] ------------------------------
+ self.start_button = QPushButton()
+ self.start_button.setMinimumSize(self.BUTTON_SIZE)
+ self.start_button.setMaximumSize(self.BUTTON_SIZE)
+
+ # ------ ... ---------------------------
+ self.start_ellipses = QLabel(". . .")
+ self.start_ellipses.setMinimumSize(self.BUTTON_SIZE)
+ self.start_ellipses.setMaximumSize(self.BUTTON_SIZE)
+
+ # --------- [3][4] ---------------------
+ self.start_buffer_container = QWidget()
+ self.start_buffer_layout = QHBoxLayout(self.start_buffer_container)
+ self.start_buffer_layout.setContentsMargins(0, 0, 0, 0)
+ self.start_buffer_layout.setSpacing(3)
+
+ # ---------------- [5] -----------------
+ self.current_page_field = QLineEdit()
+ self.current_page_field.setMinimumSize(self.BUTTON_SIZE)
+ self.current_page_field.setMaximumSize(self.BUTTON_SIZE)
+ self.current_page_field.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.validator = PageValidator(1, 0)
+ self.current_page_field.setValidator(self.validator)
+
+ # -------------------- [6][7] ----------
+ self.end_buffer_container = QWidget()
+ self.end_buffer_layout = QHBoxLayout(self.end_buffer_container)
+ self.end_buffer_layout.setContentsMargins(0, 0, 0, 0)
+ self.end_buffer_layout.setSpacing(3)
+
+ # -------------------------- ... -------
+ self.end_ellipses = QLabel(". . .")
+ self.end_ellipses.setMinimumSize(self.BUTTON_SIZE)
+ self.end_ellipses.setMaximumSize(self.BUTTON_SIZE)
+
+ # ----------------------------- [42] ---
+ self.end_button = QPushButton()
+ self.end_button.setMinimumSize(self.BUTTON_SIZE)
+ self.end_button.setMaximumSize(self.BUTTON_SIZE)
+
+ # ---------------------------------- [>]
+ self.next_button = QPushButton()
+ next_icon: Image.Image = auto_theme_overlay(_rm.bxs_right_arrow, use_alpha=False)
+ self.next_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(next_icon)))
+ self.next_button.setIconSize(QSize(12, 12))
+ self.next_button.setMinimumSize(self.BUTTON_SIZE)
+ self.next_button.setMaximumSize(self.BUTTON_SIZE)
+
+ # Finalize Layout
+ self.addStretch(1)
+ self.addWidget(self.prev_button)
+ self.addWidget(self.start_button)
+ self.addWidget(self.start_ellipses)
+ self.addWidget(self.start_buffer_container)
+ self.addWidget(self.current_page_field)
+ self.addWidget(self.end_buffer_container)
+ self.addWidget(self.end_ellipses)
+ self.addWidget(self.end_button)
+ self.addWidget(self.next_button)
+ self.addStretch(1)
+ self._populate_buffer_buttons()
+
+ def _populate_buffer_buttons(self) -> None:
+ for _ in range(max(self.BUFFER_PAGE_COUNT * 2, 5)):
+ button = QPushButton()
+ button.setMinimumSize(self.BUTTON_SIZE)
+ button.setMaximumSize(self.BUTTON_SIZE)
+ button.setHidden(True)
+ self.start_buffer_layout.addWidget(button)
+
+ end_button = QPushButton()
+ end_button.setMinimumSize(self.BUTTON_SIZE)
+ end_button.setMaximumSize(self.BUTTON_SIZE)
+ end_button.setHidden(True)
+ self.end_buffer_layout.addWidget(end_button)
diff --git a/src/tagstudio/qt/views/preview_thumb_view.py b/src/tagstudio/qt/views/preview_thumb_view.py
index 13f6c9c323..b5842bff7c 100644
--- a/src/tagstudio/qt/views/preview_thumb_view.py
+++ b/src/tagstudio/qt/views/preview_thumb_view.py
@@ -2,357 +2,83 @@
# SPDX-License-Identifier: GPL-3.0-only
-import math
-import time
-from enum import Enum, auto
-from pathlib import Path
-from typing import TYPE_CHECKING, override
+from typing import TYPE_CHECKING
-import structlog
-from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt, Signal
-from PySide6.QtGui import QAction, QMovie, QPixmap, QResizeEvent
+from PySide6.QtCore import Qt
+from PySide6.QtGui import QAction
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QStackedLayout, QWidget
-from tagstudio.core.library.alchemy.library import Library
from tagstudio.i18n.platform_strings import open_file_str, trash_term
from tagstudio.i18n.translations import Translations
-from tagstudio.qt.mixed.file_attributes import FileAttributeData
from tagstudio.qt.mixed.media_player import MediaPlayer
-from tagstudio.qt.qt_file_renderer import QtFileRenderer
-from tagstudio.qt.views.styles.rounded_pixmap_style import RoundedPixmapStyle
+from tagstudio.qt.views.styles.corner_radius_effect import CornerRadiusEffect
+from tagstudio.qt.views.styles.stylesheets import RADIUS
if TYPE_CHECKING:
from tagstudio.qt.qt_driver import QtDriver
-logger = structlog.get_logger(__name__)
+_DEFAULT_PREVIEW_SIZE = (272, 272)
-THUMB_SIZE_FACTOR = 2
+class PreviewThumbView(QStackedLayout):
+ """The layout for the file preview thumbnail widget."""
-
-class PreviewType(Enum):
- """Enum for which of the Inspector's stacked pages should be shown for a file."""
-
- ANIMATED = auto()
- AUDIO = auto()
- IMAGE = auto()
- TEXT = auto()
- VIDEO = auto()
-
-
-# TODO: Use newer MVC style guidelines
-class PreviewThumbView(QWidget):
- """The Preview Panel Widget."""
-
- check_ffmpeg = Signal(bool)
- stats_updated = Signal(Path, FileAttributeData)
-
- __img_button_size: tuple[int, int]
- __image_ratio: float
-
- _current_file: Path | None
- __should_render_on_resize: bool
- __rendered_res: tuple[int, int]
-
- def __init__(self, library: Library, driver: QtDriver) -> None:
+ def __init__(self, driver: QtDriver) -> None:
super().__init__()
- self._driver = driver
-
- self.__img_button_size = (266, 266)
- self.__image_ratio = 1.0
-
- self.__should_render_on_resize = False
-
- self.__image_layout = QStackedLayout(self)
- self.__image_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
- self.__image_layout.setStackingMode(QStackedLayout.StackingMode.StackAll)
- self.__image_layout.setContentsMargins(0, 0, 0, 0)
-
- open_file_action = QAction(Translations["file.open_file"], self)
- open_file_action.triggered.connect(self._open_file_action_callback)
- open_explorer_action = QAction(open_file_str(), self)
- open_explorer_action.triggered.connect(self._open_explorer_action_callback)
- delete_action = QAction(
- Translations.format("trash.context.singular", trash_term=trash_term()),
- self,
+ self.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.setStackingMode(QStackedLayout.StackingMode.StackAll)
+ self.setContentsMargins(0, 0, 0, 0)
+
+ self.open_file_action = QAction(Translations["file.open_file"], self)
+ self.open_explorer_action = QAction(open_file_str(), self)
+ self.delete_action = QAction(
+ Translations.format("trash.context.singular", trash_term=trash_term()), self
)
- delete_action.triggered.connect(self._delete_action_callback)
- self.__button_wrapper = QPushButton()
- self.__button_wrapper.setMinimumSize(*self.__img_button_size)
- self.__button_wrapper.setFlat(True)
- self.__button_wrapper.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
- self.__button_wrapper.addAction(open_file_action)
- self.__button_wrapper.addAction(open_explorer_action)
- self.__button_wrapper.addAction(delete_action)
- self.__button_wrapper.clicked.connect(self._button_wrapper_callback)
+ self.button_wrapper = QPushButton()
+ self.button_wrapper.setMinimumSize(*_DEFAULT_PREVIEW_SIZE)
+ self.button_wrapper.setFlat(True)
+ self.button_wrapper.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
+ self.button_wrapper.addAction(self.open_file_action)
+ self.button_wrapper.addAction(self.open_explorer_action)
+ self.button_wrapper.addAction(self.delete_action)
# In testing, it didn't seem possible to center the widgets directly
# on the QStackedLayout. Adding sublayouts allows us to center the widgets.
- self.__preview_img_page = QWidget()
- self.__stacked_page_setup(self.__preview_img_page, self.__button_wrapper)
-
- self.__preview_gif = QLabel()
- self.__preview_gif.setMinimumSize(*self.__img_button_size)
- self.__preview_gif.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
- self.__preview_gif.setCursor(Qt.CursorShape.ArrowCursor)
- self.__preview_gif.addAction(open_file_action)
- self.__preview_gif.addAction(open_explorer_action)
- self.__preview_gif.addAction(delete_action)
- self.__gif_buffer: QBuffer = QBuffer()
+ self.preview_img_page = QWidget()
+ self._stacked_page_setup(self.preview_img_page, self.button_wrapper)
- self.__preview_gif_page = QWidget()
- self.__stacked_page_setup(self.__preview_gif_page, self.__preview_gif)
-
- self.__media_player = MediaPlayer(driver)
- self.__media_player.addAction(open_file_action)
- self.__media_player.addAction(open_explorer_action)
- self.__media_player.addAction(delete_action)
- # QMediaPlayer loads duration asynchronously after setSource().
- self.__media_player.player.durationChanged.connect(
- self.__media_player_duration_changed_callback
- )
-
- # Need to watch for this to resize the player appropriately.
- self.__media_player.player.hasVideoChanged.connect(
- self.__media_player_video_changed_callback
- )
+ self.preview_gif = QLabel()
+ self.preview_gif.setMinimumSize(*_DEFAULT_PREVIEW_SIZE)
+ self.preview_gif.setScaledContents(True)
+ self.preview_gif.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
+ self.preview_gif.setCursor(Qt.CursorShape.ArrowCursor)
+ self.preview_gif.addAction(self.open_file_action)
+ self.preview_gif.addAction(self.open_explorer_action)
+ self.preview_gif.addAction(self.delete_action)
- self.__media_player_page = QWidget()
- self.__stacked_page_setup(self.__media_player_page, self.__media_player)
+ self.preview_gif.setGraphicsEffect(CornerRadiusEffect(self.preview_gif, RADIUS))
- 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)
+ self.preview_gif_page = QWidget()
+ self._stacked_page_setup(self.preview_gif_page, self.preview_gif)
- self.__image_layout.addWidget(self.__preview_img_page)
- self.__image_layout.addWidget(self.__preview_gif_page)
- self.__image_layout.addWidget(self.__media_player_page)
+ self.media_player = MediaPlayer(driver)
+ self.media_player.addAction(self.open_file_action)
+ self.media_player.addAction(self.open_explorer_action)
+ self.media_player.addAction(self.delete_action)
+ self.media_player.setGraphicsEffect(CornerRadiusEffect(self.media_player, RADIUS))
- self.setMinimumSize(*self.__img_button_size)
+ self.media_player_page = QWidget()
+ self._stacked_page_setup(self.media_player_page, self.media_player)
- self.hide_preview()
+ self.addWidget(self.preview_img_page)
+ self.addWidget(self.preview_gif_page)
+ self.addWidget(self.media_player_page)
- def _open_file_action_callback(self):
- raise NotImplementedError
-
- def _open_explorer_action_callback(self):
- raise NotImplementedError
-
- def _delete_action_callback(self):
- raise NotImplementedError
-
- def _button_wrapper_callback(self):
- raise NotImplementedError
-
- def __media_player_video_changed_callback(self, video: bool) -> None:
- self.__update_image_size((self.size().width(), self.size().height()))
-
- def __media_player_duration_changed_callback(self, duration_ms: int) -> None:
- filepath = self.__media_player.filepath
- if filepath is None or duration_ms <= 0:
- return
-
- self.stats_updated.emit(
- filepath,
- FileAttributeData(duration=duration_ms // 1000),
- )
-
- def __thumb_renderer_updated_callback(
- self, _timestamp: float, img: QPixmap, _size: QSize, _path: Path
- ) -> None:
- self.__button_wrapper.setIcon(img)
-
- def __thumb_renderer_updated_ratio_callback(self, ratio: float) -> None:
- self.__image_ratio = ratio
- self.__update_image_size((self.size().width(), self.size().height()))
-
- def __stacked_page_setup(self, page: QWidget, widget: QWidget) -> None:
+ def _stacked_page_setup(self, page: QWidget, widget: QWidget) -> None:
layout = QHBoxLayout(page)
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:
- adj_width: float = size[0]
- adj_height: float = size[1]
- # Landscape
- if self.__image_ratio > 1:
- adj_height = size[0] * (1 / self.__image_ratio)
- # Portrait
- elif self.__image_ratio <= 1:
- adj_width = size[1] * self.__image_ratio
-
- if adj_width > size[0]:
- adj_height = adj_height * (size[0] / adj_width)
- adj_width = size[0]
- elif adj_height > size[1]:
- adj_width = adj_width * (size[1] / adj_height)
- adj_height = size[1]
-
- adj_size = QSize(int(adj_width), int(adj_height))
-
- self.__img_button_size = (int(adj_width), int(adj_height))
- self.__button_wrapper.setMaximumSize(adj_size)
- self.__button_wrapper.setIconSize(adj_size)
- self.__preview_gif.setMaximumSize(adj_size)
- self.__preview_gif.setMinimumSize(adj_size)
-
- self.__media_player.setMaximumSize(adj_size)
- self.__media_player.setMinimumSize(adj_size)
-
- proxy_style = RoundedPixmapStyle(radius=8)
- self.__preview_gif.setStyle(proxy_style)
- self.__media_player.setStyle(proxy_style)
- m = self.__preview_gif.movie()
- if m:
- m.setScaledSize(adj_size)
-
- def __switch_preview(self, preview: PreviewType | None) -> None:
- if preview in [PreviewType.AUDIO, PreviewType.VIDEO]:
- self.__media_player.show()
- self.__image_layout.setCurrentWidget(self.__media_player_page)
- self.check_ffmpeg.emit(True) # noqa: FBT003
- else:
- self.__media_player.stop()
- self.__media_player.hide()
- self.check_ffmpeg.emit(False) # noqa: FBT003
-
- if preview in [PreviewType.IMAGE, PreviewType.AUDIO]:
- self.__button_wrapper.show()
- self.__image_layout.setCurrentWidget(
- self.__preview_img_page
- if preview == PreviewType.IMAGE
- else self.__media_player_page
- )
- else:
- self.__button_wrapper.hide()
-
- if preview == PreviewType.ANIMATED:
- self.__preview_gif.show()
- self.__image_layout.setCurrentWidget(self.__preview_gif_page)
- else:
- if self.__preview_gif.movie():
- self.__preview_gif.movie().stop()
- self.__gif_buffer.close()
- self.__preview_gif.hide()
-
- def __render_thumb(self, filepath: Path) -> None:
- self.__should_render_on_resize = True
-
- self.__rendered_res = (
- math.ceil(self.__img_button_size[0] * THUMB_SIZE_FACTOR),
- 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(),
- )
-
- def __update_media_player(self, filepath: Path) -> None:
- """Display either audio or video."""
- self.__media_player.play(filepath)
-
- def _display_video(self, filepath: Path, size: QSize | None) -> FileAttributeData:
- self.__should_render_on_resize = False
-
- self.__switch_preview(PreviewType.VIDEO)
- self.__update_media_player(filepath)
- stats = FileAttributeData()
-
- if size is not None:
- stats.width = size.width()
- stats.height = size.height()
-
- self.__image_ratio = stats.width / stats.height
- self.resizeEvent(
- QResizeEvent(
- QSize(stats.width, stats.height),
- QSize(stats.width, stats.height),
- )
- )
-
- return stats
-
- def _display_audio(self, filepath: Path) -> FileAttributeData:
- self.__switch_preview(PreviewType.AUDIO)
- self.__render_thumb(filepath)
- self.__update_media_player(filepath)
- return FileAttributeData()
-
- def _display_gif(self, gif_data: bytes, size: tuple[int, int]) -> FileAttributeData | None:
- """Update the animated image preview from a filepath."""
- self.__should_render_on_resize = False
-
- stats = FileAttributeData()
-
- # Ensure that any movie and buffer from previous animations are cleared.
- if self.__preview_gif.movie():
- self.__preview_gif.movie().stop()
- self.__gif_buffer.close()
-
- stats.width = size[0]
- stats.height = size[1]
-
- self.__image_ratio = stats.width / stats.height
-
- self.__gif_buffer.setData(gif_data)
- movie = QMovie(self.__gif_buffer, QByteArray())
- self.__preview_gif.setMovie(movie)
-
- # If the animation only has 1 frame, it isn't animated and shouldn't be treated as such
- if movie.frameCount() <= 1:
- return None
-
- # The animation has more than 1 frame, continue displaying it as an animation
- self.__switch_preview(PreviewType.ANIMATED)
- self.resizeEvent(
- QResizeEvent(
- QSize(stats.width, stats.height),
- QSize(stats.width, stats.height),
- )
- )
- movie.start()
-
- stats.duration = movie.frameCount() // 60
-
- return stats
-
- def _display_image(self, filepath: Path):
- """Renders the given file as an image, no matter its media type."""
- self.__switch_preview(PreviewType.IMAGE)
- self.__render_thumb(filepath)
-
- def hide_preview(self) -> None:
- """Completely hide the file preview."""
- self.__switch_preview(None)
- self._current_file = None
- self.__should_render_on_resize = False
-
- @override
- def resizeEvent(self, event: QResizeEvent) -> None:
- self.__update_image_size((self.size().width(), self.size().height()))
-
- if (
- self._current_file is not None
- and self.__should_render_on_resize
- and self.__rendered_res < self.__img_button_size
- ):
- self.__render_thumb(self._current_file)
-
- return super().resizeEvent(event)
-
- @property
- def media_player(self) -> MediaPlayer:
- return self.__media_player
-
- @property
- def current_file(self) -> Path | None:
- return self._current_file
diff --git a/src/tagstudio/qt/views/search_panel_view.py b/src/tagstudio/qt/views/search_panel_view.py
index 0bce724ac3..ad626eb481 100644
--- a/src/tagstudio/qt/views/search_panel_view.py
+++ b/src/tagstudio/qt/views/search_panel_view.py
@@ -17,7 +17,7 @@
)
from tagstudio.i18n.translations import Translations
-from tagstudio.qt.views.styles.stylesheets import list_button_style
+from tagstudio.qt.views.styles.stylesheets import PAD, list_button_style
if TYPE_CHECKING:
pass
@@ -29,13 +29,13 @@ def __init__(
) -> None:
self.is_chooser: bool = is_chooser
super().__init__()
- self.setContentsMargins(6, 0, 6, 0)
+ self.setContentsMargins(PAD, 0, PAD, 0)
# Limit container
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.setSpacing(PAD * 2)
self.limit_layout.addStretch(1)
self.limit_title = QLabel(Translations["home.search.view_limit"])
self.limit_layout.addWidget(self.limit_title)
@@ -57,7 +57,7 @@ def __init__(
# Scroll area
self.scroll_contents = QWidget()
self.scroll_layout = QVBoxLayout(self.scroll_contents)
- self.scroll_layout.setContentsMargins(6, 0, 6, 0)
+ self.scroll_layout.setContentsMargins(PAD, 0, PAD, 0)
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_area = QScrollArea()
self.scroll_area.setWidget(self.scroll_contents)
diff --git a/src/tagstudio/qt/views/styles/color_overlay.py b/src/tagstudio/qt/views/styles/color_overlay.py
index 3fb8ecc3c3..feef8e1331 100644
--- a/src/tagstudio/qt/views/styles/color_overlay.py
+++ b/src/tagstudio/qt/views/styles/color_overlay.py
@@ -50,7 +50,7 @@ def auto_theme_overlay(
Args:
image (Image): The PIL Image object to apply an overlay to.
- inverse (bool): Option inverse the overlay color relative to the current theme.
+ inverse (bool): Option to invert the overlay color relative to the current theme.
use_alpha (bool): Option to retain the base image's alpha value when applying the overlay.
"""
dark_fg: str = _THEME_DARK_FG[:-2] if not use_alpha else _THEME_DARK_FG
diff --git a/src/tagstudio/qt/views/styles/corner_radius_effect.py b/src/tagstudio/qt/views/styles/corner_radius_effect.py
new file mode 100644
index 0000000000..7a6f8c5208
--- /dev/null
+++ b/src/tagstudio/qt/views/styles/corner_radius_effect.py
@@ -0,0 +1,38 @@
+# SPDX-FileCopyrightText: (c) TagStudio Contributors
+# SPDX-License-Identifier: GPL-3.0-only
+
+
+from typing import override
+
+from PySide6.QtCore import QPoint, QRectF, Qt
+from PySide6.QtGui import QBrush, QPainter, QTransform
+from PySide6.QtWidgets import QGraphicsEffect, QWidget
+
+
+class CornerRadiusEffect(QGraphicsEffect):
+ """Add an anti-aliased corner radius effect to a widget."""
+
+ def __init__(self, parent: QWidget, radius: float) -> None:
+ super().__init__(parent)
+ self._radius = radius
+
+ @override
+ def draw(self, painter: QPainter) -> None:
+ offset = QPoint()
+ pixmap = self.sourcePixmap(
+ Qt.CoordinateSystem.LogicalCoordinates, offset, QGraphicsEffect.PixmapPadMode.NoPad
+ )
+ if pixmap.isNull():
+ return
+
+ brush = QBrush(pixmap)
+ brush.setTransform(QTransform.fromTranslate(offset.x(), offset.y()))
+
+ painter.save()
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
+ painter.setPen(Qt.PenStyle.NoPen)
+ painter.setBrush(brush)
+ painter.drawRoundedRect(
+ QRectF(offset, pixmap.deviceIndependentSize()), self._radius, self._radius
+ )
+ painter.restore()
diff --git a/src/tagstudio/qt/views/styles/rounded_pixmap_style.py b/src/tagstudio/qt/views/styles/rounded_pixmap_style.py
deleted file mode 100644
index 33395a5a77..0000000000
--- a/src/tagstudio/qt/views/styles/rounded_pixmap_style.py
+++ /dev/null
@@ -1,32 +0,0 @@
-# SPDX-FileCopyrightText: (c) 2019 Edwin Yllanes
-# SPDX-License-Identifier: CC-BY-SA-4.0
-# See: https://stackoverflow.com/questions/54230005/qmovie-with-border-radius/54231484#54231484
-
-
-from typing import override
-
-from PySide6.QtCore import QRect
-from PySide6.QtGui import QBrush, QColor, QImage, QPainter, QPixmap
-from PySide6.QtWidgets import QProxyStyle
-
-
-class RoundedPixmapStyle(QProxyStyle):
- def __init__(self, radius: int = 8):
- super().__init__()
- self._radius = radius
-
- @override
- def drawItemPixmap(
- self, painter: QPainter, rect: QRect, alignment: int, pixmap: QPixmap | QImage
- ):
- painter.save()
- pix = QPixmap(pixmap.size())
- pix.fill(QColor("transparent"))
- p = QPainter(pix)
- p.setBrush(QBrush(pixmap))
- p.setPen(QColor("transparent"))
- p.setRenderHint(QPainter.RenderHint.Antialiasing)
- p.drawRoundedRect(pixmap.rect(), self._radius, self._radius)
- p.end()
- super().drawItemPixmap(painter, rect, alignment, pix)
- painter.restore()
diff --git a/src/tagstudio/qt/views/styles/stylesheets.py b/src/tagstudio/qt/views/styles/stylesheets.py
index e41e96069a..fffad44bf5 100644
--- a/src/tagstudio/qt/views/styles/stylesheets.py
+++ b/src/tagstudio/qt/views/styles/stylesheets.py
@@ -21,8 +21,12 @@
# 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.
-# Shared with RoundedProgressBar.set_corner_radius() so both use the exact same corner arc.
-BANNER_CORNER_RADIUS = 6
+
+RADIUS = 6 # Standard corner radius
+INNER_RADIUS = RADIUS // 2 # Inner/small corder radius
+PAD = 6 # Standard padding value
+HALF_PAD = PAD // 2
+WIN_PAD = 12 # Padding used for window edge margins
def add_button_style() -> str:
@@ -33,7 +37,7 @@ def add_button_style() -> str:
color: {get_tag_color(ColorType.TEXT, TagColorEnum.DEFAULT)};
font-weight: 600;
border-color: {get_tag_color(ColorType.BORDER, TagColorEnum.DEFAULT)};
- border-radius: 6px;
+ border-radius: {RADIUS}px;
border-style: solid;
border-width: 2px;
padding-right: 4px;
@@ -61,7 +65,7 @@ def button_style() -> str:
return f"""
QPushButton{{
background-color: {ThemePalette.COLOR_BG.value};
- border-radius: 6px;
+ border-radius: {RADIUS}px;
font-weight: 500;
text-align: center;
padding: 0px 12px;
@@ -105,7 +109,7 @@ def line_edit_style_main() -> str:
return f"""
QLineEdit{{
background: {bg_color};
- border-radius: 6px;
+ border-radius: {RADIUS}px;
font-weight: 500;
text-align: center;
padding: 0px 4px;
@@ -150,7 +154,7 @@ def colored_checkbox_style(
background: rgba{primary_color.toTuple()};
color: rgba{text_color.toTuple()};
border-color: rgba{border_color.toTuple()};
- border-radius: 6px;
+ border-radius: {RADIUS}px;
border-style: solid;
border-width: 2px;
}}
@@ -184,7 +188,7 @@ def colored_radio_button_style(
background: rgba{primary_color.toTuple()};
color: rgba{text_color.toTuple()};
border-color: rgba{border_color.toTuple()};
- border-radius: 6px;
+ border-radius: {RADIUS}px;
border-style: solid;
border-width: 2px;
}}
@@ -230,7 +234,7 @@ def color_swatch_style(
color: rgba{text_color.toTuple()};
border-color: rgba{border_color.toTuple()};
{bottom_color_str}
- border-radius: 3px;
+ border-radius: {INNER_RADIUS}px;
border-style: solid;
border-width: 2px;
}}
@@ -249,22 +253,22 @@ def color_swatch_style(
QRadioButton::focus{{
outline-style: solid;
outline-width: 2px;
- outline-radius: 3px;
+ outline-radius: {INNER_RADIUS}px;
outline-color: rgba{highlight_color.toTuple()};
}}
"""
def container_style() -> str:
- """Style used for field containers."""
+ """Style used for Tile widgets."""
return f"""
- QWidget#fieldContainer{{
+ QWidget#tile{{
border-radius: 4px;
}}
- QWidget#fieldContainer::hover{{
+ QWidget#tile::hover{{
background-color: {ThemePalette.COLOR_HOVER.value};
}}
- QWidget#fieldContainer::pressed{{
+ QWidget#tile::pressed{{
background-color: {ThemePalette.COLOR_PRESSED.value};
}}
"""
@@ -278,7 +282,7 @@ def form_content_style() -> str:
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else ThemePalette.COLOR_BG_LIGHT.value
};
- border-radius: 3px;
+ border-radius: {INNER_RADIUS}px;
font-weight: 500;
padding: 1px;
}}
@@ -313,7 +317,7 @@ def list_button_style(
font-weight: 600;
{"font: italic;" if italic else ""}
border-color: rgba{border_color.toTuple()};
- border-radius: 6px;
+ border-radius: {RADIUS}px;
border-style: {border_style};
border-width: 2px;
padding-right: 4px;
@@ -354,7 +358,7 @@ def properties_style() -> str:
font-family: Oxanium;
font-weight: bold;
font-size: 12px;
- border-radius: 3px;
+ border-radius: {INNER_RADIUS}px;
padding-top: 4px;
padding-right: 1px;
padding-bottom: 1px;
@@ -376,7 +380,7 @@ def tag_style(
color: rgba{text_color.toTuple()};
font-weight: 600;
border-color: rgba{border_color.toTuple()};
- border-radius: 6px;
+ border-radius: {RADIUS}px;
border-style: {border_style};
border-width: 2px;
font-size: 13px;
@@ -420,7 +424,7 @@ def tag_remove_button_style(
color: rgba{text_color.toTuple()};
border-color: rgba{highlight_color.toTuple()};
border-width: 2;
- border-radius: 6px;
+ border-radius: {RADIUS}px;
}}
QPushButton::pressed{{
background: rgba{border_color.toTuple()};
@@ -459,7 +463,7 @@ def inset_container_style(object_name: str = "") -> str:
return f"""
QWidget{"#" + object_name if object_name else ""}{{
background: {bg_color};
- border-radius: 6px;
+ border-radius: {RADIUS}px;
}}
"""
@@ -476,8 +480,8 @@ def autofill_scroll_top_style(object_name: str = "") -> str:
return f"""
QWidget{"#" + object_name if object_name else ""}{{
background: {bg_color};
- border-top-left-radius: 6px;
- border-top-right-radius: 6px;
+ border-top-left-radius: {RADIUS}px;
+ border-top-right-radius: {RADIUS}px;
border: none;
}}
"""
@@ -494,8 +498,8 @@ def autofill_scroll_top_focus_style(object_name: str = "") -> str:
return f"""
QWidget{"#" + object_name if object_name else ""}{{
background: {bg_color};
- border-top-left-radius: 6px;
- border-top-right-radius: 6px;
+ border-top-left-radius: {RADIUS}px;
+ border-top-right-radius: {RADIUS}px;
border: solid;
border-width: 2px 2px 0px 2px;
border-color: rgba{Palette.accent().toTuple()};
@@ -519,8 +523,8 @@ def autofill_line_edit_style(is_invalid: bool = False) -> str:
return f"""
QLineEdit{{
background: {bg_color};
- border-radius: 6px;
- padding: 3px 6px;
+ border-radius: {RADIUS}px;
+ padding: {HALF_PAD}px {PAD}px;
}}
QLineEdit::focus{{
padding: 4px 4px;
@@ -544,9 +548,9 @@ def autofill_line_edit_top_style() -> str:
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;
+ border-bottom-left-radius: {RADIUS}px;
+ border-bottom-right-radius: {RADIUS}px;
+ padding: 0px 0px 2px {RADIUS}px;
}}
QLineEdit::focus{{
padding: 4px 4px;
@@ -561,7 +565,7 @@ def preview_warning_style() -> str:
return f"""
QWidget#ffmpeg_widget {{
background: {get_ui_color(ColorType.DARK_ACCENT, UiColor.RED)};
- border-radius: 6px;
+ border-radius: {RADIUS}px;
}}
"""
@@ -636,7 +640,7 @@ def banner_close_button_style() -> str:
background: transparent;
color: rgba{text_color.toTuple()};
border: none;
- border-radius: 3px;
+ border-radius: {INNER_RADIUS}px;
}}
{_banner_button_hover_style("bannerCloseButton")}
"""
@@ -652,7 +656,7 @@ def banner_action_button_style() -> str:
background-color: rgba{accent.toTuple()};
color: rgba{text_color.toTuple()};
border: none;
- border-radius: 3px;
+ border-radius: {INNER_RADIUS}px;
padding: 4px 8px;
outline: none;
}}
@@ -693,7 +697,7 @@ def banner_close_button_progress_style() -> str:
background: transparent;
color: rgba{text_color.toTuple()};
border: none;
- border-radius: 3px;
+ border-radius: {INNER_RADIUS}px;
}}
QPushButton#bannerCloseButton::hover {{
background-color: {hover};
@@ -726,6 +730,20 @@ def banner_progress_style() -> str:
"""
+def pagination_style() -> str:
+ """Style for the pagination bar."""
+ bg = QColor(banner_progress_bg_color())
+ bg.setAlpha(200)
+ border = "rgba(200, 200, 200, 30)"
+
+ return f"""
+ QWidget#pagination {{
+ background-color: rgba{bg.toTuple()};
+ border-top: 1px solid {border};
+ }}
+ """
+
+
def banner_progress_chunk_color() -> QColor:
"""Fill color for the banner's custom-painted progress bar chunk."""
is_dark = _is_dark_theme()
diff --git a/src/tagstudio/qt/views/suggest_box_view.py b/src/tagstudio/qt/views/suggest_box_view.py
index 202dc23f06..8a7dcb7478 100644
--- a/src/tagstudio/qt/views/suggest_box_view.py
+++ b/src/tagstudio/qt/views/suggest_box_view.py
@@ -10,6 +10,7 @@
from tagstudio.qt.controllers.autofill_line_edit import AutofillLineEdit
from tagstudio.qt.controllers.horizontal_scroll_area import HorizontalScrollArea
from tagstudio.qt.views.styles.stylesheets import (
+ PAD,
autofill_line_edit_style,
autofill_scroll_top_style,
)
@@ -36,7 +37,7 @@ def __init__(self, placeholder_text: str = "") -> None:
# Autocomplete ScrollArea
contents = QWidget()
self.content_layout = QHBoxLayout(contents)
- self.content_layout.setSpacing(6)
+ self.content_layout.setSpacing(PAD)
self.content_layout.setAlignment(Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignLeft)
self.content_layout.setContentsMargins(0, 0, 0, 0)
scroll_area_container = QWidget()
diff --git a/src/tagstudio/qt/views/tag_box_view.py b/src/tagstudio/qt/views/tag_box_view.py
deleted file mode 100644
index 70481b65bd..0000000000
--- a/src/tagstudio/qt/views/tag_box_view.py
+++ /dev/null
@@ -1,64 +0,0 @@
-# SPDX-FileCopyrightText: (c) TagStudio Contributors
-# SPDX-License-Identifier: GPL-3.0-only
-
-
-from collections.abc import Iterable
-from typing import TYPE_CHECKING
-
-import structlog
-
-from tagstudio.core.library.alchemy.library import Library
-from tagstudio.core.library.alchemy.models import Tag
-from tagstudio.i18n.translations import Translations
-from tagstudio.qt.controllers.capsule import Capsule
-from tagstudio.qt.mixed.field_widget import FieldWidget
-from tagstudio.qt.views.layouts.flow_layout import FlowLayout
-
-if TYPE_CHECKING:
- from tagstudio.qt.qt_driver import QtDriver
-
-logger = structlog.get_logger(__name__)
-
-
-# TODO: Use newer MVC style guidelines
-class TagBoxWidgetView(FieldWidget):
- __lib: Library
-
- def __init__(self, title: str, driver: QtDriver) -> None:
- super().__init__(title)
- self.__lib = driver.lib
-
- self.__root_layout = FlowLayout()
- self.__root_layout.enable_grid_optimizations(value=False)
- self.__root_layout.setContentsMargins(0, 0, 0, 0)
- self.setLayout(self.__root_layout)
-
- def set_tags(self, tags: Iterable[Tag]) -> None:
- tags_ = sorted(list(tags), key=lambda tag: self.__lib.tag_display_name(tag))
- logger.info("[TagBoxWidget] Tags:", tags=tags)
- while self.__root_layout.itemAt(0):
- self.__root_layout.takeAt(0).widget().deleteLater() # pyright: ignore[reportOptionalMemberAccess]
-
- for tag in tags_:
- capsule = Capsule(
- has_edit=True, has_remove=True, search_label=Translations["tag.search_for_tag"]
- )
- capsule.set_text(self.__lib.tag_display_name(tag))
- capsule.set_color_group(tag.color)
- capsule.on_click.connect(lambda t=tag: self._on_click(t))
- capsule.on_remove.connect(lambda t=tag: self._on_remove(t))
- capsule.on_edit.connect(lambda t=tag: self._on_edit(t))
- capsule.on_search.connect(lambda t=tag: self._on_search(t))
- self.__root_layout.addWidget(capsule)
-
- def _on_click(self, tag: Tag) -> None:
- raise NotImplementedError
-
- def _on_remove(self, tag: Tag) -> None:
- raise NotImplementedError
-
- def _on_edit(self, tag: Tag) -> None:
- raise NotImplementedError
-
- def _on_search(self, tag: Tag) -> None:
- raise NotImplementedError
diff --git a/src/tagstudio/qt/views/thumb_button.py b/src/tagstudio/qt/views/thumb_button.py
index 5b497981a5..b71bc3ead9 100644
--- a/src/tagstudio/qt/views/thumb_button.py
+++ b/src/tagstudio/qt/views/thumb_button.py
@@ -10,6 +10,7 @@
from PySide6.QtWidgets import QPushButton, QWidget
from tagstudio.qt.views.styles.palette import Palette
+from tagstudio.qt.views.styles.stylesheets import RADIUS
# TODO: Use newer MVC style guidelines
@@ -46,7 +47,6 @@ def paintEvent(self, arg__1: QPaintEvent) -> None:
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
path = QPainterPath()
width = 3
- radius = 6
path.addRoundedRect(
QtCore.QRectF(
width / 2,
@@ -54,8 +54,8 @@ def paintEvent(self, arg__1: QPaintEvent) -> None:
self.thumb_size[0] - width,
self.thumb_size[1] - width,
),
- radius,
- radius,
+ RADIUS,
+ RADIUS,
)
if self.selected:
diff --git a/src/tagstudio/qt/views/tiles/color_data_view.py b/src/tagstudio/qt/views/tiles/color_data_view.py
new file mode 100644
index 0000000000..164cac9dc3
--- /dev/null
+++ b/src/tagstudio/qt/views/tiles/color_data_view.py
@@ -0,0 +1,14 @@
+# SPDX-FileCopyrightText: (c) TagStudio Contributors
+# SPDX-License-Identifier: GPL-3.0-only
+
+
+from tagstudio.qt.views.layouts.flow_layout import FlowLayout
+
+
+class ColorDataView(FlowLayout):
+ """The layout used for a ColorData widget."""
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.enable_grid_optimizations(value=True)
+ self.setContentsMargins(0, 0, 0, 0)
diff --git a/src/tagstudio/qt/views/tiles/tag_data_view.py b/src/tagstudio/qt/views/tiles/tag_data_view.py
new file mode 100644
index 0000000000..b51791700f
--- /dev/null
+++ b/src/tagstudio/qt/views/tiles/tag_data_view.py
@@ -0,0 +1,14 @@
+# SPDX-FileCopyrightText: (c) TagStudio Contributors
+# SPDX-License-Identifier: GPL-3.0-only
+
+
+from tagstudio.qt.views.layouts.flow_layout import FlowLayout
+
+
+class TagDataView(FlowLayout):
+ """The layout used for a TagData widget."""
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.enable_grid_optimizations(value=False)
+ self.setContentsMargins(0, 0, 0, 0)
diff --git a/src/tagstudio/qt/views/tiles/text_data_view.py b/src/tagstudio/qt/views/tiles/text_data_view.py
new file mode 100644
index 0000000000..c6b1258ed8
--- /dev/null
+++ b/src/tagstudio/qt/views/tiles/text_data_view.py
@@ -0,0 +1,22 @@
+# SPDX-FileCopyrightText: (c) TagStudio Contributors
+# SPDX-License-Identifier: GPL-3.0-only
+
+
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import QHBoxLayout, QLabel
+
+
+class TextDataView(QHBoxLayout):
+ """The layout used for a TextData widget."""
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.setContentsMargins(0, 0, 0, 0)
+
+ self.text_label = QLabel()
+ self.text_label.setStyleSheet("font-size: 12px")
+ self.text_label.setWordWrap(True)
+ self.text_label.setTextFormat(Qt.TextFormat.MarkdownText)
+ self.text_label.setOpenExternalLinks(True)
+ self.text_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
+ self.addWidget(self.text_label)
diff --git a/src/tagstudio/qt/views/tiles/tile_list_view.py b/src/tagstudio/qt/views/tiles/tile_list_view.py
new file mode 100644
index 0000000000..5bdf7e4cac
--- /dev/null
+++ b/src/tagstudio/qt/views/tiles/tile_list_view.py
@@ -0,0 +1,40 @@
+# SPDX-FileCopyrightText: (c) TagStudio Contributors
+# SPDX-License-Identifier: GPL-3.0-only
+
+
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import QFrame, QHBoxLayout, QScrollArea, QSizePolicy, QVBoxLayout, QWidget
+
+from tagstudio.qt.views.styles.stylesheets import HALF_PAD, PAD, inset_container_style
+
+
+class TileListView(QHBoxLayout):
+ def __init__(self) -> None:
+ super().__init__()
+ self.setContentsMargins(0, 0, 0, 0)
+
+ self.scroll_layout = QVBoxLayout()
+ self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
+ self.scroll_layout.setContentsMargins(HALF_PAD, HALF_PAD, HALF_PAD, HALF_PAD)
+ self.scroll_layout.setSpacing(PAD)
+
+ scroll_container = QWidget()
+ scroll_container.setObjectName("tile_scroll_container")
+ scroll_container.setLayout(self.scroll_layout)
+
+ self.scroll_area = QScrollArea()
+ self.scroll_area.setObjectName("tile_scroll_area")
+ self.scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
+ self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+ self.scroll_area.setWidgetResizable(True)
+ self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
+ self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
+
+ # NOTE: I would rather have this style applied to the scroll_area
+ # 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(inset_container_style("tile_scroll_container"))
+ self.scroll_area.setWidget(scroll_container)
+
+ self.addWidget(self.scroll_area)
diff --git a/src/tagstudio/qt/views/tiles/tile_view.py b/src/tagstudio/qt/views/tiles/tile_view.py
new file mode 100644
index 0000000000..23c99ced24
--- /dev/null
+++ b/src/tagstudio/qt/views/tiles/tile_view.py
@@ -0,0 +1,86 @@
+# SPDX-FileCopyrightText: (c) TagStudio Contributors
+# SPDX-License-Identifier: GPL-3.0-only
+
+
+from PIL import ImageQt
+from PySide6.QtCore import QSize, Qt
+from PySide6.QtGui import QPixmap
+from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
+
+from tagstudio.qt.resource_manager import ResourceManager
+from tagstudio.qt.views.styles.color_overlay import auto_theme_overlay
+from tagstudio.qt.views.styles.stylesheets import HALF_PAD
+
+# TODO: There should be a global button theme somewhere.
+_BUTTON_SIZE = 22
+_ICON_MARGIN = 4
+_ICON_SIZE = _BUTTON_SIZE - _ICON_MARGIN
+
+
+class TileView(QVBoxLayout):
+ """The layout used for a Tile widget."""
+
+ _rm = ResourceManager()
+ copy_icon = auto_theme_overlay(_rm.copy, inverse=True)
+ edit_icon = auto_theme_overlay(_rm.edit, inverse=True)
+ trash_icon = auto_theme_overlay(_rm.trash, inverse=True)
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.setContentsMargins(0, 0, 0, 0)
+
+ self.inner_layout = QVBoxLayout()
+ self.inner_layout.setContentsMargins(HALF_PAD, 0, 0, HALF_PAD)
+ self.inner_layout.setSpacing(0)
+ self.field_container = QWidget()
+ self.field_container.setLayout(self.inner_layout)
+ self.addWidget(self.field_container)
+
+ self.title_container = QWidget()
+ self.title_layout = QHBoxLayout(self.title_container)
+ self.title_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
+ self.title_layout.setContentsMargins(0, 0, 0, 0)
+ self.title_layout.setSpacing(0)
+ self.inner_layout.addWidget(self.title_container)
+
+ self.title_widget = QLabel()
+ self.title_widget.setMinimumHeight(_BUTTON_SIZE)
+ self.title_widget.setWordWrap(True)
+ self.title_layout.addWidget(self.title_widget)
+ self.title_layout.addStretch(2)
+
+ self.copy_button = QPushButton()
+ self.copy_button.setMinimumSize(_BUTTON_SIZE, _BUTTON_SIZE)
+ self.copy_button.setMaximumSize(_BUTTON_SIZE, _BUTTON_SIZE)
+ self.copy_button.setFlat(True)
+ self.copy_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(self.copy_icon)))
+ self.copy_button.setIconSize(QSize(_ICON_SIZE, _ICON_SIZE))
+ self.copy_button.setCursor(Qt.CursorShape.PointingHandCursor)
+ self.title_layout.addWidget(self.copy_button)
+ self.copy_button.setHidden(True)
+
+ self.edit_button = QPushButton()
+ self.edit_button.setMinimumSize(_BUTTON_SIZE, _BUTTON_SIZE)
+ self.edit_button.setMaximumSize(_BUTTON_SIZE, _BUTTON_SIZE)
+ self.edit_button.setFlat(True)
+ self.edit_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(self.edit_icon)))
+ 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)
+
+ self.remove_button = QPushButton()
+ self.remove_button.setMinimumSize(_BUTTON_SIZE, _BUTTON_SIZE)
+ self.remove_button.setMaximumSize(_BUTTON_SIZE, _BUTTON_SIZE)
+ self.remove_button.setFlat(True)
+ self.remove_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(self.trash_icon)))
+ 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)
+
+ self.data = QWidget()
+ self.data_layout = QHBoxLayout()
+ self.data_layout.setContentsMargins(0, 0, 0, 0)
+ self.data.setLayout(self.data_layout)
+ self.inner_layout.addWidget(self.data)
diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json
index 45d352b50d..42d84dc44b 100644
--- a/src/tagstudio/resources/translations/en.json
+++ b/src/tagstudio/resources/translations/en.json
@@ -382,23 +382,13 @@
"sorting.direction.descending": "Descending",
"sorting.mode.random": "Random",
"splash.opening_library": "Opening Library \"{library_path}\"…",
- "status.deleted_file_plural": "Deleted {count} files!",
- "status.deleted_file_singular": "Deleted 1 file!",
- "status.deleted_none": "No files deleted.",
- "status.deleted_partial_warning": "Only deleted {count} file(s)! Check if any of the files are currently missing or in use.",
- "status.deleting_file": "Deleting file [{i}/{count}]: \"{path}\"…",
- "status.library_backup_in_progress": "Saving Library Backup…",
- "status.library_backup_success": "Library Backup Saved at: \"{path}\" ({time_span})",
- "status.library_closed": "Library Closed ({time_span})",
- "status.library_closing": "Closing Library…",
+ "status.items": "{count} Items {time_span}",
"status.library_save_success": "Library Saved and Closed!",
- "status.library_search_query": "Searching Library…",
"status.library_version_dev": "This library is using an unsupported format used for development and can't be opened.",
"status.library_version_expected": "Supported Versions:",
"status.library_version_found": "Library Version:",
"status.library_version_newer": "This library was saved using a newer version of TagStudio and can't be opened in this version.",
- "status.results": "Results",
- "status.results_found": "{count} Results Found ({time_span})",
+ "status.results_found": "{count} Results {time_span}",
"status.results.invalid_syntax": "Invalid Search Syntax:",
"tag_manager.title": "Library Tags",
"tag.add": "Add Tag",
diff --git a/tests/qt/test_field_containers.py b/tests/qt/test_tile_list.py
similarity index 88%
rename from tests/qt/test_field_containers.py
rename to tests/qt/test_tile_list.py
index cda020c065..bb33701fc2 100644
--- a/tests/qt/test_field_containers.py
+++ b/tests/qt/test_tile_list.py
@@ -20,8 +20,8 @@ def test_update_selection_empty(qt_driver: QtDriver):
qt_driver.toggle_item_selection(1, append=True, bridge=False)
panel.set_selection(qt_driver.selected)
- # FieldContainer should hide all containers
- for container in panel.containers._containers:
+ # TileList should hide all tiles
+ for container in panel.containers._tiles:
assert container.isHidden()
@@ -32,8 +32,8 @@ def test_update_selection_single(qt_driver: QtDriver, entry_full: Entry):
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
panel.set_selection(qt_driver.selected)
- # FieldContainer should show all applicable tags and field containers
- for container in panel.containers._containers:
+ # TileList should show all applicable tag and field tiles
+ for container in panel.containers._tiles:
assert not container.isHidden()
@@ -47,8 +47,8 @@ def test_update_selection_multiple(qt_driver: QtDriver):
qt_driver.toggle_item_selection(2, append=True, bridge=False)
panel.set_selection(qt_driver.selected)
- # FieldContainer should show mixed field editing
- for container in panel.containers._containers:
+ # TileList should show mixed field editing
+ for container in panel.containers._tiles:
assert container.isHidden()
@@ -136,9 +136,9 @@ def test_meta_tag_category(qt_driver: QtDriver, entry_full: Entry):
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
panel.set_selection(qt_driver.selected)
- # FieldContainer should hide all containers
- assert len(panel.containers._containers) == 3
- for i, container in enumerate(panel.containers._containers):
+ # TileList should hide all tiles
+ assert len(panel.containers._tiles) == 3
+ for i, container in enumerate(panel.containers._tiles):
match i:
case 0:
# Check if the container is the Meta Tags category
@@ -169,9 +169,9 @@ def test_custom_tag_category(qt_driver: QtDriver, entry_full: Entry):
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
panel.set_selection(qt_driver.selected)
- # FieldContainer should hide all containers
- assert len(panel.containers._containers) == 3
- for i, container in enumerate(panel.containers._containers):
+ # TileList should hide all tiles
+ assert len(panel.containers._tiles) == 3
+ for i, container in enumerate(panel.containers._tiles):
match i:
case 0:
# Check if the container is the Meta Tags category
@@ -206,8 +206,8 @@ def test_exclude_tag_category(
qt_driver.toggle_item_selection(entry.id, append=False, bridge=False)
panel.set_selection(qt_driver.selected)
- assert len(panel.containers._containers) == 1
- assert panel.containers._containers[0].title == "Tags
"
+ assert len(panel.containers._tiles) == 1
+ assert panel.containers._tiles[0].title == "Tags
"
def test_exclude_self_from_tag_category(
@@ -229,8 +229,8 @@ def test_exclude_self_from_tag_category(
qt_driver.toggle_item_selection(entry.id, append=False, bridge=False)
panel.set_selection(qt_driver.selected)
- assert len(panel.containers._containers) == 1
- assert panel.containers._containers[0].title == f"{category_parent.name}
"
+ assert len(panel.containers._tiles) == 1
+ assert panel.containers._tiles[0].title == f"{category_parent.name}
"
def test_exclude_self_from_tag_category_without_parent(
@@ -249,5 +249,5 @@ def test_exclude_self_from_tag_category_without_parent(
qt_driver.toggle_item_selection(entry.id, append=False, bridge=False)
panel.set_selection(qt_driver.selected)
- assert len(panel.containers._containers) == 1
- assert panel.containers._containers[0].title == "Tags
"
+ assert len(panel.containers._tiles) == 1
+ assert panel.containers._tiles[0].title == "Tags
"