Compare commits

..

22 Commits

Author SHA1 Message Date
Travis Abendshien fd81304fc8 docs: update docstrings 2026-07-21 17:19:35 -07:00
Travis Abendshien 99aad21e03 refactor: remove view parameters 2026-07-21 16:46:39 -07:00
Travis Abendshien a0bf6792ed ui: misc UI fixes and tweaks 2026-07-19 14:40:12 -07:00
Travis Abendshien 0fbcfdb1ea feat(ui): add underline indicator for search bar items 2026-07-19 13:14:06 -07:00
Travis Abendshien 111ea76884 refactor(ui): use Qt's .layout() method instead of _layout references 2026-07-19 11:24:23 -07:00
Travis Abendshien 4aa7b9e043 fix(ui): fix issues with tag and field widget appearances 2026-07-19 11:12:03 -07:00
Travis Abendshien e4851678e1 fix(ui): update tag search bar when entry tags are updated 2026-07-19 10:53:34 -07:00
Travis Abendshien c488e53144 feat(ui): add shortcut for opening field template search bar 2026-07-19 04:11:14 -07:00
Travis Abendshien 8e3c0fb092 feat(ui): add autocomplete search for field templates 2026-07-19 03:53:47 -07:00
Travis Abendshien 37f8eeac6f chore: cleanup comments 2026-07-18 18:48:38 -07:00
Travis Abendshien b83216b11e Revert "refactor: remove controller suffix from preview_panel.py"
This reverts commit c8ba9b15c2.
2026-07-18 18:30:26 -07:00
Travis Abendshien f29f691a79 refactor: remove unnecessary methods and properties 2026-07-18 17:23:46 -07:00
Travis Abendshien c8ba9b15c2 refactor: remove controller suffix from preview_panel.py 2026-07-18 16:54:15 -07:00
Travis Abendshien f7b86ebf2a refactor(ui): refactor preview panel into new MVC pattern 2026-07-18 16:20:18 -07:00
Travis Abendshien 55a057ffe6 chore: remove unused local import 2026-07-18 04:37:56 -07:00
Travis Abendshien 210b469bd2 chore: remove unused TYPE_CHECKING imports 2026-07-18 04:23:57 -07:00
Travis Abendshien d98beaf444 feat(ui): add edit tag setting to context menu, tweak translations 2026-07-18 03:40:03 -07:00
Travis Abendshien 669b95d580 feat: add setting to open edit window when creating tags 2026-07-18 03:04:06 -07:00
Travis Abendshien cb3164a5ff refactor: RADICAL mvc refactor... 2026-07-18 02:50:21 -07:00
Travis Abendshien f2cecb2648 refactor: code cleanup and fixes 2026-07-18 02:40:43 -07:00
Travis Abendshien 34d00d7e9a fix(ui): restore esc to deselect entries behavior 2026-07-17 19:45:47 -07:00
Travis Abendshien fdc01b7491 feat(ui): replace add tag modal with autocomplete search/create bar 2026-07-17 17:37:02 -07:00
53 changed files with 1655 additions and 729 deletions
+4 -11
View File
@@ -210,18 +210,11 @@ jobs:
steps:
- *checkout
- name: Setup Ruff
uses: astral-sh/ruff-action@v4.0.0
with:
# No-op operation, since executing Ruff cannot be disabled for the action.
# Note that `--version` has different behavior than `version`, as the
# latter will fail if passed any extra args, even if that arg is empty.
args: --version
src: ''
- parallel:
- name: Run Ruff linter
run: ruff check
uses: astral-sh/ruff-action@v4.0.0
- name: Run Ruff formatter
run: ruff format --check
uses: astral-sh/ruff-action@v4.0.0
with:
args: format --check
+1 -1
View File
@@ -8,7 +8,7 @@ path = [
"docs/CNAME",
"docs/assets/**",
"src/tagstudio/qt/resources.json",
"src/tagstudio/resources/icon*.*",
"src/tagstudio/resources/icon.*",
"src/tagstudio/resources/tagstudio.desktop",
"src/tagstudio/resources/templates/ts_ignore_template.txt",
"src/tagstudio/resources/templates/ts_ignore_template_blank.txt",
+16 -6
View File
@@ -117,7 +117,7 @@ Licensing is now accomplished using the [REUSE](https://reuse.software/spec-3.3/
!!! tip "PR Scope"
If you're unsure where to stop the scope of your PR, ask yourself: _"If I broke this up, could any parts of it still be used by the project in the meantime?"_
### :material-check-circle: Workflow Checks
### Workflow Checks
When pushing your code, several automated [workflows](https://github.com/TagStudioDev/TagStudio/tree/main/.github/workflows) will check it against predefined tests and style checks. It's _highly recommended_ that you run these checks locally beforehand to avoid having to fight back-and-forth with the workflow checks inside your pull requests. These checks currently include:
@@ -126,7 +126,7 @@ When pushing your code, several automated [workflows](https://github.com/TagStud
- [Pytest](developing.md#pytest) tests
- REUSE [license compliance](#licenses)
## :material-timer-play: Runtime Requirements
### Runtime Requirements
Code must function on all of the supported operating systems and versions:
@@ -134,8 +134,18 @@ Code must function on all of the supported operating systems and versions:
- macOS 14.0+
- Common Linux distributions and versions
Final submitted code must **_NOT:_**
## :material-file-document: Documentation Guidelines
- Contain superfluous or unnecessary logging statements
- Cause unreasonable slowdowns to the program outside of a progress-indicated task
- Cause undesirable visual glitches or artifacts on screen
Documentation contributions include anything inside of the `docs/` folder as well as the `README.md`. Documentation inside the `docs/` folder is built and hosted on our static documentation site, [docs.tagstud.io](https://docs.tagstud.io/).
- Use "[dash-case / kebab-case](https://developer.mozilla.org/en-US/docs/Glossary/Kebab_case)" for file and folder names
- Follow the folder structure pattern
- Don't add images or other media with excessively large file sizes
- Provide alt text for embedded media
- Use "[Title Case](https://apastyle.apa.org/style-grammar-guidelines/capitalization/title-case)" for title capitalization
## :material-translate: Translation Guidelines
Translations are performed on the TagStudio [Weblate project](https://hosted.weblate.org/projects/tagstudio/).
_Translation guidelines coming soon._
+2 -2
View File
@@ -118,7 +118,7 @@ If you choose to manually set up a virtual environment and install dependencies
!!! Warning "Linux Library Dependencies"
If developing TagStudio on Linux, certain libraries are required that may not be included with your distribution. A full list of these can be found [here](install.md#linux).
## Nix & NixOS
## Nix(OS)
If using [Nix](https://nixos.org/), there is a development environment already provided in the [flake](https://wiki.nixos.org/wiki/Flakes) that is accessible with the following command:
@@ -214,7 +214,7 @@ From there, Git will automatically run through the hooks during commit actions!
You can automatically enter this development shell, and keep your user shell, with a tool like [direnv](https://direnv.net/). Some reference `.envrc` files are provided in the repository at [`contrib`](https://github.com/TagStudioDev/TagStudio/tree/main/contrib).
Two currently available are for [Nix](#nix-nixos) and [uv](#installing-with-uv), to use one:
Two currently available are for [Nix](#nixos) and [uv](#installing-with-uv), to use one:
```sh
ln -s .envrc-$variant .envrc
+2 -2
View File
@@ -27,12 +27,12 @@ hide:
![TagStudio screenshot](./assets/tag_bubbles.png)
<span style="font-family: Bai Jamjuree, Roboto, sans-serif; font-size: 1.1rem; letter-spacing: -0.05rem;"><span style="font-weight: 900;">Tag</span><span style="font-weight: 500;"><i>Studio</i></span></span> is a photo & file organization application with an underlying tag-based system that focuses on giving freedom and flexibility to the user. No proprietary programs or formats, no sea of sidecar files, and no complete upheaval of your filesystem structure.
**TagStudio** is a photo & file organization application with an underlying tag-based system that focuses on giving freedom and flexibility to the user. No proprietary programs or formats, no sea of sidecar files, and no complete upheaval of your filesystem structure.
</div>
<figure markdown="span">
[:material-github: Download Latest Release](https://github.com/TagStudioDev/TagStudio/releases){ .md-button .md-button--primary }
[:material-download: Download Latest Release](https://github.com/TagStudioDev/TagStudio/releases){ .md-button .md-button--primary }
</figure>
## :material-star: Core Features
+3 -3
View File
@@ -95,9 +95,9 @@ Some external dependencies are required for TagStudio to execute. Below is a tab
Aborted (core dumped)
```
### :material-nix: Nix & NixOS
### :material-nix: Nix(OS)
For [Nix](https://nixos.org/), the TagStudio repository includes a [flake](https://wiki.nixos.org/wiki/Flakes) that provides some outputs such as a development shell and package.
For [Nix(OS)](https://nixos.org/), the TagStudio repository includes a [flake](https://wiki.nixos.org/wiki/Flakes) that provides some outputs such as a development shell and package.
Two packages are provided: `tagstudio` and `tagstudio-jxl`. The distinction was made because `tagstudio-jxl` has an extra compilation step for [JPEG-XL](https://jpeg.org/jpegxl) image support. To give either of them a test run, you can execute `nix run github:TagStudioDev/TagStudio#tagstudio`. If you are in a cloned repository and wish to run a package with the context of the repository, you can simply use `nix run` with no arguments.
@@ -256,4 +256,4 @@ To generate thumbnails for RAR-based files (like `.cbr`) you'll need an extracto
### ripgrep
A recommended tool to improve the performance of directory scanning is [ripgrep](https://github.com/BurntSushi/ripgrep), a Rust-based directory walker that natively integrates with our [`.ts_ignore`](ignore.md) (`.gitignore`-style) pattern matching system for excluding files and directories. Ripgrep is already pre-installed on some Linux distributions and also available from several package managers.
A recommended tool to improve the performance of directory scanning is [`ripgrep`](https://github.com/BurntSushi/ripgrep), a Rust-based directory walker that natively integrates with our [`.ts_ignore`](ignore.md) (`.gitignore`-style) pattern matching system for excluding files and directories. Ripgrep is already pre-installed on some Linux distributions and also available from several package managers.
+88 -179
View File
@@ -6,207 +6,116 @@ icon: material/sign-text
<!-- SPDX-FileCopyrightText: (c) TagStudio Contributors -->
<!-- SPDX-License-Identifier: GPL-3.0-only -->
<!-- prettier-ignore -->
!!! abstract "Prerequisite Reading"
This guide assumes you've read the [Developing](developing.md) and [Contributing](contributing.md) pages first.
# :material-sign-text: Style Guide
## :material-script-text: General Principles
## Formatting
- Write **clear**, **concise**, and **modular** code.
- If the purpose of a peice of code is not obvious, a **short comment** should help explain it.
- Remember to follow the rest of the [contribution guidelines](contributing.md)!
Most of the style guidelines can be checked, fixed, and enforced via Ruff. Older code may not be adhering to all of these guidelines, in which case _"do as I say, not as I do"..._
---
- Do your best to write clear, concise, and modular code.
- This should include making methods private by default (e.g. `__method()`)
- Methods should only be protected (e.g. `_method()`) or public (e.g. `method()`) when needed and warranted
- Keep a maximum column width of no more than **100** characters.
- Code comments should be used to help describe sections of code that can't speak for themselves.
- Use [Google style](https://google.github.io/styleguide/pyguide.html#s3.8-comments-and-docstrings) docstrings for any classes and functions you add.
- If you're modifying an existing function that does _not_ have docstrings, you don't _have_ to add docstrings to it... but it would be pretty cool if you did ;)
- Imports should be ordered alphabetically.
- Lists of values should be ordered using their [natural sort order](https://en.wikipedia.org/wiki/Natural_sort_order).
- Some files have their methods ordered alphabetically as well (i.e. [`thumb_renderer`](https://github.com/TagStudioDev/TagStudio/blob/main/src/tagstudio/qt/widgets/thumb_renderer.py)). If you're working in a file and notice this, please try and keep to the pattern.
- When writing text for window titles or form titles, use "[Title Case](https://apastyle.apa.org/style-grammar-guidelines/capitalization/title-case)" capitalization. Your IDE may have a command to format this for you automatically, although some may incorrectly capitalize short prepositions. In a pinch you can use a website such as [capitalizemytitle.com](https://capitalizemytitle.com/) to check.
- If it wasn't mentioned above, then stick to [**PEP-8**](https://peps.python.org/pep-0008/)!
## :material-text-box-check: Formatting
### Modules & Implementations
Linting in Python files is mostly taken care of by [ruff](developing.md#ruff) and is based on the rules declared in `pyproject.toml` and `.editorconfig`.
- **Do not** modify legacy library code in the `src/core/library/json/` directory
- Avoid direct calls to `os`
- Use `Pathlib` library instead of `os.path`
- Use `platform.system()` instead of `os.name` and `sys.platform`
- Don't prepend local imports with `tagstudio`, stick to `src`
- Use the `logger` system instead of `print` statements
- Avoid nested f-strings
- Use HTML-like tags inside Qt widgets over stylesheets where possible
Final submitted code must **_NOT:_**
- Contain superfluous or unnecessary logging statements
- Cause unreasonable slowdowns to the program outside of a progress-indicated task
- Cause undesirable visual glitches or artifacts on screen
### Formatter Configs
TagStudio provides an [EditorConfig](https://editorconfig.org/#example-file) file ([`.editorconfig`](https://github.com/TagStudioDev/TagStudio/blob/main/.editorconfig)) along with a [Prettier](https://prettier.io/) config file ([`.prettierrc.toml`](https://github.com/TagStudioDev/TagStudio/blob/main/.prettierrc.toml)) for formatting files other than .py files (Markdown, JSON, YAML, HTML, CSS, etc.). If editing these types of files it's recommended that you use a formatter that supports EditorConfig or has its settings matched to the EditorConfig and Prettier configs. Lastly, please pay attention to the `prettier-ignore` flags in present in some files if you are not using Prettier, as formatting these sections will break formatting used elsewhere such as the [MkDocs site](https://docs.tagstud.io/).
### :material-code-braces-box: Syntax Guidelines
## Qt
- Python files should always follow the [**PEP 8**]() style guide conventions, unless specifically allowed otherwise.
- The most notable exception in our project is the line length limit of **100** characters, which is enforced via ruff.
- Internal Qt methods also use `camelCase` instead of `snake_case`, so overrides of those are commonly seen in the codebase.
- Classes and attributes considered to be "[private](https://docs.python.org/3/tutorial/classes.html#private-variables)" should be prepended with a **single underscore** (e.g. `_internal_method()`).
- If _functionally necessary_, an attribute name may be prepended with a double underscore to trigger "[name mangling](https://docs.python.org/3/reference/expressions.html#private-name-mangling)" (e.g. `__mangled_method()`).
- Classes and methods should contain [Google style](https://google.github.io/styleguide/pyguide.html#s3.8-comments-and-docstrings) docstrings _(this style is enforced via ruff)_.
- Lists and JSON keys should be ordered by their [natural sort order](https://en.wikipedia.org/wiki/Natural_sort_order) unless otherwise specified or readily indicated.
- Some files have some or all of their attributes sorted. Please respect any established patterns like these in files you modify.
As of writing this section, the QT part of the code base is quite unstructured and the View and Controller parts are completely intermixed[^1]. This makes maintenance, fixes and general understanding of the code base quite challenging, because the interesting parts you are looking for are entangled in a bunch of repetitive UI setup code. To address this we are aiming to more strictly separate the view and controller aspects of the QT frontend.
---
The general structure of the QT code base should look like this:
## :material-filter-cog: Modules & Systems
### :fontawesome-brands-python: Python Modules
- Use `Pathlib` library instead of `os.path`
- Use `platform.system()` instead of `os.name` or `sys.platform`
- Avoid nested f-strings
### :material-tag: TagStudio Systems
- Translation keys can be accessed via bracket notation (e.g. `Translations["translation_key"]`) or with the `Translations.format()` method when a value needs to be passed to a placeholder in the translation.
- Avoid passing around the `QtDriver` class where possible. Instead, pass only the necessary components such as the `Library` and `GlobalSettings` instances.
- Use HTML-like tags inside strings over explicit stylesheets where possible. The `Style` class provides several handy methods for formatting text with these.
- Use the `format` method in the stylesheets class to format text headers.
---
## :material-folder-file: Project Layout
### :material-engine: Core <small>Backend</small>
Code that is integral to the core functionality of TagStudio and is UI-independent belongs under the `core/` directory. It's possible that some code that serves the UI can go here, as long as its purpose is to serve _any_ UI and is independent from Qt (e.g. file preview rendering).
```yaml title="Core Backend Directory Example"
core/
├── library/ # The TagStudio library system
│ │
│ ├── alchemy/ # Current SQLite backend w/ SQLAlchemy ORM
│ │
│ ├── json/ # Read-only legacy JSON library system, kept for migrations
│ │
│ ├── query_lang/ # The query parser
│ │
│ │ # Library files that do not involve the SQLAlchemy ORM
│ │ # NOTE: Future Non-SQLAlchemy library files will be placed here
│ ├── refresh.py
│ └── ...
└── utils/ # Utility classes and functions for the core
```
qt
├── controllers
│ ├── widgets
│ │ └── preview_panel_controller.py
│ └── main_window_controller.py
├── views
│ ├── widgets
│ │ └── preview_panel_view.py
│ └── main_window_view.py
├── ts_qt.py
└── mixed.py
```
<!-- prettier-ignore -->
!!! danger "Read-Only Legacy Code"
**Do not modify** legacy library code in the `src/core/library/json/` directory!
In this structure there are the `views` and `controllers` sub-directories. They have the exact same structure and for every `<component>_view.py` there is a `<component>_controller.py` at the same location in the other subdirectory and vice versa.
---
Typically the classes should look like this:
### :material-button-cursor: App UI <small>Frontend</small>
```py
# my_cool_widget_view.py
class MyCoolWidgetView(QWidget):
def __init__(self):
super().__init__()
self.__button = QPushButton()
self.__color_dropdown = QComboBox()
# ...
self.__connect_callbacks()
The application UI code is stored in the `qt/` directory, and contains all code specific to the Qt frontend. Qt widgets are built using an [MVC](https://www.geeksforgeeks.org/software-engineering/mvc-framework-introduction/) pattern, which is described in-depth below:
def __connect_callbacks(self):
self.__button.clicked.connect(self._button_click_callback)
self.__color_dropdown.currentIndexChanged.connect(
lambda idx: self._color_dropdown_callback(self.__color_dropdown.itemData(idx))
)
#### MVC Pattern
- **Models** are usually just objects from the [library](#core-backend).
- The **controller** interacts with these, the _view_ does **not**.
- **Views** are Qt layout classes that **_only_** contain the **layout** and **styling** for one or more widgets.
- Class names are appended with `View`, and filenames appended with `_view`.
- Not to be used standalone, but as the layouts for one or more controllers.
- Some logic is acceptable in these classes if it serves to modularize the layout and allows controllers to influence how the layout is initialized.
- **Reusable Layouts**
- If a layout class is **_not meant_** to act as a view but instead be a generic layout, it belongs in the `views/layouts/` directory and the file should be appended with `_layout`.
- **Styling Classes**
- If a class is purely a source of reusable styling, it belongs in the `views/styles/` directory.
- **Controllers** are complete widgets or base classes for complete widgets.
- Controller files simply take on the name of the final widget they create.
- This also creates naming parity with other widgets that simply extend existing Qt widgets with additional logic.
```yaml title="Qt Frontend Directory Example"
qt/
├── controllers/ # Widgets implementing views or extending other widgets
│ ├── tag_suggest_box.py # Extends from `suggest_box.py`
│ ├── suggest_box.py # Implements `suggest_box_view.py`
│ ├── main_window.py
│ └── ...
├── mixed/ # Files yet to be refactored into controllers and views
├── views/ # Everything related to widget layouts and appearances
│ │
│ ├── layouts/ # Layouts meant to be reused on their own inside other layouts
│ │ ├── flow_layout.py
│ │ └── ...
│ │
│ ├── styles/ # Classes specific for styling
│ │ ├── palette.py
│ │ ├── stylesheets.py
│ │ └── ...
│ │
│ │ # Views (layouts) that get implemented by controllers (widgets)
│ ├── main_window_view.py
│ ├── suggest_box_view.py
│ └── ...
│ # Frontend classes that aren't related to widgets, like managers
├── resource_manager.py
├── cache_manager.py
├── ts_qt.py # Qt Driver
└── ...
def _button_click_callback(self):
raise NotImplementedError()
```
<!-- prettier-ignore -->
!!! warning "Pre-MVC UI Code"
**Do not add** new files to the `qt/mixed/` directory! These files have yet to to be refactored per the current MVC style guidelines and the directory will be **removed** once those migrations have concluded.
```py
# my_cool_widget_controller.py
class MyCoolWidget(MyCoolWidgetView):
def __init__(self):
super().__init__()
Observe the following key aspects of the example below:
def _button_click_callback(self):
print("Button was clicked!")
- The **view** extends from a Qt layout class, and the **controller** simply extends from QWidget.
- The **controller** is just called `MyCoolWidget` instead of `MyCoolWidgetController` as it will be directly used by other code.
- The **view's** widgets that are intended to be controlled by the controller are **public**, while the **controller's** methods are largely **private**.
def _color_dropdown_callback(self, color: Color):
print(f"The selected color is now: {color}")
```
Observe the following key aspects of this example:
- The Controller is just called `MyCoolWidget` instead of `MyCoolWidgetController` as it will be directly used by other code
- The UI elements are in private variables
- This enforces that the controller shouldn't directly access UI elements
- Instead the view should provide a protected API (e.g. `_get_color()`) for things like setting/getting the value of a dropdown, etc.
- Instead of `_get_color()` there could also be a `_color` method marked with `@property`
- The callback methods are already defined as protected methods with NotImplementedErrors
- Defines the interface the callbacks
- Enforces that UI events be handled
<!-- prettier-ignore -->
!!! example "MVC-Separated Widget Example"
!!! tip
A good (non-exhaustive) rule of thumb is: If it requires a non-UI import, then it doesn't belong in the `*_view.py` file.
```py title="views/my_cool_widget_view.py"
class MyCoolWidgetView(QVBoxLayout):
def __init__(self):
super().__init__()
self.button = QPushButton()
self.color_dropdown = QComboBox()
self.addWidget(self.button)
self.addWidget(self.color_dropdown)
```
```py title="controllers/my_cool_widget.py"
class MyCoolWidget(QWidget):
def __init__(self):
super().__init__()
self.setLayout(MyCoolWidgetView())
self._connect_callbacks()
def _connect_callbacks(self):
self.layout().button.clicked.connect(self._button_click_callback)
self.layout().color_dropdown.currentIndexChanged.connect(
lambda idx: self._color_dropdown_callback(self.color_dropdown.itemData(idx)))
def _button_click_callback(self):
print("Button was clicked!")
def _color_dropdown_callback(self, color: Color):
print(f"The selected color is now: {color}")
```
<!-- prettier-ignore -->
!!! tip "Tip for Logic Placement"
A good rule of thumb is: If there's **conditional logic** after a widget has been created, it should probably go in a **controller**.
---
## :material-file-document: Documentation
Documentation contributions include anything inside the `docs/` folder as well as the `README.md`. Documentation inside the `docs/` folder is built and hosted on our static documentation site, [docs.tagstud.io](https://docs.tagstud.io/). Some files such as the `CHANGELOG.md`, `CONTRIBUTING.md`, and `STYLE.md` are symlinked in the repo root from the `docs/` folder.
- Use "[dash-case / kebab-case](https://developer.mozilla.org/en-US/docs/Glossary/Kebab_case)" for file and folder names
- Follow the folder structure pattern
- Don't add images or other media with excessively large file sizes
- Provide alt text for embedded media
- Use "[Title Case](https://apastyle.apa.org/style-grammar-guidelines/capitalization/title-case)" for title capitalization
---
## :material-translate: Translations
Translations are performed on the TagStudio [Weblate project](https://hosted.weblate.org/projects/tagstudio/).
- Do not change text inside placeholders
- Do not change the style tags inside translations
- Use the glossary for term definitions
[^1]: For an explanation of the Model-View-Controller (MVC) Model, checkout this article: [MVC Framework Introduction](https://www.geeksforgeeks.org/mvc-framework-introduction/).
+2 -69
View File
@@ -3,14 +3,11 @@
* SPDX-License-Identifier: GPL-3.0-only
*/
/* Dark Theme */
[data-md-color-scheme="slate"] {
--md-primary-fg-color: rgb(197, 110, 255);
--md-accent-fg-color: rgb(92, 222, 255);
--md-default-bg-color: #060617;
--md-default-fg-color: #eae1ff;
--md-default-fg-color--light: #c2a5ff;
--md-code-fg-color: #d8c7ffcc;
--md-default-fg-color--light: #b898ff;
--md-code-fg-color: #eae1ffcc;
--md-code-hl-string-color: rgb(92, 255, 228);
--md-code-hl-keyword-color: rgb(61, 155, 255);
--md-code-hl-constant-color: rgb(205, 78, 255);
@@ -20,8 +17,6 @@
/* Light Theme */
[data-md-color-scheme="default"] {
--md-primary-fg-color: #7758ff;
--md-accent-fg-color: rgb(22, 166, 255);
--md-default-fg-color--light: #090a26;
}
@@ -78,11 +73,6 @@ td {
padding: 0.5em 1em 0.5em 1em !important;
}
hr {
border-bottom-width: 2px !important;
border-color: #9988ff50 !important;
}
.md-typeset ul li ul {
margin-top: 0;
margin-bottom: 0.1rem;
@@ -129,18 +119,6 @@ h2,
margin-right: -0.8rem;
}
.md-code__nav,
.md-content__button,
.headerlink {
border-radius: 0.2rem;
background: none;
color: #9988ff50 !important;
}
.md-code__nav:hover {
background-color: #9988ff50;
}
figcaption {
margin-top: 0 !important;
}
@@ -171,51 +149,6 @@ td code {
padding: 0 !important;
}
.admonition {
border-radius: 0.4rem !important;
border-width: 2px !important;
}
.highlight > .filename {
border-width: 2px !important;
border: solid;
border-color: #9988ff10 !important;
border-radius: 0.4rem 0.4rem 0 0 !important;
}
code {
border-radius: 0.4rem !important;
border-width: 2px !important;
border: solid;
border-color: #9988ff10;
}
:is(span, ul, li, td, p, a) > code {
border-width: 1px !important;
border-radius: 0.1rem !important;
}
.filename + pre > code {
border-top-width: 0 !important;
border-radius: 0 0 0.4rem 0.4rem !important;
}
.tabbed-labels.tabbed-labels--linked {
padding-left: 0.4rem;
padding-right: 0.4rem;
box-shadow: none !important;
}
.admonition-title {
font-size: 0.7rem;
font-family: "Bai Jamjuree", Roboto, sans-serif;
font-weight: 600 !important;
}
.admonition-title span {
margin-top: 0.05rem !important;
}
/* Matches the palette used by mkdocs-material */
.priority-high {
color: #f1185a;
-27
View File
@@ -29,30 +29,3 @@ h2 {
margin-right: 0;
}
}
.grid.cards > ul > li {
border-radius: 0.4rem !important;
border-width: 2px !important;
border-color: #9988ff20 !important;
}
.md-button--primary {
margin: 1rem;
padding: 0.3rem 1.2rem !important;
border-radius: 0.4rem !important;
font-size: 1rem;
font-family: "Bai Jamjuree", Roboto, sans-serif;
background: linear-gradient(60deg, rgb(205, 78, 255) 0%, rgb(116, 123, 255) 100%);
border-style: solid;
border-width: 0 0 2px 0 !important;
border-color: #ffffff33 !important;
}
.md-button--primary:hover {
background: linear-gradient(60deg, rgb(205, 78, 255) 30%, rgb(116, 123, 255) 100%);
border-color: #ffffff55 !important;
}
.md-button--primary span {
margin-top: 0.1rem !important;
}
+1 -1
View File
@@ -40,7 +40,7 @@ Hover over the field and click the pencil icon. From there, add or edit text in
## Creating Tags
Create a new tag by accessing the "New Tag" option from the Edit menu or by pressing <kbd>Ctrl</kbd>+<kbd>T</kbd>. In the tag creation panel, enter a tag name, optional shorthand name, optional tag aliases, optional parent tags, and an optional color.
Create a new tag by accessing the "New Tag" option from the Edit menu or by pressing <kbd>Ctrl</kbd>+<kbd>N</kbd>. In the tag creation panel, enter a tag name, optional shorthand name, optional tag aliases, optional parent tags, and an optional color.
- The tag **name** is the base name of the tag. **_This does NOT have to be unique!_**
- The tag **shorthand** is a special type of alias that displays in situations where screen space is more valuable, notably with name disambiguation.
+4 -4
View File
@@ -70,16 +70,16 @@ theme:
# Palette toggle for light mode
- media: "(prefers-color-scheme: light)"
scheme: default
primary: custom
accent: custom
primary: purple
accent: purple
toggle:
icon: material/lightbulb
name: Switch to Dark Mode
# Palette toggle for dark mode
- media: "(prefers-color-scheme: dark)"
scheme: slate
primary: custom
accent: custom
primary: purple
accent: purple
toggle:
icon: material/lightbulb-night-outline
name: Switch to System Preference
+1 -1
View File
@@ -9,7 +9,7 @@ build-backend = "hatchling.build"
[project]
name = "TagStudio"
description = "A User-Focused Photo & File Management System."
version = "9.6.2"
version = "9.6.1"
license = "GPL-3.0-only"
readme = "README.md"
requires-python = ">=3.12,<3.14"
+2 -2
View File
@@ -3,8 +3,8 @@
from importlib.metadata import version
VERSION: str = version("tagstudio")
BUILD_TYPE: str = "" # Usually "", "app.nightly", or "app.pre_release"
VERSION: str = version("tagstudio") # Major.Minor.Patch
VERSION_BRANCH: str = "" # Usually "" or "Pre-Release"
COPYRIGHT_YEARS: str = "2021-2026"
COPYRIGHT: str = f"© {COPYRIGHT_YEARS} Travis Abendshien & TagStudio Contributors"
COPYRIGHT_COMPACT: str = f"© {COPYRIGHT_YEARS} Travis Abendshien\n& TagStudio Contributors"
@@ -1728,8 +1728,6 @@ class Library:
session.flush()
if aliases is not None:
for a in aliases:
a.tag_id = tag.id
self.update_aliases(tag, aliases, session)
session.flush()
+1 -2
View File
@@ -105,7 +105,6 @@ class MediaCategories:
# These sets are used either individually or together to form the final sets
# for the MediaCategory(s).
# These sets may be combined and are NOT 1:1 with the final categories.
_ADOBE_ILLUSTRATOR_SET: set[str] = {".ai"}
_ADOBE_PHOTOSHOP_SET: set[str] = {
".pdd",
".psb",
@@ -581,7 +580,7 @@ class MediaCategories:
)
PDF_TYPES = MediaCategory(
media_type=MediaType.PDF,
extensions=_PDF_SET | _ADOBE_ILLUSTRATOR_SET,
extensions=_PDF_SET,
is_iana=False,
name="pdf",
)
+2 -3
View File
@@ -10,8 +10,7 @@ import traceback
import structlog
from tagstudio.core.constants import BUILD_TYPE, VERSION
from tagstudio.qt.translations import Translations
from tagstudio.core.constants import VERSION, VERSION_BRANCH
from tagstudio.qt.ts_qt import QtDriver
logger = structlog.get_logger(__name__)
@@ -60,7 +59,7 @@ def main():
"--version",
action="version",
help="Displays TagStudio version information.",
version=f"TagStudio v{VERSION} {Translations[BUILD_TYPE] if BUILD_TYPE else ''}",
version=f"TagStudio v{VERSION} {VERSION_BRANCH}",
)
args = parser.parse_args()
@@ -0,0 +1,61 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from typing import override
import structlog
from PySide6 import QtCore, QtGui
from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
QLineEdit,
QWidget,
)
from tagstudio.qt.views.stylesheets.stylesheets import (
autofill_scroll_top_focus_style,
autofill_scroll_top_style,
)
logger = structlog.get_logger(__name__)
class AutofillLineEdit(QLineEdit):
return_pressed = Signal()
shift_return_pressed = Signal()
shift_holding = Signal(bool)
def __init__(self, popup: QWidget) -> None:
super().__init__()
self._popup = popup
@override
def focusOutEvent(self, arg__1: QtGui.QFocusEvent) -> None:
self._popup.setStyleSheet(autofill_scroll_top_style("container"))
return super().focusOutEvent(arg__1)
@override
def focusInEvent(self, arg__1: QtGui.QFocusEvent) -> None:
self._popup.setStyleSheet(autofill_scroll_top_focus_style("container"))
return super().focusInEvent(arg__1)
@override
def keyPressEvent(self, arg__1: QtGui.QKeyEvent) -> None:
if arg__1.key() == QtCore.Qt.Key.Key_Shift:
self.shift_holding.emit(True) # noqa: FBT003
if arg__1.key() == QtCore.Qt.Key.Key_Escape:
self.setText("")
self.clearFocus()
elif arg__1.key() == QtCore.Qt.Key.Key_Enter or arg__1.key() == QtCore.Qt.Key.Key_Return:
if arg__1.modifiers() and QtCore.Qt.KeyboardModifier.ShiftModifier:
self.shift_return_pressed.emit()
else:
self.return_pressed.emit()
return super().keyPressEvent(arg__1)
@override
def keyReleaseEvent(self, arg__1: QtGui.QKeyEvent) -> None:
if arg__1.key() == QtCore.Qt.Key.Key_Shift:
self.shift_holding.emit(False) # noqa: FBT003
return super().keyReleaseEvent(arg__1)
@@ -0,0 +1,157 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import typing
from typing import override
from warnings import catch_warnings
import structlog
from PySide6.QtGui import QAction, Qt
from PySide6.QtWidgets import QWidget
from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
from tagstudio.core.library.alchemy.library import Library
from tagstudio.qt.controllers.edit_field_template_modal import EditFieldTemplateModal
from tagstudio.qt.controllers.field_template_widget_controller import FieldTemplateWidget
from tagstudio.qt.controllers.suggest_box import SuggestBox
from tagstudio.qt.controllers.underlined_widget import UnderlinedWidget
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget
if typing.TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
logger = structlog.get_logger(__name__)
class FieldSuggestBox(SuggestBox[BaseFieldTemplate]):
def __init__(self, driver: "QtDriver", placeholder_text: str = ""):
super().__init__(driver, placeholder_text)
self._lib = self._driver.lib
# Context Menu Actions
edit_field_on_add_action = QAction(Translations["settings.edit_field_on_add"], self)
edit_field_on_add_action.setCheckable(True)
self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.addAction(edit_field_on_add_action)
self.layout().search_field.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.layout().search_field.addAction(edit_field_on_add_action)
edit_field_on_add_action.setChecked(self._driver.settings.edit_field_on_add)
edit_field_on_add_action.triggered.connect(
lambda checked: self.toggle_edit_on_field_add(checked)
)
def toggle_edit_on_field_add(self, checked: bool) -> None:
"""Toggle the setting for opening the edit window after adding a field."""
self._driver.settings.edit_field_on_add = checked
self._driver.settings.save()
@override
def _on_item_create(self) -> None:
"""Creates a new field template and adds it to the currently selected entries.
Optionally opens up an edit panel after creation and before adding to entries.
Populates name field using current search query.
"""
# NOTE: Unlike tags, creating new field templates will ALWAYS spawn an edit window
# since the user needs to decide what type of field it should be before it's created.
query: str = self.layout().search_field.text()
panel = EditFieldTemplateModal()
modal = PanelModal(
panel,
Translations["field_template.new"],
Translations["field_template.new"],
is_savable=True,
)
if query.strip():
panel.name_field.setText(query)
modal.saved.connect(lambda: self._create_item_from_modal(panel))
modal.show()
@override
def _on_item_edit(self, item: BaseFieldTemplate) -> None:
panel: EditFieldTemplateModal = EditFieldTemplateModal(item)
modal: PanelModal = PanelModal(
panel, item.name, Translations["field_template.edit"], is_savable=True
)
modal.saved.connect(lambda: self._edit_item(panel))
modal.show()
@override
def _on_item_chosen(self, item: BaseFieldTemplate) -> None:
self.item_chosen.emit(item)
self.done.emit()
@override
def _search_items(self, query: str) -> tuple[list[BaseFieldTemplate], list[BaseFieldTemplate]]:
if query != "":
return self._lib.search_field_templates(name=query, limit=0), []
else:
return ([], [])
@override
def _set_item_widget(self, item: BaseFieldTemplate | None, index: int) -> None:
"""Set the field template of a field template widget at a specific index."""
underlined_widget: UnderlinedWidget = self._get_item_widget(index, self._lib)
field_template_widget = underlined_widget.widget
assert isinstance(field_template_widget, FieldTemplateWidget)
field_template_widget.has_remove = False
field_template_widget.set_field_template(item)
underlined_widget.setHidden(item is None)
if item is None:
return
# TODO: Add tabbing to different items, and use underline to indicate which will be added
underlined_widget.toggle_underline(index != 0)
# Disconnect previous callbacks
with catch_warnings(record=True):
field_template_widget.on_edit.disconnect()
field_template_widget.on_remove.disconnect()
field_template_widget.on_click.disconnect()
# Connect callbacks
field_template_widget.on_edit.connect(lambda item_=item: self._on_item_edit(item_))
field_template_widget.on_click.connect(
lambda checked=False, item_=item: self._on_item_chosen(item_)
)
@override
def _create_item_from_modal(self, edit_item_panel: PanelWidget) -> None:
if isinstance(edit_item_panel, EditFieldTemplateModal):
template: BaseFieldTemplate = edit_item_panel.build_field_template()
self._lib.add_field_template(template)
self._on_item_chosen(template)
self._clear_search_query()
edit_item_panel.hide()
self._on_search_query_changed(self.layout().search_field.text())
@override
def _edit_item(self, edit_item_panel: PanelWidget) -> None:
if not isinstance(edit_item_panel, EditFieldTemplateModal):
return
self._lib.update_field_template(
edit_item_panel.old_field_type, edit_item_panel.build_field_template()
)
self._update_items(self.layout().search_field.text())
@override
def _get_item_widget(self, index: int, library: Library | None) -> UnderlinedWidget:
"""Gets the item widget at a specific index."""
# Create any new item widgets needed up to the given index
if self.layout().content_layout.count() <= index:
while self.layout().content_layout.count() <= index:
field_template_widget = FieldTemplateWidget()
widget = UnderlinedWidget(field_template_widget)
widget.setHidden(True)
self.layout().content_layout.addWidget(widget)
widget_: QWidget = self.layout().content_layout.itemAt(index).widget()
assert isinstance(widget_, UnderlinedWidget)
return widget_
@@ -3,63 +3,279 @@
import typing
from datetime import datetime as dt
from enum import IntEnum
from functools import partial
from pathlib import Path
from typing import override
from warnings import catch_warnings
from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
from tagstudio.core.library.alchemy.library import Library
import structlog
from PySide6 import QtCore
from PySide6.QtGui import QShortcut
from PySide6.QtWidgets import QWidget
from tagstudio.core.library.alchemy.fields import (
BaseField,
BaseFieldTemplate,
DatetimeField,
DatetimeFieldTemplate,
TextField,
TextFieldTemplate,
)
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
from tagstudio.qt.controllers.field_template_search_panel_controller import FieldTemplateSearchModal
from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal
from tagstudio.qt.translations import Translations
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.controllers.edit_text_controller import EditText
from tagstudio.qt.mixed.datetime_picker import DatetimePicker
from tagstudio.qt.mixed.field_containers import FieldContainers
from tagstudio.qt.mixed.file_attributes import FileAttributeData
from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations
from tagstudio.qt.views.panel_modal import PanelModal
from tagstudio.qt.views.preview_panel_view import PreviewPanelView
if typing.TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
logger = structlog.get_logger(__name__)
class PreviewPanel(PreviewPanelView):
def __init__(self, library: Library, driver: "QtDriver") -> None:
super().__init__(library, driver)
self.__add_field_modal = FieldTemplateSearchModal(self.lib, is_field_template_chooser=True)
self.__add_tag_modal = TagSearchModal(
self.lib, title=Translations["tag.add.plural"], is_tag_chooser=True
class _ItemMode(IntEnum):
TAG = 1
FIELD = 2
class PreviewPanel(QWidget):
def __init__(self, driver: "QtDriver") -> None:
super().__init__()
self._driver = driver
self._lib = self._driver.lib
self._selected: list[int]
self._current_stats: FileAttributeData | None = None
self._open_tag_search_action = QShortcut(
QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
QtCore.Qt.Key.Key_T,
),
self,
)
self._open_field_search_action = QShortcut(
QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
QtCore.Qt.Key.Key_L,
),
self,
)
self.__add_tag_modal.tsp.set_driver(driver)
self._thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning)
@typing.override
def _add_field_button_callback(self) -> None:
self.__add_field_modal.show()
self.setLayout(PreviewPanelView(driver=driver, pixel_ratio=self.devicePixelRatio()))
self._set_item_mode(None)
self._connect_callbacks()
@typing.override
def _add_tag_button_callback(self) -> None:
self.__add_tag_modal.show()
def _connect_callbacks(self) -> None:
# Tag Search
self.layout().add_tag_button.clicked.connect(lambda: self._set_item_mode(_ItemMode.TAG))
self._open_tag_search_action.activated.connect(self._open_tag_search_callback)
self.layout().tag_search_box.done.connect(self._tag_added_callback)
self.layout().containers.on_tags_update.connect(self._update_added_callback)
# Field Search
self.layout().add_field_button.clicked.connect(lambda: self._set_item_mode(_ItemMode.FIELD))
self._open_field_search_action.activated.connect(self._open_field_search_callback)
self.layout().field_search_box.done.connect(self._field_added_callback)
# Previews
self.layout().preview_thumb.stats_updated.connect(self._thumb_stats_updated_callback)
self.layout().preview_thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning)
def _set_item_mode(self, mode: _ItemMode | None):
def hide_and_disable_buttons():
self.layout().add_tag_button.setHidden(True)
self.layout().add_tag_button.setEnabled(False)
self.layout().add_field_button.setHidden(True)
self.layout().add_field_button.setEnabled(False)
def restore_buttons():
self.layout().add_tag_button.setHidden(False)
self.layout().add_tag_button.setEnabled(True)
self.layout().add_field_button.setHidden(False)
self.layout().add_field_button.setEnabled(True)
if mode == _ItemMode.TAG:
self.layout().tag_search_box.added = self.layout().containers.tags
self.layout().field_search_box.hide_and_reset()
self.layout().tag_search_box.setHidden(False)
hide_and_disable_buttons()
elif mode == _ItemMode.FIELD:
self.layout().tag_search_box.hide_and_reset()
self.layout().field_search_box.setHidden(False)
hide_and_disable_buttons()
else:
self.layout().tag_search_box.hide_and_reset()
self.layout().field_search_box.hide_and_reset()
restore_buttons()
def _open_tag_search_callback(self) -> None:
self.layout().add_tag_button.setFocus()
self.layout().add_tag_button.click()
def _open_field_search_callback(self) -> None:
self.layout().add_field_button.setFocus()
self.layout().add_field_button.click()
def _tag_added_callback(self):
self._set_item_mode(None)
self.layout().add_tag_button.setFocus()
def _field_added_callback(self):
self._set_item_mode(None)
self.layout().add_field_button.setFocus()
def _update_added_callback(self):
self.layout().tag_search_box.added = self.layout().containers.tags
def _thumb_stats_updated_callback(self, filepath: Path, stats: FileAttributeData) -> None:
if len(self._selected) != 1:
return
if filepath != self.layout().preview_thumb.current_file:
return
if self._current_stats is None:
self._current_stats = FileAttributeData()
if stats.width is not None:
self._current_stats.width = stats.width
if stats.height is not None:
self._current_stats.height = stats.height
if stats.duration is not None:
self._current_stats.duration = stats.duration
self.layout().file_attrs.update_stats(filepath, self._current_stats)
@typing.override
def _set_selection_callback(self) -> None:
with catch_warnings(record=True):
self.__add_field_modal.search_panel.field_template_chosen.disconnect()
self.__add_tag_modal.tsp.item_chosen.disconnect()
self.layout().field_search_box.item_chosen.disconnect()
self.layout().tag_search_box.item_chosen.disconnect()
self.__add_field_modal.search_panel.field_template_chosen.connect(
self._add_field_to_selected
)
self.__add_tag_modal.tsp.item_chosen.connect(self._add_tag_to_selected)
self.layout().field_search_box.item_chosen.connect(self._add_field_to_selected)
self.layout().tag_search_box.item_chosen.connect(self._add_tag_to_selected)
def _add_field_to_selected(self, template: BaseFieldTemplate) -> None:
self._containers.add_field_to_selected(template)
self.layout().containers.add_field_to_selected(template)
# TODO: Allow editing of fields across multiple entries at once.
if len(self._selected) == 1:
self._containers.update_from_entry(self._selected[0])
if self._driver.settings.edit_field_on_add:
entry = unwrap(self._lib.get_entry_full(self._selected[0]))
entry_field = None
if isinstance(template, TextFieldTemplate):
entry_field = entry.text_fields[-1]
elif isinstance(template, DatetimeFieldTemplate):
entry_field = entry.datetime_fields[-1]
if entry_field is not None:
self._edit_field(entry.id, entry_field)
self.layout().containers.update_from_entry(self._selected[0])
def _edit_field(self, entry_id: int, field: BaseField) -> None:
# TODO: A lot of this code is similar to or straight up shared with FieldContainers.
# It's possible to reuse it later, after a FieldContainers refactor.
field_name_key: str = FIELD_TYPE_KEYS.get(field.class_name, "field_type.unknown")
if type(field) is TextField:
edit_modal = PanelModal(
EditText(field.name, field.value, field.is_multiline),
window_title=f"{Translations['field.edit']} ({Translations[field_name_key]})",
is_savable=True,
inline_title=False,
)
edit_modal.saved_data.connect(
partial(self.layout().containers.update_text_field_callback, field, entry_id)
)
edit_modal.show()
elif type(field) is DatetimeField:
edit_modal = PanelModal(
DatetimePicker(self._driver, field.name, field.value or dt.now()),
window_title=f"{Translations['field.edit']} ({Translations[field_name_key]})",
is_savable=True,
inline_title=False,
)
edit_modal.saved_data.connect(
partial(self.layout().containers.update_datetime_field_callback, field, entry_id)
)
edit_modal.show()
def _add_tag_to_selected(self, tag_id: int) -> None:
self._containers.add_tags_to_selected(tag_id)
self.layout().containers.add_tags_to_selected(tag_id)
if len(self._selected) == 1:
self._containers.update_from_entry(self._selected[0])
self.layout().containers.update_from_entry(self._selected[0])
def _toggle_ffmpeg_warning(self, enable_warning: bool = True) -> None:
if enable_warning and (not FfmpegStatus.which() or not FfprobeStatus.which()):
self._ffmpeg_warning_widget.show()
self.layout().warning_banner.show()
return
self._ffmpeg_warning_widget.hide()
self.layout().warning_banner.hide()
def set_selection(self, selected: list[int], update_preview: bool = True) -> None:
"""Render the panel widgets with the newest data from the Library.
Args:
selected (list[int]): List of the IDs of the selected entries.
update_preview (bool): Should the file preview be updated?
(Only works with one or more items selected)
"""
self._selected = selected
self._set_item_mode(None)
try:
# No Items Selected
if len(selected) == 0:
self.layout().preview_thumb.hide_preview()
self._current_stats = None
self.layout().file_attrs.update_stats()
self.layout().file_attrs.update_date_label()
self.layout().containers.hide_containers()
self.layout().add_tag_button.setEnabled(False)
self.layout().add_field_button.setEnabled(False)
# One Item Selected
elif len(selected) == 1:
entry_id = selected[0]
entry: Entry = unwrap(self._lib.get_entry(entry_id))
filepath: Path = unwrap(self._lib.library_dir) / entry.path
if filepath != self.layout().preview_thumb.current_file:
self._current_stats = None
if update_preview:
stats: FileAttributeData = self.layout().preview_thumb.display_file(filepath)
self._current_stats = stats
self.layout().file_attrs.update_stats(filepath, stats)
self.layout().file_attrs.update_date_label(filepath)
self.layout().containers.update_from_entry(entry_id)
self._set_selection_callback()
# Multiple Selected Items
elif len(selected) > 1:
# items: list[Entry] = [self.lib.get_entry_full(x) for x in self.driver.selected]
self.layout().preview_thumb.hide_preview() # TODO: Render mixed selection
self._current_stats = None
self.layout().file_attrs.update_multi_selection(len(selected))
self.layout().file_attrs.update_date_label()
self.layout().containers.hide_containers() # TODO: Allow for mixed editing
self._set_selection_callback()
except Exception as e:
logger.error("[Preview Panel] Error updating selection", error=e)
def stop_media_playback(self) -> None:
"""Stop any media playback in the preview panel."""
self.layout().preview_thumb.media_player.stop()
@property
def containers(self) -> FieldContainers:
return self.layout().containers
@override
def layout(self) -> PreviewPanelView:
return super().layout() # pyright: ignore[reportReturnType]
@@ -0,0 +1,27 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import typing
from typing import override
import structlog
from PySide6 import QtCore, QtGui
from PySide6.QtWidgets import QPushButton
if typing.TYPE_CHECKING:
pass
logger = structlog.get_logger(__name__)
class ReturnButton(QPushButton):
def __init__(self, *args, **kwargs) -> None: # pyright: ignore
super().__init__(*args, **kwargs)
@override
def keyPressEvent(self, arg__1: QtGui.QKeyEvent) -> None:
if self.hasFocus() and arg__1.key() in {QtCore.Qt.Key.Key_Enter, QtCore.Qt.Key.Key_Return}:
self.click()
super().keyPressEvent(arg__1)
@@ -52,9 +52,9 @@ class SearchPanel[T](PanelWidget):
super().__init__()
self.view = view
self.is_chooser = is_chooser
self._layout = QVBoxLayout(self)
self._layout.setContentsMargins(0, 0, 0, 0)
self._layout.addWidget(self.view)
self.setLayout(QVBoxLayout(self))
self.layout().setContentsMargins(0, 0, 0, 0)
self.layout().addWidget(self.view)
self.view.connect_callbacks(self)
self._driver: QtDriver | None = None
self.exclude: list[int] = exclude or []
+229
View File
@@ -0,0 +1,229 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from typing import TYPE_CHECKING, Any, override
import structlog
from PySide6.QtCore import Signal
from PySide6.QtGui import QShowEvent
from PySide6.QtWidgets import QGraphicsOpacityEffect, QWidget
from tagstudio.core.library.alchemy.library import Library
from tagstudio.qt.controllers.autofill_line_edit import QtCore, QtGui
from tagstudio.qt.controllers.underlined_widget import UnderlinedWidget
from tagstudio.qt.views.panel_modal import PanelWidget
from tagstudio.qt.views.stylesheets.stylesheets import (
autofill_line_edit_style,
autofill_line_edit_top_style,
)
from tagstudio.qt.views.suggest_box_view import SuggestBoxView
logger = structlog.get_logger(__name__)
if TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
def _item_id(item: object) -> int:
item_id: Any = getattr(item, "id") # noqa: B009 # pyright: ignore[reportExplicitAny]
if isinstance(item_id, int):
return item_id
else:
raise AttributeError()
def _item_name(item: object) -> str:
item_name: Any = getattr(item, "name") # noqa: B009 # pyright: ignore[reportExplicitAny]
if isinstance(item_name, str):
return item_name
else:
raise AttributeError()
class SuggestBox[T](QWidget):
item_chosen = Signal(object)
done = Signal()
def __init__(self, driver: "QtDriver", placeholder_text: str = "") -> None:
super().__init__()
self._driver = driver
self._limit = 5
self._is_shift_held = False
self._search_results: list[T] = []
self.added: list[int] = []
self.excluded: list[int] = []
self.setLayout(SuggestBoxView(placeholder_text))
self._connect_callbacks()
def set_placeholder_text(self, text: str) -> None:
self.layout().search_field.setPlaceholderText(text)
def hide_and_reset(self) -> None:
self.hide()
self.layout().search_field.setDisabled(True)
self._on_shift_held(held=False)
def _connect_callbacks(self) -> None:
self.layout().search_field.textChanged.connect(self._on_search_query_changed)
self.layout().search_field.editingFinished.connect(self._editing_finished_callback)
self.layout().search_field.return_pressed.connect(
lambda: self._on_search_query_submitted(self.layout().search_field.text())
)
self.layout().search_field.shift_return_pressed.connect(
lambda: self._on_search_query_submitted(
self.layout().search_field.text(), always_create=True
)
)
self.layout().search_field.shift_holding.connect(lambda held: self._on_shift_held(held))
def _on_shift_held(self, held: bool) -> None:
if held:
self._is_shift_held = True
opacity_effect = QGraphicsOpacityEffect(self)
opacity_effect.setOpacity(0.3)
if self.layout().content_layout.count() > 0:
underlined_widget = self.layout().content_layout.itemAt(0).widget()
assert isinstance(underlined_widget, UnderlinedWidget)
underlined_widget.widget.setGraphicsEffect(opacity_effect)
else:
self._is_shift_held = False
if self.layout().content_layout.count() > 0:
underlined_widget = self.layout().content_layout.itemAt(0).widget()
assert isinstance(underlined_widget, UnderlinedWidget)
underlined_widget.widget.setGraphicsEffect(None) # pyright: ignore[reportArgumentType]
def _clear_search_query(self) -> None:
self.layout().search_field.setText("")
def _get_item_widget(self, index: int, library: Library) -> Any: # pyright: ignore
raise NotImplementedError()
def _on_search_query_changed(self, query: str) -> None:
self._update_items(query)
def _on_search_query_submitted(self, query: str, always_create: bool = False) -> None:
# Focus search field if no query
logger.info("Query submitted")
if not query:
self.done.emit()
self.hide_and_reset()
return
elif not self.isHidden():
self.layout().search_field.setFocus()
# Create and add item if no search results
if (len(self._search_results) <= 0) or always_create:
self._on_item_create()
else:
self._on_item_chosen(self._search_results[0])
self._clear_search_query()
self._update_items()
def _on_item_create(self) -> None:
raise NotImplementedError()
def _on_item_edit(self, item: T) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
def _on_item_chosen(self, item: T) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
def _is_excluded(self, item: T) -> bool:
return _item_id(item) in self.excluded
def _update_items(self, query: str | None = None) -> None:
"""Update the item list given a search query."""
logger.info("[SearchPanel] Updating items", limit=self._limit)
# Get results for the search query
query_lower = "" if not query else query.lower()
search_results: tuple[list[T], list[T]] = self._search_items(query_lower)
# Sort and prioritize the results
direct_results = list({item for item in search_results[0] if not self._is_excluded(item)})
direct_results.sort(key=lambda item: _item_name(item).lower())
ancestor_results = list({item for item in search_results[1] if not self._is_excluded(item)})
ancestor_results.sort(key=lambda item: _item_name(item).lower())
raw_results = list(direct_results + ancestor_results)
priority_results: set[T] = set()
if query and query.strip():
for raw_item in raw_results:
if _item_name(raw_item).lower().startswith(query_lower):
priority_results.add(raw_item)
all_results: list[T] = sorted(list(priority_results), key=lambda i: len(_item_name(i))) + [
item for item in raw_results if item not in priority_results
]
# Target items already added to a selection and move them to the end of the list
already_added: list[T] = [i for i in all_results if _item_id(i) in self.added]
for item in already_added:
if item in all_results:
all_results.remove(item)
all_results = all_results + already_added
if self._limit > 0:
all_results = all_results[: self._limit]
self._search_results = all_results
logger.info("[SearchPanel] Search results", results=self._search_results)
for i in range(0, self._limit):
item: T | None = all_results[i] if i < len(all_results) else None
self._set_item_widget(item=item, index=i)
if self.layout().content_layout.isEmpty():
self.layout().scroll_area.setHidden(True)
self.layout().content_layout.setContentsMargins(0, 0, 0, 0)
self.layout().search_field.setStyleSheet(autofill_line_edit_style())
else:
self.layout().scroll_area.setHidden(False)
self.layout().content_layout.setContentsMargins(6, 6, 6, 6)
self.layout().search_field.setStyleSheet(autofill_line_edit_top_style())
def _search_items(self, query: str) -> tuple[list[T], list[T]]: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
def _set_item_widget(self, item: T | None, index: int) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
def _editing_finished_callback(self) -> None:
if self.layout().search_field.text() == "":
self.done.emit()
self.hide_and_reset()
def _create_item_from_modal(self, edit_item_panel: PanelWidget) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
def _edit_item(self, edit_item_panel: PanelWidget) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
@override
def showEvent(self, event: QShowEvent) -> None:
self._update_items()
self._on_shift_held(held=False)
self.layout().search_field.setDisabled(False)
self._clear_search_query()
return super().showEvent(event)
@override
def layout(self) -> SuggestBoxView:
return super().layout() # pyright: ignore[reportReturnType]
@override
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:
# When Escape is pressed, focus back on the search box.
if event.key() in {
QtCore.Qt.Key.Key_Escape,
QtCore.Qt.Key.Key_Enter,
QtCore.Qt.Key.Key_Return,
}:
self.hide_and_reset()
@@ -0,0 +1,184 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import typing
from typing import override
from warnings import catch_warnings
import structlog
from PySide6.QtGui import QAction, Qt
from PySide6.QtWidgets import QGraphicsOpacityEffect, QWidget
from tagstudio.core.library.alchemy.enums import BrowsingState
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Tag
from tagstudio.qt.controllers.suggest_box import SuggestBox
from tagstudio.qt.controllers.underlined_widget import UnderlinedWidget
from tagstudio.qt.mixed.build_tag import BuildTagPanel
from tagstudio.qt.mixed.tag_widget import TagWidget
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget
if typing.TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
logger = structlog.get_logger(__name__)
class TagSuggestBox(SuggestBox[Tag]):
def __init__(self, driver: "QtDriver", placeholder_text: str = "") -> None:
super().__init__(driver, placeholder_text)
self._driver = driver
self._lib = self._driver.lib
# Context Menu Actions
edit_tag_on_create_action = QAction(Translations["settings.edit_tag_on_create"], self)
edit_tag_on_create_action.setCheckable(True)
self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.addAction(edit_tag_on_create_action)
self.layout().search_field.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.layout().search_field.addAction(edit_tag_on_create_action)
edit_tag_on_create_action.setChecked(self._driver.settings.edit_tag_on_create)
edit_tag_on_create_action.triggered.connect(
lambda checked: self.toggle_edit_on_tag_create(checked)
)
def _search_for_tag_callback(self, tag_id: int) -> 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)
)
def toggle_edit_on_tag_create(self, checked: bool) -> None:
"""Toggle the setting for opening the edit window after creating a tag."""
self._driver.settings.edit_tag_on_create = checked
self._driver.settings.save()
@override
def _on_item_create(self) -> None:
"""Creates a new tag and adds it to the currently selected entries.
Optionally opens up an edit panel after creation and before adding to entries.
Populates name field using current search query.
"""
query: str = self.layout().search_field.text()
if self._driver.settings.edit_tag_on_create:
panel: BuildTagPanel = BuildTagPanel(self._lib)
modal: PanelModal = PanelModal(
panel, Translations["tag.new"], Translations["tag.new"], is_savable=True
)
if query.strip():
panel.name_field.setText(query)
modal.saved.connect(lambda: self._create_item_from_modal(panel))
modal.show()
else:
tag = Tag(name=query)
self._lib.add_tag(tag)
self._on_item_chosen(tag)
self._clear_search_query()
@override
def _on_item_edit(self, item: Tag) -> None:
edit_tag_panel: BuildTagPanel = BuildTagPanel(self._lib, tag=item)
edit_tag_modal: PanelModal = PanelModal(
edit_tag_panel,
self._lib.tag_display_name(item),
Translations["tag.edit"],
is_savable=True,
)
edit_tag_modal.saved.connect(lambda: self._edit_item(edit_tag_panel))
edit_tag_modal.show()
@override
def _on_item_chosen(self, item: Tag) -> None:
self.item_chosen.emit(item.id)
self.done.emit()
@override
def _search_items(self, query: str) -> tuple[list[Tag], list[Tag]]:
if query != "":
return self._lib.search_tags(name=query, limit=0)
else:
return ([], [])
@override
def _set_item_widget(self, item: Tag | None, index: int) -> None:
"""Set the tag of a tag widget at a specific index."""
underlined_widget: UnderlinedWidget = self._get_item_widget(index, self._lib)
tag_widget = underlined_widget.widget
assert isinstance(tag_widget, TagWidget)
tag_widget.has_remove = False
tag_widget.set_tag(item)
underlined_widget.setHidden(item is None)
opacity_effect = QGraphicsOpacityEffect(self)
opacity_effect.setOpacity(0.3)
if item and item.id in self.added:
tag_widget.setGraphicsEffect(opacity_effect)
else:
tag_widget.setGraphicsEffect(None) # pyright: ignore[reportArgumentType]
if item is None:
return
# TODO: Add tabbing to different items, and use underline to indicate which will be added
underlined_widget.toggle_underline(index != 0)
# Disconnect previous callbacks
with catch_warnings(record=True):
tag_widget.on_edit.disconnect()
tag_widget.bg_button.clicked.disconnect()
tag_widget.search_for_tag_action.triggered.disconnect()
# Connect callbacks
tag_widget.on_edit.connect(lambda edit_tag=item: self._on_item_edit(edit_tag))
tag_widget.bg_button.clicked.connect(
lambda checked=False, tag=item: self._on_item_chosen(tag)
)
tag_widget.search_for_tag_action.triggered.connect(
lambda checked=False, tag_id=item.id: self._search_for_tag_callback(tag_id)
)
tag_widget.search_for_tag_action.setEnabled(True)
@override
def _create_item_from_modal(self, edit_item_panel: PanelWidget) -> None:
if isinstance(edit_item_panel, BuildTagPanel):
tag: Tag = edit_item_panel.build_tag()
self._lib.add_tag(
tag, parent_ids=edit_item_panel.parent_ids, aliases=edit_item_panel.aliases
)
self._on_item_chosen(tag)
self._clear_search_query()
edit_item_panel.hide()
self._on_search_query_changed(self.layout().search_field.text())
@override
def _edit_item(self, edit_item_panel: PanelWidget) -> None:
if not isinstance(edit_item_panel, BuildTagPanel):
return
self._lib.update_tag(
tag=edit_item_panel.build_tag(),
parent_ids=edit_item_panel.parent_ids,
aliases=edit_item_panel.aliases,
)
self._update_items(self.layout().search_field.text())
@override
def _get_item_widget(self, index: int, library: Library | None) -> UnderlinedWidget:
"""Gets the item widget at a specific index."""
# Create any new item widgets needed up to the given index
if self.layout().content_layout.count() <= index:
while self.layout().content_layout.count() <= index:
tag_widget = TagWidget(tag=None, has_edit=True, has_remove=True, library=library)
tag_widget.on_remove.connect(self._update_items)
widget = UnderlinedWidget(tag_widget)
widget.setHidden(True)
self.layout().content_layout.addWidget(widget)
widget_: QWidget = self.layout().content_layout.itemAt(index).widget()
assert isinstance(widget_, UnderlinedWidget)
return widget_
@@ -0,0 +1,26 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from typing import override
from PySide6.QtWidgets import QWidget
from tagstudio.qt.views.underlined_widget_view import UnderlinedWidgetView
class UnderlinedWidget(QWidget):
def __init__(self, widget: QWidget) -> None:
super().__init__()
self.setLayout(UnderlinedWidgetView(widget))
def toggle_underline(self, is_hidden: bool) -> None:
self.layout().underline.setHidden(is_hidden)
@property
def widget(self) -> QWidget:
return self.layout().itemAt(0).widget()
@override
def layout(self) -> UnderlinedWidgetView:
return super().layout() # pyright: ignore[reportReturnType]
+2
View File
@@ -74,6 +74,8 @@ class GlobalSettings(BaseModel):
infinite_scroll: bool = Field(default=True)
show_filepath: ShowFilepathOption = Field(default=ShowFilepathOption.DEFAULT)
tag_click_action: TagClickActionOption = Field(default=TagClickActionOption.DEFAULT)
edit_tag_on_create: bool = Field(default=False)
edit_field_on_add: bool = Field(default=True)
theme: Theme = Field(default=Theme.SYSTEM)
splash: Splash = Field(default=Splash.DEFAULT)
windows_start_command: bool = Field(default=False)
+4 -8
View File
@@ -19,12 +19,12 @@ from PySide6.QtWidgets import (
)
from tagstudio.core.constants import (
BUILD_TYPE,
COPYRIGHT,
DISCORD_URL,
DOCS_URL,
GITHUB_REPO_URL,
VERSION,
VERSION_BRANCH,
)
from tagstudio.core.ts_core import TagStudioCore
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
@@ -42,12 +42,7 @@ from tagstudio.qt.views.stylesheets.stylesheets import form_content_style, heade
class AboutModal(QWidget):
"""Modal window showing information about the TagStudio application."""
VERSION_STR: str = " ".join(
[
f"{Translations['about.version']}",
f"{VERSION} {(' (' + Translations[BUILD_TYPE] + ')') if BUILD_TYPE else ''}",
]
)
VERSION_STR: str = f"{Translations['about.version']} {VERSION} {(' (' + VERSION_BRANCH + ')') if VERSION_BRANCH else ''}" # noqa: E501
def __init__(self, config_path: Path | str):
super().__init__()
@@ -201,7 +196,8 @@ class AboutModal(QWidget):
# ripgrep Status
ripgrep_path_title = QLabel("ripgrep") # NOTE: Don't localize
ripgrep_path_content = ClickableLabel(f"{ripgrep_status}")
ripgrep_path_content = ClickableLabel()
ripgrep_path_content.setText(f"{ripgrep_status}") # TODO: Pass in constructor after #1386
ripgrep_location = RipgrepStatus.which()
if ripgrep_location:
ripgrep_path_content.clicked.connect(
+56 -50
View File
@@ -9,8 +9,7 @@ from functools import partial
from warnings import catch_warnings
import structlog
from PySide6.QtCore import Qt
from PySide6.QtGui import QGuiApplication
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
@@ -21,7 +20,6 @@ from PySide6.QtWidgets import (
QWidget,
)
from tagstudio.core.enums import Theme
from tagstudio.core.library.alchemy.fields import (
BaseField,
BaseFieldTemplate,
@@ -38,6 +36,7 @@ from tagstudio.qt.mixed.field_widget import FieldContainer
from tagstudio.qt.mixed.text_field import TextContainerWidget
from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations
from tagstudio.qt.views.panel_modal import PanelModal
from tagstudio.qt.views.stylesheets.stylesheets import inset_container_style
if typing.TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
@@ -48,6 +47,8 @@ logger = structlog.get_logger(__name__)
class FieldContainers(QWidget):
"""Widget for the tag and field containers displayed inside the Preview Panel."""
on_tags_update = Signal()
def __init__(self, library: Library, driver: "QtDriver") -> None:
super().__init__()
@@ -58,18 +59,12 @@ class FieldContainers(QWidget):
self.common_fields: list = []
self.mixed_fields: list = []
self.cached_entries: list[Entry] = []
self.containers: list[FieldContainer] = []
self.panel_bg_color = (
Theme.COLOR_BG_DARK.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_BG_LIGHT.value
)
self._containers: list[FieldContainer] = []
self.scroll_layout = QVBoxLayout()
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_layout.setContentsMargins(3, 3, 3, 3)
self.scroll_layout.setSpacing(0)
self.scroll_layout.setSpacing(6)
scroll_container: QWidget = QWidget()
scroll_container.setObjectName("entryScrollContainer")
@@ -92,9 +87,7 @@ class FieldContainers(QWidget):
# background and NOT the scroll container background, so that the
# rounded corners are maintained when scrolling. I was unable to
# find the right trick to only select that particular element.
self.scroll_area.setStyleSheet(
f"QWidget#entryScrollContainer{{background:{self.panel_bg_color};border-radius:6px;}}"
)
self.scroll_area.setStyleSheet(inset_container_style("entryScrollContainer"))
self.scroll_area.setWidget(scroll_container)
root_layout = QHBoxLayout(self)
@@ -137,8 +130,8 @@ class FieldContainers(QWidget):
self.write_field_container(index, field, is_mixed=False)
# Hide leftover container(s)
if len(self.containers) > container_len:
for i, c in enumerate(self.containers):
if len(self._containers) > container_len:
for i, c in enumerate(self._containers):
if i > (container_len - 1):
c.setHidden(True)
@@ -157,7 +150,7 @@ class FieldContainers(QWidget):
def hide_containers(self) -> None:
"""Hide all field and tag containers."""
for c in self.containers:
for c in self._containers:
c.setHidden(True)
def get_tag_categories(self, tags: set[Tag]) -> dict[Tag | None, set[Tag]]:
@@ -251,6 +244,27 @@ class FieldContainers(QWidget):
)
self.driver.add_tags_to_selected_callback(tag_ids)
def update_text_field_callback(
self, field: TextField, entry_id: int, content: dict[str, str | bool]
) -> None:
"""Callback called when a text field has updated data."""
self._update_text_field(
field, str(content["name"]), str(content["value"]), bool(content["is_multiline"])
)
self.update_from_entry(entry_id)
def update_datetime_field_callback(
self, field: DatetimeField, entry_id: int, content: dict[str, str]
) -> None:
"""Callback called when a datetime field has updated data."""
self.update_datetime_field(field, str(content["name"]), str(content["value"]))
self.update_from_entry(entry_id)
def remove_field_callback(self, field: BaseField, entry_id: int) -> None:
"""Callback called when a field needs to be removed from an entry."""
self._remove_field(field)
self.update_from_entry(entry_id)
def write_field_container(self, index: int, field: BaseField, is_mixed: bool = False) -> None:
"""Update/Create data for a field FieldContainer.
@@ -261,27 +275,6 @@ class FieldContainers(QWidget):
If True, field is not present in all selected items.
"""
def update_text_field_callback(
field: TextField, entry_id: int, content: dict[str, str | bool]
) -> None:
"""Callback called when a text field has updated data."""
self._update_text_field(
field, str(content["name"]), str(content["value"]), bool(content["is_multiline"])
)
self.update_from_entry(entry_id)
def update_datetime_field_callback(
field: DatetimeField, entry_id: int, content: dict[str, str]
) -> None:
"""Callback called when a datetime field has updated data."""
self.update_datetime_field(field, str(content["name"]), str(content["value"]))
self.update_from_entry(entry_id)
def remove_field_callback(field: BaseField, entry_id: int) -> None:
"""Callback called when a field needs to be removed from an entry."""
self._remove_field(field)
self.update_from_entry(entry_id)
def write_text_container(
container: FieldContainer, field: TextField, title: str, is_mixed: bool
):
@@ -305,14 +298,14 @@ class FieldContainers(QWidget):
inline_title=False,
)
edit_modal.saved_data.connect(
partial(update_text_field_callback, field, self.top_entry_id)
partial(self.update_text_field_callback, field, self.top_entry_id)
)
container.set_edit_callback(edit_modal.show)
container.set_remove_callback(
lambda: self.remove_message_box(
prompt=self.remove_field_prompt(title),
callback=partial(remove_field_callback, field, self.top_entry_id),
callback=partial(self.remove_field_callback, field, self.top_entry_id),
)
)
@@ -343,14 +336,14 @@ class FieldContainers(QWidget):
inline_title=False,
)
edit_modal.saved_data.connect(
partial(update_datetime_field_callback, field, self.top_entry_id)
partial(self.update_datetime_field_callback, field, self.top_entry_id)
)
container.set_edit_callback(edit_modal.show)
container.set_remove_callback(
lambda: self.remove_message_box(
prompt=self.remove_field_prompt(field.name),
callback=partial(remove_field_callback, field, self.top_entry_id),
callback=partial(self.remove_field_callback, field, self.top_entry_id),
)
)
@@ -361,7 +354,7 @@ class FieldContainers(QWidget):
container.set_remove_callback(
lambda: self.remove_message_box(
prompt=self.remove_field_prompt(field.name),
callback=partial(remove_field_callback, field, self.top_entry_id),
callback=partial(self.remove_field_callback, field, self.top_entry_id),
)
)
@@ -373,12 +366,12 @@ class FieldContainers(QWidget):
)
# Create new containers if necessary
if len(self.containers) < (index + 1):
if len(self._containers) < (index + 1):
container = FieldContainer()
self.containers.append(container)
self._containers.append(container)
self.scroll_layout.addWidget(container)
else:
container = self.containers[index]
container = self._containers[index]
# Set field title
field_name_key: str = FIELD_TYPE_KEYS.get(field.class_name, "field_type.unknown")
@@ -407,12 +400,12 @@ class FieldContainers(QWidget):
If True, field is not present in all selected items.
"""
logger.info("[FieldContainers][write_tag_container]", index=index)
if len(self.containers) < (index + 1):
if len(self._containers) < (index + 1):
container = FieldContainer()
self.containers.append(container)
self._containers.append(container)
self.scroll_layout.addWidget(container)
else:
container = self.containers[index]
container = self._containers[index]
container.set_title(Translations["entries.tags"] if not category_tag else category_tag.name)
@@ -430,7 +423,10 @@ class FieldContainers(QWidget):
inner_widget.set_tags(tags)
inner_widget.on_update.connect(
lambda: self.update_from_entry(self.cached_entries[0].id, update_badges=True)
lambda: (
self.update_from_entry(self.cached_entries[0].id, update_badges=True),
self.on_tags_update.emit(),
)
)
else:
text = f"<i>{Translations['field.mixed_data']}</i>"
@@ -480,3 +476,13 @@ class FieldContainers(QWidget):
result = remove_mb.exec_()
if result == QMessageBox.ButtonRole.ActionRole.value:
callback()
@property
def tags(self) -> list[int]:
if len(self.cached_entries) <= 0:
return []
entry = self.cached_entries[0]
entry_ = self.lib.get_entry_full(entry.id, with_fields=False)
if not entry_:
return []
return [tag.id for tag in entry_.tags]
+6 -4
View File
@@ -34,7 +34,9 @@ class FieldContainer(QWidget):
self.copy_callback: Callable[[], None] | None = None
self.edit_callback: Callable[[], None] | None = None
self.remove_callback: Callable[[], None] | None = None
button_size = 24
button_size = 22
icon_margins = 4
icon_size = button_size - icon_margins
self.root_layout = QVBoxLayout(self)
self.root_layout.setObjectName("baseLayout")
@@ -42,7 +44,7 @@ class FieldContainer(QWidget):
self.inner_layout = QVBoxLayout()
self.inner_layout.setObjectName("innerLayout")
self.inner_layout.setContentsMargins(6, 0, 6, 6)
self.inner_layout.setContentsMargins(3, 0, 0, 3)
self.inner_layout.setSpacing(0)
self.field_container = QWidget()
self.field_container.setObjectName("fieldContainer")
@@ -82,7 +84,7 @@ class FieldContainer(QWidget):
self.edit_button.setMaximumSize(button_size, button_size)
self.edit_button.setFlat(True)
self.edit_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(FieldContainer.edit_icon)))
self.edit_button.setIconSize(QSize(20, 20))
self.edit_button.setIconSize(QSize(icon_size, icon_size))
self.edit_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.title_layout.addWidget(self.edit_button)
self.edit_button.setHidden(True)
@@ -93,7 +95,7 @@ class FieldContainer(QWidget):
self.remove_button.setMaximumSize(button_size, button_size)
self.remove_button.setFlat(True)
self.remove_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(FieldContainer.trash_icon)))
self.remove_button.setIconSize(QSize(20, 20))
self.remove_button.setIconSize(QSize(icon_size, icon_size))
self.remove_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.title_layout.addWidget(self.remove_button)
self.remove_button.setHidden(True)
+1 -1
View File
@@ -500,7 +500,7 @@ class ItemThumb(FlowWidget):
):
selected = self.driver._selected
if len(selected) == 1 and entry_id in selected:
self.driver.main_window.preview_panel.field_containers_widget.update_toggled_tag(
self.driver.main_window.preview_panel.containers.update_toggled_tag(
tag_id, toggle_value
)
+18
View File
@@ -216,6 +216,20 @@ class SettingsPanel(PanelWidget):
Translations["settings.tag_click_action.label"], self.tag_click_action_combobox
)
# Open Edit Window When Creating a Tag
self.edit_tag_on_create_checkbox = QCheckBox()
self.edit_tag_on_create_checkbox.setChecked(self.driver.settings.edit_tag_on_create)
form_layout.addRow(
Translations["settings.edit_tag_on_create"], self.edit_tag_on_create_checkbox
)
# Open Edit Window When Adding a Field
self.edit_field_on_add_checkbox = QCheckBox()
self.edit_field_on_add_checkbox.setChecked(self.driver.settings.edit_field_on_add)
form_layout.addRow(
Translations["settings.edit_field_on_add"], self.edit_field_on_add_checkbox
)
# TODO: Implement Library Settings
def __build_library_settings(self): # pyright: ignore[reportUnusedFunction]
form_layout = QFormLayout(self.library_settings_container)
@@ -366,6 +380,8 @@ class SettingsPanel(PanelWidget):
"show_filepath": self.filepath_combobox.currentData(),
"theme": self.theme_combobox.currentData(),
"tag_click_action": self.tag_click_action_combobox.currentData(),
"edit_tag_on_create": self.edit_tag_on_create_checkbox.isChecked(),
"edit_field_on_add": self.edit_field_on_add_checkbox.isChecked(),
"date_format": self.dateformat_combobox.currentData(),
"hour_format": self.hourformat_checkbox.isChecked(),
"zero_padding": self.zeropadding_checkbox.isChecked(),
@@ -388,6 +404,8 @@ class SettingsPanel(PanelWidget):
driver.settings.show_filepath = settings["show_filepath"]
driver.settings.theme = settings["theme"]
driver.settings.tag_click_action = settings["tag_click_action"]
driver.settings.edit_tag_on_create = settings["edit_tag_on_create"]
driver.settings.edit_field_on_add = settings["edit_field_on_add"]
driver.settings.date_format = settings["date_format"]
driver.settings.hour_format = settings["hour_format"]
driver.settings.zero_padding = settings["zero_padding"]
+2 -2
View File
@@ -125,7 +125,7 @@ class TagColorManager(QWidget):
self.setup_color_groups(),
()
if len(self.driver.selected) < 1
else self.driver.main_window.preview_panel.field_containers_widget.update_from_entry( # noqa: E501
else self.driver.main_window.preview_panel.containers.update_from_entry( # noqa: E501
self.driver.selected[0], update_badges=False
),
)
@@ -142,7 +142,7 @@ class TagColorManager(QWidget):
self.setup_color_groups(),
()
if len(self.driver.selected) < 1
else self.driver.main_window.preview_panel.field_containers_widget.update_from_entry( # noqa: E501
else self.driver.main_window.preview_panel.containers.update_from_entry( # noqa: E501
self.driver.selected[0], update_badges=False
),
),
+6 -6
View File
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, override
import structlog
from PySide6.QtCore import QEvent, Qt, Signal
from PySide6.QtGui import QAction, QColor, QEnterEvent, QFontMetrics
from PySide6.QtWidgets import QHBoxLayout, QLineEdit, QPushButton, QVBoxLayout, QWidget
from PySide6.QtWidgets import QHBoxLayout, QLineEdit, QPushButton, QSizePolicy, QVBoxLayout, QWidget
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.library.alchemy.models import Tag
@@ -148,23 +148,23 @@ class TagWidget(QWidget):
self.inner_layout = QHBoxLayout()
self.inner_layout.setObjectName("innerLayout")
self.inner_layout.setContentsMargins(0, 0, 0, 0)
self.inner_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self._delete_button = QPushButton(self)
self._delete_button.setFlat(True)
self._delete_button.setText("")
self._delete_button.setHidden(True)
self._delete_button.setMinimumSize(22, 22)
self._delete_button.setMaximumSize(22, 22)
self._delete_button.setFixedSize(22, 22)
self._delete_button.clicked.connect(self.on_remove.emit)
self._delete_button.setHidden(True)
self.inner_layout.addWidget(self._delete_button)
self.inner_layout.addStretch(1)
self.bg_button.setLayout(self.inner_layout)
self.bg_button.setMinimumSize(44, 22)
self.bg_button.setMinimumHeight(22)
self.bg_button.setMaximumHeight(22)
self.bg_button.setFixedHeight(22)
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
self.base_layout.addWidget(self.bg_button)
+22
View File
@@ -7,12 +7,34 @@ from enum import IntEnum
from typing import Any
import structlog
from PySide6.QtGui import QPalette
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.utils.singleton import Singleton
logger = structlog.get_logger(__name__)
class Palette(metaclass=Singleton):
_palette: QPalette | None = None
_accent: str | None = None
@staticmethod
def set_palette(palette: QPalette) -> None:
Palette._palette = palette
@staticmethod
def accent() -> str:
if not Palette._palette:
logger.error("[Style] No QPalette set!")
return get_ui_color(ColorType.PRIMARY, UiColor.BLUE)
if not Palette._accent:
Palette._accent = (
f"rgba{QPalette.color(Palette._palette, QPalette.ColorRole.Accent).toTuple()}"
)
return Palette._accent
class ColorType(IntEnum):
PRIMARY = 0
TEXT = 1
+4 -5
View File
@@ -1295,12 +1295,11 @@ class ThumbRenderer(QObject):
return im
@staticmethod
def _pdf_thumb(filepath: Path, size: int, ext: str) -> Image.Image | None:
"""Render a thumbnail for a PDF or Adobe Illustator file.
def _pdf_thumb(filepath: Path, size: int) -> Image.Image | None:
"""Render a thumbnail for a PDF file.
filepath (Path): The path of the file.
size (int): The size of the icon.
ext (str): The file extension.
"""
im: Image.Image | None = None
@@ -1322,7 +1321,7 @@ class ThumbRenderer(QObject):
else:
page_size *= size / page_size.width()
# Enlarge image for anti-aliasing
scale_factor = 2.5 if ext in {".pdf"} else 1
scale_factor = 2.5
page_size *= scale_factor
# Render image with no anti-aliasing for speed
render_options: QPdfDocumentRenderOptions = QPdfDocumentRenderOptions()
@@ -1911,7 +1910,7 @@ class ThumbRenderer(QObject):
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.PDF_TYPES, mime_fallback=True
):
image = self._pdf_thumb(_filepath, adj_size, ext)
image = self._pdf_thumb(_filepath, adj_size)
# Archives =====================================================
elif MediaCategories.is_ext_in_category(ext, MediaCategories.ARCHIVE_TYPES):
image = self._archive_thumb(_filepath, ext)
+20 -11
View File
@@ -40,7 +40,7 @@ from PySide6.QtGui import (
from PySide6.QtWidgets import QApplication, QFileDialog, QMessageBox, QPushButton, QScrollArea
import tagstudio.qt.resources_rc # noqa: F401 # pyright: ignore[reportUnusedImport]
from tagstudio.core.constants import BUILD_TYPE, TAG_ARCHIVED, TAG_FAVORITE, VERSION
from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE, VERSION, VERSION_BRANCH
from tagstudio.core.driver import DriverMixin
from tagstudio.core.enums import AppCacheItems, MacroID, ShowFilepathOption
from tagstudio.core.library.alchemy.enums import BrowsingState, SortingModeEnum
@@ -51,8 +51,6 @@ from tagstudio.core.library.refresh import RefreshTracker
from tagstudio.core.media_types import MediaCategories
from tagstudio.core.query_lang.util import ParsingError
from tagstudio.core.ts_core import TagStudioCore
# This import has side-effect of importing PySide resources
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
from tagstudio.core.utils.module_status import ModuleStatus
from tagstudio.core.utils.ripgrep_status import RipgrepStatus
@@ -77,7 +75,7 @@ from tagstudio.qt.mixed.migration_modal import JsonMigrationModal
from tagstudio.qt.mixed.progress_bar import ProgressWidget
from tagstudio.qt.mixed.settings_panel import SettingsPanel
from tagstudio.qt.mixed.tag_color_manager import TagColorManager
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
from tagstudio.qt.models.palette import ColorType, Palette, UiColor, get_ui_color
from tagstudio.qt.platform_strings import trash_term
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.translations import Translations
@@ -203,7 +201,7 @@ class QtDriver(DriverMixin, QObject):
self.scrollbar_pos = 0
self.spacing = None
self.branch: str = (" (" + Translations[BUILD_TYPE] + ")") if BUILD_TYPE else ""
self.branch: str = (" (" + VERSION_BRANCH + ")") if VERSION_BRANCH else ""
self.base_title: str = f"TagStudio Alpha {VERSION}{self.branch}"
# self.title_text: str = self.base_title
# self.buffer = {}
@@ -274,7 +272,7 @@ class QtDriver(DriverMixin, QObject):
dir = QFileDialog.getExistingDirectory(
parent=None,
caption=Translations["window.title.open_create_library"],
dir="/",
dir=str(Path.home()),
options=QFileDialog.Option.ShowDirsOnly,
)
if dir not in (None, ""):
@@ -303,19 +301,28 @@ class QtDriver(DriverMixin, QObject):
elif self.settings.theme == Theme.LIGHT:
self.app.styleHints().setColorScheme(Qt.ColorScheme.Light)
pal: QPalette = self.app.palette()
# BUG: Changing the palette in any way here seems to affect the accent colors of certain
# widgets, like QLineEdit focused borders and QComboBox highlighted items and borders.
# Need to figure out the cause of this.
if (
platform.system() == "Darwin" or platform.system() == "Windows"
) and QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark:
pal: QPalette = self.app.palette()
pal.setColor(QPalette.ColorGroup.Normal, QPalette.ColorRole.Window, QColor("#1e1e1e"))
pal.setColor(QPalette.ColorGroup.Normal, QPalette.ColorRole.Button, QColor("#1e1e1e"))
pal.setColor(
QPalette.ColorGroup.Inactive, QPalette.ColorRole.ToolTipBase, QColor("#1e1e1e")
)
pal.setColor(
QPalette.ColorGroup.Inactive, QPalette.ColorRole.ToolTipText, QColor("#FFFFFF")
)
pal.setColor(QPalette.ColorGroup.Inactive, QPalette.ColorRole.Window, QColor("#232323"))
pal.setColor(QPalette.ColorGroup.Inactive, QPalette.ColorRole.Button, QColor("#232323"))
pal.setColor(
QPalette.ColorGroup.Inactive, QPalette.ColorRole.ButtonText, QColor("#666666")
)
self.app.setPalette(pal)
Palette.set_palette(pal)
self.app.setPalette(pal)
# Handle OS signals
self.setup_signals()
@@ -627,8 +634,10 @@ class QtDriver(DriverMixin, QObject):
if path_result.success and path_result.library_path:
self.open_library(path_result.library_path)
self.check_for_update()
self.main_window.search_field.setFocus()
self.app.exec()
self.check_for_update()
self.shutdown()
def show_error_message(self, error_name: str, error_desc: str | None = None):
@@ -954,7 +963,7 @@ class QtDriver(DriverMixin, QObject):
for i, tup in enumerate(pending):
e_id, f = tup
if (origin_path == f) or (not origin_path):
self.main_window.preview_panel.preview_thumb.media_player.stop()
self.main_window.preview_panel.stop_media_playback()
msg = Translations.format(
"status.deleting_file", i=i, count=len(pending), path=f
@@ -2,8 +2,8 @@
# SPDX-License-Identifier: GPL-3.0-only
from PySide6.QtCore import Signal
from PySide6.QtGui import QColor
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout, QWidget
from PySide6.QtGui import QColor, Qt
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QSizePolicy, QVBoxLayout, QWidget
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.qt.models.palette import ColorType, get_tag_color
@@ -12,8 +12,10 @@ from tagstudio.qt.views.stylesheets.stylesheets import (
get_tag_highlight_color,
get_tag_text_color,
list_button_style,
tag_remove_button_style,
)
# TODO: These colors and logic should be moved to and reworked in the stylesheets file.
primary_color: QColor = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
border_color: QColor = get_tag_border_color(primary_color)
highlight_color: QColor = get_tag_highlight_color(primary_color)
@@ -30,7 +32,6 @@ class FieldTemplateWidgetView(QWidget):
self.__root_layout = QVBoxLayout(self)
self.__root_layout.setObjectName("root_layout")
self.__root_layout.setContentsMargins(0, 0, 0, 0)
# Background button
@@ -47,6 +48,8 @@ class FieldTemplateWidgetView(QWidget):
self.__inner_layout.setObjectName("inner_layout")
self._bg_button.setLayout(self.__inner_layout)
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
self.__inner_layout.setContentsMargins(0, 0, 0, 0)
# Remove button
@@ -56,9 +59,12 @@ class FieldTemplateWidgetView(QWidget):
self._delete_button.setHidden(True)
self._delete_button.setMinimumSize(22, 22)
self._delete_button.setMaximumSize(22, 22)
self._delete_button.setStyleSheet(
tag_remove_button_style(primary_color, text_color, border_color, highlight_color)
)
self.__inner_layout.addWidget(self._delete_button)
self.__inner_layout.addStretch(1)
self.__inner_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.__connect_callbacks()
+14 -6
View File
@@ -5,10 +5,11 @@
import typing
from collections.abc import Callable
from pathlib import Path
from typing import override
import structlog
from PIL import Image, ImageQt
from PySide6 import QtCore
from PySide6 import QtCore, QtGui
from PySide6.QtCore import QMetaObject, QSize, QStringListModel, Qt
from PySide6.QtGui import QAction, QPixmap
from PySide6.QtWidgets import (
@@ -182,10 +183,10 @@ class MainMenuBar(QMenuBar):
self.new_tag_action.setShortcut(
QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
QtCore.Qt.Key.Key_T,
QtCore.Qt.Key.Key_N,
)
)
self.new_tag_action.setToolTip("Ctrl+T")
self.new_tag_action.setToolTip("Ctrl+N")
self.new_tag_action.setEnabled(False)
self.edit_menu.addAction(self.new_tag_action)
@@ -220,9 +221,8 @@ class MainMenuBar(QMenuBar):
# Clear Selection
self.clear_select_action = QAction(Translations["select.clear"], self)
self.clear_select_action.setShortcut(QtCore.Qt.Key.Key_Escape)
self.clear_select_action.setToolTip("Esc")
self.clear_select_action.setEnabled(False)
self.clear_select_action.setToolTip("Esc")
self.edit_menu.addAction(self.clear_select_action)
# Copy Fields
@@ -700,10 +700,12 @@ class MainWindow(QMainWindow):
self.content_splitter.addWidget(self.entry_list_container)
def setup_preview_panel(self, driver: "QtDriver"):
self.preview_panel = PreviewPanel(driver.lib, driver)
self.preview_panel = PreviewPanel(driver)
self.content_splitter.addWidget(self.preview_panel)
def setup_status_bar(self):
# BUG: Clicking the status bar does not count as losing focus on other widgets
# (for example, the "Add Tag" line edit). Can this be fixed?
self.status_bar = QStatusBar(self)
self.status_bar.setObjectName("status_bar")
status_bar_size_policy = QSizePolicy(
@@ -746,3 +748,9 @@ class MainWindow(QMainWindow):
def show_hidden_entries(self) -> bool:
"""Whether to show entries tagged with hidden tags."""
return self.show_hidden_entries_checkbox.isChecked()
@override
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:
if event.key() == QtCore.Qt.Key.Key_Escape:
self.menu_bar.clear_select_action.trigger()
return super().keyPressEvent(event)
+51 -173
View File
@@ -3,29 +3,20 @@
import math
import traceback
import typing
from pathlib import Path
import structlog
from PySide6.QtCore import Qt
from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QPushButton,
QSplitter,
QVBoxLayout,
QWidget,
)
from PySide6.QtWidgets import QHBoxLayout, QLabel, QSplitter, QVBoxLayout, QWidget
from tagstudio.core.constants import FFMPEG_HELP_URL
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.controllers.field_suggest_box import FieldSuggestBox
from tagstudio.qt.controllers.preview_thumb_controller import PreviewThumb
from tagstudio.qt.controllers.return_button import ReturnButton
from tagstudio.qt.controllers.tag_suggest_box import TagSuggestBox
from tagstudio.qt.mixed.field_containers import FieldContainers
from tagstudio.qt.mixed.file_attributes import FileAttributeData, FileAttributes
from tagstudio.qt.mixed.file_attributes import FileAttributes
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.stylesheets.stylesheets import button_style, preview_warning_style
@@ -36,33 +27,36 @@ if typing.TYPE_CHECKING:
logger = structlog.get_logger(__name__)
class PreviewPanelView(QWidget):
lib: Library
_selected: list[int]
def __init__(self, library: Library, driver: "QtDriver") -> None:
class PreviewPanelView(QVBoxLayout):
def __init__(self, driver: "QtDriver", pixel_ratio: float) -> None:
super().__init__()
self.lib = library
self.setContentsMargins(0, 0, 0, 0)
self.setSpacing(6)
rm = ResourceManager()
self._thumb = PreviewThumb(self.lib, driver)
self._file_attrs = FileAttributes(self.lib, driver)
self._containers = FieldContainers(
self.lib, driver
) # TODO: this should be name mangled, but is still needed on the controller side atm
self.__current_stats: FileAttributeData | None = None
def ph_text(key: str) -> str:
return " ".join([Translations[key], Translations["home.search.how_to_exit"]])
# Search/Create Boxes
self.field_search_box = FieldSuggestBox(driver, ph_text("home.search_or_create_fields"))
self.tag_search_box = TagSuggestBox(driver, ph_text("home.search_or_create_tags"))
self.preview_thumb = PreviewThumb(driver.lib, driver)
self.file_attrs = FileAttributes(driver.lib, driver)
self.containers = FieldContainers(driver.lib, driver)
# Visual Preview
preview_section = QWidget()
preview_layout = QVBoxLayout(preview_section)
preview_layout.setContentsMargins(0, 0, 0, 0)
preview_layout.setSpacing(6)
self._ffmpeg_warning_widget = QWidget()
self._ffmpeg_warning_widget.setObjectName("ffmpeg_widget")
ffmpeg_warning_layout = QHBoxLayout(self._ffmpeg_warning_widget)
# Warning Banner (Missing FFmpeg, etc.)
self.warning_banner = QWidget()
self.warning_banner.setObjectName("ffmpeg_widget")
ffmpeg_warning_layout = QHBoxLayout(self.warning_banner)
ffmpeg_warning_layout.setContentsMargins(3, 3, 3, 3)
self._ffmpeg_warning_widget.setStyleSheet(preview_warning_style())
self.warning_banner.setStyleSheet(preview_warning_style())
ffmpeg_warning_label = QLabel(
Translations.format(
"preview.missing_module.multimedia",
@@ -75,16 +69,16 @@ class PreviewPanelView(QWidget):
)
warning_icon = QLabel()
warning_icon_pixmap = rm.alert.scaled(
math.floor(20 * self.devicePixelRatio()), math.floor(20 * self.devicePixelRatio())
math.floor(20 * pixel_ratio), math.floor(20 * pixel_ratio)
)
warning_icon_pixmap.setDevicePixelRatio(self.devicePixelRatio())
warning_icon_pixmap.setDevicePixelRatio(pixel_ratio)
warning_icon.setPixmap(warning_icon_pixmap)
ffmpeg_warning_layout.addWidget(warning_icon)
ffmpeg_warning_layout.addWidget(ffmpeg_warning_label)
ffmpeg_warning_layout.setStretch(1, 1)
self.warning_banner.hide()
self._ffmpeg_warning_widget.hide()
# File Information
info_section = QWidget()
info_layout = QVBoxLayout(info_section)
info_layout.setContentsMargins(0, 0, 0, 0)
@@ -94,154 +88,38 @@ class PreviewPanelView(QWidget):
splitter.setOrientation(Qt.Orientation.Vertical)
splitter.setHandleWidth(12)
# Add Tag/Field Buttons
add_buttons_container = QWidget()
add_buttons_layout = QHBoxLayout(add_buttons_container)
add_buttons_layout.setContentsMargins(0, 0, 0, 0)
add_buttons_layout.setSpacing(6)
self.__add_tag_button = QPushButton(Translations["tag.add"])
self.__add_tag_button.setEnabled(False)
self.__add_tag_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.__add_tag_button.setMinimumHeight(28)
self.__add_tag_button.setStyleSheet(button_style())
self.add_tag_button = ReturnButton(Translations["tag.add"])
self.add_tag_button.setEnabled(False)
self.add_tag_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.add_tag_button.setMinimumHeight(30)
self.add_tag_button.setStyleSheet(button_style())
self.__add_field_button = QPushButton(Translations["field.add"])
self.__add_field_button.setEnabled(False)
self.__add_field_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.__add_field_button.setMinimumHeight(28)
self.__add_field_button.setStyleSheet(button_style())
self.add_field_button = ReturnButton(Translations["field.add"])
self.add_field_button.setEnabled(False)
self.add_field_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.add_field_button.setMinimumHeight(30)
self.add_field_button.setStyleSheet(button_style())
add_buttons_layout.addWidget(self.__add_tag_button)
add_buttons_layout.addWidget(self.__add_field_button)
add_buttons_layout.addWidget(self.add_tag_button)
add_buttons_layout.addWidget(self.add_field_button)
add_buttons_layout.addWidget(self.tag_search_box)
add_buttons_layout.addWidget(self.field_search_box)
preview_layout.addWidget(self._thumb)
info_layout.addWidget(self._ffmpeg_warning_widget)
info_layout.addWidget(self._file_attrs)
info_layout.addWidget(self._containers)
# Finalize Layout
preview_layout.addWidget(self.preview_thumb)
info_layout.addWidget(self.warning_banner)
info_layout.addWidget(self.file_attrs)
info_layout.addWidget(self.containers)
splitter.addWidget(preview_section)
splitter.addWidget(info_section)
splitter.setStretchFactor(1, 2)
root_layout = QVBoxLayout(self)
root_layout.setContentsMargins(0, 0, 0, 0)
root_layout.addWidget(splitter)
root_layout.addWidget(add_buttons_container)
self.__connect_callbacks()
def __connect_callbacks(self) -> None:
self.__add_field_button.clicked.connect(self._add_field_button_callback)
self.__add_tag_button.clicked.connect(self._add_tag_button_callback)
self._thumb.stats_updated.connect(self.__thumb_stats_updated_callback)
def _add_field_button_callback(self) -> None:
raise NotImplementedError()
def _add_tag_button_callback(self) -> None:
raise NotImplementedError()
def __thumb_stats_updated_callback(self, filepath: Path, stats: FileAttributeData) -> None:
if len(self._selected) != 1:
return
if filepath != self._thumb.current_file:
return
if self.__current_stats is None:
self.__current_stats = FileAttributeData()
if stats.width is not None:
self.__current_stats.width = stats.width
if stats.height is not None:
self.__current_stats.height = stats.height
if stats.duration is not None:
self.__current_stats.duration = stats.duration
self._file_attrs.update_stats(filepath, self.__current_stats)
def _set_selection_callback(self) -> None:
raise NotImplementedError()
def set_selection(self, selected: list[int], update_preview: bool = True) -> None:
"""Render the panel widgets with the newest data from the Library.
Args:
selected (list[int]): List of the IDs of the selected entries.
update_preview (bool): Should the file preview be updated?
(Only works with one or more items selected)
"""
self._selected = selected
try:
# No Items Selected
if len(selected) == 0:
self._thumb.hide_preview()
self.__current_stats = None
self._file_attrs.update_stats()
self._file_attrs.update_date_label()
self._containers.hide_containers()
self.add_buttons_enabled = False
# One Item Selected
elif len(selected) == 1:
entry_id = selected[0]
entry: Entry = unwrap(self.lib.get_entry(entry_id))
filepath: Path = unwrap(self.lib.library_dir) / entry.path
if filepath != self._thumb.current_file:
self.__current_stats = None
if update_preview:
stats: FileAttributeData = self._thumb.display_file(filepath)
self.__current_stats = stats
self._file_attrs.update_stats(filepath, stats)
self._file_attrs.update_date_label(filepath)
self._containers.update_from_entry(entry_id)
self._set_selection_callback()
self.add_buttons_enabled = True
# Multiple Selected Items
elif len(selected) > 1:
# items: list[Entry] = [self.lib.get_entry_full(x) for x in self.driver.selected]
self._thumb.hide_preview() # TODO: Render mixed selection
self.__current_stats = None
self._file_attrs.update_multi_selection(len(selected))
self._file_attrs.update_date_label()
self._containers.hide_containers() # TODO: Allow for mixed editing
self._set_selection_callback()
self.add_buttons_enabled = True
except Exception as e:
logger.error("[Preview Panel] Error updating selection", error=e)
traceback.print_exc()
@property
def add_buttons_enabled(self) -> bool: # needed for the tests
field = self.__add_field_button.isEnabled()
tag = self.__add_tag_button.isEnabled()
assert field == tag
return field
@add_buttons_enabled.setter
def add_buttons_enabled(self, enabled: bool) -> None:
self.__add_field_button.setEnabled(enabled)
self.__add_tag_button.setEnabled(enabled)
@property
def _file_attributes_widget(self) -> FileAttributes: # needed for the tests
"""Getter for the file attributes widget."""
return self._file_attrs
@property
def field_containers_widget(self) -> FieldContainers: # needed for the tests
"""Getter for the field containers widget."""
return self._containers
@property
def preview_thumb(self) -> PreviewThumb:
return self._thumb
self.addWidget(splitter)
self.addWidget(add_buttons_container)
@@ -160,6 +160,7 @@ class PreviewThumbView(QWidget):
layout.addWidget(widget)
layout.setAlignment(widget, Qt.AlignmentFlag.AlignCenter)
layout.setContentsMargins(0, 0, 0, 0)
widget.setCursor(Qt.CursorShape.PointingHandCursor)
page.setLayout(layout)
def __update_image_size(self, size: tuple[int, int]) -> None:
+2 -7
View File
@@ -10,7 +10,7 @@ from PySide6.QtCore import QRect, Qt
from PySide6.QtGui import QColor, QFont, QPainter, QPen, QPixmap
from PySide6.QtWidgets import QSplashScreen, QWidget
from tagstudio.core.constants import BUILD_TYPE, COPYRIGHT, COPYRIGHT_COMPACT, VERSION
from tagstudio.core.constants import COPYRIGHT, COPYRIGHT_COMPACT, VERSION, VERSION_BRANCH
from tagstudio.qt.global_settings import Splash
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.translations import Translations
@@ -21,12 +21,7 @@ logger = structlog.get_logger(__name__)
class SplashScreen:
"""The custom splash screen widget for TagStudio."""
VERSION_STR: str = " ".join(
[
f"{Translations['about.version']}",
f"{VERSION} {(' (' + Translations[BUILD_TYPE] + ')') if BUILD_TYPE else ''}",
]
)
VERSION_STR: str = f"{Translations['about.version']} {VERSION} {(' (' + VERSION_BRANCH + ')') if VERSION_BRANCH else ''}" # noqa: E501
DEFAULT_SPLASH = Splash.AURORA
def __init__(
+166 -11
View File
@@ -8,7 +8,7 @@ from PySide6.QtGui import QColor, QGuiApplication
from tagstudio.core.enums import Theme
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.library.alchemy.models import Tag
from tagstudio.qt.models.palette import ColorType, UiColor, get_tag_color, get_ui_color
from tagstudio.qt.models.palette import ColorType, Palette, UiColor, get_tag_color, get_ui_color
# TODO: There's plenty of good opportunities here to consolidate similar styles.
# Work should be done to more closely use Qt's theming systems rather than override them.
@@ -53,18 +53,29 @@ def button_style() -> str:
border-radius: 6px;
font-weight: 500;
text-align: center;
padding: 0px 12px;
}}
QPushButton::hover{{
background-color: {Theme.COLOR_HOVER.value};
border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)};
border-style: solid;
border-width: 2px;
border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)};
padding: 0px 8px;
}}
QPushButton::pressed{{
background-color: {Theme.COLOR_PRESSED.value};
border-color: {get_ui_color(ColorType.LIGHT_ACCENT, UiColor.THEME_DARK)};
outline: none;
background-color: palette(light);
border-style: solid;
border-width: 2px;
border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)};
padding: 0px 8px;
}}
QPushButton::focus{{
outline: none;
border: solid;
border-width: 2px;
border-color: {Palette.accent()};
padding: 0px 8px;
}}
QPushButton::disabled{{
background-color: {Theme.COLOR_DISABLED_BG.value};
@@ -72,6 +83,40 @@ def button_style() -> str:
"""
def line_edit_style_main() -> str:
"""Style used for common QLineEdits."""
bg_color = (
Theme.COLOR_BG_DARK.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_BG_LIGHT.value
)
return f"""
QLineEdit{{
background: {bg_color};
border-radius: 6px;
font-weight: 500;
text-align: center;
padding: 0px 4px;
}}
QLineEdit::hover{{
border-style: solid;
border-width: 2px;
border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)};
padding: 0px 2px;
}}
QLineEdit::focus{{
border-style: solid;
border-width: 2px;
border-color: {Palette.accent()};
padding: 0px 2px;
}}
QLineEdit::disabled{{
background-color: {Theme.COLOR_DISABLED_BG.value};
}}
"""
def checkbox_style() -> str:
"""Style used for QCheckBoxes."""
primary_color = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
@@ -229,6 +274,7 @@ def line_edit_style() -> str:
def list_button_style(
color: QColor | None = None,
border_style: str = "solid",
italic: bool = False,
) -> str:
"""Style used for special QPushButtons found in lists."""
if color is None:
@@ -243,6 +289,7 @@ def list_button_style(
background: rgba{color.toTuple()};
color: rgba{text_color.toTuple()};
font-weight: 600;
{"font: italic;" if italic else ""}
border-color: rgba{border_color.toTuple()};
border-radius: 6px;
border-style: {border_style};
@@ -310,9 +357,9 @@ def tag_style(
border-radius: 6px;
border-style: {border_style};
border-width: 2px;
font-size: 13px;
padding-right: 4px;
padding-left: 4px;
font-size: 13px
}}
QPushButton::hover{{
border-color: rgba{highlight_color.toTuple()};
@@ -323,12 +370,9 @@ def tag_style(
border-color: rgba{primary_color.toTuple()};
}}
QPushButton::focus{{
padding-right: 0px;
padding-left: 0px;
outline-style: solid;
outline-width: 1px;
outline-radius: 4px;
outline-color: rgba{text_color.toTuple()};
outline: none;
border-width: 3px;
border-color: rgba{text_color.toTuple()};
}}
"""
@@ -366,6 +410,13 @@ def tag_remove_button_style(
"""
def widget_underline_style() -> str:
return f"""
background: {Palette.accent()};
border-radius: 2px;
"""
def title_line_edit_style() -> str:
"""Used to mimic an H3-like header style inside a QLineEdit."""
return """
@@ -374,6 +425,110 @@ def title_line_edit_style() -> str:
"""
def inset_container_style(object_name: str = "") -> str:
"""Used for darkened inset areas."""
bg_color = (
Theme.COLOR_BG_DARK.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_BG_LIGHT.value
)
return f"""
QWidget{"#" + object_name if object_name else ""}{{
background: {bg_color};
border-radius: 6px;
}}
"""
# TODO: Combine the autofill styles into one method?
def autofill_scroll_top_style(object_name: str = "") -> str:
"""Used autofill lists positioned on top of line edits."""
bg_color = (
Theme.COLOR_BG_DARK.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_BG_LIGHT.value
)
return f"""
QWidget{"#" + object_name if object_name else ""}{{
background: {bg_color};
border-top-left-radius: 6px;
border-top-right-radius: 6px;
border: none;
}}
"""
def autofill_scroll_top_focus_style(object_name: str = "") -> str:
"""Used autofill lists positioned on top of line edits."""
bg_color = (
Theme.COLOR_BG_DARK.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_BG_LIGHT.value
)
return f"""
QWidget{"#" + object_name if object_name else ""}{{
background: {bg_color};
border-top-left-radius: 6px;
border-top-right-radius: 6px;
border: solid;
border-width: 2px 2px 0px 2px;
border-color: {Palette.accent()};
}}
"""
def autofill_line_edit_style() -> str:
"""Used for QLineEdits."""
bg_color = (
Theme.COLOR_BG_DARK.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_BG_LIGHT.value
)
return f"""
QLineEdit{{
background: {bg_color};
border-radius: 6px;
padding: 3px 6px;
}}
QLineEdit::focus{{
padding: 4px 4px;
border: solid;
border-width: 2px;
border-color: {Palette.accent()};
}}
"""
def autofill_line_edit_top_style() -> str:
"""Used for QLineEdits when there's a top autofill section present."""
bg_color = (
Theme.COLOR_BG_DARK.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_BG_LIGHT.value
)
return f"""
QLineEdit{{
background: {bg_color};
border-top-left-radius: 0px;
border-top-right-radius: 0px;
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
padding: 0px 0px 2px 6px;
}}
QLineEdit::focus{{
padding: 4px 4px;
border: solid;
border-width: 0px 2px 2px 2px;
border-color: {Palette.accent()};
}}
"""
def preview_warning_style() -> str:
return f"""
QWidget#ffmpeg_widget {{
@@ -0,0 +1,84 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import structlog
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
QScrollArea,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from tagstudio.qt.controllers.autofill_line_edit import AutofillLineEdit
from tagstudio.qt.views.stylesheets.stylesheets import (
autofill_line_edit_style,
autofill_scroll_top_style,
)
logger = structlog.get_logger(__name__)
class SuggestBoxView(QVBoxLayout):
def __init__(self, placeholder_text: str = "") -> None:
super().__init__()
self.setContentsMargins(0, 0, 0, 0)
self.setSpacing(0)
# HACK: The transparent border allows for the focus border color to
# still show above the tags at the edges... sort of (overlaps on left when h-scrolling)
scroll_area_style = """
QScrollArea{
background: transparent;
border: solid;
border-color: transparent;
border-width: 0px 2px;
padding-left: -2px;
}
QScrollArea > QWidget > QWidget{
background: transparent;
}
"""
# Autocomplete ScrollArea
contents = QWidget()
self.content_layout = QHBoxLayout(contents)
self.content_layout.setSpacing(6)
self.content_layout.setAlignment(Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignLeft)
self.content_layout.setContentsMargins(0, 0, 0, 0)
scroll_area_container = QWidget()
scroll_area_container.setObjectName("container")
scroll_area_container_layout = QHBoxLayout(scroll_area_container)
scroll_area_container_layout.setContentsMargins(0, 0, 0, 0)
scroll_area_container_layout.setSpacing(0)
scroll_area_container.setStyleSheet(autofill_scroll_top_style("container"))
self.scroll_area = QScrollArea()
self.scroll_area.setStyleSheet(scroll_area_style)
scroll_area_container_layout.addWidget(self.scroll_area)
self.scroll_area.setWidget(contents)
search_bar_height = 28
underline_padding = 7
self.scroll_area.setMaximumHeight(search_bar_height + underline_padding)
self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.scroll_area.verticalScrollBar().setEnabled(False)
self.scroll_area.setContentsMargins(0, 0, 0, 0)
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
self.scroll_area.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
# Search Field
self.search_field = AutofillLineEdit(scroll_area_container)
self.search_field.setStyleSheet(autofill_line_edit_style())
self.search_field.setObjectName("search_field")
self.search_field.setMinimumHeight(28)
self.search_field.setPlaceholderText(placeholder_text)
self.scroll_area.setFocusProxy(self.search_field)
# Finalize Layout
self.addWidget(scroll_area_container)
self.addWidget(self.search_field)
-5
View File
@@ -39,17 +39,12 @@ class TagBoxWidgetView(FieldWidget):
for tag in tags_:
tag_widget = TagWidget(tag, library=self.__lib, has_edit=True, has_remove=True)
tag_widget.on_click.connect(lambda t=tag: self._on_click(t))
tag_widget.on_remove.connect(lambda t=tag: self._on_remove(t))
tag_widget.on_edit.connect(lambda t=tag: self._on_edit(t))
tag_widget.search_for_tag_action.triggered.connect(
lambda checked=False, t=tag: self._on_search(t)
)
self.__root_layout.addWidget(tag_widget)
def _on_click(self, tag: Tag) -> None:
@@ -1,6 +1,8 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from typing import override
from PySide6.QtWidgets import QWidget
from tagstudio.core.library.alchemy.library import Library
@@ -16,6 +18,7 @@ class TagSearchPanelView(SearchPanelView):
self.search_field.setPlaceholderText(Translations["home.search_tags"])
self.create_button.setText(Translations["tag.create"])
@override
def get_item_widget(self, index: int, library: Library | None) -> TagWidget:
"""Gets the item widget at a specific index."""
# Create any new item widgets needed up to the given index
@@ -0,0 +1,27 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QPushButton, QVBoxLayout, QWidget
from tagstudio.qt.views.stylesheets.stylesheets import widget_underline_style
class UnderlinedWidgetView(QVBoxLayout):
def __init__(self, widget: QWidget) -> None:
super().__init__()
self.setContentsMargins(0, 0, 0, 0)
self.setSpacing(3)
self.setAlignment(Qt.AlignmentFlag.AlignTop)
# HACK: I don't know why I can't just use a QFrame for the outline.
# The styling and sizing only seems to work if it's something like a QPushButton.
self.underline = QPushButton()
self.underline.setFlat(True)
self.underline.setDisabled(True)
self.underline.setMaximumHeight(4)
self.underline.setStyleSheet(widget_underline_style())
self.addWidget(widget)
self.addWidget(self.underline)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

+29 -25
View File
@@ -10,7 +10,6 @@
"about.version.latest": "{built_version} (Latest Release: {latest_version})",
"about.website": "Website",
"app.git": "Git Commit",
"app.nightly": "Nightly",
"app.pre_release": "Pre-Release",
"app.title": "{base_title} - Library '{library_dir}'",
"color_manager.title": "Manage Tag Colors",
@@ -31,9 +30,9 @@
"drop_import.description": "The following files match file paths that already exist in the library",
"drop_import.duplicates_choice.plural": "The following {count} files match file paths that already exist in the library.",
"drop_import.duplicates_choice.singular": "The following file matches a file path that already exists in the library.",
"drop_import.progress.label.initial": "Importing New Files...",
"drop_import.progress.label.plural": "Importing New Files...\n{count} Files Imported.{suffix}",
"drop_import.progress.label.singular": "Importing New Files...\n1 File imported.{suffix}",
"drop_import.progress.label.initial": "Importing New Files",
"drop_import.progress.label.plural": "Importing New Files\n{count} Files Imported.{suffix}",
"drop_import.progress.label.singular": "Importing New Files\n1 File imported.{suffix}",
"drop_import.progress.window_title": "Import Files",
"drop_import.title": "Conflicting File(s)",
"edit.color_manager": "Manage Tag Colors",
@@ -41,26 +40,26 @@
"edit.paste_fields": "Paste Fields",
"edit.tag_manager": "Manage Tags",
"entries.duplicate.merge": "Merge Duplicate Entries",
"entries.duplicate.merge.label": "Merging Duplicate Entries...",
"entries.duplicate.merge.label": "Merging Duplicate Entries",
"entries.duplicate.refresh": "Refresh Duplicate Entries",
"entries.duplicates.description": "Duplicate entries are defined as multiple entries which point to the same file on disk. Merging these will combine the tags and metadata from all duplicates into a single consolidated entry. These are not to be confused with \"duplicate files\", which are duplicates of your files themselves outside of TagStudio.",
"entries.generic.refresh_alt": "&Refresh",
"entries.generic.remove.removing": "Removing Entries",
"entries.generic.remove.removing_count": "Removing {count} Entries...",
"entries.generic.remove.removing_count": "Removing {count} Entries",
"entries.ignored.description": "File entries are considered to be \"ignored\" if they were added to the library before the user's ignore rules (via the '.ts_ignore' file) were updated to exclude it. Ignored files are kept in the library by default in order to prevent accidental data loss when updating ignore rules.",
"entries.ignored.ignored_count": "Ignored Entries: {count}",
"entries.ignored.remove": "Remove Ignored Entries",
"entries.ignored.remove_alt": "Remo&ve Ignored Entries",
"entries.ignored.scanning": "Scanning Library for Ignored Entries...",
"entries.ignored.scanning": "Scanning Library for Ignored Entries",
"entries.ignored.title": "Fix Ignored Entries",
"entries.mirror": "&Mirror",
"entries.mirror.confirmation": "Are you sure you want to mirror the following {count} Entries?",
"entries.mirror.label": "Mirroring {idx}/{total} Entries...",
"entries.mirror.label": "Mirroring {idx}/{total} Entries",
"entries.mirror.title": "Mirroring Entries",
"entries.mirror.window_title": "Mirror Entries",
"entries.remove.plural.confirm": "Are you sure you want to remove these <b>{count}</b> entries from your library? No files on disk will be deleted.",
"entries.remove.singular.confirm": "Are you sure you want to remove this entry from your library? No files on disk will be deleted.",
"entries.running.dialog.new_entries": "Adding {total} New File Entries...",
"entries.running.dialog.new_entries": "Adding {total} New File Entries",
"entries.running.dialog.title": "Adding New File Entries",
"entries.tags": "Tags",
"entries.unlinked.description": "Each library entry is linked to a file in one of your directories. If a file linked to an entry is moved or deleted outside of TagStudio, it is then considered unlinked.<br><br>Unlinked entries may be automatically relinked via searching your directories or deleted if desired.",
@@ -69,7 +68,7 @@
"entries.unlinked.relink.title": "Relinking Entries",
"entries.unlinked.remove": "Remove Unlinked Entries",
"entries.unlinked.remove_alt": "Remo&ve Unlinked Entries",
"entries.unlinked.scanning": "Scanning Library for Unlinked Entries...",
"entries.unlinked.scanning": "Scanning Library for Unlinked Entries",
"entries.unlinked.search_and_relink": "&Search && Relink",
"entries.unlinked.title": "Fix Unlinked Entries",
"entries.unlinked.unlinked_count": "Unlinked Entries: {count}",
@@ -162,9 +161,12 @@
"generic.yes": "Yes",
"home.search": "Search",
"home.search_entries": "Search Entries",
"home.search_field_templates": "Search Field Templates",
"home.search_field_templates": "Search Field Templates",
"home.search_library": "Search Library",
"home.search_tags": "Search Tags",
"home.search_or_create_fields": "Search or Create Fields…",
"home.search_or_create_tags": "Search or Create Tags…",
"home.search_tags": "Search Tags…",
"home.search.how_to_exit": "(Esc to Exit)",
"home.search.view_limit": "View Limit:",
"home.show_hidden_entries": "Show Hidden Entries",
"home.thumbnail_size": "Thumbnail Size",
@@ -174,8 +176,8 @@
"home.thumbnail_size.mini": "Mini Thumbnails",
"home.thumbnail_size.small": "Small Thumbnails",
"ignore.open_file": "Show \"{ts_ignore}\" File on Disk",
"json_migration.checking_for_parity": "Checking for Parity...",
"json_migration.creating_database_tables": "Creating SQL Database Tables...",
"json_migration.checking_for_parity": "Checking for Parity",
"json_migration.creating_database_tables": "Creating SQL Database Tables",
"json_migration.description": "<br>Start and preview the results of the library migration process. The converted library will <i>not</i> be used unless you click \"Finish Migration\". <br><br>Library data should either have matching values or feature a \"Matched\" label. Values that do not match will be displayed in red and feature a \"<b>(!)</b>\" symbol next to them.<br><center><i>This process may take up to several minutes for larger libraries.</i></center>",
"json_migration.discrepancies_found": "Library Discrepancies Found",
"json_migration.discrepancies_found.description": "Discrepancies were found between the original and converted library formats. Please review and choose to whether continue with the migration or to cancel.",
@@ -190,7 +192,7 @@
"json_migration.heading.paths": "Paths:",
"json_migration.heading.shorthands": "Shorthands:",
"json_migration.info.description": "Library save files created with TagStudio versions <b>9.4 and below</b> will need to be migrated to the new <b>v9.5+</b> format.<br><h2>What you need to know:</h2><ul><li>Your existing library save file will <b><i>NOT</i></b> be deleted</li><li>Your personal files will <b><i>NOT</i></b> be deleted, moved, or modified</li><li>The new v9.5+ save format can not be opened in earlier versions of TagStudio</li></ul><h3>What's changed:</h3><ul><li>\"Tag Fields\" have been replaced by \"Tag Categories\". Instead of adding tags to fields first, tags now get added directly to file entries. They're then automatically organized into categories based on parent tags marked with the new \"Is Category\" property in the tag editing menu. Any tag can be marked as a category, and child tags will sort themselves underneath parent tags marked as categories. The \"Favorite\" and \"Archived\" tags now inherit from a new \"Meta Tags\" tag which is marked as a category by default.</li><li>Tag colors have been tweaked and expanded upon. Some colors have been renamed or consolidated, however all tag colors will still convert to exact or close matches in v9.5.</li></ul><ul>",
"json_migration.migrating_files_entries": "Migrating {entries:,d} File Entries...",
"json_migration.migrating_files_entries": "Migrating {entries:,d} File Entries",
"json_migration.migration_complete": "Migration Complete!",
"json_migration.migration_complete_with_discrepancies": "Migration Complete, Discrepancies Found",
"json_migration.start_and_preview": "Start and Preview",
@@ -249,12 +251,12 @@
"library_object.slug_required": "ID Slug (Required)",
"library.missing": "Library Location is Missing",
"library.name": "Library",
"library.refresh.scanning_preparing": "Scanning Directories for New Files...\nPreparing...",
"library.refresh.scanning.plural": "Scanning Directories for New Files...\n{searched_count} Files Searched, {found_count} New Files Found",
"library.refresh.scanning.singular": "Scanning Directories for New Files...\n{searched_count} File Searched, {found_count} New Files Found",
"library.refresh.scanning_preparing": "Scanning Directories for New Files\nPreparing",
"library.refresh.scanning.plural": "Scanning Directories for New Files\n{searched_count} Files Searched, {found_count} New Files Found",
"library.refresh.scanning.singular": "Scanning Directories for New Files\n{searched_count} File Searched, {found_count} New Files Found",
"library.refresh.title": "Refreshing Directories",
"library.scan_library.title": "Scanning Library",
"macros.running.dialog.new_entries": "Running Configured Macros on {count}/{total} New File Entries...",
"macros.running.dialog.new_entries": "Running Configured Macros on {count}/{total} New File Entries",
"macros.running.dialog.title": "Running Macros on New Entries",
"media_player.autoplay": "Autoplay",
"media_player.loop": "Loop",
@@ -284,7 +286,7 @@
"menu.macros": "&Macros",
"menu.macros.folders_to_tags": "Folders to Tags",
"menu.select": "Select",
"menu.settings": "Settings...",
"menu.settings": "Settings",
"menu.tools": "&Tools",
"menu.tools.fix_duplicate_files": "Fix &Duplicate Files",
"menu.tools.fix_ignored_entries": "Fix &Ignored Entries",
@@ -316,6 +318,8 @@
"settings.dateformat.international": "International",
"settings.dateformat.label": "Date Format",
"settings.dateformat.system": "System",
"settings.edit_field_on_add": "Edit After Adding a Field",
"settings.edit_tag_on_create": "Edit After Creating a New Tag",
"settings.filepath.label": "Filepath Visibility",
"settings.filepath.option.full": "Show Full Paths",
"settings.filepath.option.name": "Show Filenames Only",
@@ -355,18 +359,18 @@
"sorting.direction.ascending": "Ascending",
"sorting.direction.descending": "Descending",
"sorting.mode.random": "Random",
"splash.opening_library": "Opening Library \"{library_path}\"...",
"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.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.library_closing": "Closing Library",
"status.library_save_success": "Library Saved and Closed!",
"status.library_search_query": "Searching Library...",
"status.library_search_query": "Searching Library",
"status.library_version_expected": "Expected:",
"status.library_version_found": "Found:",
"status.library_version_mismatch": "Library Version Mismatch!",
+38 -40
View File
@@ -1,16 +1,16 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# pyright: reportPrivateUsage=false
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry, Tag
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel
from tagstudio.qt.ts_qt import QtDriver
def test_update_selection_empty(qt_driver: QtDriver, library: Library):
panel = PreviewPanel(library, qt_driver)
def test_update_selection_empty(qt_driver: QtDriver):
panel = PreviewPanel(qt_driver)
# Clear the library selection (selecting 1 then unselecting 1)
qt_driver.toggle_item_selection(1, append=False, bridge=False)
@@ -18,26 +18,26 @@ def test_update_selection_empty(qt_driver: QtDriver, library: Library):
panel.set_selection(qt_driver.selected)
# FieldContainer should hide all containers
for container in panel.field_containers_widget.containers:
for container in panel.containers._containers:
assert container.isHidden()
def test_update_selection_single(qt_driver: QtDriver, library: Library, entry_full: Entry):
panel = PreviewPanel(library, qt_driver)
def test_update_selection_single(qt_driver: QtDriver, entry_full: Entry):
panel = PreviewPanel(qt_driver)
# Select the single 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.field_containers_widget.containers:
for container in panel.containers._containers:
assert not container.isHidden()
def test_update_selection_multiple(qt_driver: QtDriver, library: Library):
def test_update_selection_multiple(qt_driver: QtDriver):
# TODO: Implement mixed field editing. Currently these containers will be hidden,
# same as the empty selection behavior.
panel = PreviewPanel(library, qt_driver)
panel = PreviewPanel(qt_driver)
# Select the multiple entries
qt_driver.toggle_item_selection(1, append=False, bridge=False)
@@ -45,12 +45,12 @@ def test_update_selection_multiple(qt_driver: QtDriver, library: Library):
panel.set_selection(qt_driver.selected)
# FieldContainer should show mixed field editing
for container in panel.field_containers_widget.containers:
for container in panel.containers._containers:
assert container.isHidden()
def test_add_tag_to_selection_single(qt_driver: QtDriver, library: Library, entry_full: Entry):
panel = PreviewPanel(library, qt_driver)
def test_add_tag_to_selection_single(qt_driver: QtDriver, entry_full: Entry):
panel = PreviewPanel(qt_driver)
assert {t.id for t in entry_full.tags} == {1000}
@@ -59,15 +59,15 @@ def test_add_tag_to_selection_single(qt_driver: QtDriver, library: Library, entr
panel.set_selection(qt_driver.selected)
# Add new tag
panel.field_containers_widget.add_tags_to_selected(2000)
panel.containers.add_tags_to_selected(2000)
# Then reload entry
refreshed_entry: Entry = next(library.all_entries(with_joins=True))
refreshed_entry: Entry = next(qt_driver.lib.all_entries(with_joins=True))
assert {t.id for t in refreshed_entry.tags} == {1000, 2000}
def test_add_same_tag_to_selection_single(qt_driver: QtDriver, library: Library, entry_full: Entry):
panel = PreviewPanel(library, qt_driver)
def test_add_same_tag_to_selection_single(qt_driver: QtDriver, entry_full: Entry):
panel = PreviewPanel(qt_driver)
assert {t.id for t in entry_full.tags} == {1000}
@@ -76,16 +76,16 @@ def test_add_same_tag_to_selection_single(qt_driver: QtDriver, library: Library,
panel.set_selection(qt_driver.selected)
# Add an existing tag
panel.field_containers_widget.add_tags_to_selected(1000)
panel.containers.add_tags_to_selected(1000)
# Then reload entry
refreshed_entry = next(library.all_entries(with_joins=True))
refreshed_entry = next(qt_driver.lib.all_entries(with_joins=True))
assert {t.id for t in refreshed_entry.tags} == {1000}
def test_add_tag_to_selection_multiple(qt_driver: QtDriver, library: Library):
panel = PreviewPanel(library, qt_driver)
all_entries = library.all_entries(with_joins=True)
def test_add_tag_to_selection_multiple(qt_driver: QtDriver):
panel = PreviewPanel(qt_driver)
all_entries = qt_driver.lib.all_entries(with_joins=True)
# We want to verify that tag 1000 is on some, but not all entries already.
tag_present_on_some: bool = False
@@ -101,15 +101,15 @@ def test_add_tag_to_selection_multiple(qt_driver: QtDriver, library: Library):
assert tag_absent_on_some
# Select the multiple entries
for i, e in enumerate(library.all_entries(with_joins=True), start=0):
for i, e in enumerate(qt_driver.lib.all_entries(with_joins=True), start=0):
qt_driver.toggle_item_selection(e.id, append=(True if i == 0 else False), bridge=False) # noqa: SIM210
panel.set_selection(qt_driver.selected)
# Add new tag
panel.field_containers_widget.add_tags_to_selected(1000)
panel.containers.add_tags_to_selected(1000)
# Then reload all entries and recheck the presence of tag 1000
refreshed_entries = library.all_entries(with_joins=True)
refreshed_entries = qt_driver.lib.all_entries(with_joins=True)
tag_present_on_some = False
tag_absent_on_some = False
@@ -123,23 +123,23 @@ def test_add_tag_to_selection_multiple(qt_driver: QtDriver, library: Library):
assert not tag_absent_on_some
def test_meta_tag_category(qt_driver: QtDriver, library: Library, entry_full: Entry):
panel = PreviewPanel(library, qt_driver)
def test_meta_tag_category(qt_driver: QtDriver, entry_full: Entry):
panel = PreviewPanel(qt_driver)
# Ensure the Favorite tag is on entry_full
library.add_tags_to_entries(1, entry_full.id)
qt_driver.lib.add_tags_to_entries(1, entry_full.id)
# Select the single 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.field_containers_widget.containers) == 3
for i, container in enumerate(panel.field_containers_widget.containers):
assert len(panel.containers._containers) == 3
for i, container in enumerate(panel.containers._containers):
match i:
case 0:
# Check if the container is the Meta Tags category
tag: Tag = unwrap(library.get_tag(2))
tag: Tag = unwrap(qt_driver.lib.get_tag(2))
assert container.title == f"<h4>{tag.name}</h4>"
case 1:
# Check if the container is the Tags category
@@ -151,30 +151,28 @@ def test_meta_tag_category(qt_driver: QtDriver, library: Library, entry_full: En
pass
def test_custom_tag_category(qt_driver: QtDriver, library: Library, entry_full: Entry):
panel = PreviewPanel(library, qt_driver)
def test_custom_tag_category(qt_driver: QtDriver, entry_full: Entry):
panel = PreviewPanel(qt_driver)
# Set tag 1000 (foo) as a category
tag: Tag = unwrap(library.get_tag(1000))
tag: Tag = unwrap(qt_driver.lib.get_tag(1000))
tag.is_category = True
library.update_tag(
tag,
)
qt_driver.lib.update_tag(tag)
# Ensure the Favorite tag is on entry_full
library.add_tags_to_entries(1, entry_full.id)
qt_driver.lib.add_tags_to_entries(1, entry_full.id)
# Select the single 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.field_containers_widget.containers) == 3
for i, container in enumerate(panel.field_containers_widget.containers):
assert len(panel.containers._containers) == 3
for i, container in enumerate(panel.containers._containers):
match i:
case 0:
# Check if the container is the Meta Tags category
tag_2: Tag = unwrap(library.get_tag(2))
tag_2: Tag = unwrap(qt_driver.lib.get_tag(2))
assert container.title == f"<h4>{tag_2.name}</h4>"
case 1:
# Check if the container is the custom "foo" category
+9 -11
View File
@@ -1,6 +1,7 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# pyright: reportPrivateUsage=false, reportAttributeAccessIssue=false
import os
from collections.abc import Callable
@@ -8,9 +9,7 @@ from pathlib import Path
from unittest.mock import patch
import pytest
from PySide6.QtGui import (
QAction,
)
from PySide6.QtGui import QAction
from PySide6.QtWidgets import QMenu, QMenuBar
from pytestqt.qtbot import QtBot
@@ -60,11 +59,10 @@ def test_filepath_setting(qtbot: QtBot, qt_driver: QtDriver, filepath_option: Sh
)
def test_file_path_display(
qt_driver: QtDriver,
library: Library,
filepath_option: ShowFilepathOption,
expected_path: Callable[[Library], Path],
):
panel = PreviewPanel(library, qt_driver)
panel = PreviewPanel(qt_driver)
# Select 2
qt_driver.toggle_item_selection(2, append=False, bridge=False)
@@ -73,15 +71,15 @@ def test_file_path_display(
qt_driver.settings.show_filepath = filepath_option
# Apply the mock value
entry = library.get_entry(2)
entry = qt_driver.lib.get_entry(2)
assert isinstance(entry, Entry)
filename = entry.path
panel._file_attributes_widget.update_stats(filepath=unwrap(library.library_dir) / filename) # pyright: ignore[reportPrivateUsage]
panel.layout().file_attrs.update_stats(filepath=unwrap(qt_driver.lib.library_dir) / filename)
# Generate the expected file string.
# This is copied directly from the file_attributes.py file
# can be imported as a function in the future
display_path: Path = expected_path(library)
display_path: Path = expected_path(qt_driver.lib)
file_str: str = ""
separator: str = f"<a style='color: #777777'><b>{os.path.sep}</a>" # Gray
for i, part in enumerate(display_path.parts):
@@ -94,7 +92,7 @@ def test_file_path_display(
file_str += f"<b>{'\u200b'.join(part_)}</b>"
# Assert the file path is displayed correctly
assert panel._file_attributes_widget.file_label.text() == file_str # pyright: ignore[reportPrivateUsage]
assert panel.layout().file_attrs.file_label.text() == file_str
@pytest.mark.parametrize(
@@ -146,7 +144,7 @@ def test_title_update(
qt_driver.main_window.menu_bar.folders_to_tags_action = QAction(menu_bar)
# Trigger the update
qt_driver._init_library(library_dir, open_status) # pyright: ignore[reportPrivateUsage]
qt_driver._init_library(library_dir, open_status)
# Assert the title is updated correctly
qt_driver.main_window.setWindowTitle.assert_called_with(expected_title(library_dir, base_title)) # pyright: ignore[reportAttributeAccessIssue]
qt_driver.main_window.setWindowTitle.assert_called_with(expected_title(library_dir, base_title))
+16 -10
View File
@@ -1,15 +1,15 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# pyright: reportPrivateUsage=false
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel
from tagstudio.qt.ts_qt import QtDriver
def test_update_selection_empty(qt_driver: QtDriver, library: Library):
panel = PreviewPanel(library, qt_driver)
def test_update_selection_empty(qt_driver: QtDriver):
panel = PreviewPanel(qt_driver)
# Clear the library selection (selecting 1 then unselecting 1)
qt_driver.toggle_item_selection(1, append=False, bridge=False)
@@ -17,22 +17,27 @@ def test_update_selection_empty(qt_driver: QtDriver, library: Library):
panel.set_selection(qt_driver.selected)
# Panel should disable UI that allows for entry modification
assert not panel.add_buttons_enabled
assert panel.layout().add_tag_button.isEnabled() == panel.layout().add_field_button.isEnabled()
assert (
not panel.layout().add_tag_button.isEnabled()
and not panel.layout().add_field_button.isEnabled()
)
def test_update_selection_single(qt_driver: QtDriver, library: Library, entry_full: Entry):
panel = PreviewPanel(library, qt_driver)
def test_update_selection_single(qt_driver: QtDriver, entry_full: Entry):
panel = PreviewPanel(qt_driver)
# Select the single entry
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
panel.set_selection(qt_driver.selected)
# Panel should enable UI that allows for entry modification
assert panel.add_buttons_enabled
assert panel.layout().add_tag_button.isEnabled() == panel.layout().add_field_button.isEnabled()
assert panel.layout().add_tag_button.isEnabled() and panel.layout().add_field_button.isEnabled()
def test_update_selection_multiple(qt_driver: QtDriver, library: Library):
panel = PreviewPanel(library, qt_driver)
def test_update_selection_multiple(qt_driver: QtDriver):
panel = PreviewPanel(qt_driver)
# Select the multiple entries
qt_driver.toggle_item_selection(1, append=False, bridge=False)
@@ -40,4 +45,5 @@ def test_update_selection_multiple(qt_driver: QtDriver, library: Library):
panel.set_selection(qt_driver.selected)
# Panel should enable UI that allows for entry modification
assert panel.add_buttons_enabled
assert panel.layout().add_tag_button.isEnabled() == panel.layout().add_field_button.isEnabled()
assert panel.layout().add_tag_button.isEnabled() and panel.layout().add_field_button.isEnabled()