diff --git a/README.md b/README.md index 14c13052..0fbdccf3 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,39 @@ -# Shortcut composer +# Shortcut composer **v1.3.0** -**Extension** for painting application **Krita**, which allows to create custom, complex **keyboard shortcuts**. +[![python](https://img.shields.io/badge/Python-3.8-3776AB.svg?style=flat&logo=python&logoColor=white)](https://www.python.org) +[![Code style: black](https://img.shields.io/badge/code%20style-autopep8-333333.svg)](https://pypi.org/project/autopep8/) +[![License: GPLv3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) +[![wojtryb website](https://img.shields.io/badge/YouTube-wojtryb-ee0000.svg?style=flat&logo=youtube)](https://youtube.com/wojtryb) +[![wojtryb twitter](https://img.shields.io/badge/Twitter-wojtryb-00aced.svg?style=flat&logo=twitter)](https://twitter.com/wojtryb) +[![wojtryb portfolio](https://img.shields.io/badge/Art_Portfolio-wojtryb-000000.svg?style=flat&logo=)](https://cara.app/wojtryb) -The plugin adds new shortcuts of the following types: -- `Pie menu` - while key is pressed, displays a pie menu, which allows to pick values by hovering a mouse. -- `Cursor tracker` - while key is pressed, tracks a cursor, switching values according to cursor offset. -- `Canvas preview` - Temporarily changes canvas elements while the key is pressed. -- `Multiple assignment` - repeatedly pressing a key, cycles between multiple values of krita property. -- `Temporary key` - temporarily activates a krita property with long press or toggles it on/off with short press. - -## What's new in **1.2** +--- -[![PIE MENUS - introducing Shortcut Composer](http://img.youtube.com/vi/Tkf2-U0OyG4/0.jpg)](https://www.youtube.com/watch?v=Tkf2-U0OyG4 "PIE MENUS - introducing Shortcut Composer") +**`Extension`** for painting application [**`Krita`**](https://krita.org/), which allows to create custom, complex **`keyboard shortcuts`**. -- [hotfix **1.2.2**] - Fixed MultipleAssignment actions sharing one configuration window. -- [hotfix **1.2.1**] - Fixed pie menus in edit mode hiding when clicked outside on the canvas. +The plugin adds new shortcuts of the following types: +- [**`Pie menu`**](https://github.com/wojtryb/Shortcut-Composer/wiki/Plugin-actions#pie-menus) - while key is pressed, displays a pie menu, which allows to pick values by hovering a mouse. +- [**`Cursor tracker`**](https://github.com/wojtryb/Shortcut-Composer/wiki/Plugin-actions#cursor-trackers) - while key is pressed, tracks a cursor, switching values according to cursor offset. +- [**`Canvas preview`**](https://github.com/wojtryb/Shortcut-Composer/wiki/Plugin-actions#canvas-previews) - Temporarily changes canvas elements while the key is pressed. +- [**`Multiple assignment`**](https://github.com/wojtryb/Shortcut-Composer/wiki/Plugin-actions#multiple-assignments) - repeatedly pressing a key, cycles between multiple values of krita property. +- [**`Temporary key`**](https://github.com/wojtryb/Shortcut-Composer/wiki/Plugin-actions#temporary-keys) - temporarily activates a krita property with long press or toggles it on/off with short press. +## Important links +> Download the [**`latest version`**](https://github.com/wojtryb/Shortcut-Composer/archive/refs/heads/main.zip) of the plugin, or visit its [**`github page`**](https://github.com/wojtryb/Shortcut-Composer/archive/refs/heads/main.zip). -### Added -- Adding and removing PieMenu icons with drag and drop. -- Class-oriented configuration system with automatic value parsing. Can be reused in other plugins. -- PieMenus are now able to handle their own local configuration with Pie settings (Replaces section in `Configure Shortcut Composer`). -- Change PieIcon border's color when hovered in EditMode (Replaces gray indicator) -- Allow changes to PieMenu and PieIcon size for each pie separately. -- Separate button to enter PieMenu edit mode. Replaces clicking on PieIcon which was easy to do unintentionally. -- Preset PieMenus tag now can be chosen directly from the Pie settings. (Replaces section in `Configure Shortcut Composer`) -- Preset PieMenus now reload automatically when the tag content changes. (Replaces `Reload Shortcut Composer` action) -- PieMenus now automatically change between dark/light theme when the krita theme changes between these two theme families. -- `Cycle selection tools` action now is configured by local settings activated with a button that appears on long press (Replaces section in `Configure Shortcut Composer`) +--- -### Fixed -- Support tags with quote and double-quote signs. -- Make `input_adapter` package independent from the rest of the plugin to improve re-usability. -- Fix crash when picking a deleted preset with PieMenu. +- [Watch video tutorials 📺](https://www.youtube.com/playlist?list=PLeiJahtD9hCrtKRRYjdi-JqRtqyvH3xCG) +- [Read user manual 📄](https://github.com/wojtryb/Shortcut-Composer/wiki) +- [Join community discussion 👥](https://krita-artists.org/t/shortcut-composer-v1-2-2-plugin-for-pie-menus-multiple-key-assignment-mouse-trackers-and-more/55314) +- [Report a bug 🦗](https://github.com/wojtryb/Shortcut-Composer/issues) +- [Request a new feature 💡](https://github.com/wojtryb/Shortcut-Composer/discussions) -Check out historic [changelogs](https://github.com/wojtryb/Shortcut-Composer/releases). +## What's new in the latest release? -## Plugin release video: +Watch the video below, or read the [changelog](https://github.com/wojtryb/Shortcut-Composer/releases). -[![PIE MENUS - introducing Shortcut Composer](http://img.youtube.com/vi/hrjBycVYFZM/0.jpg)](https://www.youtube.com/watch?v=hrjBycVYFZM "PIE MENUS - introducing Shortcut Composer") +[![PIE MENUS - introducing Shortcut Composer](https://user-images.githubusercontent.com/51094047/244950488-83bd44ff-87f6-4b95-82c7-0f5031bb1b8e.png)](https://www.youtube.com/watch?v=eHK5LBMNiU0 "Managing BRUSHES with Shortcut Composer 1.3") ## Requirements - Version of krita on plugin release: **5.1.5** @@ -50,341 +45,28 @@ OS support state: - [ ] MacOS (Known bug of canvas losing focus after using PieMenu) - [ ] Android (Does not support python plugins yet) -## How to install or update the plugin: -1. on [github project page](https://github.com/wojtryb/Shortcut-Composer), click the green button code and pick the download zip option. Do not extract it. -2. in krita's topbar, open **Tools > Scripts > Import Python Plugin From File** and pick the downloaded .zip file -3. restart krita. -4. set custom shortcuts in **Settings > Configure Krita > Keyboard Shortcuts** under **Scripts > Shortcut Composer: Complex Actions** section. By intention, there are no default bindings. - -## Pre-made actions -While Shortcut-Composer is highly configurable and extendable, the add-on comes with pre-made, plug-and-play actions. - -### (`Pie menus`): -Pie menu is a widget displayed on the canvas while a key is pressed. It will disappear as soon, as the key is released. Moving cursor in a direction of an icon, activates its value on key release. **The action only requires hovering**. Pie menu does nothing if the cursor is not moved out of the deadzone. - -Clicking the settings button in bottom-right corner switches into `Edit mode` which allows to modify pie. At this point the keyboard button no longer needs to be pressed. In this mode values one can: -- drag icons to change their order -- drag icons out of the ring to remove them -- drag icons from the settings window to add them - -Settings window visible in `Edit mode` also allows to change the local settings of the pie. When done, press the tick button to apply the changes. - -- ### Pick brush presets (red, green, blue) - Three color coded pie menus that let you pick a **brush preset** from related **tag** with brush presets. - - Used tag can be changed in pie settings by entering `Edit mode`. Presets in the pie depend on the tag, so they cannot be removed or added with dragging, but it is possible to change their order. When presets are added or remove from the tag, pie should update automatically. - - Default tag mapping is as follows: - - red: "★ My Favorites" - - green: "RGBA" - - blue: "Erasers" - -- ### Pick misc tools - Pie menu for picking **active tools**. It is recommended to change the default values being: - - crop tool, - - reference tool, - - gradient tool, - - multi_brush tool, - - assistant tool - -- ### Pick painting blending modes - Pie menu for picking **painting blending modes**. It is recommended to change the default values being: - - normal - - overlay, - - color, - - multiply, - - add, - - screen, - - darken, - - lighten - -- ### Create painting layer with blending mode - Pie menu for creating a new layer with picked **blending mode**. It is recommended to change the default values being: - - normal - - erase - - overlay, - - color, - - multiply, - - add, - - screen, - - darken, - - lighten - -### (`Cursor trackers`): -Cursor tracker is an action for switching values using cursor movement, while the keyboard key is pressed. It changes a single krita property according to the cursor movement along horizontal or vertical axis. **The action does not recognise mouse clicks, and only requires hovering** - -- ### Scroll isolated layers - Scrolls the layers by sliding the cursor vertically. Can be used for picking the active layer and analizing the layer stack. While the key is pressed, isolates the active layer to give better response of which layer is active. - - Key press: isloate active layer - - Horizontal: - - - Vertical: scroll all layers - -- ### Scroll timeline or animated layers - Variation on "Scroll isolated layers" for animators. Scrolling is restricted only to layers pinned to the animation timeline. Horizontal mouse movement changes the current frame. - - Key press: isloate active layer - - Horizontal: scroll animation frames - - Vertical: scroll layers pinned to timeline - -- ### Scroll undo stack - Extends the krita undo action ctrl+z. While the key is pressed, horizontal mouse movement controls the undo stack by performing undo and redo actions. Usual undo with short key press is still possible. - - Key press: undo last operation - - Horizontal: scroll left to undo, or right to redo - - Vertical: - - -- ### Scroll brush size or opacity - Allows to control both `brush size` or `opacity` with a single key. Opacity changes contiguously with vertical mouse movement, while brush size snaps to custom values. - - Key press: - - - Horizontal: scroll brush size (descrete) - - Vertical: scroll painting opacity (contiguous) - -- ### Scroll canvas zoom or rotation - Allows to control both `canvas zoom` or `canvas rotation` with a single key. Does not block the ability to paint. - - Key press: - - - Horizontal: canvas rotation (contiguous) - - Vertical: canvas zoom (contiguous) - -### (`Canvas previews`): -Canvas preview is an action which changes canvas elements when the key is pressed, and changes them back to their original state on key release. - -- ### Preview current layer visibility - Changes active layer visibility on key press and release. Allows to quickly check layer's content. +> **Note** +> On **Linux** the only oficially supported version of Krita is **.appimage**, which ships with all required dependencies. Running the plugin on Krita installed from Snap or distribution repositories is not recommended as it may not work out of the box and may require extra dependency-related work. -- ### Preview projection below - Hides all visible layers above the active one on key press, and reverses this change on key release. Allows to check what is the position of current layer in a stack. It is possible to paint while action is active. - -### (`Multiple assignments`): -Multiple assignment is an action which cycles between multiple values of single krita property. Each key press activates next list element. Performing a long press breaks the cycle and sets a default value, which does not have to belong the the list. - -- ### Cycle selection tools - Pressing a key repeatedly cycles most commonly used selection tools: - - freehand selection tool, - - rectangular selection tool, - - contiguous selection tool - - Performing a long press, goes back to the `freehand brush tool`. Tools can be used while the key is pressed. - - Default values can be modified in `Edit mode`. To enter it, long press the button, and click on the button which appears in top-left corner of painting area. - - Values are added by selecting the list value(s) on the left and pressing the green **add** button. Analogically, selecting values on the right, and pressing the **remove** button, removes the values from action. Dragging values on the right, allow to change their order. - -- ### Cycle painting opacity - Pressing a key repeatedly cycles brush predefined opacity values: `100%`, `70%`, `50%`, `30%` - - Performing a long press, goes back to the `100%` opacity. Modified opacity can be used while the key is pressed. - - Currently does not allow to configure predefined values without editing code. - -### (`Temporary keys`): -- ### Temporary move tool - Pressing a key temporarily activates the `move tool` which goes back to the `freehand brush tool` after the key release. Short key presses allow to permanently toggle between those two tools. - -- ### Temporary eraser - Pressing a key temporarily activates the `eraser` mode which gets turned off after the key is released. Short key presses allow to permanently toggle between those two states. - -- ### Temporary preserve alpha - Pressing a key temporarily activates the `preserve alpha` mode which gets turned off after the key is released. Short key presses allow to permanently toggle between those two states. - -## Modifying default plugin behaviour - -### Tweaking the global parameters -Shortcut-Composer comes with a settings dialog available from krita topbar: **Tools > Scripts > Configure Shortcut Composer**. The dialog allows to change the following aspects of actions: - -- Common settings - - `Short vs long press time` - Time in seconds distinguishing short key presses from long ones. - - `FPS limit` - Maximum rate of Mouse Tracker and Pie Menu refresh. -- Cursor trackers - - `Tracker sensitivity scale` - Sensitivity multiplier of all Mouse Trackers. - - `Tracker deadzone` - Amount of pixels a mouse needs to moved for Mouse Trackers to start work. -- Pie menus display - - `Pie global scale` - Global scale factor for base of every pie menu. - - `Pie icon global scale` - Global scale factor for icons of every pie menu. - - `Pie deadzone global scale` - Global scale factor for the deadzone area of every pie menu. - - `Pie animation time` - Time (in seconds) for fade-in animation when showing the pie menu. - -### Modifying actions and creating custom ones -While the settings dialog allows to tweak the values common for plugin actions, it does not allow to modify the behaviour of the actions or create new ones. +## How to install or update the plugin: +Installation steps are THE SAME for installing the plugin for the first time and for updating it to the new version: -To achieve that it is required to modify actions implementation: -- in krita's topbar, open **Settings > Manage Resources > Open Resource Folder** -- navigate to **./pykrita/shortcut_composer/** directory. -- action definitions are located in `actions.action` file. -- actions implementation is located in `actions.py` file. +1. Download the plugin: + - Use the direct link for [stable](https://github.com/wojtryb/Shortcut-Composer/archive/refs/heads/main.zip) or [development](https://github.com/wojtryb/Shortcut-Composer/archive/refs/heads/development.zip) release. + - Alternatively, on [github page](https://github.com/wojtryb/Shortcut-Composer/archive/refs/heads/main.zip) switch from `main` to any of the unstable versions, click the green button `code` and pick the `download zip` option. +2. In krita's topbar, open **Tools > Scripts > Import Python Plugin From File** and pick the downloaded .zip file +3. Restart krita. +4. Set custom shortcuts in **Settings > Configure Krita > Keyboard Shortcuts** under **Scripts > Shortcut Composer: ...** sections. By intention, there are no default bindings. -1. Define an action in `actions.action` file by duplicating one of the existing definitions and using an unique name for it. -2. Implement an action in `actions.py` file. Once again, duplicate one of the existing implementations. It is best to pick the one that feels closest to desired action. Fill its arguments, making sure the name is exactly the same as defined earlier. - -## Worth noting -- Key bindings with modifiers like ctrl or shift are supported. When assigned to a key combination, the key is considered released when the main key in sequence (non-modifier) is released. -- Multiple shortcuts from this plugin can be used at the same time, unless bindings make it technically impossible. For example holding both keys for `Temporary eraser` and `Cycle painting opacity` result in an eraser with 70% opacity. +> **Warning** +> Some keyboard buttons like **Space, R, Y, V, 1, 2, 3, 4, 5, 6** are reserved for Krita's Canvas Inputs. Assigning those keys to actions (including those from the plugin) may result in conflicts and abnormal behaviour different for each OS. Either avoid those keys, or remove their bindings in **Settings > Configure Krita > Canvas Input Settings**. -### Known limitations -- Pressing a modifier while the usual key is pressed, will result in conflict. For instance, pressing ctrl while using temporary eraser assigned to x will result in unwanted ctrl+x operation which cuts current layer. -- It is possible to activate multiple pie menus at the same time. -- Keyboard shortcuts assigned to actions can conflict with Canvas Input (General limitation of Krita). ## For krita plugin programmers Some parts of plugin code solve general problems, which can apply outside of Shortcut Composer. Those solutions were placed in separate packages that can be copy-pasted into any other plugin and reused there. -They depend only on original Krita API and PyQt5 with which krita is shipped. - -### Custom keyboard shortcut interface -Package `input_adapter` consists of `ActionManager` and `ComplexActionInterface` which together allow to recognise more keyboard events than usual krita action does. - -While usual actions can only recognise key press, implementing `ComplexActionInterface` lets you override methods performed on: -- key press -- short key release -- long key release -- every key release - -Each action needs to have public `name: str` attribute which is the same, as the one used in .action file, as well as `short_vs_long_press_time: float` which determines how many seconds need to elapse to consider that a key press was long. - -Use `ActionManager` instance to bind objects of those custom actions to krita during `CreateActions` phase: - -```python -""" -Print whether action key was released before of after -0.2 seconds from being pressed. -""" -from krita import Krita -from input_adapter import ActionManager, ComplexActionInterface - - -class CustomAction(ComplexActionInterface): - def __init__(self, name: str, press_time: float = 0.2): - self.name = name - self.short_vs_long_press_time = press_time - - def on_key_press(self): print("key was pressed") - def on_short_key_release(self): print("key released before than 0.2s") - def on_long_key_release(self): print("key released later than after 0.2s") - def on_every_key_release(self): pass - - -class MyExtension(Extension): - def setup(self) -> None: pass - def createActions(self, window) -> None: - action = CustomAction(name="Custom action name") - self.manager = ActionManager(window) - self.manager.bind_action(action) - -Krita.instance().addExtension(MyExtension(Krita.instance())) -``` - -### Config system -Package `config_system` consists of `Field` and `FieldGroup` which grant object-oriented API to control kritarc configuration file easier, than with API of krita. - ---- - -`Field` represents a single value in kritarc file. Once initialized with its group name, name and default value, it allows to: -- write a given value to kritarc. -- read current value from kritarc, parsing it to correct python type. -- reset the value to default. -- register a callback run on each value change. - -Type of default value passed on initlization is remembered, and used to parse values both on read and write. Supported types are: -- `int`, `list[int]`, -- `float`, `list[float]`, -- `str`, `list[str]`, -- `bool`, `list[bool]`, -- `Enum`, `list[Enum]` - -For empty, homogeneous lists, `parser_type` argument must be used to determine type of list elements. Default values are not saved when until the field does not exist in kritarc. Repeated saves of the same value are filtered, so that callbacks are not called when the same value is written multiple times one after the other. - ---- - -`FieldGroup` represents a section of fields in kritarc file. It simplifies the field creation by auto-completing the group name. - -FieldGroup holds and aggregates fields created with it. It allows to reset all the fields at once, and register a callback to all its fields: both existing and future ones. - ---- - -Example usage: -```python -from enum import Enum -from config_system import FieldGroup - - -class EnumMock(Enum): - MODE_A = 0 - MODE_B = 1 - -# Create a config group -group = FieldGroup("MyGroup") -# Register a callback on all three fields -group.register_callback(lambda: print("any field changed")) - -# Create three fields inside a group - for string and two enum lists -str_field = group.field(name="my_str", default="Sketch") -enums_field_1 = group.field("my_enums_1", [], parser_type=EnumMock) -enums_field_2 = group.field("my_enums_2", [EnumMock.MODE_A]) - -# Register a different callback on each field -str_field.register_callback(lambda: print("string changed")) -enums_field_1.register_callback(lambda: print("enum 1 changed")) -enums_field_2.register_callback(lambda: print("enum 2 changed")) - -# Change the value from default "Sketch" to "Digital" -str_field.write("Digital") -# Change the value from empty list to one with two values -enums_field_1.write([EnumMock.MODE_A, EnumMock.MODE_B]) -# Repeat the default value. Will be filtered -enums_field_2.write([EnumMock.MODE_A]) - -# The program will not break, as red values are the same as written ones -assert str_field.read() == "Digital" -assert enums_field_1.read() == [EnumMock.MODE_A, EnumMock.MODE_B] -assert enums_field_2.read() == [EnumMock.MODE_A] -``` - -The code above produces "MyGroup" section in kritarc file. my_enums_2 is missing, as the default value was not changed: -``` -[MyGroup] -my_str=Digital -my_enums_1=MODE_A\tMODE_B -``` - -Registered callbacks outputs on the terminal: -``` -any field changed -string changed -any field changed -enum 1 changed -``` - -Calling `group.reset_defaults()` would change both values back to their defaults, and produce the same output on the terminal, as resetting changes the fields. - -### Alternative API -Package `api_krita` wraps krita api offering PEP8 compatibility, typings, and docstring documentation. Most of objects attributes are now available as settables properties. - -```python -from .api_krita import Krita -from .api_krita.enums import BlendingMode, Tool, Toggle - -# active tool operations -tool = Krita.active_tool # get current tool -Krita.active_tool = Tool.FREEHAND_BRUSH # set current tool -Tool.FREEHAND_BRUSH.activate() # set current tool (alternative way) - -# operations on a document -document = Krita.get_active_document() -all_nodes = document.get_all_nodes() # all nodes with flattened structure -picked_node = all_nodes[3] - -picked_node.name = "My layer name" -picked_node.visible = True -picked_node.opacity = 50 # remapped from 0-255 go 0-100 [%] -document.active_node = picked_node -document.refresh() - -# Operations on a view -view = Krita.get_active_view() -view.brush_size = 100 -view.blending_mode = BlendingMode.NORMAL # Enumerated blending modes - -# Handling checkable actions -mirror_state = Toggle.MIRROR_CANVAS.state # get mirror state -Toggle.SOFT_PROOFING.state = False # turn off soft proofing -Toggle.PRESERVE_ALPHA.switch_state() # change state of preserve alpha -``` +They depend only on original [Krita API](https://api.kde.org/krita/html/classKrita.html) and PyQt5 with which krita is shipped. -Only functionalities that were needed during this plugin development are wrapped, so some of them are not yet available. The syntax can also change over time. +- [Custom keyboard shortcut interface](./shortcut_composer/input_adapter/) +- [Config system](./shortcut_composer/config_system/) +- [Alternative Krita API](./shortcut_composer/api_krita/) diff --git a/shortcut_composer/INFO.py b/shortcut_composer/INFO.py index 638a0d70..a702ce2e 100644 --- a/shortcut_composer/INFO.py +++ b/shortcut_composer/INFO.py @@ -1,6 +1,6 @@ # SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later -__version__ = "1.2.2" +__version__ = "1.3.0" __author__ = "Wojciech Trybus" __license__ = "GPL-3.0-or-later" diff --git a/shortcut_composer/actions.action b/shortcut_composer/actions.action index 5b262740..5adc32a6 100755 --- a/shortcut_composer/actions.action +++ b/shortcut_composer/actions.action @@ -11,14 +11,17 @@ 1 + krita_tool_move 1 + draw-eraser 1 + bar-transparency-unlocked @@ -27,10 +30,12 @@ 1 + current-layer 1 + all-layers @@ -39,10 +44,12 @@ 1 + opacity-increase 1 + selectionMask @@ -51,22 +58,27 @@ 1 + edit-undo 1 + all-layers 1 + addduplicateframe 1 + brushsize-increase 1 + zoom-original @@ -75,30 +87,42 @@ 1 + krita_tool_grid 1 + format-text-bold 1 + krita_tool_transform 1 + config-popup-palette 1 + config-popup-palette 1 + config-popup-palette + + + + 1 + config-popup-palette 1 + addlayer @@ -107,36 +131,43 @@ 0 + configure - + 1 1 + krita_tool_transform - + 1 1 + transform_icons_perspective 1 1 + transform_icons_warp 1 1 + transform_icons_cage 1 1 + transform_icons_liquify_main 1 1 + transform_icons_mesh diff --git a/shortcut_composer/actions.py b/shortcut_composer/actions.py index 9ce87b39..f8ed3c4e 100644 --- a/shortcut_composer/actions.py +++ b/shortcut_composer/actions.py @@ -275,6 +275,15 @@ def create_actions() -> List[templates.RawInstructions]: return [ active_color=QColor(110, 160, 235), ), + templates.PieMenu( + name="Pick local brush presets", + controller=controllers.PresetController(), + instructions=[instructions.SetBrushOnNonPaintable()], + values=[], + save_local=True, + active_color=QColor(234, 172, 0), + ), + # ....................................... # Insert your actions implementation here # ....................................... diff --git a/shortcut_composer/api_krita/README.md b/shortcut_composer/api_krita/README.md new file mode 100644 index 00000000..d400ecbc --- /dev/null +++ b/shortcut_composer/api_krita/README.md @@ -0,0 +1,35 @@ +### Alternative Krita API +Package `api_krita` wraps krita api offering PEP8 compatibility, typings, and docstring documentation. Most of objects attributes are now available as settables properties. + +```python +from .api_krita import Krita +from .api_krita.enums import BlendingMode, Tool, Toggle + +# active tool operations +tool = Krita.active_tool # get current tool +Krita.active_tool = Tool.FREEHAND_BRUSH # set current tool +Tool.FREEHAND_BRUSH.activate() # set current tool (alternative way) + +# operations on a document +document = Krita.get_active_document() +all_nodes = document.get_all_nodes() # all nodes with flattened structure +picked_node = all_nodes[3] + +picked_node.name = "My layer name" +picked_node.visible = True +picked_node.opacity = 50 # remapped from 0-255 go 0-100 [%] +document.active_node = picked_node +document.refresh() + +# Operations on a view +view = Krita.get_active_view() +view.brush_size = 100 +view.blending_mode = BlendingMode.NORMAL # Enumerated blending modes + +# Handling checkable actions +mirror_state = Toggle.MIRROR_CANVAS.state # get mirror state +Toggle.SOFT_PROOFING.state = False # turn off soft proofing +Toggle.PRESERVE_ALPHA.switch_state() # change state of preserve alpha +``` + +Only functionalities that were needed during this plugin development are wrapped, so some of them are not yet available. The syntax can also change over time. diff --git a/shortcut_composer/api_krita/core_api.py b/shortcut_composer/api_krita/core_api.py index dbea2368..08358070 100644 --- a/shortcut_composer/api_krita/core_api.py +++ b/shortcut_composer/api_krita/core_api.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from krita import Krita as Api, Extension, qApp -from typing import Callable, Protocol, Any, Optional +from typing import Callable, Protocol, Any, Dict, Optional from PyQt5.QtWidgets import ( QMainWindow, @@ -36,9 +36,12 @@ def get_active_view(self) -> View: """Return wrapper of krita `View`.""" return View(self.instance.activeWindow().activeView()) - def get_active_document(self) -> Document: + def get_active_document(self) -> Optional[Document]: """Return wrapper of krita `Document`.""" - return Document(self.instance.activeDocument()) + document = self.instance.activeDocument() + if document is None: + return None + return Document(document) def get_active_canvas(self) -> Canvas: """Return wrapper of krita `Canvas`.""" @@ -57,7 +60,7 @@ def get_action_shortcut(self, action_name: str) -> QKeySequence: """Return shortcut of krita action called `action_name`.""" return self.instance.action(action_name).shortcut() - def get_presets(self) -> dict: + def get_presets(self) -> Dict[str, Any]: """Return a list of unwrapped preset objects""" return self.instance.resources('preset') diff --git a/shortcut_composer/api_krita/enums/blending_mode.py b/shortcut_composer/api_krita/enums/blending_mode.py index 190c3571..381c5cae 100644 --- a/shortcut_composer/api_krita/enums/blending_mode.py +++ b/shortcut_composer/api_krita/enums/blending_mode.py @@ -142,7 +142,7 @@ class BlendingMode(Enum): REFLECT_FREEZE = "reflect_freeze" @property - def pretty_name(self): + def pretty_name(self) -> str: """Format blending mode name like: `Darker Color`.""" parts = self.name.split("_") parts = [f"{part[0]}{part[1:].lower()}" for part in parts] diff --git a/shortcut_composer/api_krita/enums/node_types.py b/shortcut_composer/api_krita/enums/node_types.py index 7ee0359e..4d15091e 100644 --- a/shortcut_composer/api_krita/enums/node_types.py +++ b/shortcut_composer/api_krita/enums/node_types.py @@ -34,7 +34,7 @@ def icon(self) -> QIcon: return Api.instance().icon(icon_name) @property - def pretty_name(self): + def pretty_name(self) -> str: """Format node type name like: `Paint layer`.""" return f"{self.name[0]}{self.name[1:].lower().replace('_', ' ')}" diff --git a/shortcut_composer/api_krita/enums/toggle.py b/shortcut_composer/api_krita/enums/toggle.py index 1e59ee63..41bca388 100644 --- a/shortcut_composer/api_krita/enums/toggle.py +++ b/shortcut_composer/api_krita/enums/toggle.py @@ -27,7 +27,7 @@ class Toggle(Enum): SNAP_TO_GRID = "view_snap_to_grid" @property - def pretty_name(self): + def pretty_name(self) -> str: """Format toggle name like: `Preserve alpha`.""" return f"{self.name[0]}{self.name[1:].lower().replace('_', ' ')}" diff --git a/shortcut_composer/api_krita/enums/tool.py b/shortcut_composer/api_krita/enums/tool.py index 760e09b1..38a631ae 100644 --- a/shortcut_composer/api_krita/enums/tool.py +++ b/shortcut_composer/api_krita/enums/tool.py @@ -64,11 +64,10 @@ def is_paintable(tool: 'Tool') -> bool: @property def icon(self) -> QIcon: """Return the icon of this tool.""" - icon_name = _ICON_NAME_MAP.get(self, "edit-delete") - return Api.instance().icon(icon_name) + return Api.instance().action(self.value).icon() @property - def pretty_name(self): + def pretty_name(self) -> str: """Format tool name like: `Shape select tool`.""" return f"{self.name[0]}{self.name[1:].lower().replace('_', ' ')} tool" @@ -83,43 +82,3 @@ def pretty_name(self): Tool.POLYLINE, } """Set of tools that are used to paint on the canvas.""" - -_ICON_NAME_MAP = { - Tool.FREEHAND_BRUSH: "krita_tool_freehand", - Tool.FREEHAND_SELECTION: "tool_outline_selection", - Tool.GRADIENT: "krita_tool_gradient", - Tool.LINE: "krita_tool_line", - Tool.TRANSFORM: "krita_tool_transform", - Tool.MOVE: "krita_tool_move", - Tool.RECTANGULAR_SELECTION: "tool_rect_selection", - Tool.CONTIGUOUS_SELECTION: "tool_contiguous_selection", - Tool.REFERENCE: "krita_tool_reference_images", - Tool.CROP: "tool_crop", - Tool.BEZIER_PATH: "krita_draw_path", - Tool.FREEHAND_PATH: "krita_tool_freehandvector", - Tool.POLYLINE: "polyline", - Tool.SHAPE_SELECT: "select", - Tool.ASSISTANTS: "krita_tool_assistant", - Tool.COLOR_SAMPLER: "krita_tool_color_sampler", - Tool.POLYGON: "krita_tool_polygon", - Tool.MEASUREMENT: "krita_tool_measure", - Tool.TEXT: "draw-text", - Tool.ELLIPSE: "krita_tool_ellipse", - Tool.FILL: "krita_tool_color_fill", - Tool.ENCLOSE_AND_FILL: "krita_tool_enclose_and_fill", - Tool.BEZIER_SELECTION: "tool_path_selection", - Tool.DYNAMIC_BRUSH: "krita_tool_dyna", - Tool.RECTANGLE: "krita_tool_rectangle", - Tool.PAN: "tool_pan", - Tool.MULTI_BRUSH: "krita_tool_multihand", - Tool.EDIT_SHAPES: "shape_handling", - Tool.ELIPTICAL_SELECTION: "tool_elliptical_selection", - Tool.SMART_PATCH: "krita_tool_smart_patch", - Tool.COLORIZE_MASK: "krita_tool_lazybrush", - Tool.SIMILAR_COLOR_SELECTION: "tool_similar_selection", - Tool.ZOOM: "tool_zoom", - Tool.MAGNETIC_SELECTION: "tool_magnetic_selection", - Tool.CALLIGRAPHY: "calligraphy", - Tool.POLYGONAL_SELECTION: "tool_polygonal_selection" -} -"""Maps tools to names of their icons.""" diff --git a/shortcut_composer/api_krita/pyqt/__init__.py b/shortcut_composer/api_krita/pyqt/__init__.py index a340f019..cfa67343 100644 --- a/shortcut_composer/api_krita/pyqt/__init__.py +++ b/shortcut_composer/api_krita/pyqt/__init__.py @@ -4,6 +4,7 @@ """Wrappers and utilities based on PyQt5 objects.""" from .custom_widgets import AnimatedWidget, BaseWidget +from .safe_confirm_button import SafeConfirmButton from .pixmap_transform import PixmapTransform from .round_button import RoundButton from .colorizer import Colorizer @@ -12,6 +13,7 @@ from .text import Text __all__ = [ + "SafeConfirmButton", "PixmapTransform", "AnimatedWidget", "RoundButton", diff --git a/shortcut_composer/api_krita/pyqt/custom_widgets.py b/shortcut_composer/api_krita/pyqt/custom_widgets.py index de9b085f..9af152c4 100644 --- a/shortcut_composer/api_krita/pyqt/custom_widgets.py +++ b/shortcut_composer/api_krita/pyqt/custom_widgets.py @@ -27,7 +27,7 @@ def move_center(self, new_center: QPoint) -> None: """Move the widget by providing a new center point.""" self.move(new_center-self.center) # type: ignore - def setGeometry(self, ax: int, ay: int, aw: int, ah: int): + def setGeometry(self, ax: int, ay: int, aw: int, ah: int) -> None: center = self.center_global super().setGeometry(ax, ay, aw, ah) self.move_center(center) @@ -42,20 +42,20 @@ def __init__(self, parent, animation_time: float = 0) -> None: self._animation_interval = self._read_animation_interval() self._animation_timer = Timer(self._increase_opacity, 17) - def show(self): + def show(self) -> None: """Decrease opacity to 0, and start a timer which animates it.""" self.setWindowOpacity(0) self._animation_timer.start() super().show() - def _increase_opacity(self): + def _increase_opacity(self) -> None: """Add interval to current opacity, stop the timer when full.""" current_opacity = self.windowOpacity() self.setWindowOpacity(current_opacity+self._animation_interval) if current_opacity >= 1: self._animation_timer.stop() - def _read_animation_interval(self): + def _read_animation_interval(self) -> float: """Return how much opacity (0-1) should be increased on each frame.""" if time := self._animation_time: return 0.0167/time diff --git a/shortcut_composer/api_krita/pyqt/round_button.py b/shortcut_composer/api_krita/pyqt/round_button.py index bb94e759..991a6176 100644 --- a/shortcut_composer/api_krita/pyqt/round_button.py +++ b/shortcut_composer/api_krita/pyqt/round_button.py @@ -41,7 +41,7 @@ def __init__( self.resize(initial_radius) self.show() - def resize(self, radius: int): + def resize(self, radius: int) -> None: """Change the size and repaint the button.""" self.setGeometry(0, 0, radius*2, radius*2) @@ -64,7 +64,7 @@ def _color_to_str(color: QColor) -> str: return f'''rgba( {color.red()}, {color.green()}, {color.blue()}, {color.alpha()})''' @property - def _border_color(self): + def _border_color(self) -> QColor: """Color of button border.""" return QColor( min(self._background_color.red()+15, 255), diff --git a/shortcut_composer/api_krita/pyqt/safe_confirm_button.py b/shortcut_composer/api_krita/pyqt/safe_confirm_button.py new file mode 100644 index 00000000..ab26fa2a --- /dev/null +++ b/shortcut_composer/api_krita/pyqt/safe_confirm_button.py @@ -0,0 +1,89 @@ +from typing import Optional + +from PyQt5.QtGui import QIcon +from PyQt5.QtCore import pyqtSignal, QEvent +from PyQt5.QtWidgets import QWidget, QPushButton + + +class SafeConfirmButton(QPushButton): + """ + Button that requires repeating click to confirm first one was intentional. + + After first click, the border is changed, and button changes its + label to "Confirm". + + Moving the mouse out of the button aborts the confirmation mode. + """ + + clicked = pyqtSignal() # type: ignore + _empty_icon = QIcon() + + def __init__( + self, + icon: QIcon = QIcon(), + text: str = "", + confirm_text: str = "Confirm?", + parent: Optional[QWidget] = None + ) -> None: + super().__init__(icon, text, parent) + super().clicked.connect(self._clicked) + self._main_text = text + self.confirm_text = confirm_text + self._icon = icon + self._confirm_mode = False + + def _clicked(self) -> None: + """Enter the confirmation mode. If already there, forward the click.""" + if self._confirm_mode: + self._confirm_mode = False + return self.clicked.emit() + self._confirm_mode = True + + @property + def _confirm_mode(self) -> bool: + """Return whether in confirmation mode.""" + return self.__confirm_mode + + @_confirm_mode.setter + def _confirm_mode(self, value: bool) -> None: + """Set mode. Confirmation mode requires red border and other text.""" + if value is True: + self.setText(self.confirm_text) + self.setIcon(self._empty_icon) + self.setStyleSheet( + "border-style: solid;" + "border-color: Tomato;" + "border-radius: 3px;" + "border-width: 1px") + else: + self.setText(self._main_text) + self.setIcon(self._icon) + self.setStyleSheet("") + self.__confirm_mode = value + + @property + def main_text(self) -> str: + """Return the text displayed when not in confirmation mode.""" + return self._main_text + + @main_text.setter + def main_text(self, text: str) -> None: + """Set the text displayed when not in confirmation mode.""" + self._main_text = text + self.setText(self._main_text) + + @property + def icon(self) -> QIcon: + """Return the icon displayed when not in confirmation mode.""" + return self._icon + + @icon.setter + def icon(self, icon: QIcon) -> None: + """Set the icon displayed when not in confirmation mode.""" + self._icon = icon + self.setIcon(self._icon) + + def leaveEvent(self, e: QEvent) -> None: + """Abort confirmation mode when mouse leaves the button.""" + self._confirm_mode = False + super().leaveEvent(e) diff --git a/shortcut_composer/api_krita/pyqt/text.py b/shortcut_composer/api_krita/pyqt/text.py index 347261c2..6beedb94 100644 --- a/shortcut_composer/api_krita/pyqt/text.py +++ b/shortcut_composer/api_krita/pyqt/text.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later -from dataclasses import dataclass +from dataclasses import dataclass, field from PyQt5.QtGui import QColor @@ -10,4 +10,4 @@ class Text: """Text along with its color.""" value: str - color: QColor = QColor("white") + color: QColor = field(default_factory=lambda: QColor("white")) diff --git a/shortcut_composer/api_krita/pyqt/timer.py b/shortcut_composer/api_krita/pyqt/timer.py index 1191e805..2b7fa0d7 100644 --- a/shortcut_composer/api_krita/pyqt/timer.py +++ b/shortcut_composer/api_krita/pyqt/timer.py @@ -16,10 +16,10 @@ def __init__(self, target: EmptyCallback, interval_ms: int) -> None: self._timer.timeout.connect(target) self._interval_ms = interval_ms - def start(self): + def start(self) -> None: """Start a timer.""" self._timer.start(self._interval_ms) - def stop(self): + def stop(self) -> None: """Stop a timer.""" self._timer.stop() diff --git a/shortcut_composer/api_krita/wrappers/database.py b/shortcut_composer/api_krita/wrappers/database.py index 0bcea040..021045da 100644 --- a/shortcut_composer/api_krita/wrappers/database.py +++ b/shortcut_composer/api_krita/wrappers/database.py @@ -68,7 +68,8 @@ def get_brush_tags(self) -> List[str]: t.active = 1 AND t.resource_type_id = 5 ''' - return self._single_column_query(sql_query, "tag") + presets = self._single_column_query(sql_query, "tag") + return sorted(presets, key=str.lower) def close(self) -> None: """Close the connection with the database.""" diff --git a/shortcut_composer/api_krita/wrappers/document.py b/shortcut_composer/api_krita/wrappers/document.py index b8441681..390592d0 100644 --- a/shortcut_composer/api_krita/wrappers/document.py +++ b/shortcut_composer/api_krita/wrappers/document.py @@ -3,8 +3,8 @@ from dataclasses import dataclass from typing import List, Protocol +from PyQt5.QtCore import QByteArray from ..enums import NodeType - from .node import Node, KritaNode @@ -19,6 +19,14 @@ def resolution(self) -> int: ... def currentTime(self) -> int: ... def setCurrentTime(self, time: int) -> None: ... def refreshProjection(self) -> None: ... + def annotation(self, type: str) -> QByteArray: ... + def annotationTypes(self) -> List[str]: ... + + def setAnnotation( + self, + type: str, + description: str, + annotation: bytes) -> None: ... @dataclass @@ -41,14 +49,15 @@ def create_node(self, name: str, node_type: NodeType) -> Node: """ Create a Node. - IMPORTANT: Created node must be then added to node tree to be usable from Krita. - For example with add_child_node() method of Node Class. + IMPORTANT: Created node must be then added to node tree to be + usable from Krita. For example with add_child_node() method of + Node Class. - When relevant, the new Node will have the colorspace of the image by default; - that can be changed with Node::setColorSpace. + When relevant, the new Node will have the colorspace of the + image by default; that can be changed with Node::setColorSpace. - The settings and selections for relevant layer and mask types can also be set - after the Node has been created. + The settings and selections for relevant layer and mask types + can also be set after the Node has been created. """ return Node(self.document.createNode(name, node_type.value)) @@ -66,11 +75,11 @@ def get_top_nodes(self) -> List[Node]: """Return a list of `Nodes` without a parent.""" return [Node(node) for node in self.document.topLevelNodes()] - def get_all_nodes(self) -> List[Node]: + def get_all_nodes(self, include_collapsed: bool = False) -> List[Node]: """Return a list of all `Nodes` in this document bottom to top.""" def recursive_search(nodes: List[Node], found_so_far: List[Node]): for node in nodes: - if not node.collapsed: + if include_collapsed or not node.collapsed: recursive_search(node.get_child_nodes(), found_so_far) found_so_far.append(node) return found_so_far @@ -85,6 +94,17 @@ def refresh(self) -> None: """Refresh OpenGL projection of this document.""" self.document.refreshProjection() - def __bool__(self) -> bool: - """Return true if the wrapped document exists.""" - return bool(self.document) + def read_annotation(self, name: str) -> str: + """Read annotation from .kra document parsed as string.""" + return self.document.annotation(name).data().decode(encoding="utf-8") + + def write_annotation(self, name: str, description: str, value: str): + """Write annotation to .kra document.""" + self.document.setAnnotation( + name, + description, + value.encode(encoding="utf-8")) + + def contains_annotation(self, name: str) -> bool: + """Return if annotation of given name is stored in .kra.""" + return name in self.document.annotationTypes() diff --git a/shortcut_composer/config_system/README.md b/shortcut_composer/config_system/README.md new file mode 100644 index 00000000..05c6754f --- /dev/null +++ b/shortcut_composer/config_system/README.md @@ -0,0 +1,82 @@ +### Config system +Package `config_system` consists of `Field` and `FieldGroup` which grant object-oriented API to control kritarc configuration file easier, than with API of krita. + +--- + +`Field` represents a single value in kritarc file. Once initialized with its group name, name and default value, it allows to: +- write a given value to kritarc. +- read current value from kritarc, parsing it to correct python type. +- reset the value to default. +- register a callback run on each value change. + +Type of default value passed on initlization is remembered, and used to parse values both on read and write. Supported types are: +- `int`, `list[int]`, +- `float`, `list[float]`, +- `str`, `list[str]`, +- `bool`, `list[bool]`, +- `Enum`, `list[Enum]` + +For empty, homogeneous lists, `parser_type` argument must be used to determine type of list elements. Default values are not saved when until the field does not exist in kritarc. Repeated saves of the same value are filtered, so that callbacks are not called when the same value is written multiple times one after the other. + +--- + +`FieldGroup` represents a section of fields in kritarc file. It simplifies the field creation by auto-completing the group name. + +FieldGroup holds and aggregates fields created with it. It allows to reset all the fields at once, and register a callback to all its fields: both existing and future ones. + +--- + +Example usage: +```python +from enum import Enum +from config_system import FieldGroup + + +class EnumMock(Enum): + MODE_A = 0 + MODE_B = 1 + +# Create a config group +group = FieldGroup("MyGroup") +# Register a callback on all three fields +group.register_callback(lambda: print("any field changed")) + +# Create three fields inside a group - for string and two enum lists +str_field = group.field(name="my_str", default="Sketch") +enums_field_1 = group.field("my_enums_1", [], parser_type=EnumMock) +enums_field_2 = group.field("my_enums_2", [EnumMock.MODE_A]) + +# Register a different callback on each field +str_field.register_callback(lambda: print("string changed")) +enums_field_1.register_callback(lambda: print("enum 1 changed")) +enums_field_2.register_callback(lambda: print("enum 2 changed")) + +# Change the value from default "Sketch" to "Digital" +str_field.write("Digital") +# Change the value from empty list to one with two values +enums_field_1.write([EnumMock.MODE_A, EnumMock.MODE_B]) +# Repeat the default value. Will be filtered +enums_field_2.write([EnumMock.MODE_A]) + +# The program will not break, as red values are the same as written ones +assert str_field.read() == "Digital" +assert enums_field_1.read() == [EnumMock.MODE_A, EnumMock.MODE_B] +assert enums_field_2.read() == [EnumMock.MODE_A] +``` + +The code above produces "MyGroup" section in kritarc file. my_enums_2 is missing, as the default value was not changed: +``` +[MyGroup] +my_str=Digital +my_enums_1=MODE_A\tMODE_B +``` + +Registered callbacks outputs on the terminal: +``` +any field changed +string changed +any field changed +enum 1 changed +``` + +Calling `group.reset_defaults()` would change both values back to their defaults, and produce the same output on the terminal, as resetting changes the fields. diff --git a/shortcut_composer/config_system/api_krita.py b/shortcut_composer/config_system/api_krita.py index f16bc553..d4f9be8b 100644 --- a/shortcut_composer/config_system/api_krita.py +++ b/shortcut_composer/config_system/api_krita.py @@ -4,7 +4,10 @@ """Required part of api_krita package, so that no dependency is needed.""" from krita import Krita as Api -from typing import Any, Optional +from typing import Any, Optional, Protocol, List +from dataclasses import dataclass + +from PyQt5.QtCore import QByteArray class KritaInstance: @@ -33,5 +36,47 @@ def write_setting(self, group: str, name: str, value: Any) -> None: """Write setting to kritarc file. Value type will be lost.""" self.instance.writeSetting(group, name, str(value)) + def get_active_document(self) -> Optional['Document']: + """Return wrapper of krita `Document`.""" + document = self.instance.activeDocument() + if document is None: + return None + return Document(document) + + +class KritaDocument(Protocol): + """Krita `Document` object API.""" + + def setAnnotation( + self, + type: str, + description: str, + annotation: bytes) -> None: ... + + def annotation(self, type: str) -> QByteArray: ... + def annotationTypes(self) -> List[str]: ... + + +@dataclass +class Document: + """Wraps krita `Document` for typing, docs and PEP8 compatibility.""" + + document: KritaDocument + + def read_annotation(self, name: str) -> str: + """Read annotation from .kra document parsed as string.""" + return self.document.annotation(name).data().decode(encoding="utf-8") + + def write_annotation(self, name: str, description: str, value: str): + """Write annotation to .kra document.""" + self.document.setAnnotation( + name, + description, + value.encode(encoding="utf-8")) + + def contains_annotation(self, name: str) -> bool: + """Return if annotation of given name is stored in .kra.""" + return name in self.document.annotationTypes() + Krita = KritaInstance() diff --git a/shortcut_composer/config_system/field.py b/shortcut_composer/config_system/field.py index 7b2ff8f5..efeed33e 100644 --- a/shortcut_composer/config_system/field.py +++ b/shortcut_composer/config_system/field.py @@ -8,12 +8,14 @@ class Field(Generic[T]): """ - Representation of a single value in kritarc file. + Representation of a single configuration value. + + Based on `local`, data can be saved in kritarc or .kra document. Once initialized with its group name, name, and default value, it allows to: - - write a given value to kritarc. - - read current value from kritarc, parsing it to correct python type. + - write a given value to kritarc or .kra. + - read current value and parse it to correct python type. - reset the value to default. - register a callback run on each value change. @@ -29,7 +31,7 @@ class Field(Generic[T]): determine type of list elements. Default values are not saved when until the field does not exist in - kritarc. Repeated saves of the same value are filtered, so that + its location. Repeated saves of the same value are filtered, so that callbacks are not called when the same value is written multiple times one after the other. """ @@ -39,14 +41,16 @@ def __new__( config_group: str, name: str, default: T, - parser_type: Optional[type] = None + parser_type: Optional[type] = None, + local: bool = False, ) -> 'Field[T]': from .field_implementations import ListField, NonListField cls.original = super().__new__ - if isinstance(default, list): - return ListField(config_group, name, default, parser_type) - return NonListField(config_group, name, default) + if not isinstance(default, list): + return NonListField( + config_group, name, default, parser_type, local) + return ListField(config_group, name, default, parser_type, local) config_group: str """Configuration section in kritarc toml file.""" diff --git a/shortcut_composer/config_system/field_base.py b/shortcut_composer/config_system/field_base.py index 3ba0c97e..bb2af0f9 100644 --- a/shortcut_composer/config_system/field_base.py +++ b/shortcut_composer/config_system/field_base.py @@ -1,13 +1,13 @@ # SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later -from typing import TypeVar, Generic, Callable, List +from typing import TypeVar, Generic, Callable, List, Optional from abc import ABC, abstractmethod from enum import Enum -from .api_krita import Krita from .parsers import Parser, BoolParser, EnumParser, BasicParser from .field import Field +from .save_location import SaveLocation T = TypeVar('T') E = TypeVar('E', bound=Enum) @@ -27,17 +27,21 @@ def __init__( config_group: str, name: str, default: T, - ): + parser_type: Optional[type] = None, + local: bool = False, + ) -> None: self.config_group = config_group self.name = name self.default = default + self.parser_type = parser_type + self.location = SaveLocation.LOCAL if local else SaveLocation.GLOBAL self._on_change_callbacks: List[Callable[[], None]] = [] - def register_callback(self, callback: Callable[[], None]): + def register_callback(self, callback: Callable[[], None]) -> None: """Store callback in internal list.""" self._on_change_callbacks.append(callback) - def write(self, value: T): + def write(self, value: T) -> None: """Write value to file and run callbacks if it was not redundant.""" if not isinstance(value, type(self.default)): raise TypeError(f"{value} not of type {type(self.default)}") @@ -45,7 +49,7 @@ def write(self, value: T): if self._is_write_redundant(value): return - Krita.write_setting( + self.location.write( group=self.config_group, name=self.name, value=self._to_string(value)) @@ -72,7 +76,7 @@ def _is_write_redundant(self, value: T) -> bool: """ if self.read() == value: return True - raw = Krita.read_setting(self.config_group, self.name) + raw = self.location.read(self.config_group, self.name) return raw is None and value == self.default def reset_default(self) -> None: diff --git a/shortcut_composer/config_system/field_group.py b/shortcut_composer/config_system/field_group.py index 6dbf72ec..97658d18 100644 --- a/shortcut_composer/config_system/field_group.py +++ b/shortcut_composer/config_system/field_group.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later -from typing import TypeVar, Optional, Callable, List +from typing import TypeVar, Optional, Callable, Iterator, List from .field import Field T = TypeVar('T') @@ -29,26 +29,27 @@ def field( self, name: str, default: T, - parser_type: Optional[type] = None + parser_type: Optional[type] = None, + local: bool = False, ) -> Field[T]: """Create and return a new field in the group.""" - field = Field(self.name, name, default, parser_type) + field = Field(self.name, name, default, parser_type, local) self._fields.append(field) for callback in self._callbacks: field.register_callback(callback) return field - def reset_default(self): + def reset_default(self) -> None: """Reset values of all fields stored in this group.""" for field in self._fields: field.reset_default() - def register_callback(self, callback: Callable[[], None]): + def register_callback(self, callback: Callable[[], None]) -> None: """Register a callback on every past and future field in group.""" self._callbacks.append(callback) for field in self._fields: field.register_callback(callback) - def __iter__(self): + def __iter__(self) -> Iterator[Field]: """Iterate over all fields in the group.""" return iter(self._fields) diff --git a/shortcut_composer/config_system/field_implementations.py b/shortcut_composer/config_system/field_implementations.py index c8cd98e1..83e88988 100644 --- a/shortcut_composer/config_system/field_implementations.py +++ b/shortcut_composer/config_system/field_implementations.py @@ -1,13 +1,8 @@ # SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later -from typing import ( - TypeVar, - Generic, - Optional, - List) +from typing import TypeVar, Generic, Optional, List -from .api_krita import Krita from .parsers import Parser from .field_base import FieldBase @@ -23,13 +18,14 @@ def __init__( name: str, default: T, parser_type: Optional[type] = None, + local: bool = False, ) -> None: - super().__init__(config_group, name, default) + super().__init__(config_group, name, default, parser_type, local) self._parser: Parser[T] = self._get_parser(type(self.default)) def read(self) -> T: """Return value from kritarc parsed to field type.""" - raw = Krita.read_setting(self.config_group, self.name) + raw = self.location.read(self.config_group, self.name) if raw is None: return self.default return self._parser.parse_to(raw) @@ -48,15 +44,17 @@ def __init__( name: str, default: List[T], parser_type: Optional[type] = None, + local: bool = False, ) -> None: - super().__init__(config_group, name, default) - self._parser: Parser[T] = self._get_parser(self._get_type(parser_type)) + super().__init__(config_group, name, default, parser_type, local) + self._parser: Parser[T] = self._get_parser( + self._get_type(self.parser_type)) - def write(self, value: List[T]): + def write(self, value: List[T]) -> None: for element in value: if not isinstance(element, self._parser.type): raise ValueError(f"{value} not of type {type(self.default)}") - return super().write(value) + super().write(value) def _get_type(self, passed_type: Optional[type]) -> type: """ @@ -77,10 +75,13 @@ def read(self) -> List[T]: Each list element requires parsing. """ - raw = Krita.read_setting(self.config_group, self.name) + raw = self.location.read(self.config_group, self.name) if raw is None: return self.default + if raw == "": + return [] + values_list = raw.split("\t") return [self._parser.parse_to(value) for value in values_list] diff --git a/shortcut_composer/config_system/fields/__init__.py b/shortcut_composer/config_system/fields/__init__.py new file mode 100644 index 00000000..5099fa0b --- /dev/null +++ b/shortcut_composer/config_system/fields/__init__.py @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from .dual_field import DualField +from .field_with_editable_default import FieldWithEditableDefault + +__all__ = ["DualField", "FieldWithEditableDefault"] diff --git a/shortcut_composer/config_system/fields/dual_field.py b/shortcut_composer/config_system/fields/dual_field.py new file mode 100644 index 00000000..7b4e23f1 --- /dev/null +++ b/shortcut_composer/config_system/fields/dual_field.py @@ -0,0 +1,81 @@ +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from ..field import Field +from ..field_group import FieldGroup + +from typing import Callable, Generic, Optional, TypeVar + +T = TypeVar("T") +F = TypeVar("F", bound=Field) + + +class DualField(Field, Generic[T]): + """ + Field switching save location based on passed field. + Implementation uses two identical fields, but with different save + location. Each time DualField is red or written, correct field is + picked from the determiner field. + NOTE: Callbacks are always stored in the global field, as they + wouldn't run in local one when switching between documents. + """ + def __new__(cls, *args, **kwargs) -> 'DualField[T]': + obj = object.__new__(cls) + obj.__init__(*args, **kwargs) + return obj + + def __init__( + self, + group: FieldGroup, + is_local_determiner: Field[bool], + field_name: str, + default: T, + parser_type: Optional[type] = None + ) -> None: + self.name = field_name + self.config_group = group.name + self._is_local_determiner = is_local_determiner + self._is_local_determiner.register_callback(self.refresh) + self._loc = group.field(field_name, default, parser_type, local=True) + self._glob = group.field(field_name, default, parser_type, local=False) + + @property + def default(self) -> T: + return self._glob.default + + @default.setter + def default(self, value: T) -> None: + self._loc.default = value + self._glob.default = value + + def write(self, value: T) -> None: + """ + Write to correct internal fields, based on determiner. + Global field must always be written to activate callbacks. + """ + if self._is_local_determiner.read(): + self._loc.write(value) + self._glob.write(value) + + def read(self) -> T: + """Read from local or global field, based on determiner.""" + if self._is_local_determiner.read(): + return self._loc.read() + return self._glob.read() + + def register_callback(self, callback: Callable[[], None]) -> None: + """Subscribe callback to both fields, as only one changes on write.""" + self._glob.register_callback(callback) + + def reset_default(self) -> None: + """Reset both fields to default.""" + self._loc.reset_default() + self._glob.reset_default() + + def refresh(self) -> None: + """ + Write red value back to itself. + Need to be performed manually when active document changes, as it does + not run callbacks. + """ + self.write(self.read()) diff --git a/shortcut_composer/config_system/fields/field_with_editable_default.py b/shortcut_composer/config_system/fields/field_with_editable_default.py new file mode 100644 index 00000000..0ce89e73 --- /dev/null +++ b/shortcut_composer/config_system/fields/field_with_editable_default.py @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from ..field import Field + +from typing import Callable, Generic, TypeVar + +T = TypeVar("T") +F = TypeVar("F", bound=Field) + + +class FieldWithEditableDefault(Field, Generic[T, F]): + def __new__(cls, *args, **kwargs) -> 'FieldWithEditableDefault[T, F]': + obj = object.__new__(cls) + obj.__init__(*args, **kwargs) + return obj + + def __init__(self, field: F, field_with_default: Field[T]): + self.field = field + self._default_field = field_with_default + + def handle_change_of_default(): + self.field.default = self._default_field.read() + self._default_field.register_callback(handle_change_of_default) + handle_change_of_default() + + self.config_group = self.field.config_group + self.name = self.field.name + + @property + def default(self) -> T: + return self.field.default + + @default.setter + def default(self, value: T) -> None: + self.field.default = value + self._default_field.write(value) + + def write(self, value: T) -> None: + self.field.write(value) + + def read(self) -> T: + return self.field.read() + + def register_callback(self, callback: Callable[[], None]) -> None: + self.field.register_callback(callback) + + def reset_default(self) -> None: + self.field.reset_default() diff --git a/shortcut_composer/config_system/save_location.py b/shortcut_composer/config_system/save_location.py new file mode 100644 index 00000000..0762c1ab --- /dev/null +++ b/shortcut_composer/config_system/save_location.py @@ -0,0 +1,80 @@ +from typing import Any, Optional, Protocol +from enum import Enum +from .api_krita import Krita + + +class SupportsReadWrite(Protocol): + """Allows reading and writing configuration which groups its fields.""" + + def write(self, group: str, name: str, value: Any) -> None: ... + def read(self, group: str, name: str, default: str) -> Optional[str]: ... + + +class GlobalSettings(SupportsReadWrite): + """Gives read/write interface for kritarc file.""" + + @staticmethod + def write(group: str, name: str, value: Any) -> None: + """Write value to kritarc.""" + Krita.write_setting(group=group, name=name, value=value) + + @staticmethod + def read( + group: str, + name: str, + default: str = "Not stored" + ) -> Optional[str]: + """Write value from kritarc.""" + return Krita.read_setting(group=group, name=name, default=default) + + +class LocalSettings(SupportsReadWrite): + """Gives read/write interface to .kra document annotations. """ + + @staticmethod + def write(group: str, name: str, value: Any) -> None: + """Write value to .kra document as its annotation.""" + document = Krita.get_active_document() + if document is not None: + document.write_annotation(f"{group} {name}", "", str(value)) + + @staticmethod + def read( + group: str, + name: str, + default: str = "Not stored" + ) -> Optional[str]: + """Read value from .kra document stored in its annotation.""" + document = Krita.get_active_document() + annotation_name = f"{group} {name}" + + if (document is None + or not document.contains_annotation(annotation_name)): + return None if default == "Not stored" else default + + return document.read_annotation(annotation_name) + + +class SaveLocation(Enum): + """Enum with types of configuration fields. Grants the same interface.""" + + GLOBAL = GlobalSettings + LOCAL = LocalSettings + + def write(self, group: str, name: str, value: Any) -> None: + """Write value to picked location.""" + self.value.write(group, name, value) + + def read( + self, + group: str, + name: str, + default: str = "Not stored" + ) -> Optional[str]: + """Read value from picked location.""" + return self.value.read(group, name, default) + + @property + def value(self) -> SupportsReadWrite: + """Enum holds values of type which support ReadWrite interface.""" + return super().value diff --git a/shortcut_composer/config_system/ui/config_form_widget.py b/shortcut_composer/config_system/ui/config_form_widget.py index 396a9581..4679e56e 100644 --- a/shortcut_composer/config_system/ui/config_form_widget.py +++ b/shortcut_composer/config_system/ui/config_form_widget.py @@ -50,7 +50,7 @@ def add_row(self, element: ConfigBasedWidget) -> None: self._widgets.append(element) self._layout.addRow(f"{element.pretty_name}:", element.widget) - def add_title(self, text: str): + def add_title(self, text: str) -> None: """Add a label with given text.""" label = QLabel(text) label.setAlignment(Qt.AlignCenter) diff --git a/shortcut_composer/core_components/controller_base.py b/shortcut_composer/core_components/controller_base.py index ace7f9de..4900cd85 100644 --- a/shortcut_composer/core_components/controller_base.py +++ b/shortcut_composer/core_components/controller_base.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later -from typing import Optional, Union, Generic, TypeVar +from typing import Optional, Union, Generic, TypeVar, Type from PyQt5.QtGui import QPixmap, QIcon from api_krita.pyqt import Text @@ -11,7 +11,8 @@ class Controller(Generic[T]): """Component that allows to get and set a specific property of krita.""" - default_value: Optional[T] = None + TYPE: Type[T] + DEFAULT_VALUE: Optional[T] = None def refresh(self) -> None: """Refresh stored krita components.""" diff --git a/shortcut_composer/core_components/controllers/canvas_controllers.py b/shortcut_composer/core_components/controllers/canvas_controllers.py index 0ed025f6..44d4c567 100644 --- a/shortcut_composer/core_components/controllers/canvas_controllers.py +++ b/shortcut_composer/core_components/controllers/canvas_controllers.py @@ -22,7 +22,8 @@ class CanvasZoomController(CanvasBasedController, Controller[float]): - Defaults to `100` """ - default_value: float = 100.0 + TYPE = float + DEFAULT_VALUE: float = 100.0 def get_value(self) -> float: """Get current zoom level in %""" @@ -50,7 +51,8 @@ class CanvasRotationController(CanvasBasedController, Controller[float]): - Defaults to `0.0` """ - default_value: float = 0.0 + TYPE = float + DEFAULT_VALUE: float = 0.0 def get_value(self) -> float: """Get canvas rotation in degrees.""" diff --git a/shortcut_composer/core_components/controllers/core_controllers.py b/shortcut_composer/core_components/controllers/core_controllers.py index a7daba25..d82d7ca5 100644 --- a/shortcut_composer/core_components/controllers/core_controllers.py +++ b/shortcut_composer/core_components/controllers/core_controllers.py @@ -20,7 +20,8 @@ class ToolController(Controller[Tool]): - Defaults to `Tool.FREEHAND_BRUSH` """ - default_value: Tool = Tool.FREEHAND_BRUSH + TYPE = Tool + DEFAULT_VALUE: Tool = Tool.FREEHAND_BRUSH @staticmethod def get_value() -> Tool: @@ -49,7 +50,8 @@ class TransformModeController(Controller[TransformMode]): - Defaults to `TransformMode.FREE` """ - default_value: TransformMode = TransformMode.FREE + TYPE = TransformMode + DEFAULT_VALUE: TransformMode = TransformMode.FREE def __init__(self) -> None: self.button_finder = TransformModeFinder() @@ -86,7 +88,8 @@ class ToggleController(Controller[bool]): """ toggle: Toggle - default_value = False + TYPE = bool + DEFAULT_VALUE = False def get_value(self) -> bool: """Return whether the toggle action is on.""" @@ -101,7 +104,6 @@ def get_pretty_name(self, value: Tool) -> str: return value.pretty_name -@dataclass class UndoController(Controller[float]): """ Gives access to `undo` and `redo` actions. @@ -115,7 +117,8 @@ class UndoController(Controller[float]): """ state = 0 - default_value = 0 + TYPE = float + DEFAULT_VALUE = 0 def get_value(self) -> int: """Return remembered position on undo stack""" diff --git a/shortcut_composer/core_components/controllers/document_controllers.py b/shortcut_composer/core_components/controllers/document_controllers.py index 9653be6f..a5f1cb55 100644 --- a/shortcut_composer/core_components/controllers/document_controllers.py +++ b/shortcut_composer/core_components/controllers/document_controllers.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later +from typing import Optional from api_krita import Krita from api_krita.wrappers import Node from api_krita.pyqt import Text @@ -12,7 +13,10 @@ class DocumentBasedController: def refresh(self): """Refresh currently stored active document.""" - self.document = Krita.get_active_document() + document = Krita.get_active_document() + if document is None: + raise ValueError("Controller refreshed during initialization") + self.document = document class ActiveLayerController(DocumentBasedController, Controller[Node]): @@ -24,7 +28,9 @@ class ActiveLayerController(DocumentBasedController, Controller[Node]): - Does not have a default """ - def get_value(self) -> Node: + TYPE = Node + + def get_value(self) -> Optional[Node]: """Get current node.""" return self.document.active_node @@ -45,7 +51,8 @@ class TimeController(DocumentBasedController, Controller[int]): - Defaults to `0` """ - default_value = 0 + TYPE = int + DEFAULT_VALUE = 0 def get_value(self) -> int: """Get current frame on animation timeline.""" diff --git a/shortcut_composer/core_components/controllers/node_controllers.py b/shortcut_composer/core_components/controllers/node_controllers.py index d72db10a..19d47f94 100644 --- a/shortcut_composer/core_components/controllers/node_controllers.py +++ b/shortcut_composer/core_components/controllers/node_controllers.py @@ -12,8 +12,15 @@ class NodeBasedController: def refresh(self): """Refresh currently stored active node.""" - self.active_document = Krita.get_active_document() - self.active_node = self.active_document.active_node + active_document = Krita.get_active_document() + if active_document is None: + raise ValueError("Controller refreshed during initialization") + active_node = active_document.active_node + if active_node is None: + raise ValueError("Controller refreshed during initialization") + + self.active_document = active_document + self.active_node = active_node class LayerOpacityController(NodeBasedController, Controller[int]): @@ -24,7 +31,8 @@ class LayerOpacityController(NodeBasedController, Controller[int]): - Defaults to `100` """ - default_value: int = 100 + TYPE = int + DEFAULT_VALUE = 100 def get_value(self) -> int: """Get currently active blending mode.""" @@ -54,7 +62,8 @@ class LayerBlendingModeController(NodeBasedController, - Defaults to `BlendingMode.NORMAL` """ - default_value = BlendingMode.NORMAL + TYPE = BlendingMode + DEFAULT_VALUE = BlendingMode.NORMAL def get_value(self) -> BlendingMode: """Get current brush opacity.""" @@ -83,7 +92,8 @@ class LayerVisibilityController(NodeBasedController, Controller[bool]): - Defaults to `True` """ - default_value: bool = True + TYPE = bool + DEFAULT_VALUE = True def get_value(self) -> bool: """Get current brush opacity.""" @@ -100,7 +110,8 @@ class CreateLayerWithBlendingController(NodeBasedController, Controller[BlendingMode]): """Creates Paint Layer with set Blending Mode.""" - default_value = BlendingMode.NORMAL + TYPE = BlendingMode + DEFAULT_VALUE = BlendingMode.NORMAL def get_value(self) -> BlendingMode: """Get current layer blending mode.""" diff --git a/shortcut_composer/core_components/controllers/view_controllers.py b/shortcut_composer/core_components/controllers/view_controllers.py index 7ba9656c..a5895d49 100644 --- a/shortcut_composer/core_components/controllers/view_controllers.py +++ b/shortcut_composer/core_components/controllers/view_controllers.py @@ -27,6 +27,8 @@ class PresetController(ViewBasedController, Controller[str]): Example preset name: `"b) Basic-5 Size Opacity"` """ + TYPE = str + def get_value(self) -> str: """Get currently active preset.""" return self.view.brush_preset @@ -53,7 +55,8 @@ class BrushSizeController(ViewBasedController, Controller[int]): - Defaults to `100` """ - default_value: float = 100 + TYPE = int + DEFAULT_VALUE: float = 100 def get_value(self) -> float: """Get current brush size.""" @@ -80,7 +83,8 @@ class BlendingModeController(ViewBasedController, Controller[BlendingMode]): - Defaults to `BlendingMode.NORMAL` """ - default_value = BlendingMode.NORMAL + TYPE = BlendingMode + DEFAULT_VALUE = BlendingMode.NORMAL def get_value(self) -> BlendingMode: """Get currently active blending mode.""" @@ -107,7 +111,8 @@ class OpacityController(ViewBasedController, Controller[int]): - Defaults to `100` """ - default_value: int = 100 + TYPE = int + DEFAULT_VALUE: int = 100 def get_value(self) -> int: """Get current brush opacity.""" @@ -134,7 +139,8 @@ class FlowController(ViewBasedController, Controller[int]): - Defaults to `100` """ - default_value: int = 100 + TYPE = int + DEFAULT_VALUE: int = 100 def get_value(self) -> int: """Get current brush flow.""" diff --git a/shortcut_composer/core_components/instruction_base.py b/shortcut_composer/core_components/instruction_base.py index 155b7427..a317af44 100644 --- a/shortcut_composer/core_components/instruction_base.py +++ b/shortcut_composer/core_components/instruction_base.py @@ -30,7 +30,7 @@ class InstructionHolder: def __init__(self, instructions: List[Instruction]) -> None: self._instructions = instructions - def append(self, instruction: Instruction): + def append(self, instruction: Instruction) -> None: """Add new instruction to the list on runtime.""" self._instructions.append(instruction) diff --git a/shortcut_composer/core_components/instructions/layer_hide.py b/shortcut_composer/core_components/instructions/layer_hide.py index 423dae18..8bc3989d 100644 --- a/shortcut_composer/core_components/instructions/layer_hide.py +++ b/shortcut_composer/core_components/instructions/layer_hide.py @@ -10,7 +10,11 @@ class ToggleLayerVisibility(Instruction): def on_key_press(self) -> None: """Change the active layer visibility.""" - self.document = Krita.get_active_document() + document = Krita.get_active_document() + if document is None: + raise ValueError("Controller refreshed during initialization") + + self.document = document self.affected_node = self.document.active_node self.affected_node.toggle_visility() self.document.refresh() @@ -26,8 +30,12 @@ class ToggleVisibilityAbove(Instruction): def on_key_press(self) -> None: """Remember visibility of layers above, and turn them off.""" - self.document = Krita.get_active_document() - all_nodes = self.document.get_all_nodes() + document = Krita.get_active_document() + if document is None: + raise ValueError("Controller refreshed during initialization") + + self.document = document + all_nodes = self.document.get_all_nodes(include_collapsed=True) top_nodes = all_nodes[all_nodes.index(self.document.active_node)+1:] top_nodes = [node for node in top_nodes if not node.is_group_layer] diff --git a/shortcut_composer/data_components/pick_strategy.py b/shortcut_composer/data_components/pick_strategy.py index 39ac5b3a..dff46dc8 100644 --- a/shortcut_composer/data_components/pick_strategy.py +++ b/shortcut_composer/data_components/pick_strategy.py @@ -10,12 +10,12 @@ def _pick_all(document: Document) -> List[Node]: """Pick all nodes from document as list without group hierarchy""" - return document.get_all_nodes() + return document.get_all_nodes(include_collapsed=False) def _pick_current_visibility(document: Document) -> List[Node]: """Pick nodes from document that has the same visibility as active one.""" - nodes = document.get_all_nodes() + nodes = document.get_all_nodes(include_collapsed=False) current_visibility = document.active_node.visible return [node for node in nodes if node.visible == current_visibility] @@ -23,7 +23,7 @@ def _pick_current_visibility(document: Document) -> List[Node]: def _pick_node_attribute(document: Document, attribute: str) -> List[Node]: """Pick nodes from document based on a single attribute.""" - nodes = document.get_all_nodes() + nodes = document.get_all_nodes(include_collapsed=False) return [node for node in nodes if getattr(node, attribute) or node == document.active_node] diff --git a/shortcut_composer/data_components/tag.py b/shortcut_composer/data_components/tag.py index bc67a1a5..23b60213 100644 --- a/shortcut_composer/data_components/tag.py +++ b/shortcut_composer/data_components/tag.py @@ -3,16 +3,33 @@ from typing import List from api_krita.wrappers import Database +from config_system import Field class Tag(List[str]): """List representing names of presets in a tag of given name.""" - def __init__(self, tag_name: str): + def __init__(self, tag_name: str) -> None: self.tag_name = tag_name + self.refresh() + + def refresh(self) -> None: + """Update itself with current list of presets that belong to tag.""" + self.clear() self.extend(self._read_presets()) def _read_presets(self) -> List[str]: - """Read the brush presets from the database using tag name.""" + """ + Read the brush presets from the database using tag name. + + Take into consideration order stored in config. + """ with Database() as database: - return database.get_preset_names_from_tag(self.tag_name) + from_krita = database.get_preset_names_from_tag(self.tag_name) + + field = Field("ShortcutComposer: Tag order", self.tag_name, [], str) + from_config = field.read() + + preset_order = [p for p in from_config if p in from_krita] + missing = [p for p in from_krita if p not in from_config] + return preset_order + missing diff --git a/shortcut_composer/input_adapter/README.md b/shortcut_composer/input_adapter/README.md new file mode 100644 index 00000000..b69d0b4c --- /dev/null +++ b/shortcut_composer/input_adapter/README.md @@ -0,0 +1,42 @@ +### Custom keyboard shortcut interface +Package `input_adapter` consists of `ActionManager` and `ComplexActionInterface` which together allow to recognise more keyboard events than usual krita action does. + +While usual actions can only recognise key press, implementing `ComplexActionInterface` lets you override methods performed on: +- key press +- short key release +- long key release +- every key release + +Each action needs to have public `name: str` attribute which is the same, as the one used in .action file, as well as `short_vs_long_press_time: float` which determines how many seconds need to elapse to consider that a key press was long. + +Use `ActionManager` instance to bind objects of those custom actions to krita during `CreateActions` phase: + +```python +""" +Print whether action key was released before of after +0.2 seconds from being pressed. +""" +from krita import Krita +from input_adapter import ActionManager, ComplexActionInterface + + +class CustomAction(ComplexActionInterface): + def __init__(self, name: str, press_time: float = 0.2): + self.name = name + self.short_vs_long_press_time = press_time + + def on_key_press(self): print("key was pressed") + def on_short_key_release(self): print("key released before than 0.2s") + def on_long_key_release(self): print("key released later than after 0.2s") + def on_every_key_release(self): pass + + +class MyExtension(Extension): + def setup(self) -> None: pass + def createActions(self, window) -> None: + action = CustomAction(name="Custom action name") + self.manager = ActionManager(window) + self.manager.bind_action(action) + +Krita.instance().addExtension(MyExtension(Krita.instance())) +``` \ No newline at end of file diff --git a/shortcut_composer/templates/cursor_tracker.py b/shortcut_composer/templates/cursor_tracker.py index 1093ac1e..696c5c3c 100644 --- a/shortcut_composer/templates/cursor_tracker.py +++ b/shortcut_composer/templates/cursor_tracker.py @@ -80,16 +80,14 @@ def __new__( instructions=instructions, slider_handler=SliderHandler( slider=horizontal_slider, - is_horizontal=True) - ) + is_horizontal=True)) if not horizontal_slider and vertical_slider: return SingleAxisTracker( name=name, instructions=instructions, slider_handler=SliderHandler( slider=vertical_slider, - is_horizontal=False) - ) + is_horizontal=False)) if horizontal_slider and vertical_slider: return DoubleAxisTracker( name=name, @@ -99,6 +97,5 @@ def __new__( is_horizontal=True), vertical_handler=SliderHandler( slider=vertical_slider, - is_horizontal=False) - ) + is_horizontal=False)) raise ValueError("At least one slider needed.") diff --git a/shortcut_composer/templates/multiple_assignment.py b/shortcut_composer/templates/multiple_assignment.py index 9cd36ded..97711f5a 100644 --- a/shortcut_composer/templates/multiple_assignment.py +++ b/shortcut_composer/templates/multiple_assignment.py @@ -27,7 +27,7 @@ class MultipleAssignment(RawInstructions, Generic[T]): - `name` -- unique name of action. Must match the definition in shortcut_composer.action file - `controller` -- defines which krita property will be modified - - `values` -- list of values compatibile with controller to cycle + - `values` -- list of values to cycle compatibile with controller - `default_value` -- (optional*) value to switch to after long press. Does not have to belong to the list. If not given, taken from a controller. @@ -122,7 +122,7 @@ def _reset_iterator(self) -> None: def _read_default_value(self, value: Optional[T]) -> T: """Read value from controller if it was not given.""" - if (default := self._controller.default_value) is None: + if (default := self._controller.DEFAULT_VALUE) is None: raise ValueError( f"{self._controller} can't be used with MultipleAssignment.") return value if value is not None else default diff --git a/shortcut_composer/templates/multiple_assignment_utils/settings_handler.py b/shortcut_composer/templates/multiple_assignment_utils/settings_handler.py index f582b128..45e91f9a 100644 --- a/shortcut_composer/templates/multiple_assignment_utils/settings_handler.py +++ b/shortcut_composer/templates/multiple_assignment_utils/settings_handler.py @@ -49,7 +49,7 @@ def __init__( instructions.append(HandlerInstruction(self._settings, self._button)) - def _on_button_click(self): + def _on_button_click(self) -> None: """Show the settings and hide the button after it was clicked.""" self._settings.show() self._button.hide() @@ -67,7 +67,7 @@ def on_key_press(self) -> None: """Start a timer which soon will run a callback once.""" self._timer.start() - def timer_callback(self): + def timer_callback(self) -> None: """Show a button in top left corner of painting area.""" if not self._settings.isVisible(): mdiArea = Krita.get_active_mdi_area() diff --git a/shortcut_composer/templates/multiple_assignment_utils/value_list.py b/shortcut_composer/templates/multiple_assignment_utils/value_list.py index 65008846..8574d8bf 100644 --- a/shortcut_composer/templates/multiple_assignment_utils/value_list.py +++ b/shortcut_composer/templates/multiple_assignment_utils/value_list.py @@ -43,21 +43,21 @@ def insert(self, position: int, value: str): self.clearSelection() self.setCurrentRow(position+1) - def get_all(self): + def get_all(self) -> List[str]: """Get list of all the strings in the list.""" items: List[str] = [] for i in range(self.count()): items.append(self.item(i).text()) return items - def remove(self, value: str): + def remove(self, value: str) -> None: """Remove strings by passed value and select the previous one.""" for item in self.findItems(value, Qt.MatchExactly): index = self.row(item) self.takeItem(index) self.setCurrentRow(index-1) - def remove_selected(self): + def remove_selected(self) -> None: """Remove all the selected values.""" for item in self.selected: self.remove(item) diff --git a/shortcut_composer/templates/pie_menu.py b/shortcut_composer/templates/pie_menu.py index b9c1ead6..c2942a62 100644 --- a/shortcut_composer/templates/pie_menu.py +++ b/shortcut_composer/templates/pie_menu.py @@ -1,7 +1,8 @@ # SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later -from typing import List, TypeVar, Generic, Optional +from typing import List, Type, TypeVar, Generic, Optional +from functools import cached_property from enum import Enum from PyQt5.QtCore import QPoint @@ -9,10 +10,16 @@ from api_krita import Krita from core_components import Controller, Instruction +from .pie_menu_utils.settings_gui import ( + PieSettings, + NumericPieSettings, + PresetPieSettings, + EnumPieSettings) from .pie_menu_utils import ( - create_pie_settings_window, - create_local_config, + NonPresetPieConfig, + PresetPieConfig, PieManager, + PieConfig, PieWidget, PieStyle, Label) @@ -29,20 +36,21 @@ class PieMenu(RawInstructions, Generic[T]): - Widget is displayed under the cursor between key press and release - Moving mouse in a direction of a value activates in on key release - When the mouse was not moved past deadzone, value is not changed - - Edit button activates mode in pie does not hide and can be changed + - Edit button activates mode in which pie does not hide on key + release and can be configured ### Arguments: - `name` -- unique name of action. Must match the definition in shortcut_composer.action file - `controller` -- defines which krita property will be modified - - `values` -- list of values compatibile with controller to cycle + - `values` -- default list of values to display in pie - `instructions` -- (optional) list of additional instructions to perform on key press and release - - `pie_radius_scale` -- (optional) widget size multiplier - - `icon_radius_scale` -- (optional) icons size multiplier - - `background_color` -- (optional) rgba color of background - - `active_color` -- (optional) rgba color of active pie + - `pie_radius_scale` -- (optional) default widget size multiplier + - `icon_radius_scale` -- (optional) default icons size multiplier + - `background_color` -- (optional) default rgba color of background + - `active_color` -- (optional) default rgba color of active pie - `short_vs_long_press_time` -- (optional) time [s] that specifies if key press is short or long @@ -74,81 +82,103 @@ def __init__( icon_radius_scale: float = 1.0, background_color: Optional[QColor] = None, active_color: QColor = QColor(100, 150, 230, 255), + save_local: bool = False, short_vs_long_press_time: Optional[float] = None ) -> None: super().__init__(name, instructions, short_vs_long_press_time) self._controller = controller - self._config = create_local_config( - name=name, - values=values, - pie_radius_scale=pie_radius_scale, - icon_radius_scale=icon_radius_scale, - background_color=background_color, - active_color=active_color) - - self._last_values: List[T] = [] + + def _dispatch_config_type() -> Type[PieConfig[T]]: + if issubclass(self._controller.TYPE, str): + return PresetPieConfig # type: ignore + return NonPresetPieConfig + + self._config = _dispatch_config_type()(**{ + "name": f"ShortcutComposer: {name}", + "values": values, + "pie_radius_scale": pie_radius_scale, + "icon_radius_scale": icon_radius_scale, + "save_local": save_local, + "background_color": background_color, + "active_color": active_color}) + self._config.ORDER.register_callback(self._reset_labels) + self._labels: List[Label] = [] - self._reset_labels(self._labels, self._config.values()) - self._all_labels: List[Label] = [] - self._reset_labels(self._all_labels, self._get_all_values(values)) self._edit_mode = EditMode(self) self._style = PieStyle(items=self._labels, pie_config=self._config) - self.pie_settings = create_pie_settings_window( - style=self._style, - values=self._all_labels, - used_values=self._labels, - pie_config=self._config) - self.pie_widget = PieWidget( + @cached_property + def pie_widget(self) -> PieWidget: + """Qwidget of the Pie for selecting values.""" + return PieWidget( style=self._style, labels=self._labels, config=self._config) - self.pie_manager = PieManager( - pie_widget=self.pie_widget, - pie_settings=self.pie_settings) - self.settings_button = PieButton( + @cached_property + def pie_settings(self) -> PieSettings: + """Create and return the right settings based on labels type.""" + if issubclass(self._controller.TYPE, str): + return PresetPieSettings(self._config, self._style) # type: ignore + elif issubclass(self._controller.TYPE, float): + return NumericPieSettings(self._config, self._style) + elif issubclass(self._controller.TYPE, Enum): + return EnumPieSettings( + self._controller, self._config, self._style) # type: ignore + raise ValueError(f"Unknown pie config {self._config}") + + @cached_property + def pie_manager(self) -> PieManager: + """Manager which shows, hides and moves Pie widget and its settings.""" + return PieManager(pie_widget=self.pie_widget) + + @cached_property + def settings_button(self): + """Button with which user can enter the edit mode.""" + settings_button = PieButton( icon=Krita.get_icon("properties"), icon_scale=1.1, parent=self.pie_widget, radius_callback=lambda: self._style.setting_button_radius, style=self._style, config=self._config) - self.settings_button.clicked.connect(lambda: self._edit_mode.set(True)) - self.accept_button = PieButton( + settings_button.clicked.connect(lambda: self._edit_mode.set(True)) + return settings_button + + @cached_property + def accept_button(self): + """Button displayed in edit mode, which allows to hide the pie.""" + accept_button = PieButton( icon=Krita.get_icon("dialog-ok"), icon_scale=1.5, parent=self.pie_widget, radius_callback=lambda: self._style.accept_button_radius, style=self._style, config=self._config) - self.accept_button.clicked.connect(lambda: self._edit_mode.set(False)) - self.accept_button.hide() + accept_button.clicked.connect(lambda: self._edit_mode.set(False)) + accept_button.hide() + return accept_button def _move_buttons(self): - """Move accept button to center and setting button to bottom-right.""" + """Move accept and setting buttons to their correct positions.""" self.accept_button.move_center(self.pie_widget.center) self.settings_button.move(QPoint( self.pie_widget.width()-self.settings_button.width(), self.pie_widget.height()-self.settings_button.height())) def on_key_press(self) -> None: - """Reload labels, start GUI manager and run instructions.""" + """Handle the event of user pressing the action key.""" + super().on_key_press() + if self.pie_widget.isVisible(): return self._controller.refresh() - - new_values = self._config.values() - if self._last_values != new_values: - self._reset_labels(self._labels, new_values) - self._last_values = new_values - self.pie_widget.label_holder.reset() # HACK: should be automatic - + self._reset_labels() + self.pie_widget.label_holder.reset() # HACK: should be automatic self._move_buttons() self.pie_manager.start() - super().on_key_press() def on_every_key_release(self) -> None: """ @@ -166,30 +196,28 @@ def on_every_key_release(self) -> None: if label := self.pie_widget.active: self._controller.set_value(label.value) - def _reset_labels( - self, - label_list: List[Label[T]], - values: List[T] - ) -> None: + INVALID_VALUES: 'set[T]' = set() + + def _reset_labels(self) -> None: """Replace list values with newly created labels.""" - label_list.clear() + values = self._config.values() + + # Workaround of krita tags sometimes returning invalid presets + # Bad values are remembered in class attribute and filtered out + filtered_values = [v for v in values if v not in self.INVALID_VALUES] + current_values = [label.value for label in self._labels] + + # Method is expensive, and should not be performed when values + # did not in fact change. + if filtered_values == current_values: + return + + self._labels.clear() for value in values: - label = self._controller.get_label(value) + label = Label.from_value(value, self._controller) if label is not None: - label_list.append(Label( - value=value, - display_value=label, - pretty_name=self._controller.get_pretty_name(value))) - - @staticmethod - def _get_all_values(values: List[T]) -> List[T]: - """Return list of available enum values. HACK""" - if not values: - return [] - - value_type = values[0] - if not isinstance(value_type, Enum): - return [] - - names = type(value_type)._member_names_ - return [type(value_type)[name] for name in names] + self._labels.append(label) + else: + self.INVALID_VALUES.add(value) + + self._config.refresh_order() diff --git a/shortcut_composer/templates/pie_menu_utils/__init__.py b/shortcut_composer/templates/pie_menu_utils/__init__.py index bf6d8ab4..083268a9 100644 --- a/shortcut_composer/templates/pie_menu_utils/__init__.py +++ b/shortcut_composer/templates/pie_menu_utils/__init__.py @@ -3,7 +3,7 @@ """Implementation of PieMenu main elements.""" -from .dispatchers import create_local_config, create_pie_settings_window +from .pie_config import PieConfig, PresetPieConfig, NonPresetPieConfig from .label_widget import LabelWidget from .pie_manager import PieManager from .pie_widget import PieWidget @@ -11,9 +11,10 @@ from .label import Label __all__ = [ - "create_pie_settings_window", - "create_local_config", + "NonPresetPieConfig", + "PresetPieConfig", "LabelWidget", + "PieConfig", "PieManager", "PieWidget", "PieStyle", diff --git a/shortcut_composer/templates/pie_menu_utils/dispatchers.py b/shortcut_composer/templates/pie_menu_utils/dispatchers.py deleted file mode 100644 index d0203aa3..00000000 --- a/shortcut_composer/templates/pie_menu_utils/dispatchers.py +++ /dev/null @@ -1,55 +0,0 @@ -# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus -# SPDX-License-Identifier: GPL-3.0-or-later - -from typing import List, TypeVar, Optional -from enum import Enum - -from PyQt5.QtGui import QColor - -from data_components import Tag -from .pie_config import PieConfig, PresetPieConfig, NonPresetPieConfig -from .settings_gui import ( - PieSettings, - PresetPieSettings, - EnumPieSettings, - NumericPieSettings, -) -from .label import Label -from .pie_style import PieStyle - -T = TypeVar("T") - - -def create_local_config( - name: str, - values: List[T], - pie_radius_scale: float, - icon_radius_scale: float, - background_color: Optional[QColor], - active_color: QColor, -) -> PieConfig[T]: - """Create and return the right local config based on values type.""" - config_name = f"ShortcutComposer: {name}" - args = [config_name, values, pie_radius_scale, icon_radius_scale, - background_color, active_color] - if isinstance(values, Tag): - return PresetPieConfig(*args) # type: ignore - return NonPresetPieConfig(*args) - - -def create_pie_settings_window( - values: List[Label], - used_values: List[Label], - style: PieStyle, - pie_config: PieConfig, - parent=None -) -> PieSettings: - """Create and return the right settings based on config and value type.""" - args = [pie_config, style, parent] - if isinstance(pie_config, PresetPieConfig): - return PresetPieSettings(*args) - elif isinstance(pie_config, NonPresetPieConfig): - if not values or isinstance(values[0], Enum): - return NumericPieSettings(*args) - return EnumPieSettings(values, used_values, *args) - raise ValueError(f"Unknown pie config {pie_config}") diff --git a/shortcut_composer/templates/pie_menu_utils/label.py b/shortcut_composer/templates/pie_menu_utils/label.py index 7c5ba31f..fe829e87 100644 --- a/shortcut_composer/templates/pie_menu_utils/label.py +++ b/shortcut_composer/templates/pie_menu_utils/label.py @@ -2,13 +2,14 @@ # SPDX-License-Identifier: GPL-3.0-or-later from api_krita.pyqt import Text -from typing import Union, Generic, TypeVar +from typing import Union, Generic, TypeVar, Final, Optional from dataclasses import dataclass from PyQt5.QtCore import QPoint from PyQt5.QtGui import QPixmap, QIcon from composer_utils import Config +from core_components import Controller T = TypeVar("T") @@ -27,7 +28,7 @@ class Label(Generic[T]): - `activation_progress` -- state of animation in range <0-1> """ - value: T + value: Final[T] center: QPoint = QPoint(0, 0) angle: int = 0 display_value: Union[QPixmap, QIcon, Text, None] = None @@ -47,6 +48,22 @@ def __eq__(self, other: T) -> bool: return False return self.value == other.value + def __hash__(self) -> int: + """Use value for hashing, as it should not change over time.""" + return hash(self.value) + + @staticmethod + def from_value(value: T, controller: Controller) -> 'Optional[Label[T]]': + """Use provided controller to create a label holding passed value.""" + label = controller.get_label(value) + if label is None: + return None + + return Label( + value=value, + display_value=label, + pretty_name=controller.get_pretty_name(value)) + class AnimationProgress: """ diff --git a/shortcut_composer/templates/pie_menu_utils/label_widget.py b/shortcut_composer/templates/pie_menu_utils/label_widget.py index 4f4ae00c..1d78ac26 100644 --- a/shortcut_composer/templates/pie_menu_utils/label_widget.py +++ b/shortcut_composer/templates/pie_menu_utils/label_widget.py @@ -34,12 +34,13 @@ def __init__( ) -> None: super().__init__(parent) self.setGeometry(0, 0, style.icon_radius*2, style.icon_radius*2) + self.setCursor(Qt.ArrowCursor) + self.label = label self._style = style self._is_unscaled = is_unscaled - self.draggable = self._draggable = True - + self._draggable = True self._enabled = True self._hovered = False @@ -57,6 +58,8 @@ def draggable(self) -> bool: @draggable.setter def draggable(self, value: bool) -> None: """Make the widget accept dragging or not.""" + if self._draggable == value: + return self._draggable = value if value: return self.setCursor(Qt.ArrowCursor) @@ -70,6 +73,8 @@ def enabled(self): @enabled.setter def enabled(self, value: bool) -> None: """Make the widget interact with mouse or not.""" + if self._enabled == value: + return self._enabled = value if not value: self.draggable = False diff --git a/shortcut_composer/templates/pie_menu_utils/label_widget_utils/icon_label_widget.py b/shortcut_composer/templates/pie_menu_utils/label_widget_utils/icon_label_widget.py index cd7fd3ea..a35cb5b1 100644 --- a/shortcut_composer/templates/pie_menu_utils/label_widget_utils/icon_label_widget.py +++ b/shortcut_composer/templates/pie_menu_utils/label_widget_utils/icon_label_widget.py @@ -20,5 +20,4 @@ def _prepare_image(self) -> QPixmap: size = round(self.icon_radius*1.1) return PixmapTransform.scale_pixmap( pixmap=to_display.pixmap(size, size), - size_px=size - ) + size_px=size) diff --git a/shortcut_composer/templates/pie_menu_utils/pie_config.py b/shortcut_composer/templates/pie_menu_utils/pie_config.py index cea2bcb5..185f838e 100644 --- a/shortcut_composer/templates/pie_menu_utils/pie_config.py +++ b/shortcut_composer/templates/pie_menu_utils/pie_config.py @@ -2,27 +2,29 @@ # SPDX-License-Identifier: GPL-3.0-or-later from abc import ABC, abstractmethod -from typing import List, Generic, TypeVar, Optional +from typing import List, Callable, Generic, TypeVar, Optional, Union from PyQt5.QtGui import QColor from config_system import Field, FieldGroup +from config_system.fields import DualField, FieldWithEditableDefault from data_components import Tag T = TypeVar("T") +U = TypeVar("U") class PieConfig(FieldGroup, Generic[T], ABC): """Abstract FieldGroup representing config of PieMenu.""" - ALLOW_REMOVE: bool + allow_value_edit: bool """Is it allowed to remove elements in runtime. """ name: str - """Name of the group in kritarc.""" + """Name of field group.""" background_color: Optional[QColor] active_color: QColor - ORDER: Field[List[T]] - """Value order stored in kritarc.""" + SAVE_LOCAL: Field[bool] + ORDER: FieldWithEditableDefault[List[T], DualField[List[T]]] PIE_RADIUS_SCALE: Field[float] ICON_RADIUS_SCALE: Field[float] @@ -31,6 +33,51 @@ def values(self) -> List[T]: """Return values to display as icons on the pie.""" ... + @abstractmethod + def set_values(self, values: List[T]) -> None: + """Change current values to new ones.""" + ... + + @abstractmethod + def refresh_order(self) -> None: + """Refresh the values in case the active document changed.""" + ... + + @abstractmethod + def set_current_as_default(self) -> None: + """Set current pie values as a new default list of values.""" + ... + + @abstractmethod + def reset_the_default(self) -> None: + """Set empty pie as a new default list of values.""" + ... + + @abstractmethod + def reset_to_default(self) -> None: + """Replace current list of values in pie with the default list.""" + ... + + @abstractmethod + def is_order_default(self) -> bool: + """Return whether order is the same as default one.""" + ... + + def register_to_order_related(self, callback: Callable[[], None]) -> None: + """Register callback to all fields related to value order.""" + ... + + def _create_editable_dual_field( + self, + field_name: str, + default: U, + parser_type: Optional[type] = None + ) -> FieldWithEditableDefault[U, DualField[U]]: + """Return field which can switch save location and default value.""" + return FieldWithEditableDefault( + DualField(self, self.SAVE_LOCAL, field_name, default, parser_type), + self.field(f"{field_name} default", default, parser_type)) + class PresetPieConfig(PieConfig[str]): """ @@ -40,14 +87,13 @@ class PresetPieConfig(PieConfig[str]): and the custom order saved by the user in kritarc. """ - ALLOW_REMOVE = False - def __init__( self, name: str, - values: Tag, + values: Union[Tag, List[str]], pie_radius_scale: float, icon_radius_scale: float, + save_local: bool, background_color: Optional[QColor], active_color: QColor, ) -> None: @@ -55,33 +101,87 @@ def __init__( self.PIE_RADIUS_SCALE = self.field("Pie scale", pie_radius_scale) self.ICON_RADIUS_SCALE = self.field("Icon scale", icon_radius_scale) - self.TAG_NAME = self.field("Tag", values.tag_name) - self.ORDER = self.field("Values", [], parser_type=str) + + self.SAVE_LOCAL = self.field("Save local", save_local) + + tag_mode = isinstance(values, Tag) + tag_name = values.tag_name if isinstance(values, Tag) else "" + self.TAG_MODE = self._create_editable_dual_field("Tag mode", tag_mode) + self.TAG_NAME = self._create_editable_dual_field("Tag", tag_name) + self.ORDER = self._create_editable_dual_field("Values", [], str) self.background_color = background_color self.active_color = active_color - def values(self) -> List[str]: - """Return all presets from the tag. Respect order from kritarc.""" - saved_order = self.ORDER.read() - tag_values = Tag(self.TAG_NAME.read()) + @property + def allow_value_edit(self) -> bool: + """Return whether user can add and remove items from the pie.""" + return not self.TAG_MODE.read() - preset_order = [p for p in saved_order if p in tag_values] - missing = [p for p in tag_values if p not in saved_order] - return preset_order + missing + def values(self) -> List[str]: + """Return all presets based on mode and stored order.""" + if not self.TAG_MODE.read(): + return self.ORDER.read() + return Tag(self.TAG_NAME.read()) + + def set_values(self, values: List[str]) -> None: + """When in tag mode, remember the tag order. Then write normally.""" + if self.TAG_MODE.read(): + group = "ShortcutComposer: Tag order" + field = Field(group, self.TAG_NAME.read(), [], str) + field.write(values) + + self.ORDER.write(values) + + def refresh_order(self) -> None: + """Refresh the values in case the active document changed.""" + self.TAG_MODE.field.refresh() + self.TAG_NAME.field.refresh() + self.ORDER.write(self.values()) + + def set_current_as_default(self): + """Set current pie values as a new default list of values.""" + self.TAG_MODE.default = self.TAG_MODE.read() + self.TAG_NAME.default = self.TAG_NAME.read() + self.ORDER.default = self.ORDER.read() + + def reset_the_default(self) -> None: + """Set empty pie as a new default list of values.""" + self.TAG_MODE.default = False + self.TAG_NAME.default = "" + self.ORDER.default = [] + + def reset_to_default(self) -> None: + """Replace current list of values in pie with the default list.""" + self.TAG_MODE.reset_default() + self.TAG_NAME.reset_default() + self.ORDER.reset_default() + self.refresh_order() + + def is_order_default(self) -> bool: + """Return whether order is the same as default one.""" + return ( + self.TAG_MODE.read() == self.TAG_MODE.default + and self.TAG_NAME.read() == self.TAG_NAME.default + and self.ORDER.read() == self.ORDER.default) + + def register_to_order_related(self, callback: Callable[[], None]) -> None: + """Register callback to all fields related to value order.""" + self.TAG_MODE.register_callback(callback) + self.TAG_NAME.register_callback(callback) + self.ORDER.register_callback(callback) class NonPresetPieConfig(PieConfig[T], Generic[T]): """FieldGroup representing config of PieMenu of non-preset values.""" - ALLOW_REMOVE = True - def __init__( self, name: str, values: List[T], pie_radius_scale: float, icon_radius_scale: float, + save_local: bool, background_color: Optional[QColor], active_color: QColor, ) -> None: @@ -89,11 +189,42 @@ def __init__( self.PIE_RADIUS_SCALE = self.field("Pie scale", pie_radius_scale) self.ICON_RADIUS_SCALE = self.field("Icon scale", icon_radius_scale) - self.ORDER = self.field("Values", values) + + self.SAVE_LOCAL = self.field("Save local", save_local) + self.ORDER = self._create_editable_dual_field("Values", values) self.background_color = background_color self.active_color = active_color + self.allow_value_edit = True def values(self) -> List[T]: - """Return values to display as icons as defined be the user.""" + """Return values defined be the user to display as icons.""" return self.ORDER.read() + + def set_values(self, values: List[T]) -> None: + """Change current values to new ones.""" + self.ORDER.write(values) + + def refresh_order(self) -> None: + """Refresh the values in case the active document changed.""" + self.ORDER.write(self.values()) + + def set_current_as_default(self): + """Set current pie values as a new default list of values.""" + self.ORDER.default = self.ORDER.read() + + def reset_the_default(self) -> None: + """Set empty pie as a new default list of values.""" + self.ORDER.default = [] + + def reset_to_default(self) -> None: + self.ORDER.reset_default() + self.refresh_order() + + def is_order_default(self) -> bool: + """Return whether order is the same as default one.""" + return self.ORDER.read() == self.ORDER.default + + def register_to_order_related(self, callback: Callable[[], None]) -> None: + """Register callback to all fields related to value order.""" + self.ORDER.register_callback(callback) diff --git a/shortcut_composer/templates/pie_menu_utils/pie_manager.py b/shortcut_composer/templates/pie_menu_utils/pie_manager.py index 9c02d3a9..0c0c9bf6 100644 --- a/shortcut_composer/templates/pie_menu_utils/pie_manager.py +++ b/shortcut_composer/templates/pie_menu_utils/pie_manager.py @@ -7,7 +7,6 @@ from api_krita.pyqt import Timer from composer_utils import Config -from .settings_gui import PieSettings from .pie_widget import PieWidget from .label import Label from .widget_utils import CirclePoints @@ -21,25 +20,16 @@ class PieManager: - Starts a thread loop which checks for changes of active label. """ - def __init__(self, pie_widget: PieWidget, pie_settings: PieSettings): + def __init__(self, pie_widget: PieWidget): self._pie_widget = pie_widget - self._pie_settings = pie_settings self._timer = Timer(self._handle_cursor, Config.get_sleep_time()) self._animator = LabelAnimator(pie_widget) - self._circle: CirclePoints - def start(self) -> None: """Show widget under the mouse and start the mouse tracking loop.""" self._pie_widget.move_center(QCursor().pos()) self._pie_widget.show() - # Qt bug workaround. Settings does not move right when hidden. - self._pie_settings.show() - self._pie_settings.move_to_pie_side() - self._pie_settings.hide() - - self._circle = CirclePoints(self._pie_widget.center_global, 0) self._timer.start() # Make sure the pie widget is not draggable. It could have been @@ -63,10 +53,11 @@ def _handle_cursor(self) -> None: return self.stop() cursor = QCursor().pos() - if self._circle.distance(cursor) < self._pie_widget.deadzone: + circle = CirclePoints(self._pie_widget.center_global, 0) + if circle.distance(cursor) < self._pie_widget.deadzone: return self._set_active_label(None) - angle = self._circle.angle_from_point(cursor) + angle = circle.angle_from_point(cursor) holder = self._pie_widget.label_holder.widget_holder self._set_active_label(holder.on_angle(angle).label) diff --git a/shortcut_composer/templates/pie_menu_utils/pie_style.py b/shortcut_composer/templates/pie_menu_utils/pie_style.py index ad854052..4be4d10e 100644 --- a/shortcut_composer/templates/pie_menu_utils/pie_style.py +++ b/shortcut_composer/templates/pie_menu_utils/pie_style.py @@ -85,9 +85,7 @@ def deadzone_radius(self) -> float: """Deadzone can be configured, but when pie is empty, becomes inf.""" if not self._items: return float("inf") - return ( - 40 * self._base_size - * Config.PIE_DEADZONE_GLOBAL_SCALE.read()) + return self.accept_button_radius @property def widget_radius(self) -> int: @@ -122,9 +120,9 @@ def setting_button_radius(self) -> int: @property def accept_button_radius(self) -> int: """Radius of the button which applies the changes from edit mode.""" - default_radius = self.setting_button_radius - radius = self.deadzone_radius - return int(radius) if radius != float("inf") else default_radius + return round( + 40 * self._base_size + * Config.PIE_DEADZONE_GLOBAL_SCALE.read()) @property def active_color(self): @@ -173,6 +171,5 @@ def font_multiplier(self): "Linux": 0.175, "Windows": 0.11, "Darwin": 0.265, - "": 0.125, - } + "": 0.125} """Scale to fix different font sizes each OS..""" diff --git a/shortcut_composer/templates/pie_menu_utils/pie_widget.py b/shortcut_composer/templates/pie_menu_utils/pie_widget.py index d63d4d3a..186b1379 100644 --- a/shortcut_composer/templates/pie_menu_utils/pie_widget.py +++ b/shortcut_composer/templates/pie_menu_utils/pie_widget.py @@ -78,7 +78,6 @@ def __init__( style=self._style, config=self.config, owner=self) - self._circle_points: CirclePoints self.set_draggable(False) self._reset() @@ -87,9 +86,6 @@ def _reset(self): """Set widget geometry according to style and refresh CirclePoints.""" widget_diameter = self._style.widget_radius*2 self.setGeometry(0, 0, widget_diameter, widget_diameter) - self._circle_points = CirclePoints( - center=self.center, - radius=self._style.pie_radius) @property def deadzone(self) -> float: @@ -112,7 +108,10 @@ def dragMoveEvent(self, e: QDragMoveEvent) -> None: e.accept() source_widget = e.source() pos = e.pos() - distance = self._circle_points.distance(pos) + circle_points = CirclePoints( + center=self.center, + radius=self._style.pie_radius) + distance = circle_points.distance(pos) if not isinstance(source_widget, LabelWidget): # Drag incoming from outside the PieWidget ecosystem @@ -126,11 +125,16 @@ def dragMoveEvent(self, e: QDragMoveEvent) -> None: if distance > self._style.widget_radius: # Dragged out of the PieWidget return self.label_holder.remove(source_widget.label) + + if not self._labels: + # First label dragged to empty pie + return self.label_holder.insert(0, source_widget.label) + if distance < self._style.deadzone_radius: # Do nothing in deadzone return - angle = self._circle_points.angle_from_point(pos) + angle = circle_points.angle_from_point(pos) _a = self._widget_holder.on_angle(angle) if source_widget.label not in self.label_holder or not self._labels: @@ -138,9 +142,9 @@ def dragMoveEvent(self, e: QDragMoveEvent) -> None: index = self.label_holder.index(_a.label) return self.label_holder.insert(index, source_widget.label) - # Dragged existing label to a new location _b = self._widget_holder.on_label(source_widget.label) if _a != _b: + # Dragged existing label to a new location self.label_holder.swap(_a.label, _b.label) self.repaint() diff --git a/shortcut_composer/templates/pie_menu_utils/settings_gui/enum_pie_settings.py b/shortcut_composer/templates/pie_menu_utils/settings_gui/enum_pie_settings.py index 99b317db..89c0891c 100644 --- a/shortcut_composer/templates/pie_menu_utils/settings_gui/enum_pie_settings.py +++ b/shortcut_composer/templates/pie_menu_utils/settings_gui/enum_pie_settings.py @@ -1,11 +1,9 @@ # SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later -from typing import List, Optional +from enum import Enum -from PyQt5.QtWidgets import QVBoxLayout, QTabWidget, QWidget - -from config_system.ui import ConfigFormWidget, ConfigSpinBox +from core_components import Controller from ..label import Label from ..pie_style import PieStyle from ..pie_config import NonPresetPieConfig @@ -24,53 +22,25 @@ class EnumPieSettings(PieSettings): def __init__( self, - values: List[Label], - used_values: List[Label], + controller: Controller[Enum], config: NonPresetPieConfig, style: PieStyle, - parent: Optional[QWidget] = None, ) -> None: - super().__init__(config, style, parent) - - self._used_values = used_values - - tab_holder = QTabWidget() + super().__init__(config, style) - self._action_values = ScrollArea(values, self._style, 3) - self._action_values.setMinimumHeight( - round(style.unscaled_icon_radius*6.2)) + names = controller.TYPE._member_names_ + values = [controller.TYPE[name] for name in names] + labels = [Label.from_value(value, controller) for value in values] + labels = [label for label in labels if label is not None] - tab_holder.addTab(self._action_values, "Action values") - self._local_settings = ConfigFormWidget([ - ConfigSpinBox(config.PIE_RADIUS_SCALE, self, "Pie scale", 0.05, 4), - ConfigSpinBox( - config.ICON_RADIUS_SCALE, self, "Icon max scale", 0.05, 4), - ]) - tab_holder.addTab(self._local_settings, "Local settings") + self._action_values = ScrollArea(self._style, 3) + self._action_values.replace_handled_labels(labels) + self._tab_holder.insertTab(1, self._action_values, "Values") + self._tab_holder.setCurrentIndex(1) - layout = QVBoxLayout(self) - layout.addWidget(tab_holder) - self.setLayout(layout) + self._config.ORDER.register_callback(self._refresh_draggable) + self._refresh_draggable() - self._config.ORDER.register_callback(self.refresh) - self.refresh() - - def refresh(self): + def _refresh_draggable(self) -> None: """Make all values currently used in pie undraggable and disabled.""" - for widget in self._action_values.children_list: - if widget.label in self._used_values: - widget.enabled = False - widget.draggable = False - else: - widget.enabled = True - widget.draggable = True - - def show(self): - """Show the window after its settings are refreshed.""" - self._local_settings.refresh() - super().show() - - def hide(self) -> None: - """Hide the window after its settings are saved to kritarc.""" - self._local_settings.apply() - super().hide() + self._action_values.mark_used_values(self._config.values()) diff --git a/shortcut_composer/templates/pie_menu_utils/settings_gui/numeric_pie_settings.py b/shortcut_composer/templates/pie_menu_utils/settings_gui/numeric_pie_settings.py index d70b90bb..21be21d3 100644 --- a/shortcut_composer/templates/pie_menu_utils/settings_gui/numeric_pie_settings.py +++ b/shortcut_composer/templates/pie_menu_utils/settings_gui/numeric_pie_settings.py @@ -1,44 +1,9 @@ # SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later -from typing import Optional - -from PyQt5.QtWidgets import QWidget -from PyQt5.QtWidgets import QVBoxLayout - -from config_system.ui import ConfigFormWidget, ConfigSpinBox -from ..pie_config import NonPresetPieConfig -from ..pie_style import PieStyle from .pie_settings import PieSettings class NumericPieSettings(PieSettings): """Pie setting window for pie values being numeric (float or int).""" - - def __init__( - self, - config: NonPresetPieConfig, - style: PieStyle, - parent: Optional[QWidget] = None, - ) -> None: - super().__init__(config, style, parent) - - self._local_settings = ConfigFormWidget([ - ConfigSpinBox(config.PIE_RADIUS_SCALE, self, "Pie scale", 0.05, 4), - ConfigSpinBox( - config.ICON_RADIUS_SCALE, self, "Icon max scale", 0.05, 4), - ]) - - layout = QVBoxLayout(self) - layout.addWidget(self._local_settings) - self.setLayout(layout) - - def show(self): - """Show the window after its settings are refreshed.""" - self._local_settings.refresh() - super().show() - - def hide(self) -> None: - """Hide the window after its settings are saved to kritarc.""" - self._local_settings.apply() - super().hide() + # By far is the same as the base class diff --git a/shortcut_composer/templates/pie_menu_utils/settings_gui/offset_grid_layout.py b/shortcut_composer/templates/pie_menu_utils/settings_gui/offset_grid_layout.py new file mode 100644 index 00000000..9680dc90 --- /dev/null +++ b/shortcut_composer/templates/pie_menu_utils/settings_gui/offset_grid_layout.py @@ -0,0 +1,101 @@ +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from typing import List, NamedTuple + +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QWidget, QGridLayout + +from ..label_widget import LabelWidget + + +class GridPosition(NamedTuple): + gridrow: int + gridcol: int + + +class OffsetGridLayout(QGridLayout): + """ + Layout displaying widgets, as the grid in which even rows have offset. + + Even rows have one item less than uneven rows, and are moved half + the widget width to make them overlap with each other. + + The layout acts like list of widgets it's responsibility is to + automatically refresh, when changes are being made to it. + + Implemented using QGridLayout in which every widget uses 2x2 fields. + + max_columns -- Amount of widgets in uneven rows. + When set to 4, rows will cycle: (4, 3, 4, 3, 4...) + group -- Two consecutive rows of widgets. + When max_columns is 4 will consist of 7 (4+3) widgets + """ + + def __init__(self, max_columns: int, owner: QWidget) -> None: + super().__init__() + self._widgets: List[LabelWidget] = [] + self._max_columns = max_columns + self._items_in_group = 2*max_columns - 1 + self._owner = owner + self.setAlignment(Qt.AlignTop | Qt.AlignLeft) # type: ignore + self.setVerticalSpacing(5) + self.setHorizontalSpacing(5) + + def __len__(self) -> int: + """Amount of held LabelWidgets.""" + return len(self._widgets) + + def _get_position(self, index: int) -> GridPosition: + """Return a GridPosition (row, col) of it's widget.""" + group, item = divmod(index, self._items_in_group) + + if item < self._max_columns: + return GridPosition(gridrow=group*4, gridcol=item*2) + + col = item-self._max_columns + return GridPosition(gridrow=group*4+2, gridcol=col*2+1) + + def _internal_insert(self, index: int, widget: LabelWidget) -> None: + """Insert widget at given index if not stored already.""" + if widget in self._widgets: + return + widget.setParent(self._owner) + widget.show() + self._widgets.insert(index, widget) + + def insert(self, index: int, widget: LabelWidget) -> None: + """Insert the widget at given index and refresh the layout.""" + self._internal_insert(index, widget) + self._refresh() + + def append(self, widget: LabelWidget) -> None: + """Append the widget at the end and refresh the layout.""" + self._internal_insert(len(self), widget) + self._refresh() + + def extend(self, widgets: List[LabelWidget]) -> None: + """Extend layout with the given widgets and refresh the layout.""" + for widget in widgets: + self._internal_insert(len(self), widget) + self._refresh() + + def replace(self, widgets: List[LabelWidget]) -> None: + """Replace all existing widgets with the ones provided.""" + if widgets == self._widgets: + return + + for kept_widget in self._widgets: + if kept_widget not in widgets: + kept_widget.hide() + self.removeWidget(kept_widget) + kept_widget.setParent(None) # type: ignore + + self._widgets.clear() + self.extend(widgets) + self._refresh() + + def _refresh(self) -> None: + """Refresh the layout by adding all the internal widgets to it.""" + for i, widget in enumerate(self._widgets): + self.addWidget(widget, *self._get_position(i), 2, 2) diff --git a/shortcut_composer/templates/pie_menu_utils/settings_gui/pie_settings.py b/shortcut_composer/templates/pie_menu_utils/settings_gui/pie_settings.py index 39bd1887..b210a698 100644 --- a/shortcut_composer/templates/pie_menu_utils/settings_gui/pie_settings.py +++ b/shortcut_composer/templates/pie_menu_utils/settings_gui/pie_settings.py @@ -3,11 +3,18 @@ from typing import Optional -from PyQt5.QtCore import QPoint, Qt -from PyQt5.QtGui import QCursor -from PyQt5.QtWidgets import QWidget +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import ( + QWidget, + QTabWidget, + QVBoxLayout, + QHBoxLayout, + QLabel, + QSizePolicy) -from api_krita.pyqt import AnimatedWidget, BaseWidget +from api_krita import Krita +from api_krita.pyqt import AnimatedWidget, BaseWidget, SafeConfirmButton +from config_system.ui import ConfigFormWidget, ConfigSpinBox from composer_utils import Config from ..pie_style import PieStyle from ..pie_config import PieConfig @@ -17,17 +24,23 @@ class PieSettings(AnimatedWidget, BaseWidget): """ Abstract widget that allows to change values in passed config. - Meant to be displayed next to pie menu, having the same heigth. + Meant to be displayed next to the pie menu when it enters edit mode. + + Consists of two obligatory tabs: + - form with general configuration values. + - tab for switching location in which values are saved. + + Subclasses can add their own tabs - they should do so with the tab + with available values to drag into the pie. """ def __init__( self, config: PieConfig, style: PieStyle, - parent: Optional[QWidget] = None, ) -> None: - AnimatedWidget.__init__(self, parent, Config.PIE_ANIMATION_TIME.read()) - self.setMaximumHeight(round(style.widget_radius*3)) + AnimatedWidget.__init__(self, None, Config.PIE_ANIMATION_TIME.read()) + self.setMinimumHeight(round(style.widget_radius*2)) self.setAcceptDrops(True) self.setWindowFlags(( self.windowFlags() | # type: ignore @@ -38,16 +51,183 @@ def __init__( self._style = style self._config = config - self._config.register_callback(self._reset) - self._reset() + self._config.register_to_order_related(self._reset) + + self._tab_holder = QTabWidget() + self._local_settings = ConfigFormWidget([ + ConfigSpinBox(config.PIE_RADIUS_SCALE, self, "Pie scale", 0.05, 4), + ConfigSpinBox(config.ICON_RADIUS_SCALE, self, "Icon max scale", + 0.05, 4), + ]) + self._tab_holder.addTab(self._local_settings, "Preferences") + self._tab_holder.addTab(LocationTab(self._config), "Save location") + + layout = QVBoxLayout(self) + layout.addWidget(self._tab_holder) + self.setLayout(layout) + + def show(self) -> None: + """Show the window after its settings are refreshed.""" + self._local_settings.refresh() + super().show() - def move_to_pie_side(self): - """Move the widget on the right side of the pie.""" - offset = self.width()//2 + self._style.widget_radius * 1.05 - point = QPoint(round(offset), 0) - # HACK Assume the pie center should be at the cursor - self.move_center(QCursor().pos() + point) # type: ignore + def hide(self) -> None: + """Hide the window after its settings are saved to kritarc.""" + self._local_settings.apply() + super().hide() - def _reset(self): + def _reset(self) -> None: """React to change in pie size.""" self.setMinimumHeight(self._style.widget_radius*2) + + +class LocationTab(QWidget): + """PieSettings tab for changing location in which values are saved.""" + + def __init__( + self, + config: PieConfig, + parent: Optional[QWidget] = None + ) -> None: + """Tab that allows to switch location in which icon order is saved.""" + super().__init__(parent) + self._config = config + + self._location_button = self._init_location_button() + self._mode_title = self._init_mode_title() + self._mode_description = self._init_mode_description() + self._set_new_default_button = self._init_set_new_default_button() + self._reset_to_default_button = self._init_reset_to_default_button() + + self._config.register_callback(self._update_button_activity) + self._update_button_activity() + + self.setLayout(self._init_layout()) + self.is_local_mode = self._config.SAVE_LOCAL.read() + + def _init_layout(self) -> QVBoxLayout: + """ + Create and set a layout of the tab. + + - Header holds a button for switching save locations. + - Main area consists of labels describing active location. + - Footer consists of buttons with additional value management + actions. + """ + header = QHBoxLayout() + header_label = QLabel(text="Change save location:") + header.addWidget(header_label, 2, Qt.AlignCenter) + header.addWidget(self._location_button, 1) + + layout = QVBoxLayout() + layout.addLayout(header) + layout.addWidget(self._mode_title) + layout.addWidget(self._mode_description) + layout.addStretch() + layout.addWidget(self._set_new_default_button) + layout.addWidget(self._reset_to_default_button) + return layout + + def _init_location_button(self) -> SafeConfirmButton: + """Return button that switches between save locations.""" + def switch_mode(): + values = self._config.ORDER.read() + + self.is_local_mode = not self.is_local_mode + if self.is_local_mode: + self._config.reset_the_default() + + # make sure the icons stay the same + self._config.ORDER.write(values) + + button = SafeConfirmButton(text="Change mode") + button.clicked.connect(switch_mode) + button.setFixedHeight(button.sizeHint().height()*2) + return button + + def _init_mode_title(self) -> QLabel: + """Return QLabel with one-line description of the active mode.""" + label = QLabel() + label.setStyleSheet("font-weight: bold") + label.setAlignment(Qt.AlignHCenter) + label.setWordWrap(True) + label.setSizePolicy( + QSizePolicy.Ignored, + QSizePolicy.Ignored) + return label + + def _init_mode_description(self) -> QLabel: + """Return QLabel with onedetailed description of the active mode.""" + label = QLabel() + label.setSizePolicy( + QSizePolicy.Ignored, + QSizePolicy.Ignored) + label.setWordWrap(True) + return label + + def _init_set_new_default_button(self) -> SafeConfirmButton: + """Return button saving currently active values as the default ones.""" + button = SafeConfirmButton( + text="Set pie values as a new default", + icon=Krita.get_icon("document-save")) + button.clicked.connect(self._config.set_current_as_default) + button.clicked.connect(self._update_button_activity) + button.setFixedHeight(button.sizeHint().height()*2) + return button + + def _init_reset_to_default_button(self) -> SafeConfirmButton: + """Return button which resets values in pie to default ones.""" + button = SafeConfirmButton( + text="Reset pie values to default", + icon=Krita.get_icon("edit-delete")) + button.clicked.connect(self._config.reset_to_default) + button.clicked.connect(self._update_button_activity) + button.setFixedHeight(button.sizeHint().height()*2) + return button + + @property + def is_local_mode(self) -> bool: + """Return whether pie saves the values locally.""" + return self._config.SAVE_LOCAL.read() + + @is_local_mode.setter + def is_local_mode(self, value: bool) -> None: + """Return whether pie should save the values locally.""" + if value: + self._location_button.main_text = "Local" + self._location_button.icon = Krita.get_icon("folder-documents") + self._mode_title.setText( + "Pie values are saved in the .kra document.\n") + self._mode_description.setText( + "Each new document starts with the default set of " + "values which are can to be modified to those used " + "in given file the most.\n\n" + + "Saved values are not lost between sessions.\n\n" + + "Switching between documents, results in pie switching " + "the values to those saved in the active document.\n\n" + + "For resources, only resource names are stored. " + "Saved value will be lost, when the resource is missing.") + else: + self._location_button.main_text = "Global" + self._location_button.icon = Krita.get_icon("properties") + self._mode_title.setText( + "Pie values are saved in krita settings.\n") + self._mode_description.setText( + "Values remain the same until modified by the user.\n\n" + + "Selected values and their order is saved between " + "sessions. This mode is meant to be used for values that " + "remain useful regardless of which document is edited.") + self._config.SAVE_LOCAL.write(value) + + def _update_button_activity(self): + """Disable location action buttons, when they won't do anything.""" + if not self._config.is_order_default(): + self._set_new_default_button.setEnabled(True) + self._reset_to_default_button.setEnabled(True) + else: + self._set_new_default_button.setDisabled(True) + self._reset_to_default_button.setDisabled(True) diff --git a/shortcut_composer/templates/pie_menu_utils/settings_gui/preset_pie_settings.py b/shortcut_composer/templates/pie_menu_utils/settings_gui/preset_pie_settings.py index 4b8a6bc9..8f91e768 100644 --- a/shortcut_composer/templates/pie_menu_utils/settings_gui/preset_pie_settings.py +++ b/shortcut_composer/templates/pie_menu_utils/settings_gui/preset_pie_settings.py @@ -1,19 +1,109 @@ # SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later -from typing import List, Optional +from typing import List, Dict, Union, Optional, Iterable -from PyQt5.QtWidgets import QVBoxLayout -from PyQt5.QtWidgets import QWidget +from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout +from config_system import Field +from config_system.ui import ConfigComboBox +from core_components.controllers import PresetController +from data_components import Tag +from api_krita import Krita from api_krita.wrappers import Database -from config_system.ui import ( - ConfigFormWidget, - ConfigComboBox, - ConfigSpinBox) +from api_krita.pyqt import SafeConfirmButton +from ..label import Label from ..pie_style import PieStyle from ..pie_config import PresetPieConfig from .pie_settings import PieSettings +from .scroll_area import ScrollArea + + +class TagComboBox(ConfigComboBox): + """ + Combobox for picking preset tags, which can be saved in config. + + When `allow_all` flag is True, the combobox will contain "All" item + will be added above the actual tags. + """ + + def __init__( + self, + config_field: Field[str], + parent: Optional[QWidget] = None, + pretty_name: Optional[str] = None, + additional_fields: List[str] = [], + ) -> None: + self._additional_fields = additional_fields + super().__init__(config_field, parent, pretty_name) + self.config_field.register_callback( + lambda: self.set(self.config_field.read())) + + def reset(self) -> None: + """Replace list of available tags with those red from database.""" + self._combo_box.clear() + self._combo_box.addItems(self._additional_fields) + with Database() as database: + self._combo_box.addItems(database.get_brush_tags()) + self.set(self.config_field.read()) + + +class PresetScrollArea(ScrollArea): + """ + Scroll area for holding preset pies. + + Extends usual scroll area with the combobox over the area for + picking displayed tag. The picked tag is saved to given field. + + Operates in two modes: + - Tag mode - the presets are determined by tracking krita tag + - Manual mode - the presets are manually picked by the user + """ + + known_labels: Dict[str, Union[Label, None]] = {} + + def __init__( + self, + style: PieStyle, + columns: int, + field: Field, + parent=None + ) -> None: + super().__init__(style, columns, parent) + self._field = field + self.tag_chooser = TagComboBox( + self._field, + additional_fields=["---Select tag---", "All"]) + self.tag_chooser.widget.currentTextChanged.connect(self._display_tag) + self._layout.insertWidget(0, self.tag_chooser.widget) + self._display_tag() + + def _display_tag(self) -> None: + """Update preset widgets according to tag selected in combobox.""" + picked_tag = self.tag_chooser.widget.currentText() + if picked_tag == "All": + presets = Krita.get_presets().keys() + else: + presets = Tag(picked_tag) + + self.replace_handled_labels(self._create_labels(presets)) + self._apply_search_bar_filter() + self.tag_chooser.save() + + def _create_labels(self, values: Iterable[str]) -> List[Label[str]]: + """Create labels from list of preset names.""" + controller = PresetController() + labels: list[Optional[Label]] = [] + + for preset in values: + if preset in self.known_labels: + label = self.known_labels[preset] + else: + label = Label.from_value(preset, controller) + self.known_labels[preset] = label + labels.append(label) + + return [label for label in labels if label is not None] class PresetPieSettings(PieSettings): @@ -23,37 +113,118 @@ def __init__( self, config: PresetPieConfig, style: PieStyle, - parent: Optional[QWidget] = None, ) -> None: - super().__init__(config, style, parent) - - self._tags: List[str] = [] - self._refresh_tags() - - self._local_settings = ConfigFormWidget([ - ConfigComboBox(config.TAG_NAME, self, "Tag name", self._tags), - ConfigSpinBox(config.PIE_RADIUS_SCALE, self, "Pie scale", 0.05, 4), - ConfigSpinBox( - config.ICON_RADIUS_SCALE, self, "Icon max scale", 0.05, 4), - ]) - - layout = QVBoxLayout(self) - layout.addWidget(self._local_settings) - self.setLayout(layout) - - def show(self): - """Show the window after its settings are refreshed.""" - self._refresh_tags() - self._local_settings.refresh() - super().show() - - def hide(self) -> None: - """Hide the window after its settings are saved to kritarc.""" - self._local_settings.apply() - super().hide() - - def _refresh_tags(self): - """Replace list of available tags with those red from database.""" - self._tags.clear() - with Database() as database: - self._tags.extend(sorted(database.get_brush_tags(), key=str.lower)) + super().__init__(config, style) + self._config: PresetPieConfig + + self._preset_scroll_area = self._init_preset_scroll_area() + self._mode_button = self._init_mode_button() + self._auto_combobox = self._init_auto_combobox() + self._manual_combobox = self._preset_scroll_area.tag_chooser + + self.set_tag_mode(self._config.TAG_MODE.read()) + action_values = self._init_action_values() + self._tab_holder.insertTab(1, action_values, "Values") + self._tab_holder.setCurrentIndex(1) + + def _init_preset_scroll_area(self) -> PresetScrollArea: + """Create preset scroll area which tracks which ones are used.""" + preset_scroll_area = PresetScrollArea( + style=self._style, + columns=3, + field=self._config.field("Last tag selected", "---Select tag---")) + policy = preset_scroll_area.sizePolicy() + policy.setRetainSizeWhenHidden(True) + preset_scroll_area.setSizePolicy(policy) + + def refresh_draggable(): + """Mark which pies are currently used in the pie.""" + preset_scroll_area.mark_used_values(self._config.values()) + + self._config.ORDER.register_callback(refresh_draggable) + preset_scroll_area.widgets_changed.connect(refresh_draggable) + refresh_draggable() + return preset_scroll_area + + def _init_mode_button(self) -> SafeConfirmButton: + """Create button which switches between tag and manual mode.""" + def switch_mode(): + """Change the is_tag_mode to the opposite state.""" + is_tag_mode = not self.get_tag_mode() + self.set_tag_mode(is_tag_mode) + if is_tag_mode: + self._auto_combobox.set(self._manual_combobox.read()) + self._auto_combobox.save() + # Reset hidden combobox to prevent unnecesary icon loading + self._manual_combobox.set( + self._manual_combobox.config_field.default) + self._manual_combobox.save() + else: + self._manual_combobox.set(self._auto_combobox.read()) + self._manual_combobox.save() + + mode_button = SafeConfirmButton(confirm_text="Change?") + mode_button.clicked.connect(switch_mode) + mode_button.setFixedHeight(mode_button.sizeHint().height()*2) + self._config.TAG_MODE.register_callback( + lambda: self.set_tag_mode(self._config.TAG_MODE.read(), False)) + return mode_button + + def _init_auto_combobox(self) -> TagComboBox: + """Create tag modecombobox, which sets tag presets to the pie.""" + def handle_picked_tag(): + """Save used tag in config and report the values changed.""" + auto_combobox.save() + self._config.refresh_order() + + auto_combobox = TagComboBox(self._config.TAG_NAME, self, "Tag name") + auto_combobox.widget.currentTextChanged.connect(handle_picked_tag) + return auto_combobox + + def _init_action_values(self) -> QWidget: + """ + Create Action Values tab of the Settings Window. + + - Mode button and two comboboxes are places at the top + - Below them lies the preset scroll area + - Two comboboxes will swap with each other when the mode changes + - Scroll area combobox is taken out of it, and placed with the + other one to save space. + """ + top_layout = QHBoxLayout() + top_layout.addWidget(self._mode_button, 1) + top_layout.addWidget(self._auto_combobox.widget, 2) + top_layout.addWidget(self._manual_combobox.widget, 2) + + action_layout = QVBoxLayout() + action_layout.addLayout(top_layout) + action_layout.addWidget(self._preset_scroll_area) + action_layout.addStretch() + + action_values_tab = QWidget() + action_values_tab.setLayout(action_layout) + return action_values_tab + + def get_tag_mode(self) -> bool: + """Return whether pie is in tag mode or not (manual mode).""" + return self._config.TAG_MODE.read() + + def set_tag_mode(self, value: bool, notify: bool = True) -> None: + """Set the pie mode to tag (True) or manual (False).""" + if notify: + self._config.TAG_MODE.write(value) + self._config.refresh_order() + if value: + # moving to tag mode + self._mode_button.main_text = "Tag mode" + self._mode_button.icon = Krita.get_icon("tag") + self._preset_scroll_area.hide() + self._manual_combobox.widget.hide() + self._auto_combobox.widget.show() + else: + # moving to manual mode + self._mode_button.main_text = "Manual mode" + self._mode_button.icon = Krita.get_icon("color-to-alpha") + self._preset_scroll_area.show() + self._manual_combobox.widget.show() + self._auto_combobox.widget.hide() diff --git a/shortcut_composer/templates/pie_menu_utils/settings_gui/scroll_area.py b/shortcut_composer/templates/pie_menu_utils/settings_gui/scroll_area.py index 5807dfa1..3af534ed 100644 --- a/shortcut_composer/templates/pie_menu_utils/settings_gui/scroll_area.py +++ b/shortcut_composer/templates/pie_menu_utils/settings_gui/scroll_area.py @@ -1,20 +1,24 @@ # SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later -from typing import List, NamedTuple +import re +from typing import List, Protocol, Callable -from PyQt5.QtCore import Qt +from PyQt5.QtCore import Qt, QTimer, pyqtSignal from PyQt5.QtWidgets import ( QWidget, QScrollArea, + QScroller, QLabel, - QGridLayout, - QVBoxLayout) + QLineEdit, + QVBoxLayout, + QHBoxLayout) from ..label import Label from ..label_widget import LabelWidget from ..label_widget_utils import create_label_widget from ..pie_style import PieStyle +from .offset_grid_layout import OffsetGridLayout class ChildInstruction: @@ -32,134 +36,149 @@ def on_leave(self, label: Label) -> None: self._display_label.setText("") +class EmptySignal(Protocol): + """Protocol fixing the wrong PyQt typing.""" + + def emit(self) -> None: ... + def connect(self, method: Callable[[], None]) -> None: ... + + class ScrollArea(QWidget): """ Widget containing a scrollable list of PieWidgets. - Widgets are created based on the passed labels and then made - publically available in `children_list` attribute, so that the owner - of the class can change their state (draggable, enabled). + Widgets are defined with replace_handled_labels method which + creates the widgets representing them if needed. Using the method + again will replace handled widgets with new ones representing newer + passed labels. + + All the created widgets are stored in case they may need to be + reused when labels change again. + + Currently handled widgets are publically available, so that the + class owner can change their state (draggable, enabled). ScrollArea comes with embedded QLabel showing the name of the - children widget over which mouse was hovered. + children widget over which mouse was hovered, and a filter bar. + + Writing something to the filter results in widgets which do not + match the phrase to not be displayed. Hidden widgets, are still + available under children_list. """ + widgets_changed: EmptySignal = pyqtSignal() # type: ignore + def __init__( self, - labels: List[Label], style: PieStyle, columns: int, parent=None ) -> None: super().__init__(parent) self._style = style - self._labels = labels + self._columns = columns - self._scroll_area_layout = OffsetGridLayout(columns, self) - scroll_widget = QWidget() - scroll_widget.setLayout(self._scroll_area_layout) - area = QScrollArea() - area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - area.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn) - area.setWidgetResizable(True) - area.setWidget(scroll_widget) + self._known_children: dict[Label, LabelWidget] = {} + self._children_list: List[LabelWidget] = [] - layout = QVBoxLayout() - layout.addWidget(area) + self._grid = OffsetGridLayout(self._columns, self) self._active_label_display = QLabel(self) - layout.addWidget(self._active_label_display) - self.setLayout(layout) - - self.children_list = self._create_children() + self._search_bar = self._init_search_bar() + self._layout = self._init_layout() - def _create_children(self) -> List[LabelWidget]: - """Create LabelWidgets that represent the labels.""" - children: List[LabelWidget] = [] + self.setLayout(self._layout) - for label in self._labels: - child = create_label_widget( - label=label, - style=self._style, - parent=self, - is_unscaled=True) - child.setFixedSize(child.icon_radius*2, child.icon_radius*2) - child.draggable = True - child.add_instruction(ChildInstruction(self._active_label_display)) - children.append(child) + def _init_layout(self) -> QVBoxLayout: + """ + Create scroll area layout. - self._scroll_area_layout.extend(children) - return children + - most part is taken by the scrollable widget with icons + - below there is a footer which consists of: + - label displaying hovered icon name + - search bar which filters icons + """ + footer = QHBoxLayout() + footer.addWidget(self._active_label_display, 1) + footer.addWidget(self._search_bar, 1) + layout = QVBoxLayout() + layout.addWidget(self._init_scroll_area()) + layout.addLayout(footer) + return layout -class GridPosition(NamedTuple): - gridrow: int - gridcol: int - - -class OffsetGridLayout(QGridLayout): - """ - Layout displaying widgets, as the grid in which even rows have offset. - - Even rows have one item less than uneven rows, and are moved half - the widget width to make them overlap with each other. - - The layout acts like list of widgets it's responsibility is to - automatically refresh, when changes are being made to it. - - Implemented using QGridLayout in which every widget uses 2x2 fields. - - max_columns -- Amount of widgets in uneven rows. - When set to 4, rows will cycle: (4, 3, 4, 3, 4...) - group -- Two consecutive rows of widgets. - When max_columns is 4 will consist of 7 (4+3) widgets - """ + def _init_scroll_area(self) -> QScrollArea: + """Create a widget, which scrolls internal widget with grid layout.""" + internal = QWidget() + internal.setLayout(self._grid) - def __init__(self, max_columns: int, owner: QWidget): - super().__init__() - self._widgets: List[QWidget] = [] - self._max_columns = max_columns - self._items_in_group = 2*max_columns - 1 - self._owner = owner - - def __len__(self) -> int: - """Amount of held LabelWidgets.""" - return len(self._widgets) - - def _get_position(self, index: int) -> GridPosition: - """Return a GridPosition (row, col) of it's widget.""" - group, item = divmod(index, self._items_in_group) - - if item < self._max_columns: - return GridPosition(gridrow=group*4, gridcol=item*2) - - col = item-self._max_columns - return GridPosition(gridrow=group*4+2, gridcol=col*2+1) - - def _internal_insert(self, index: int, widget: LabelWidget) -> None: - """Insert widget at given index if not stored already.""" - if widget in self._widgets: - return - widget.setParent(self._owner) - widget.show() - self._widgets.insert(index, widget) - - def insert(self, index: int, widget: LabelWidget) -> None: - """Insert the widget at given index and refresh the layout.""" - self._internal_insert(index, widget) - self._refresh() - - def append(self, widget: LabelWidget) -> None: - """Append the widget at the end and refresh the layout.""" - self._internal_insert(len(self), widget) - self._refresh() - - def extend(self, widgets: List[LabelWidget]) -> None: - """Extend layout with the given widgets and refresh the layout.""" - for widget in widgets: - self._internal_insert(len(self), widget) - self._refresh() - - def _refresh(self): - """Refresh the layout by adding all the internal widgets to it.""" - for i, widget in enumerate(self._widgets): - self.addWidget(widget, *self._get_position(i), 2, 2) + area = QScrollArea() + area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + area.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn) + radius = self._style.unscaled_icon_radius + area.setMinimumWidth(round(radius*self._columns*2.3)) + area.setMinimumHeight(round(radius*9.2)) + area.setWidgetResizable(True) + area.setWidget(internal) + QScroller.grabGesture( + area.viewport(), QScroller.MiddleMouseButtonGesture) + + return area + + def _init_search_bar(self) -> QLineEdit: + """Create search bar which hides icons not matching its text.""" + search_bar = QLineEdit(self) + search_bar.setPlaceholderText("Search") + search_bar.setClearButtonEnabled(True) + search_bar.textChanged.connect(self._apply_search_bar_filter) + return search_bar + + def _apply_search_bar_filter(self) -> None: + """Replace widgets in layout with those thich match the filter.""" + self.setUpdatesEnabled(False) + pattern = re.escape(self._search_bar.text()) + regex = re.compile(pattern, flags=re.IGNORECASE) + + children = [child for child in self._children_list + if regex.search(child.label.pretty_name)] + + self._grid.replace(children) + QTimer.singleShot(10, lambda: self.setUpdatesEnabled(True)) + + def _create_child(self, label: Label) -> LabelWidget: + """Create LabelWidget that represent the label.""" + child = create_label_widget( + label=label, + style=self._style, + parent=self, + is_unscaled=True) + child.setFixedSize(child.icon_radius*2, child.icon_radius*2) + child.draggable = True + child.add_instruction(ChildInstruction(self._active_label_display)) + + self._known_children[label] = child + return child + + def replace_handled_labels(self, labels: List[Label]) -> None: + """Replace current list of widgets with new ones.""" + self.setUpdatesEnabled(False) + self._children_list.clear() + + for label in labels: + if label in self._known_children: + self._children_list.append(self._known_children[label]) + else: + self._children_list.append(self._create_child(label)) + + self._grid.extend(self._children_list) + QTimer.singleShot(10, lambda: self.setUpdatesEnabled(True)) + self.widgets_changed.emit() + + def mark_used_values(self, used_values: list) -> None: + """Make all values currently used in pie undraggable and disabled.""" + for widget in self._children_list: + if widget.label.value in used_values: + widget.enabled = False + widget.draggable = False + else: + widget.enabled = True + widget.draggable = True diff --git a/shortcut_composer/templates/pie_menu_utils/widget_utils/circle_points.py b/shortcut_composer/templates/pie_menu_utils/widget_utils/circle_points.py index 3272870d..9009b40d 100644 --- a/shortcut_composer/templates/pie_menu_utils/widget_utils/circle_points.py +++ b/shortcut_composer/templates/pie_menu_utils/widget_utils/circle_points.py @@ -36,8 +36,7 @@ def point_from_angle(self, angle: float) -> QPoint: rad_angle = math.radians(angle) return QPoint( round(self._center.x() + self._radius*math.sin(rad_angle)), - round(self._center.y() - self._radius*math.cos(rad_angle)), - ) + round(self._center.y() - self._radius*math.cos(rad_angle))) def angle_from_point(self, point: QPoint) -> float: """Count clockwise angle of cursor in relation to pie center.""" diff --git a/shortcut_composer/templates/pie_menu_utils/widget_utils/edit_mode.py b/shortcut_composer/templates/pie_menu_utils/widget_utils/edit_mode.py index 56c05195..c2a0619f 100644 --- a/shortcut_composer/templates/pie_menu_utils/widget_utils/edit_mode.py +++ b/shortcut_composer/templates/pie_menu_utils/widget_utils/edit_mode.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from typing import TYPE_CHECKING +from PyQt5.QtCore import QPoint if TYPE_CHECKING: from ...pie_menu import PieMenu @@ -25,9 +26,6 @@ def get(self) -> bool: def set(self, mode_to_set: bool) -> None: """Update the mode and change Pie's content accordingly.""" - if not mode_to_set and self._edit_mode: - self._write_settings() - if not self._edit_mode ^ mode_to_set: return @@ -44,9 +42,21 @@ def set_edit_mode_true(self): self._obj.pie_widget.is_edit_mode = True self._obj.pie_widget.repaint() self._obj.pie_settings.show() + self._obj.pie_settings.resize(self._obj.pie_settings.sizeHint()) + self._move_settings_next_to_pie() self._obj.accept_button.show() self._obj.settings_button.hide() + def _move_settings_next_to_pie(self): + """Move settings window so that it lies on right side of pie.""" + settings_offset = round(0.5*( + self._obj.pie_widget.width() + + self._obj.pie_settings.width()*1.05 + )) + self._obj.pie_settings.move_center( + self._obj.pie_widget.center_global + + QPoint(settings_offset, 0)) # type: ignore + def set_edit_mode_false(self): """Set the edit mode off.""" self._obj.pie_widget.hide() @@ -59,13 +69,3 @@ def set_edit_mode_false(self): def swap_mode(self): """Change the edit mode to the other one.""" self.set(not self._edit_mode) - - def _write_settings(self) -> None: - """If values were not hardcoded, but from config, write them back.""" - widget = self._obj.pie_widget - - if not widget.label_holder or widget.config is None: - return - - values = [label.value for label in widget.label_holder] - widget.config.ORDER.write(values) diff --git a/shortcut_composer/templates/pie_menu_utils/widget_utils/label_holder.py b/shortcut_composer/templates/pie_menu_utils/widget_utils/label_holder.py index 293238c3..32329659 100644 --- a/shortcut_composer/templates/pie_menu_utils/widget_utils/label_holder.py +++ b/shortcut_composer/templates/pie_menu_utils/widget_utils/label_holder.py @@ -37,25 +37,26 @@ def __init__( # in config as holder was not their cause self._config.register_callback(partial(self.reset, notify=False)) self._owner = owner + self._locked = False self.widget_holder = WidgetHolder() self.reset(notify=False) def append(self, label: Label): """Append the new label to the holder.""" - self._labels.append(label) - self.reset() + if (self._config.allow_value_edit): + self._labels.append(label) + self.reset() def insert(self, index: int, label: Label): """Insert the new label to the holder at given index.""" - self._labels.insert(index, label) - self.reset() + if (self._config.allow_value_edit): + self._labels.insert(index, label) + self.reset() def remove(self, label: Label): """Remove the label from the holder.""" - if (label in self._labels - and len(self._labels) > 1 - and self._config.ALLOW_REMOVE): + if (label in self._labels and self._config.allow_value_edit): self._labels.remove(label) self.reset() @@ -64,14 +65,29 @@ def index(self, label: Label): return self._labels.index(label) def swap(self, _a: Label, _b: Label): - """Swap positions of two labels from the holder.""" - _a.swap_locations(_b) + """ + Swap positions of two labels from the holder. + + As this operation only changes label order, fully reseting all + the widgets is not needed. + """ + if self._locked: + return + + idx_a = self._labels.index(_a) + idx_b = self._labels.index(_b) - idx_a, idx_b = self._labels.index(_a), self._labels.index(_b) self._labels[idx_b] = _a self._labels[idx_a] = _b - self.reset() + widget_a = self.widget_holder.on_label(self._labels[idx_a]) + widget_b = self.widget_holder.on_label(self._labels[idx_b]) + + self.widget_holder.swap(widget_a, widget_b) + + self._locked = True + self._config.set_values([label.value for label in self._labels]) + self._locked = False def __iter__(self): """Iterate over all labels in the holder.""" @@ -79,13 +95,16 @@ def __iter__(self): def reset(self, notify: bool = True) -> None: """ - Ensure the icon widgets properly represet this container. + Ensure the icon widgets properly represents this container. If notify flag is set to True, saves the new order to config. - - HACK: Small changes in container should not result in complete - widget recreation. """ + if self._locked: + return + current_labels = [widget.label for widget in self.widget_holder] + if current_labels == self._labels: + return + for child in self.widget_holder: child.setParent(None) # type: ignore self.widget_holder.clear() @@ -104,10 +123,10 @@ def reset(self, notify: bool = True) -> None: child.show() child.label.angle = angle child.label.center = point - child.move_to_label() child.draggable = True self.widget_holder.add(child) + self._locked = True if notify: - values = [label.value for label in self._labels] - self._config.ORDER.write(values) + self._config.set_values([label.value for label in self._labels]) + self._locked = False diff --git a/shortcut_composer/templates/pie_menu_utils/widget_utils/widget_holder.py b/shortcut_composer/templates/pie_menu_utils/widget_utils/widget_holder.py index 11a5601f..6bf779b8 100644 --- a/shortcut_composer/templates/pie_menu_utils/widget_utils/widget_holder.py +++ b/shortcut_composer/templates/pie_menu_utils/widget_utils/widget_holder.py @@ -15,6 +15,19 @@ def __init__(self): def add(self, widget: LabelWidget) -> None: """Add a new LabelWidget to the holder.""" self._widgets[widget.label.angle] = widget + widget.move_to_label() + + def swap(self, w_a: LabelWidget, w_b: LabelWidget) -> None: + """Swap position of two widgets.""" + a_angle = w_a.label.angle + b_angle = w_b.label.angle + + self._widgets[a_angle] = w_b + self._widgets[b_angle] = w_a + + w_a.label.swap_locations(w_b.label) + w_a.move_to_label() + w_b.move_to_label() def on_angle(self, angle: float) -> LabelWidget: """Return LabelWidget which is the closest to given `angle`.""" diff --git a/shortcut_composer/templates/temporary_key.py b/shortcut_composer/templates/temporary_key.py index de3b3e3c..3ab2e505 100644 --- a/shortcut_composer/templates/temporary_key.py +++ b/shortcut_composer/templates/temporary_key.py @@ -104,7 +104,7 @@ def on_long_key_release(self) -> None: def _read_default_value(self, value: Optional[T]) -> T: """Read value from controller if it was not given.""" - if (default := self._controller.default_value) is None: + if (default := self._controller.DEFAULT_VALUE) is None: raise ValueError( f"{self._controller} can't be used with TemporaryKeys.") return value if value is not None else default