mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-07-25 14:54:17 +02:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fd81304fc8 | |||
| 99aad21e03 | |||
| a0bf6792ed | |||
| 0fbcfdb1ea | |||
| 111ea76884 | |||
| 4aa7b9e043 | |||
| e4851678e1 | |||
| c488e53144 | |||
| 8e3c0fb092 | |||
| 37f8eeac6f | |||
| b83216b11e | |||
| f29f691a79 | |||
| c8ba9b15c2 | |||
| f7b86ebf2a | |||
| 55a057ffe6 | |||
| 210b469bd2 | |||
| d98beaf444 | |||
| 669b95d580 | |||
| cb3164a5ff | |||
| f2cecb2648 | |||
| 34d00d7e9a | |||
| fdc01b7491 |
@@ -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
@@ -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
@@ -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
@@ -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
@@ -27,12 +27,12 @@ hide:
|
||||
|
||||

|
||||
|
||||
<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
@@ -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
@@ -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/).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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__(
|
||||
|
||||
@@ -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)
|
||||
@@ -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 |
@@ -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!",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user