diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 1428eb13..00000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,33 +0,0 @@ -# Change Log - -## [1.1.1] - 2023-02-04 -### Fixed -- Fix a crash when any PieMenu is empty. -- Support multiple krita windows. -- Fix transform modes ocasionally stopping to work. -- Handle invalid configuration. -- Add missing icon for transform tool. - -## [1.1.0] - 2023-01-20 -### Added -- Edit mode for PieMenus - click or drag the value icon to enter it. While in edit mode, the values can be dragged across the PieMenu to change their order. -- Action values tab in Configure Shortcut Composer for adding and removing values in PieMenus and MultipleAssignment actions. -- PieValues backtrounds are now animated. -- New PieMenu to create a layer with chosen blending mode. - -### Fixed -- Allow scrolling through masks while using layey mouse trackers -- Make sure that presets in PieMenu are displayed only once. -- Allow using "Enclose and fill" tool -- Fix icon of "Colorize Mask" and "Color Sampler" tools -- Fix scaling issues of Text labels in PieMenu - -## [1.0.1] - 2023-01-09 -Fixes following issues: -- Fix displaying inactive presets and tags -- Detect and support light krita theme, switch between them when changed -- PieWidget as popup - pies no longer recognized by OS as windows -- Allow different parameters for each configuration value -- Use default font to fix MacOS helvetica issue -- Allow using actions when active layer is locked -- Work around krita bug: zoom being dependent on document dpi diff --git a/README.md b/README.md index f763852d..6ce46b70 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,6 @@ **Extension** for painting application **Krita**, which allows to create custom, complex **keyboard shortcuts**. - 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. @@ -10,26 +9,45 @@ The plugin adds new shortcuts of the following types: - `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. -[![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") +## What's new in **1.2.0** + +[![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") + + +### 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`) -## What's new in v1.1.1 ### Fixed -- Fix a crash when any PieMenu is empty. -- Support multiple krita windows. -- Fix transform modes ocasionally stopping to work. -- Handle invalid configuration. -- Add missing icon for transform tool. +- 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. + +Check out historic [changelogs](https://github.com/wojtryb/Shortcut-Composer/wiki/Change-log). + +## Plugin release video: + +[![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") ## Requirements -Shortcut Composer **v1.1.1** Requires krita **5.1.0** or later. +- Version of krita on plugin release: **5.1.5** +- Required version of krita: **5.1.0** OS support state: -- [x] Windows 10/11 -- [x] Linux +- [x] Windows (10, 11) +- [x] Linux (Ubuntu 20.04, 22.04) - [ ] MacOS (Known bug of canvas losing focus after using PieMenu) - [ ] Android (Does not support python plugins yet) -## Installation: +## 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. @@ -39,20 +57,27 @@ OS support state: 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 a icon, activates its value on key release. **The action does not recognise mouse clicks, and only requires hovering**. Pie menu does nothing if the cursor is not moved out of the deadzone. +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. -Dragging a value enters `Edit mode` in which the keyboard button no longer needs to be pressed. In this mode values can be dragged accross the widget to change their order. When done, press the tick button to apply the changes. +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 tags can be changed in **Tools > Scripts > Configure Shortcut Composer**. Default tag mapping is as follows: + 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" - Presets in edited tags do not reload by themselves. Use **Tools > Scripts > Reload Shortcut Composer** or press apply/ok button in plugin configuration dialog. - - ### Pick misc tools - Pie menu for picking **active tools**. Includes tools that are used rather sporadically, and may not be worth a dedicated keyboard shortcut each: + Pie menu for picking **active tools**. It is recommended to change the default values being: - crop tool, - reference tool, - gradient tool, @@ -60,7 +85,7 @@ Dragging a value enters `Edit mode` in which the keyboard button no longer needs - assistant tool - ### Pick painting blending modes - Pie menu for picking **painting blending modes**. Consists of most commonly used ones: + Pie menu for picking **painting blending modes**. It is recommended to change the default values being: - normal - overlay, - color, @@ -71,7 +96,7 @@ Dragging a value enters `Edit mode` in which the keyboard button no longer needs - lighten - ### Create painting layer with blending mode - Pie menu for creating a new layer with picked **blending mode**. Consists of most commonly used ones: + Pie menu for creating a new layer with picked **blending mode**. It is recommended to change the default values being: - normal - erase - overlay, @@ -135,11 +160,17 @@ Multiple assignment is an action which cycles between multiple values of single 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. @@ -155,10 +186,6 @@ Multiple assignment is an action which cycles between multiple values of single ### 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: -- Preset pie-menus mapping - - `Tag (red)` - tag to be used in red preset pie-menu - - `Tag (green)` - tag to be used in green preset pie-menu - - `Tag (blue)` - tag to be used in blue preset pie-menu - 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. @@ -171,16 +198,6 @@ Shortcut-Composer comes with a settings dialog available from krita topbar: **To - `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. -### Changing the values in Pie menus and Multiple assignments -**Configure Shortcut Composer** has a separate tab called `Action values` in which you can add new values to some of the actions, as well as remove those that are not needed. - -Action to modify can be selected using the combobox on the top. - -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. This can also be done using the `Edit mode` directly from the PieMenu (does not apply to MultipleAssignments) - - ### 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. @@ -199,12 +216,143 @@ To achieve that it is required to modify actions implementation: ### 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 -The extension consists of elements that can be reused in other krita plugins under GPL-3.0-or-later license. Package `api_krita` wrappes krita api offering PEP8 compatibility, typings, and docstring documentation. Most of objects attributes are now available as settables properties. +Package `api_krita` wraps krita api offering PEP8 compatibility, typings, and docstring documentation. Most of objects attributes are now available as settables properties. -Copy `api_krita` to the extension directory, to access syntax such as: ```python from .api_krita import Krita from .api_krita.enums import BlendingMode, Tool, Toggle @@ -237,34 +385,3 @@ 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. - -### Custom keyboard shortcut interface -Package `input_adapter` consists of `ActionManager` and `ComplexAction` which grants extended interface for creating keyboard shortcuts. - -While usual actions can only recognise key press, subclassing `ComplexAction` lets you override methods performed on: -- key press -- short key release -- long key release -- every key release - -Then use `ActionManager` instance to bind objects of those custom actions to krita during CreateActions phase: - -```python -from api_krita import Extension -from input_adapter import ActionManager, ComplexAction - - -class CustomAction(ComplexAction): - def on_key_press(self): ... - def on_short_key_release(self): ... - def on_long_key_release(self): ... - def on_every_key_release(self): ... - - -class ExtensionName(Extension): - ... - def createActions(self, window) -> None: - action = CustomAction(name="Custom action name") - self.manager = ActionManager(window) - self.manager.bind_action(action) -``` diff --git a/shortcut_composer/INFO.py b/shortcut_composer/INFO.py new file mode 100644 index 00000000..d324cbd8 --- /dev/null +++ b/shortcut_composer/INFO.py @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +__version__ = "1.2.0" +__author__ = "Wojciech Trybus" +__license__ = "GPL-3.0-or-later" diff --git a/shortcut_composer/__init__.py b/shortcut_composer/__init__.py index 3f079920..fb94de0c 100755 --- a/shortcut_composer/__init__.py +++ b/shortcut_composer/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later """ @@ -16,6 +16,8 @@ from .shortcut_composer import ShortcutComposer from .api_krita import Krita +from .composer_utils.compatibility_fix import fix_config +fix_config() Krita.add_extension(ShortcutComposer) sys.path.remove(directory) diff --git a/shortcut_composer/actions.action b/shortcut_composer/actions.action index 1112e449..5b262740 100755 --- a/shortcut_composer/actions.action +++ b/shortcut_composer/actions.action @@ -108,10 +108,6 @@ 0 - - - 0 - 1 diff --git a/shortcut_composer/actions.py b/shortcut_composer/actions.py index 0149509b..9ce87b39 100644 --- a/shortcut_composer/actions.py +++ b/shortcut_composer/actions.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later """ @@ -14,23 +14,19 @@ from PyQt5.QtGui import QColor -from api_krita.enums import BlendingMode, Tool, Toggle, TransformMode +from api_krita.enums import Tool, Toggle, BlendingMode, TransformMode from core_components import instructions, controllers -from composer_utils import Config -from input_adapter import ComplexAction from data_components import ( CurrentLayerStack, PickStrategy, - EnumConfigValues, - TagConfigValues, Slider, Range, + Tag, ) infinity = float("inf") -def create_actions() -> List[ComplexAction]: return [ - +def create_actions() -> List[templates.RawInstructions]: return [ # Switch between FREEHAND BRUSH and the MOVE tool templates.TemporaryKey( name="Temporary move tool", @@ -95,7 +91,11 @@ def create_actions() -> List[ComplexAction]: return [ name="Cycle selection tools", controller=controllers.ToolController(), default_value=Tool.FREEHAND_BRUSH, - values=EnumConfigValues(Config.SELECTION_TOOLS_VALUES, Tool), + values=[ + Tool.FREEHAND_SELECTION, + Tool.RECTANGULAR_SELECTION, + Tool.CONTIGUOUS_SELECTION, + ], ), # Control undo and redo actions by sliding the cursor horizontally @@ -183,7 +183,13 @@ def create_actions() -> List[ComplexAction]: return [ templates.PieMenu( name="Pick misc tools", controller=controllers.ToolController(), - values=EnumConfigValues(Config.MISC_TOOLS_VALUES, Tool), + values=[ + Tool.CROP, + Tool.REFERENCE, + Tool.GRADIENT, + Tool.MULTI_BRUSH, + Tool.ASSISTANTS, + ], pie_radius_scale=0.9 ), @@ -193,22 +199,47 @@ def create_actions() -> List[ComplexAction]: return [ name="Pick painting blending modes", controller=controllers.BlendingModeController(), instructions=[instructions.SetBrushOnNonPaintable()], - values=EnumConfigValues(Config.BLENDING_MODES_VALUES, BlendingMode), + values=[ + BlendingMode.NORMAL, + BlendingMode.OVERLAY, + BlendingMode.COLOR, + BlendingMode.MULTIPLY, + BlendingMode.ADD, + BlendingMode.SCREEN, + BlendingMode.DARKEN, + BlendingMode.LIGHTEN, + ] ), # Use pie menu to create painting layer with selected blending mode. templates.PieMenu( name="Create painting layer with blending mode", controller=controllers.CreateLayerWithBlendingController(), - values=EnumConfigValues( - Config.CREATE_BLENDING_LAYER_VALUES, BlendingMode), + values=[ + BlendingMode.NORMAL, + BlendingMode.ERASE, + BlendingMode.OVERLAY, + BlendingMode.COLOR, + BlendingMode.MULTIPLY, + BlendingMode.ADD, + BlendingMode.SCREEN, + BlendingMode.DARKEN, + BlendingMode.LIGHTEN, + ], ), # Pick one of the transform tool modes. templates.PieMenu( name="Pick transform tool modes", controller=controllers.TransformModeController(), - values=EnumConfigValues(Config.TRANSFORM_MODES_VALUES, TransformMode), + values=[ + TransformMode.FREE, + TransformMode.PERSPECTIVE, + TransformMode.WARP, + TransformMode.CAGE, + TransformMode.LIQUIFY, + TransformMode.MESH, + ] ), # Use pie menu to pick one of presets from tag specified in settings. @@ -217,7 +248,7 @@ def create_actions() -> List[ComplexAction]: return [ name="Pick brush presets (red)", controller=controllers.PresetController(), instructions=[instructions.SetBrushOnNonPaintable()], - values=TagConfigValues(Config.TAG_RED, Config.TAG_RED_VALUES), + values=Tag("★ My Favorites"), background_color=QColor(95, 65, 65, 190), active_color=QColor(200, 70, 70), ), @@ -228,7 +259,7 @@ def create_actions() -> List[ComplexAction]: return [ name="Pick brush presets (green)", controller=controllers.PresetController(), instructions=[instructions.SetBrushOnNonPaintable()], - values=TagConfigValues(Config.TAG_GREEN, Config.TAG_GREEN_VALUES), + values=Tag("RGBA"), background_color=QColor(65, 95, 65, 190), active_color=QColor(70, 200, 70), ), @@ -239,7 +270,7 @@ def create_actions() -> List[ComplexAction]: return [ name="Pick brush presets (blue)", controller=controllers.PresetController(), instructions=[instructions.SetBrushOnNonPaintable()], - values=TagConfigValues(Config.TAG_BLUE, Config.TAG_BLUE_VALUES), + values=Tag("Erasers"), background_color=QColor(70, 70, 105, 190), active_color=QColor(110, 160, 235), ), diff --git a/shortcut_composer/api_krita/__init__.py b/shortcut_composer/api_krita/__init__.py index 327de44f..4a513e89 100644 --- a/shortcut_composer/api_krita/__init__.py +++ b/shortcut_composer/api_krita/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later """ diff --git a/shortcut_composer/api_krita/actions/__init__.py b/shortcut_composer/api_krita/actions/__init__.py index 349c2560..96512cb7 100644 --- a/shortcut_composer/api_krita/actions/__init__.py +++ b/shortcut_composer/api_krita/actions/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from .transform_actions import TransformModeActions, TransformModeFinder diff --git a/shortcut_composer/api_krita/actions/transform_actions.py b/shortcut_composer/api_krita/actions/transform_actions.py index 947673a4..9e0b5b0f 100644 --- a/shortcut_composer/api_krita/actions/transform_actions.py +++ b/shortcut_composer/api_krita/actions/transform_actions.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from typing import Dict, Optional @@ -14,6 +14,7 @@ from ..enums import Tool, TransformMode from ..core_api import KritaInstance + Krita = KritaInstance() diff --git a/shortcut_composer/api_krita/core_api.py b/shortcut_composer/api_krita/core_api.py index 5c5dbf74..dbea2368 100644 --- a/shortcut_composer/api_krita/core_api.py +++ b/shortcut_composer/api_krita/core_api.py @@ -1,10 +1,14 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from krita import Krita as Api, Extension, qApp -from typing import Callable, Protocol, Any +from typing import Callable, Protocol, Any, Optional -from PyQt5.QtWidgets import QMainWindow, QDesktopWidget, QWidgetAction +from PyQt5.QtWidgets import ( + QMainWindow, + QDesktopWidget, + QWidgetAction, + QMdiArea) from PyQt5.QtGui import QKeySequence, QColor, QIcon from PyQt5.QtCore import QTimer @@ -61,15 +65,30 @@ def get_active_qwindow(self) -> QMainWindow: """Return qt window of krita. Don't use on plugin init phase.""" return self.instance.activeWindow().qwindow() + def get_active_mdi_area(self) -> QMdiArea: + return self.get_active_qwindow().findChild(QMdiArea) # type: ignore + def get_icon(self, icon_name: str) -> QIcon: return self.instance.icon(icon_name) - def read_setting(self, group: str, name: str, default: str) -> str: - """Read setting from .kritarc file as string.""" - return self.instance.readSetting(group, name, default) + def read_setting( + self, + group: str, + name: str, + default: str = "Not stored" + ) -> Optional[str]: + """ + Read a setting from kritarc file. + + - Return string red from file if present + - Return default if it was given + - Return None if default was not given + """ + red_value = self.instance.readSetting(group, name, default) + return None if red_value == "Not stored" else red_value def write_setting(self, group: str, name: str, value: Any) -> None: - """Write setting to .kritarc file. Value type will be lost.""" + """Write setting to kritarc file. Value type will be lost.""" self.instance.writeSetting(group, name, str(value)) def create_action( @@ -78,7 +97,7 @@ def create_action( name: str, group: str = "", callback: Callable[[], None] = lambda: None - ): + ) -> QWidgetAction: """ Create a new action in krita. @@ -111,8 +130,7 @@ def connect_callback(): def is_light_theme_active(self) -> bool: """Return if currently set theme is light using it's main color.""" main_color: QColor = qApp.palette().window().color() - average = (main_color.red()+main_color.green()+main_color.blue()) // 3 - return average > 128 + return main_color.value() > 128 class KritaWindow(Protocol): diff --git a/shortcut_composer/api_krita/enums/__init__.py b/shortcut_composer/api_krita/enums/__init__.py index 901335d8..113d3df3 100644 --- a/shortcut_composer/api_krita/enums/__init__.py +++ b/shortcut_composer/api_krita/enums/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later """Enumerated values used in krita api wrappers.""" diff --git a/shortcut_composer/api_krita/enums/blending_mode.py b/shortcut_composer/api_krita/enums/blending_mode.py index 686614ab..190c3571 100644 --- a/shortcut_composer/api_krita/enums/blending_mode.py +++ b/shortcut_composer/api_krita/enums/blending_mode.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from enum import Enum @@ -9,137 +9,6 @@ class BlendingMode(Enum): Contains all known blending modes in krita. Example usage: `BlendingMode.NORMAL` - - Available tools blending modes: - - `NORMAL` - - `ADD` - - `BURN` - - `COLOR` - - `DODGE` - - `DARKEN` - - `DIVIDE` - - `ERASE` - - `LIGHTEN` - - `LUMINIZE` - - `MULTIPLY` - - `OVERLAY` - - `SATURATION` - - `SCREEN` - - `SOFT_LIGHT_SVG` - - `INVERSE_SUBTRACT` - - `SUBTRACT` - - `AND` - - `CONVERSE` - - `IMPLICATION` - - `NAND` - - `NOR` - - `NOT_CONVERSE` - - `NOT_IMPLICATION` - - `OR` - - `XNOR` - - `XOR` - - `DARKER_COLOR` - - `EASY_BURN` - - `FOG_DARKEN_IFS_ILLUSIONS` - - `GAMMA_DARK` - - `SHADE_IFS_ILLUSIONS` - - `LINEAR_BURN` - - `COLOR_HSI` - - `DEC_INTENSITY` - - `DEC_SATURATION_HSI` - - `HUE_HSI` - - `INC_INTENSITY` - - `INC_SATURATION_HSI` - - `INTENSITY` - - `SATURATION_HSI` - - `DEC_LIGHTNESS` - - `COLOR_HSL` - - `DEC_SATURATION_HSL` - - `HUE_HSL` - - `INC_LIGHTNESS` - - `INC_SATURATION_HSL` - - `LIGHTNESS` - - `SATURATION_HSL` - - `COLOR_HSV` - - `DEC_SATURATION_HSV` - - `DEC_VALUE` - - `HUE_HSV` - - `INC_SATURATION_HSV` - - `INC_VALUE` - - `SATURATION_HSV` - - `VALUE` - - `DEC_SATURATION` - - `DEC_LUMINOSITY` - - `HUE` - - `INC_LUMINOSITY` - - `INC_SATURATION` - - `EASY_DODGE` - - `FLAT_LIGHT` - - `GAMMA_ILLUMINATION` - - `FOG_LIGHTEN_IFS_ILLUSIONS` - - `GAMMA_LIGHT` - - `HARD_LIGHT` - - `LIGHTER_COLOR` - - `LINEAR_DODGE` - - `LINEAR_LIGHT` - - `LUMINOSITY_SAI` - - `PNORM_A` - - `PNORM_B` - - `PIN_LIGHT` - - `SOFT_LIGHT_IFS_ILLUSIONS` - - `SOFT_LIGHT_PEGTOP_DELPHI` - - `SOFT_LIGHT` - - `SUPER_LIGHT` - - `TINT_IFS_ILLUSIONS` - - `VIVID_LIGHT` - - `BUMPMAP` - - `COMBINE_NORMAL` - - `COPY` - - `COPY_BLUE` - - `COPY_GREEN` - - `COPY_RED` - - `DISSOLVE` - - `TANGENT_NORMALMAP` - - `ALLANON` - - `ALPHADARKEN` - - `BEHIND` - - `DESTINATION_ATOP` - - `DESTINATION_IN` - - `GEOMETRIC_MEAN` - - `GRAIN_EXTRACT` - - `GRAIN_MERGE` - - `GREATER` - - `HARD_MIX` - - `HARD_MIX_PHOTOSHOP` - - `HARD_MIX_SOFTER_PHOTOSHOP` - - `HARD_OVERLAY` - - `INTERPOLATION` - - `INTERPOLATION_2X` - - `PARALLEL` - - `PENUMBRA_A` - - `PENUMBRA_B` - - `PENUMBRA_C` - - `PENUMBRA_D` - - `DIVISIVE_MODULO` - - `DIVISIVE_MODULO_CONTINUOUS` - - `MODULO_CONTINUOUS` - - `MODULO_SHIFT` - - `MODULO_SHIFT_CONTINUOUS` - - `ADDITIVE_SUBTRACTIVE` - - `ARC_TANGENT` - - `DIFF` - - `EQUIVALENCE` - - `EXCLUSION` - - `NEGATION` - - `FREEZE` - - `FREEZE_REFLECT` - - `GLOW` - - `GLOW_HEAT` - - `HEAT` - - `HEAT_GLOW` - - `HEAT_GLOW_FREEZE_REFLECT_HYBRID` - - `REFLECT` - - `REFLECT_FREEZE` """ NORMAL = "normal" @@ -271,3 +140,10 @@ class BlendingMode(Enum): HEAT_GLOW_FREEZE_REFLECT_HYBRID = "heat_glow_freeze_reflect_hybrid" REFLECT = "reflect" REFLECT_FREEZE = "reflect_freeze" + + @property + def pretty_name(self): + """Format blending mode name like: `Darker Color`.""" + parts = self.name.split("_") + parts = [f"{part[0]}{part[1:].lower()}" for part in parts] + return " ".join(parts) diff --git a/shortcut_composer/api_krita/enums/node_types.py b/shortcut_composer/api_krita/enums/node_types.py index c6c554a0..7ee0359e 100644 --- a/shortcut_composer/api_krita/enums/node_types.py +++ b/shortcut_composer/api_krita/enums/node_types.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from krita import Krita as Api @@ -6,27 +6,14 @@ from PyQt5.QtGui import QIcon + class NodeType(Enum): """ Contains all known node types in krita. Example usage: `NodeType.PAINT_LAYER` - - Available node types: - - `PAINT_LAYER` - - `GROUP_LAYER` - - `FILE_LAYER` - - `FILTER_LAYER` - - `FILL_LAYER` - - `CLONE_LAYER` - - `VECTOR_LAYER` - - `TRANSPARENCY_MASK` - - `FILTER_MASK` - - `TRANSFORM_MASK` - - `SELECTION_MASK` - - `COLORIZE_MASK` """ - + PAINT_LAYER = "paintlayer" GROUP_LAYER = "grouplayer" FILE_LAYER = "filelayer" @@ -45,7 +32,13 @@ def icon(self) -> QIcon: """Return the icon of this node type.""" icon_name = _ICON_NAME_MAP.get(self, "edit-delete") return Api.instance().icon(icon_name) - + + @property + def pretty_name(self): + """Format node type name like: `Paint layer`.""" + return f"{self.name[0]}{self.name[1:].lower().replace('_', ' ')}" + + _ICON_NAME_MAP = { NodeType.PAINT_LAYER: "paintLayer", NodeType.GROUP_LAYER: "groupLayer", @@ -60,4 +53,4 @@ def icon(self) -> QIcon: NodeType.SELECTION_MASK: "selectionMask", NodeType.COLORIZE_MASK: "colorizeMask" } -"""Maps node types to names of their icons.""" \ No newline at end of file +"""Maps node types to names of their icons.""" diff --git a/shortcut_composer/api_krita/enums/toggle.py b/shortcut_composer/api_krita/enums/toggle.py index 4c0a48ee..1e59ee63 100644 --- a/shortcut_composer/api_krita/enums/toggle.py +++ b/shortcut_composer/api_krita/enums/toggle.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from krita import Krita as Api @@ -10,21 +10,6 @@ class Toggle(Enum): Contains all known actions that toggle (can be activated and deactivated). Example usage: `Toggle.ERASER` - - Available toggle actions: - - `ERASER` - - `PRESERVE_ALPHA` - - `MIRROR_CANVAS` - - `SOFT_PROOFING` - - `ISOLATE_LAYER` - - `VIEW_REFERENCE_IMAGES` - - `VIEW_ASSISTANTS` - - `VIEW_ASSISTANTS_PREVIEWS` - - `VIEW_GRID` - - `VIEW_RULER` - - `VIEW_ONION_SKIN` - - `SNAP_ASSISTANT` - - `SNAP_TO_GRID` """ ERASER = "erase_action" @@ -41,6 +26,11 @@ class Toggle(Enum): SNAP_ASSISTANT = "toggle_assistant" SNAP_TO_GRID = "view_snap_to_grid" + @property + def pretty_name(self): + """Format toggle name like: `Preserve alpha`.""" + return f"{self.name[0]}{self.name[1:].lower().replace('_', ' ')}" + @property def state(self) -> bool: """Return state of checkable krita action called `action_name`.""" diff --git a/shortcut_composer/api_krita/enums/tool.py b/shortcut_composer/api_krita/enums/tool.py index 6a13fad5..760e09b1 100644 --- a/shortcut_composer/api_krita/enums/tool.py +++ b/shortcut_composer/api_krita/enums/tool.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from krita import Krita as Api @@ -14,81 +14,44 @@ class Tool(Enum): Extended with modes of the transform tool. Example usage: `Tool.FREEHAND_BRUSH` - - Available tools: - - `FREEHAND_BRUSH` - - `FREEHAND_SELECTION` - - `GRADIENT` - - `LINE` - - `TRANSFORM` - - `MOVE` - - `RECTANGULAR_SELECTION` - - `CONTIGUOUS_SELECTION` - - `REFERENCE` - - `CROP` - - `BEZIER_PATH` - - `FREEHAND_PATH` - - `POLYLINE` - - `SHAPE_SELECT` - - `ASSISTANTS` - - `COLOR_SAMPLER` - - `POLYGON` - - `MEASUREMENT` - - `TEXT` - - `ELLIPSE` - - `FILL` - - `BEZIER_SELECTION` - - `DYNAMIC_BRUSH` - - `RECTANGLE` - - `PAN` - - `MULTI_BRUSH` - - `EDIT_SHAPES` - - `ELIPTICAL_SELECTION` - - `SMART_PATCH` - - `COLORIZE_MASK` - - `SIMILAR_COLOR_SELECTION` - - `ZOOM` - - `MAGNETIC_SELECTION` - - `CALLIGRAPHY` - - `POLYGONAL_SELECTION` """ + SHAPE_SELECT = "InteractionTool" + TEXT = "SvgTextTool" + EDIT_SHAPES = "PathTool" + CALLIGRAPHY = "KarbonCalligraphyTool" FREEHAND_BRUSH = "KritaShape/KisToolBrush" - FREEHAND_SELECTION = "KisToolSelectOutline" - GRADIENT = "KritaFill/KisToolGradient" LINE = "KritaShape/KisToolLine" + RECTANGLE = "KritaShape/KisToolRectangle" + ELLIPSE = "KritaShape/KisToolEllipse" + POLYGON = "KisToolPolygon" + POLYLINE = "KisToolPolyline" + BEZIER_PATH = "KisToolPath" + FREEHAND_PATH = "KisToolPencil" + DYNAMIC_BRUSH = "KritaShape/KisToolDyna" + MULTI_BRUSH = "KritaShape/KisToolMultiBrush" TRANSFORM = "KisToolTransform" MOVE = "KritaTransform/KisToolMove" - RECTANGULAR_SELECTION = "KisToolSelectRectangular" - CONTIGUOUS_SELECTION = "KisToolSelectContiguous" - REFERENCE = "ToolReferenceImages" CROP = "KisToolCrop" - BEZIER_PATH = "KisToolPath" - FREEHAND_PATH = "KisToolPencil" - POLYLINE = "KisToolPolyline" - SHAPE_SELECT = "InteractionTool" - ASSISTANTS = "KisAssistantTool" + GRADIENT = "KritaFill/KisToolGradient" COLOR_SAMPLER = "KritaSelected/KisToolColorSampler" - POLYGON = "KisToolPolygon" - MEASUREMENT = "KritaShape/KisToolMeasure" - TEXT = "SvgTextTool" - ELLIPSE = "KritaShape/KisToolEllipse" + COLORIZE_MASK = "KritaShape/KisToolLazyBrush" + SMART_PATCH = "KritaShape/KisToolSmartPatch" FILL = "KritaFill/KisToolFill" ENCLOSE_AND_FILL = "KisToolEncloseAndFill" - BEZIER_SELECTION = "KisToolSelectPath" - DYNAMIC_BRUSH = "KritaShape/KisToolDyna" - RECTANGLE = "KritaShape/KisToolRectangle" - PAN = "PanTool" - MULTI_BRUSH = "KritaShape/KisToolMultiBrush" - EDIT_SHAPES = "PathTool" + ASSISTANTS = "KisAssistantTool" + MEASUREMENT = "KritaShape/KisToolMeasure" + REFERENCE = "ToolReferenceImages" + RECTANGULAR_SELECTION = "KisToolSelectRectangular" ELIPTICAL_SELECTION = "KisToolSelectElliptical" - SMART_PATCH = "KritaShape/KisToolSmartPatch" - COLORIZE_MASK = "KritaShape/KisToolLazyBrush" + POLYGONAL_SELECTION = "KisToolSelectPolygonal" + FREEHAND_SELECTION = "KisToolSelectOutline" + CONTIGUOUS_SELECTION = "KisToolSelectContiguous" SIMILAR_COLOR_SELECTION = "KisToolSelectSimilar" - ZOOM = "ZoomTool" + BEZIER_SELECTION = "KisToolSelectPath" MAGNETIC_SELECTION = "KisToolSelectMagnetic" - CALLIGRAPHY = "KarbonCalligraphyTool" - POLYGONAL_SELECTION = "KisToolSelectPolygonal" + ZOOM = "ZoomTool" + PAN = "PanTool" def activate(self): Api.instance().action(self.value).trigger() @@ -104,6 +67,11 @@ def icon(self) -> QIcon: icon_name = _ICON_NAME_MAP.get(self, "edit-delete") return Api.instance().icon(icon_name) + @property + def pretty_name(self): + """Format tool name like: `Shape select tool`.""" + return f"{self.name[0]}{self.name[1:].lower().replace('_', ' ')} tool" + _PAINTABLE = { Tool.FREEHAND_BRUSH, diff --git a/shortcut_composer/api_krita/enums/transform_mode.py b/shortcut_composer/api_krita/enums/transform_mode.py index eb0328a7..27d745af 100644 --- a/shortcut_composer/api_krita/enums/transform_mode.py +++ b/shortcut_composer/api_krita/enums/transform_mode.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from krita import Krita as Api @@ -14,14 +14,6 @@ class TransformMode(Enum): Extended with modes of the transform tool. Example usage: `Tool.FREEHAND_BRUSH` - - Available modes: - - `FREE` - - `PERSPECTIVE` - - `WARP` - - `CAGE` - - `LIQUIFY` - - `MESH` """ FREE = "Transform tool: free" @@ -46,6 +38,11 @@ def icon(self) -> QIcon: icon_name = _ICON_NAME_MAP[self] return Api.instance().icon(icon_name) + @property + def pretty_name(self) -> str: + """Format mode name like: `Liquify`.""" + return f"{self.name[0]}{self.name[1:].lower()}" + _ICON_NAME_MAP = { TransformMode.FREE: "krita_tool_transform", diff --git a/shortcut_composer/api_krita/pyqt/__init__.py b/shortcut_composer/api_krita/pyqt/__init__.py index 812be5ac..a340f019 100644 --- a/shortcut_composer/api_krita/pyqt/__init__.py +++ b/shortcut_composer/api_krita/pyqt/__init__.py @@ -1,10 +1,11 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later """Wrappers and utilities based on PyQt5 objects.""" from .custom_widgets import AnimatedWidget, BaseWidget from .pixmap_transform import PixmapTransform +from .round_button import RoundButton from .colorizer import Colorizer from .painter import Painter from .timer import Timer @@ -13,6 +14,7 @@ __all__ = [ "PixmapTransform", "AnimatedWidget", + "RoundButton", "BaseWidget", "Colorizer", "Painter", diff --git a/shortcut_composer/api_krita/pyqt/colorizer.py b/shortcut_composer/api_krita/pyqt/colorizer.py index 62de22dd..95a4270f 100644 --- a/shortcut_composer/api_krita/pyqt/colorizer.py +++ b/shortcut_composer/api_krita/pyqt/colorizer.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from collections import defaultdict diff --git a/shortcut_composer/api_krita/pyqt/custom_widgets.py b/shortcut_composer/api_krita/pyqt/custom_widgets.py index 11d42d31..de9b085f 100644 --- a/shortcut_composer/api_krita/pyqt/custom_widgets.py +++ b/shortcut_composer/api_krita/pyqt/custom_widgets.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + from PyQt5.QtWidgets import QWidget from PyQt5.QtCore import QPoint @@ -24,6 +27,11 @@ 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): + center = self.center_global + super().setGeometry(ax, ay, aw, ah) + self.move_center(center) + class AnimatedWidget(QWidget): """Adds the fade-in animation when the widget is shown (60 FPS).""" diff --git a/shortcut_composer/api_krita/pyqt/painter.py b/shortcut_composer/api_krita/pyqt/painter.py index d5420812..bbc5da21 100644 --- a/shortcut_composer/api_krita/pyqt/painter.py +++ b/shortcut_composer/api_krita/pyqt/painter.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later import math diff --git a/shortcut_composer/api_krita/pyqt/pixmap_transform.py b/shortcut_composer/api_krita/pyqt/pixmap_transform.py index 5f9c687f..264448b8 100644 --- a/shortcut_composer/api_krita/pyqt/pixmap_transform.py +++ b/shortcut_composer/api_krita/pyqt/pixmap_transform.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from PyQt5.QtCore import Qt diff --git a/shortcut_composer/api_krita/pyqt/round_button.py b/shortcut_composer/api_krita/pyqt/round_button.py new file mode 100644 index 00000000..bb94e759 --- /dev/null +++ b/shortcut_composer/api_krita/pyqt/round_button.py @@ -0,0 +1,73 @@ +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from typing import Optional + +from PyQt5.QtWidgets import QWidget, QPushButton +from PyQt5.QtGui import QColor, QIcon +from PyQt5.QtCore import Qt + +from .custom_widgets import BaseWidget + + +class RoundButton(QPushButton, BaseWidget): + """Round button with custom icon.""" + + def __init__( + self, *, + icon: QIcon = QIcon(), + icon_scale: float = 1, + initial_radius: int, + background_color: QColor, + active_color: QColor, + parent: Optional[QWidget] = None, + ) -> None: + QPushButton.__init__(self, icon, "", parent) + self.setCursor(Qt.ArrowCursor) + + self._icon_scale = icon_scale + self._background_color = background_color + self._active_color = active_color + + if parent is None: + self.setWindowFlags(( + self.windowFlags() | # type: ignore + Qt.Tool | + Qt.FramelessWindowHint | + Qt.NoDropShadowWindowHint)) + self.setAttribute(Qt.WA_TranslucentBackground) + self.setStyleSheet("background: transparent;") + + self.resize(initial_radius) + self.show() + + def resize(self, radius: int): + """Change the size and repaint the button.""" + self.setGeometry(0, 0, radius*2, radius*2) + + self.setStyleSheet(f""" + QPushButton [ + border: {round(radius*0.06)}px + {self._color_to_str(self._border_color)}; + border-radius: {radius}px; + border-style: outset; + background: {self._color_to_str(self._background_color)}; + qproperty-iconSize:{round(radius*self._icon_scale)}px; + ] + QPushButton:hover [ + background: {self._color_to_str(self._active_color)}; + ] + """.replace('[', '{').replace(']', '}')) + + @staticmethod + def _color_to_str(color: QColor) -> str: return f'''rgba( + {color.red()}, {color.green()}, {color.blue()}, {color.alpha()})''' + + @property + def _border_color(self): + """Color of button border.""" + return QColor( + min(self._background_color.red()+15, 255), + min(self._background_color.green()+15, 255), + min(self._background_color.blue()+15, 255), + 255) diff --git a/shortcut_composer/api_krita/pyqt/text.py b/shortcut_composer/api_krita/pyqt/text.py index a4d28e0f..347261c2 100644 --- a/shortcut_composer/api_krita/pyqt/text.py +++ b/shortcut_composer/api_krita/pyqt/text.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from dataclasses import dataclass diff --git a/shortcut_composer/api_krita/pyqt/timer.py b/shortcut_composer/api_krita/pyqt/timer.py index fdaffea4..1191e805 100644 --- a/shortcut_composer/api_krita/pyqt/timer.py +++ b/shortcut_composer/api_krita/pyqt/timer.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + from typing import Callable from PyQt5.QtCore import QTimer diff --git a/shortcut_composer/api_krita/wrappers/__init__.py b/shortcut_composer/api_krita/wrappers/__init__.py index ef640ecf..f19927cc 100644 --- a/shortcut_composer/api_krita/wrappers/__init__.py +++ b/shortcut_composer/api_krita/wrappers/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later """ diff --git a/shortcut_composer/api_krita/wrappers/canvas.py b/shortcut_composer/api_krita/wrappers/canvas.py index bd54e562..7fa2ccac 100644 --- a/shortcut_composer/api_krita/wrappers/canvas.py +++ b/shortcut_composer/api_krita/wrappers/canvas.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from dataclasses import dataclass diff --git a/shortcut_composer/api_krita/wrappers/cursor.py b/shortcut_composer/api_krita/wrappers/cursor.py index c1e004a1..1df187eb 100644 --- a/shortcut_composer/api_krita/wrappers/cursor.py +++ b/shortcut_composer/api_krita/wrappers/cursor.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from dataclasses import dataclass diff --git a/shortcut_composer/api_krita/wrappers/database.py b/shortcut_composer/api_krita/wrappers/database.py index 80ca6ae0..0bcea040 100644 --- a/shortcut_composer/api_krita/wrappers/database.py +++ b/shortcut_composer/api_krita/wrappers/database.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from krita import Krita as Api @@ -45,6 +45,7 @@ def _single_column_query(self, sql_query: str, value: str) -> List[Any]: def get_preset_names_from_tag(self, tag_name: str) -> List[str]: """Return list of all preset names that belong to given tag.""" + tag_name = tag_name.replace("\"", "\"\"") sql_query = f''' SELECT DISTINCT r.name AS preset FROM tags t @@ -53,7 +54,7 @@ def get_preset_names_from_tag(self, tag_name: str) -> List[str]: JOIN resources r ON r.id = rt.resource_id WHERE - t.name='{tag_name}' + t.name="{tag_name}" AND rt.active = 1 ''' return self._single_column_query(sql_query, "preset") diff --git a/shortcut_composer/api_krita/wrappers/document.py b/shortcut_composer/api_krita/wrappers/document.py index 7617b98d..b8441681 100644 --- a/shortcut_composer/api_krita/wrappers/document.py +++ b/shortcut_composer/api_krita/wrappers/document.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from dataclasses import dataclass @@ -40,7 +40,7 @@ def active_node(self, node: Node) -> None: 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. diff --git a/shortcut_composer/api_krita/wrappers/node.py b/shortcut_composer/api_krita/wrappers/node.py index 4b17113c..6f7dfb96 100644 --- a/shortcut_composer/api_krita/wrappers/node.py +++ b/shortcut_composer/api_krita/wrappers/node.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from dataclasses import dataclass @@ -38,11 +38,11 @@ class Node(): def add_child_node(self, child: 'Node', above: 'Node') -> bool: """ Add the given node in the list of children. - + Parameters: child - the node to be added above - the node above which this node will be placed - + Returns false if adding the node failed. """ return self.node.addChildNode(child.node, above.node) diff --git a/shortcut_composer/api_krita/wrappers/tool_descriptor.py b/shortcut_composer/api_krita/wrappers/tool_descriptor.py index baae7c91..b28db866 100644 --- a/shortcut_composer/api_krita/wrappers/tool_descriptor.py +++ b/shortcut_composer/api_krita/wrappers/tool_descriptor.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from krita import Krita as Api diff --git a/shortcut_composer/api_krita/wrappers/view.py b/shortcut_composer/api_krita/wrappers/view.py index 915fd2ef..424cd3e7 100644 --- a/shortcut_composer/api_krita/wrappers/view.py +++ b/shortcut_composer/api_krita/wrappers/view.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from krita import Krita as Api @@ -11,11 +11,13 @@ class _KritaPreset(Protocol): """Krita `Resource` object API.""" + def name(self) -> str: ... class KritaView(Protocol): """Krita `View` object API.""" + def currentBrushPreset(self) -> _KritaPreset: ... def currentBlendingMode(self) -> str: ... def paintingOpacity(self) -> float: ... @@ -47,7 +49,8 @@ def brush_preset(self) -> str: @brush_preset.setter def brush_preset(self, preset_name: str) -> None: """Set brush preset inside this `View` using its name.""" - self.view.setCurrentBrushPreset(self.preset_map[preset_name]) + if preset_name in self.preset_map: + self.view.setCurrentBrushPreset(self.preset_map[preset_name]) @property def blending_mode(self) -> BlendingMode: diff --git a/shortcut_composer/composer_utils/__init__.py b/shortcut_composer/composer_utils/__init__.py index 885f4b6f..ea41dc79 100644 --- a/shortcut_composer/composer_utils/__init__.py +++ b/shortcut_composer/composer_utils/__init__.py @@ -1,9 +1,10 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later """Utilities specific for this plugin. Not directly reusable elsewhere.""" from .settings_dialog import SettingsDialog -from .config import Config +from .buttons_layout import ButtonsLayout +from .global_config import Config -__all__ = ["SettingsDialog", "Config"] +__all__ = ["SettingsDialog", "ButtonsLayout", "Config"] diff --git a/shortcut_composer/composer_utils/layouts/buttons_layout.py b/shortcut_composer/composer_utils/buttons_layout.py similarity index 95% rename from shortcut_composer/composer_utils/layouts/buttons_layout.py rename to shortcut_composer/composer_utils/buttons_layout.py index fa148141..b85c6e8d 100644 --- a/shortcut_composer/composer_utils/layouts/buttons_layout.py +++ b/shortcut_composer/composer_utils/buttons_layout.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from typing import Callable diff --git a/shortcut_composer/composer_utils/compatibility_fix.py b/shortcut_composer/composer_utils/compatibility_fix.py new file mode 100644 index 00000000..ef22ddf2 --- /dev/null +++ b/shortcut_composer/composer_utils/compatibility_fix.py @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from api_krita import Krita + + +def fix_config(): + """Rewrites config values from their position in 1.1.1 to 1.2.0.""" + def fix(group: str, old_name: str, new_name: str): + if Krita.read_setting(group, new_name, "not given") != "not given": + return + value = Krita.read_setting("ShortcutComposer", old_name, "not given") + if value != "not given": + Krita.write_setting(group, new_name, value) + + data = ( + ("Pick brush presets (red)", "Tag (red)", "Tag"), + ("Pick brush presets (green)", "Tag (green)", "Tag"), + ("Pick brush presets (blue)", "Tag (blue)", "Tag"), + + ("Pick brush presets (red)", "Tag (red) values", "Values"), + ("Pick brush presets (green)", "Tag (green) values", "Values"), + ("Pick brush presets (blue)", "Tag (blue) values", "Values"), + + ("Pick painting blending modes", "Blending modes values", "Values"), + ("Pick misc tools", "Misc tools values", "Values"), + ("Cycle selection tools", "Selection tools values", "Values"), + ("Pick transform tool modes", "Transform modes values", "Values"), + ( + "Create painting layer with blending mode", + "Create blending layer values", + "Values"), + ) + + for group, old_name, new_name in data: + fix(f"ShortcutComposer: {group}", old_name, new_name) diff --git a/shortcut_composer/composer_utils/config.py b/shortcut_composer/composer_utils/config.py deleted file mode 100644 index dd7960ba..00000000 --- a/shortcut_composer/composer_utils/config.py +++ /dev/null @@ -1,177 +0,0 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus -# SPDX-License-Identifier: GPL-3.0-or-later - -from typing import Union, Any, TypeVar, List -from enum import Enum - -from api_krita import Krita -from api_krita.enums import Tool, BlendingMode, TransformMode - -T = TypeVar('T', bound=Enum) - - -class Config(Enum): - """ - Configuration fields available in the plugin. - - Each field remembers its default value: - - `SHORT_VS_LONG_PRESS_TIME` = 0.3 - - `TRACKER_SENSITIVITY_SCALE` = 1.0 - - `TRACKER_DEADZONE` = 0 - - `FPS_LIMIT` = 60 - - `PIE_GLOBAL_SCALE` = 1.0 - - `PIE_ICON_GLOBAL_SCALE` = 1.0 - - `PIE_DEADZONE_GLOBAL_SCALE` = 1.0 - - `PIE_ANIMATION_TIME` = 0.2 - - `TAG_RED` = "★ My Favorites" - - `TAG_GREEN` = "RGBA" - - `TAG_BLUE` = "Erasers" - - `TAG_RED_VALUES` = ... - - `TAG_GREEN_VALUES` = ... - - `TAG_BLUE_VALUES` = ... - - `BLENDING_MODES_VALUES` = ... - - `MISC_TOOLS_VALUES` = ... - - `SELECTION_TOOLS_VALUES` = ... - - `TRANSFORM_MODES_VALUES` = ... - - `CREATE_BLENDING_LAYER_VALUES` = ... - - Each field can: - - return its default value - - read current value from krita config file in correct type - - write given value to krita config file - - Class holds a staticmethod which resets all config files. - - VALUES configs are string representations of lists. They hold values - to use in given action with elements separated with tabulators. - These are needed to be further parsed using TagConfigValues or - EnumConfigValues. - """ - - SHORT_VS_LONG_PRESS_TIME = "Short vs long press time" - TRACKER_SENSITIVITY_SCALE = "Tracker sensitivity scale" - TRACKER_DEADZONE = "Tracker deadzone" - FPS_LIMIT = "FPS limit" - PIE_GLOBAL_SCALE = "Pie global scale" - PIE_ICON_GLOBAL_SCALE = "Pie icon global scale" - PIE_DEADZONE_GLOBAL_SCALE = "Pie deadzone global scale" - PIE_ANIMATION_TIME = "pie animation time" - - TAG_RED = "Tag (red)" - TAG_GREEN = "Tag (green)" - TAG_BLUE = "Tag (blue)" - - TAG_RED_VALUES = "Tag (red) values" - TAG_GREEN_VALUES = "Tag (green) values" - TAG_BLUE_VALUES = "Tag (blue) values" - - BLENDING_MODES_VALUES = "Blending modes values" - MISC_TOOLS_VALUES = "Misc tools values" - SELECTION_TOOLS_VALUES = "Selection tools values" - TRANSFORM_MODES_VALUES = "Transform modes values" - CREATE_BLENDING_LAYER_VALUES = "Create blending layer values" - - @property - def default(self) -> Union[float, int, str]: - """Return default value of the field.""" - return _defaults[self] - - def read(self) -> Any: - """Read current value from krita config file.""" - setting = Krita.read_setting( - group="ShortcutComposer", - name=self.value, - default=str(self.default), - ) - try: - return type(self.default)(setting) - except ValueError: - print(f"Can't parse {setting} to {type(self.default)}") - return self.default - - def write(self, value: Any) -> None: - """Write given value to krita config file.""" - Krita.write_setting( - group="ShortcutComposer", - name=self.value, - value=value - ) - - @staticmethod - def reset_defaults() -> None: - """Reset all config files.""" - for field, default in _defaults.items(): - field.write(default) - - @staticmethod - def get_sleep_time() -> int: - """Read sleep time from FPS_LIMIT config field.""" - fps_limit = Config.FPS_LIMIT.read() - return round(1000/fps_limit) if fps_limit else 1 - - @staticmethod - def format_enums(enums: List[Enum]) -> str: - return "\t".join([enum.name for enum in enums]) - - -_defaults = { - Config.SHORT_VS_LONG_PRESS_TIME: 0.3, - Config.TRACKER_SENSITIVITY_SCALE: 1.0, - Config.TRACKER_DEADZONE: 0, - Config.FPS_LIMIT: 60, - Config.PIE_GLOBAL_SCALE: 1.0, - Config.PIE_ICON_GLOBAL_SCALE: 1.0, - Config.PIE_DEADZONE_GLOBAL_SCALE: 1.0, - Config.PIE_ANIMATION_TIME: 0.2, - - Config.TAG_RED: "★ My Favorites", - Config.TAG_GREEN: "RGBA", - Config.TAG_BLUE: "Erasers", - - Config.TAG_RED_VALUES: "", - Config.TAG_GREEN_VALUES: "", - Config.TAG_BLUE_VALUES: "", - - Config.SELECTION_TOOLS_VALUES: Config.format_enums([ - Tool.FREEHAND_SELECTION, - Tool.RECTANGULAR_SELECTION, - Tool.CONTIGUOUS_SELECTION, - ]), - Config.MISC_TOOLS_VALUES: Config.format_enums([ - Tool.CROP, - Tool.REFERENCE, - Tool.GRADIENT, - Tool.MULTI_BRUSH, - Tool.ASSISTANTS, - ]), - Config.BLENDING_MODES_VALUES: Config.format_enums([ - BlendingMode.NORMAL, - BlendingMode.OVERLAY, - BlendingMode.COLOR, - BlendingMode.MULTIPLY, - BlendingMode.ADD, - BlendingMode.SCREEN, - BlendingMode.DARKEN, - BlendingMode.LIGHTEN, - ]), - Config.CREATE_BLENDING_LAYER_VALUES: Config.format_enums([ - BlendingMode.NORMAL, - BlendingMode.ERASE, - BlendingMode.OVERLAY, - BlendingMode.COLOR, - BlendingMode.MULTIPLY, - BlendingMode.ADD, - BlendingMode.SCREEN, - BlendingMode.DARKEN, - BlendingMode.LIGHTEN, - ]), - Config.TRANSFORM_MODES_VALUES: Config.format_enums([ - TransformMode.FREE, - TransformMode.PERSPECTIVE, - TransformMode.WARP, - TransformMode.CAGE, - TransformMode.LIQUIFY, - TransformMode.MESH, - ]) -} -"""Maps default values to config fields.""" diff --git a/shortcut_composer/composer_utils/global_config.py b/shortcut_composer/composer_utils/global_config.py new file mode 100644 index 00000000..b169b8d0 --- /dev/null +++ b/shortcut_composer/composer_utils/global_config.py @@ -0,0 +1,44 @@ +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from config_system import FieldGroup + + +class GlobalConfig(FieldGroup): + """ + Configuration fields available in the plugin. + + Each field can: + - return its default value + - read current value from krita config file in correct type + - write given value to krita config file + + Class holds a staticmethod which resets all config files. + + VALUES configs are string representations of lists. They hold values + to use in given action with elements separated with tabulators. + These are needed to be further parsed using TagConfigValues or + EnumConfigValues. + """ + + def __init__(self, name: str) -> None: + super().__init__(name) + self.SHORT_VS_LONG_PRESS_TIME = self.field( + "Short vs long press time", 0.3) + self.TRACKER_SENSITIVITY_SCALE = self.field( + "Tracker sensitivity scale", 1.0) + self.TRACKER_DEADZONE = self.field("Tracker deadzone", 0) + self.FPS_LIMIT = self.field("FPS limit", 60) + self.PIE_GLOBAL_SCALE = self.field("Pie global scale", 1.0) + self.PIE_ICON_GLOBAL_SCALE = self.field("Pie icon global scale", 1.0) + self.PIE_DEADZONE_GLOBAL_SCALE = self.field( + "Pie deadzone global scale", 1.0) + self.PIE_ANIMATION_TIME = self.field("Pie animation time", 0.2) + + def get_sleep_time(self) -> int: + """Read sleep time from FPS_LIMIT config field.""" + fps_limit = self.FPS_LIMIT.read() + return round(1000/fps_limit) if fps_limit else 1 + + +Config = GlobalConfig("ShortcutComposer") diff --git a/shortcut_composer/composer_utils/layouts/__init__.py b/shortcut_composer/composer_utils/layouts/__init__.py deleted file mode 100644 index 9a5165a9..00000000 --- a/shortcut_composer/composer_utils/layouts/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus -# SPDX-License-Identifier: GPL-3.0-or-later - -"""Layouts to put inside the settings dialog.""" - -from .combo_boxes_layout import ComboBoxesLayout -from .spin_boxes_layout import SpinBoxesLayout -from .buttons_layout import ButtonsLayout - -__all__ = ["ComboBoxesLayout", "SpinBoxesLayout", "ButtonsLayout"] diff --git a/shortcut_composer/composer_utils/layouts/combo_boxes_layout.py b/shortcut_composer/composer_utils/layouts/combo_boxes_layout.py deleted file mode 100644 index d30142d6..00000000 --- a/shortcut_composer/composer_utils/layouts/combo_boxes_layout.py +++ /dev/null @@ -1,65 +0,0 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus -# SPDX-License-Identifier: GPL-3.0-or-later - -from typing import Dict -from itertools import count -from PyQt5.QtWidgets import ( - QGridLayout, - QComboBox, - QLabel, -) -from PyQt5.QtCore import Qt - -from api_krita.wrappers import Database -from ..config import Config - - -class ComboBoxesLayout(QGridLayout): - """Dialog zone consisting of combo boxes.""" - - def __init__(self) -> None: - super().__init__() - self.setAlignment(Qt.AlignTop) - self._combo_boxes: Dict[Config, QComboBox] = {} - self._row_counter = count() - - self._add_label("Preset pie-menus mapping") - self._add_row(Config.TAG_RED) - self._add_row(Config.TAG_GREEN) - self._add_row(Config.TAG_BLUE) - - def _add_row(self, config: Config) -> None: - """Add a combobox to the layout along with its description.""" - row_id = next(self._row_counter) - label = QLabel(config.value) - label.setFixedWidth(100) - self.addWidget(label, row_id, 0) - self.addWidget(self._create_combobox(config), row_id, 1) - - def _add_label(self, text: str): - row_id = next(self._row_counter) - label = QLabel(text) - label.setAlignment(Qt.AlignCenter) - self.addWidget(label, row_id, 0, 1, 2) - - def _create_combobox(self, config: Config) -> QComboBox: - """Store and return combobox that represents given config field.""" - combo_box = QComboBox() - combo_box.setObjectName(config.value) - self._combo_boxes[config] = combo_box - return combo_box - - def refresh(self) -> None: - """Read list of tags and set it to all stored comboboxes.""" - with Database() as database: - tags = database.get_brush_tags() - - for config, combo_box in self._combo_boxes.items(): - combo_box.clear() - combo_box.addItems(sorted(tags, key=str.lower)) - combo_box.setCurrentText(config.read()) - - def apply(self) -> None: - """Write values from all stored comboboxes to krita config file.""" - for config, combo in self._combo_boxes.items(): - config.write(combo.currentText()) diff --git a/shortcut_composer/composer_utils/layouts/spin_boxes_layout.py b/shortcut_composer/composer_utils/layouts/spin_boxes_layout.py deleted file mode 100644 index 90a5cf00..00000000 --- a/shortcut_composer/composer_utils/layouts/spin_boxes_layout.py +++ /dev/null @@ -1,121 +0,0 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus -# SPDX-License-Identifier: GPL-3.0-or-later - -from typing import Dict, Union -from dataclasses import dataclass -from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import ( - QDoubleSpinBox, - QFormLayout, - QSplitter, - QSpinBox, - QLabel, -) - -from ..config import Config - -SpinBox = Union[QSpinBox, QDoubleSpinBox] - - -class SpinBoxesLayout(QFormLayout): - """Dialog zone consisting of spin boxes.""" - - @dataclass - class ConfigParams: - """Adds spinbox parametrization to the config field.""" - config: Config - step: float - max_value: float - is_int: bool - - def __init__(self) -> None: - super().__init__() - self._forms: Dict[Config, SpinBox] = {} - - self._add_label("Common settings") - - self._add_row(self.ConfigParams( - Config.SHORT_VS_LONG_PRESS_TIME, - step=0.05, - max_value=4, - is_int=False)) - - self._add_row(self.ConfigParams( - Config.FPS_LIMIT, - step=5, - max_value=300, - is_int=True)) - - self._add_label("Cursor trackers") - - self._add_row(self.ConfigParams( - Config.TRACKER_SENSITIVITY_SCALE, - step=0.05, - max_value=4, - is_int=False)) - - self._add_row(self.ConfigParams( - Config.TRACKER_DEADZONE, - step=1, - max_value=200, - is_int=True)) - - self._add_label("Pie menus display") - - self._add_row(self.ConfigParams( - Config.PIE_GLOBAL_SCALE, - step=0.05, - max_value=4, - is_int=False)) - - self._add_row(self.ConfigParams( - Config.PIE_ICON_GLOBAL_SCALE, - step=0.05, - max_value=4, - is_int=False)) - - self._add_row(self.ConfigParams( - Config.PIE_DEADZONE_GLOBAL_SCALE, - step=0.05, - max_value=4, - is_int=False)) - - self._add_row(self.ConfigParams( - Config.PIE_ANIMATION_TIME, - step=0.01, - max_value=1, - is_int=False)) - - def _add_row(self, config_params: ConfigParams) -> None: - """Add a spin box to the layout along with its description.""" - self.addRow( - config_params.config.value, - self._create_form(config_params) - ) - - def _add_label(self, text: str): - label = QLabel(text) - label.setAlignment(Qt.AlignCenter) - self.addRow(QSplitter(Qt.Horizontal)) - self.addRow(label) - - def _create_form(self, config_params: ConfigParams) -> SpinBox: - """Store and return new spin box for required type (int or float).""" - form = QSpinBox() if config_params.is_int else QDoubleSpinBox() - form.setObjectName(config_params.config.value) - form.setMinimum(0) - form.setMaximum(config_params.max_value) # type: ignore - form.setSingleStep(config_params.step) # type: ignore - - self._forms[config_params.config] = form - return form - - def refresh(self) -> None: - """Read values from krita config and apply them to stored boxes.""" - for config, form in self._forms.items(): - form.setValue(config.read()) # type: ignore - - def apply(self) -> None: - """Write values from stored spin boxes to krita config file.""" - for config, form in self._forms.items(): - config.write(form.value()) diff --git a/shortcut_composer/composer_utils/settings_dialog.py b/shortcut_composer/composer_utils/settings_dialog.py index 8395bd5f..6ec5e36e 100644 --- a/shortcut_composer/composer_utils/settings_dialog.py +++ b/shortcut_composer/composer_utils/settings_dialog.py @@ -1,18 +1,15 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later -from PyQt5.QtWidgets import ( - QVBoxLayout, - QTabWidget, - QDialog, -) -from PyQt5.QtCore import QSize +from PyQt5.QtWidgets import QVBoxLayout, QDialog +from PyQt5.QtCore import Qt, QSize from PyQt5.QtGui import QCursor +from INFO import __version__, __author__, __license__ from api_krita import Krita -from .config import Config -from .layouts import ButtonsLayout -from .tabs import GeneralSettingsTab, ActionValuesTab +from config_system.ui import ConfigFormWidget, ConfigSpinBox +from .global_config import Config +from .buttons_layout import ButtonsLayout class SettingsDialog(QDialog): @@ -20,20 +17,34 @@ class SettingsDialog(QDialog): def __init__(self) -> None: super().__init__() + self.setWindowFlags( + self.windowFlags() | Qt.WindowStaysOnTopHint) # type: ignore self.setMinimumSize(QSize(300, 200)) self.setWindowTitle("Configure Shortcut Composer") - self._tab_dict = { - "General": GeneralSettingsTab(), - "Action values": ActionValuesTab(), - } - tab_holder = QTabWidget() - for name, tab in self._tab_dict.items(): - tab_holder.addTab(tab, name) + self._general_tab = ConfigFormWidget([ + "Common settings", + ConfigSpinBox( + Config.SHORT_VS_LONG_PRESS_TIME, self, None, 0.05, 4), + ConfigSpinBox(Config.FPS_LIMIT, self, None, 5, 500), + "Cursor trackers", + ConfigSpinBox( + Config.TRACKER_SENSITIVITY_SCALE, self, None, 0.05, 400), + ConfigSpinBox(Config.TRACKER_DEADZONE, self, None, 1, 200), + "Pie menus display", + ConfigSpinBox(Config.PIE_GLOBAL_SCALE, self, None, 0.05, 4), + ConfigSpinBox(Config.PIE_ICON_GLOBAL_SCALE, self, None, 0.05, 4), + ConfigSpinBox( + Config.PIE_DEADZONE_GLOBAL_SCALE, self, None, 0.05, 4), + ConfigSpinBox(Config.PIE_ANIMATION_TIME, self, None, 0.01, 1), + f"Shortcut Composer v{__version__}\n" + f"Maintainer: {__author__}\n" + f"License: {__license__}", + ]) full_layout = QVBoxLayout(self) - full_layout.addWidget(tab_holder) + full_layout.addWidget(self._general_tab) full_layout.addLayout(ButtonsLayout( ok_callback=self.ok, apply_callback=self.apply, @@ -50,8 +61,7 @@ def show(self) -> None: def apply(self) -> None: """Ask all dialog zones to apply themselves.""" - for tab in self._tab_dict.values(): - tab.apply() + self._general_tab.apply() Krita.trigger_action("Reload Shortcut Composer") def ok(self) -> None: @@ -61,11 +71,9 @@ def ok(self) -> None: def reset(self) -> None: """Reset all config values to defaults in krita and elements.""" - Config.reset_defaults() + Config.reset_default() self.refresh() Krita.trigger_action("Reload Shortcut Composer") def refresh(self): - """Ask all tabs to refresh themselves. """ - for tab in self._tab_dict.values(): - tab.refresh() + self._general_tab.refresh() diff --git a/shortcut_composer/composer_utils/tabs/__init__.py b/shortcut_composer/composer_utils/tabs/__init__.py deleted file mode 100644 index 4e7a0132..00000000 --- a/shortcut_composer/composer_utils/tabs/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus -# SPDX-License-Identifier: GPL-3.0-or-later - -from .general_settings_tab import GeneralSettingsTab -from .action_values_tab import ActionValuesTab - - -__all__ = ["GeneralSettingsTab", "ActionValuesTab"] diff --git a/shortcut_composer/composer_utils/tabs/action_values_tab.py b/shortcut_composer/composer_utils/tabs/action_values_tab.py deleted file mode 100644 index 87578597..00000000 --- a/shortcut_composer/composer_utils/tabs/action_values_tab.py +++ /dev/null @@ -1,69 +0,0 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus -# SPDX-License-Identifier: GPL-3.0-or-later - -from PyQt5.QtWidgets import ( - QVBoxLayout, - QComboBox, - QWidget, -) - -from api_krita.enums import BlendingMode, Tool, TransformMode -from ..utils import ActionValues -from ..config import Config - - -class ActionValuesTab(QWidget): - """Tab in which user can change values used in actions and their order.""" - - def __init__(self) -> None: - super().__init__() - layout = QVBoxLayout() - self.combo_widget_picker = QComboBox() - self.widgets = { - "Blending modes": ActionValues( - allowed_values=set(BlendingMode._member_names_), - config=Config.BLENDING_MODES_VALUES - ), - "Create layer with blending": ActionValues( - allowed_values=set(BlendingMode._member_names_), - config=Config.CREATE_BLENDING_LAYER_VALUES - ), - "Selection tools": ActionValues( - allowed_values=set(Tool._member_names_), - config=Config.SELECTION_TOOLS_VALUES - ), - "Misc tools": ActionValues( - allowed_values=set(Tool._member_names_), - config=Config.MISC_TOOLS_VALUES - ), - "Transform modes": ActionValues( - allowed_values=set(TransformMode._member_names_), - config=Config.TRANSFORM_MODES_VALUES - ), - } - self.combo_widget_picker.addItems(self.widgets.keys()) - self.combo_widget_picker.currentTextChanged.connect( - self._change_widget) - - layout.addWidget(self.combo_widget_picker) - for widget in self.widgets.values(): - widget.hide() - layout.addWidget(widget) - self.widgets["Blending modes"].show() - self.setLayout(layout) - - def _change_widget(self): - """Show a selectable list for a different action.""" - for widget in self.widgets.values(): - widget.hide() - self.widgets[self.combo_widget_picker.currentText()].show() - - def apply(self) -> None: - """Save values and their order for each selectable list.""" - for widget in self.widgets.values(): - widget.apply() - - def refresh(self) -> None: - """Refresh each selectable list by loading values from settings.""" - for widget in self.widgets.values(): - widget.refresh() diff --git a/shortcut_composer/composer_utils/tabs/general_settings_tab.py b/shortcut_composer/composer_utils/tabs/general_settings_tab.py deleted file mode 100644 index b326b547..00000000 --- a/shortcut_composer/composer_utils/tabs/general_settings_tab.py +++ /dev/null @@ -1,44 +0,0 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus -# SPDX-License-Identifier: GPL-3.0-or-later - -from PyQt5.QtWidgets import ( - QVBoxLayout, - QHBoxLayout, - QWidget, -) - -from ..layouts import ( - ComboBoxesLayout, - SpinBoxesLayout, -) - - -class GeneralSettingsTab(QWidget): - """Dialog which allows to change global settings of the plugin.""" - - def __init__(self) -> None: - super().__init__() - - self._layouts_dict = { - "ComboBoxes": ComboBoxesLayout(), - "SpinBoxes": SpinBoxesLayout(), - } - layout = QVBoxLayout() - for layout_part in self._layouts_dict.values(): - layout.addLayout(layout_part) - - stretched = QHBoxLayout() - stretched.addStretch() - stretched.addLayout(layout) - stretched.addStretch() - self.setLayout(stretched) - - def apply(self) -> None: - """Ask all dialog zones to apply themselves.""" - for layout in self._layouts_dict.values(): - layout.apply() - - def refresh(self) -> None: - """Ask all dialog zones to refresh themselves. """ - for layout in self._layouts_dict.values(): - layout.refresh() diff --git a/shortcut_composer/composer_utils/utils/__init__.py b/shortcut_composer/composer_utils/utils/__init__.py deleted file mode 100644 index 2164e89c..00000000 --- a/shortcut_composer/composer_utils/utils/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus -# SPDX-License-Identifier: GPL-3.0-or-later - -"""Widgets to display on the settings dialog.""" - -from .action_values import ActionValues -from .value_list import ValueList - -__all__ = ["ActionValues", "ValueList"] diff --git a/shortcut_composer/config_system/__init__.py b/shortcut_composer/config_system/__init__.py new file mode 100644 index 00000000..e9a392df --- /dev/null +++ b/shortcut_composer/config_system/__init__.py @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +""" +System granting easier API to control krita configuration in kritarc. + +Consists of two classes: "Field" and "FieldGroup". +Read the documentation of those classes for more info. + +Holds a subpackage with ui elements dependent on the introducad +configuration concept. +""" + +from .field import Field +from .field_group import FieldGroup + +__all__ = ["Field", "FieldGroup"] diff --git a/shortcut_composer/config_system/api_krita.py b/shortcut_composer/config_system/api_krita.py new file mode 100644 index 00000000..f16bc553 --- /dev/null +++ b/shortcut_composer/config_system/api_krita.py @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Required part of api_krita package, so that no dependency is needed.""" + +from krita import Krita as Api +from typing import Any, Optional + + +class KritaInstance: + """Wraps krita API for typing, documentation and PEP8 compatibility.""" + + def __init__(self) -> None: + self.instance = Api.instance() + + def read_setting( + self, + group: str, + name: str, + default: str = "Not stored" + ) -> Optional[str]: + """ + Read a setting from kritarc file. + + - Return string red from file if present + - Return default if it was given + - Return None if default was not given + """ + red_value = self.instance.readSetting(group, name, default) + return None if red_value == "Not stored" else red_value + + 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)) + + +Krita = KritaInstance() diff --git a/shortcut_composer/config_system/field.py b/shortcut_composer/config_system/field.py new file mode 100644 index 00000000..7b2ff8f5 --- /dev/null +++ b/shortcut_composer/config_system/field.py @@ -0,0 +1,70 @@ +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from typing import TypeVar, Generic, Optional, Callable + +T = TypeVar('T') + + +class Field(Generic[T]): + """ + Representation of 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. + """ + + def __new__( + cls, + config_group: str, + name: str, + default: T, + parser_type: Optional[type] = None + ) -> '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) + + config_group: str + """Configuration section in kritarc toml file.""" + name: str + """Field name in entire config group.""" + default: T + """Default value used when the field is not present in file.""" + + def write(self, value: T) -> None: + """Write a value to kritarc file.""" + + def read(self) -> T: + """Return value from kritarc parsed to field type.""" + ... + + def register_callback(self, callback: Callable[[], None]) -> None: + """Register a method which will be called when field value changes.""" + + def reset_default(self) -> None: + """Write a default value to kritarc file.""" + ... diff --git a/shortcut_composer/config_system/field_base.py b/shortcut_composer/config_system/field_base.py new file mode 100644 index 00000000..3ba0c97e --- /dev/null +++ b/shortcut_composer/config_system/field_base.py @@ -0,0 +1,93 @@ +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from typing import TypeVar, Generic, Callable, List +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 + +T = TypeVar('T') +E = TypeVar('E', bound=Enum) +ListT = TypeVar('ListT', bound=List[Enum]) + + +class FieldBase(ABC, Field, Generic[T]): + """Implementation base of List, and NonList field.""" + + def __new__(cls, *args, **kwargs) -> 'FieldBase[T]': + obj = object.__new__(cls) + obj.__init__(*args, **kwargs) + return obj + + def __init__( + self, + config_group: str, + name: str, + default: T, + ): + self.config_group = config_group + self.name = name + self.default = default + self._on_change_callbacks: List[Callable[[], None]] = [] + + def register_callback(self, callback: Callable[[], None]): + """Store callback in internal list.""" + self._on_change_callbacks.append(callback) + + def write(self, value: T): + """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)}") + + if self._is_write_redundant(value): + return + + Krita.write_setting( + group=self.config_group, + name=self.name, + value=self._to_string(value)) + for callback in self._on_change_callbacks: + callback() + + @abstractmethod + def read(self) -> T: + """Return value from kritarc parsed to field type.""" + ... + + @abstractmethod + def _to_string(self, value: T) -> str: + """Convert a value of field type to string.""" + ... + + def _is_write_redundant(self, value: T) -> bool: + """ + Return if writing a value is not necessary. + + That is when: + - the value is the same as the one stored in file + - value is a default one and it is not present in file + """ + if self.read() == value: + return True + raw = Krita.read_setting(self.config_group, self.name) + return raw is None and value == self.default + + def reset_default(self) -> None: + """Write a default value to kritarc file.""" + self.write(self.default) + + @staticmethod + def _get_parser(parser_type: type) -> Parser[T]: + """Return field parser.""" + if issubclass(parser_type, Enum): + return EnumParser(parser_type) # type: ignore + + return { + int: BasicParser(int), + float: BasicParser(float), + str: BasicParser(str), + bool: BoolParser() + }[parser_type] # type: ignore diff --git a/shortcut_composer/config_system/field_group.py b/shortcut_composer/config_system/field_group.py new file mode 100644 index 00000000..6dbf72ec --- /dev/null +++ b/shortcut_composer/config_system/field_group.py @@ -0,0 +1,54 @@ +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from typing import TypeVar, Optional, Callable, List +from .field import Field + +T = TypeVar('T') + + +class FieldGroup: + """ + Representation of section in fields in kritarc file. + + All fields in the group should be created using `field()` method. + It simplifies the field creation by auto-completing the group name. + + FieldGroup holds and aggregates fields created with it. + + Allows to reset all the fields at once, and register a callback to + all its fields: both existing and future ones. + """ + + def __init__(self, name: str) -> None: + self.name = name + self._fields: List[Field] = [] + self._callbacks: List[Callable[[], None]] = [] + + def field( + self, + name: str, + default: T, + parser_type: Optional[type] = None + ) -> Field[T]: + """Create and return a new field in the group.""" + field = Field(self.name, name, default, parser_type) + self._fields.append(field) + for callback in self._callbacks: + field.register_callback(callback) + return field + + def reset_default(self): + """Reset values of all fields stored in this group.""" + for field in self._fields: + field.reset_default() + + def register_callback(self, callback: Callable[[], 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): + """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 new file mode 100644 index 00000000..c8cd98e1 --- /dev/null +++ b/shortcut_composer/config_system/field_implementations.py @@ -0,0 +1,89 @@ +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from typing import ( + TypeVar, + Generic, + Optional, + List) + +from .api_krita import Krita +from .parsers import Parser +from .field_base import FieldBase + +T = TypeVar('T') + + +class NonListField(FieldBase, Generic[T]): + """Config field containing a basic, non-list value.""" + + def __init__( + self, + config_group: str, + name: str, + default: T, + parser_type: Optional[type] = None, + ) -> None: + super().__init__(config_group, name, default) + 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) + if raw is None: + return self.default + return self._parser.parse_to(raw) + + def _to_string(self, value: T) -> str: + """Parse the field value to string using parser.""" + return self._parser.parse_from(value) + + +class ListField(FieldBase, Generic[T]): + """Config field containing a list value.""" + + def __init__( + self, + config_group: str, + name: str, + default: List[T], + parser_type: Optional[type] = None, + ) -> None: + super().__init__(config_group, name, default) + self._parser: Parser[T] = self._get_parser(self._get_type(parser_type)) + + def write(self, value: List[T]): + 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) + + def _get_type(self, passed_type: Optional[type]) -> type: + """ + Determine parser type based on default value or passed type. + + - For non empty list, parser depends on first list element. + - For empty list, parsed type must be used directly + """ + if not self.default: + if passed_type is None: + raise ValueError("Type not given for a list") + return passed_type + return type(self.default[0]) + + def read(self) -> List[T]: + """ + Return value from kritarc parsed to field type. + + Each list element requires parsing. + """ + raw = Krita.read_setting(self.config_group, self.name) + if raw is None: + return self.default + + values_list = raw.split("\t") + return [self._parser.parse_to(value) for value in values_list] + + def _to_string(self, value: List[T]) -> str: + """Convert list of values to string by parsing each element alone.""" + return "\t".join([self._parser.parse_from(item) for item in value]) diff --git a/shortcut_composer/config_system/parsers.py b/shortcut_composer/config_system/parsers.py new file mode 100644 index 00000000..65ac1850 --- /dev/null +++ b/shortcut_composer/config_system/parsers.py @@ -0,0 +1,69 @@ +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from typing import Generic, TypeVar, Type, Protocol +from enum import Enum + +T = TypeVar("T") +Basic = TypeVar("Basic", str, int, float) +EnumT = TypeVar("EnumT", bound=Enum) + + +class Parser(Generic[T], Protocol): + """Parses from string to specific type and vice-versa.""" + + type: type + + def parse_to(self, value: str) -> T: + """Parse from string to specific type.""" + ... + + def parse_from(self, value: T) -> str: + """Parse from specific type to string.""" + ... + + +class BasicParser(Parser[Basic]): + """Parses from string to basic type and vice-versa.""" + + def __init__(self, type: Type[Basic]) -> None: + self.type = type + + def parse_to(self, value: str) -> Basic: + """Parse from string to a string or number to .""" + return self.type(value) + + def parse_from(self, value: Basic) -> str: + """Parse from string to string or number.""" + return str(value) + + +class BoolParser(Parser[bool]): + """Parses from string to bool and vice-versa.""" + + type = bool + + def parse_to(self, value: str) -> bool: + """Parses from string to bool.""" + if value not in ("true", "false"): + raise ValueError(f"Cant parse {value} to bool") + return value == "true" + + def parse_from(self, value: bool) -> str: + """Parses from bool to string.""" + return str(value).lower() + + +class EnumParser(Parser[EnumT]): + """Parses from string to enum and vice-versa.""" + + def __init__(self, type: Type[EnumT]) -> None: + self.type = type + + def parse_to(self, value: str) -> EnumT: + """Parse from string to enum.""" + return self.type[value] # type: ignore + + def parse_from(self, value: EnumT) -> str: + """Parse from enum to string.""" + return str(value.name) diff --git a/shortcut_composer/config_system/ui/__init__.py b/shortcut_composer/config_system/ui/__init__.py new file mode 100644 index 00000000..957f79c9 --- /dev/null +++ b/shortcut_composer/config_system/ui/__init__.py @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +""" +UI elements based on the config concept introduced in parent package. + +Grants QWidgets paired with configuration fields. +All widgets have unified interface for reading and setting their values. + +They also allow to directly fill them with values from kritarc, save +their current values to kritarc, and reset them to default values of +fields they hold. + +ConfigFormWidget is a class that aggregates multiple input widgets, +display them in a form, and allow to perform actions with all of them at +once using their unified interface. +""" + +from .config_based_widget import ConfigBasedWidget +from .config_form_widget import ConfigFormWidget +from .widgets import ConfigComboBox, ConfigSpinBox + +__all__ = [ + "ConfigBasedWidget", + "ConfigFormWidget", + "ConfigComboBox", + "ConfigSpinBox" +] diff --git a/shortcut_composer/config_system/ui/config_based_widget.py b/shortcut_composer/config_system/ui/config_based_widget.py new file mode 100644 index 00000000..f17e5a95 --- /dev/null +++ b/shortcut_composer/config_system/ui/config_based_widget.py @@ -0,0 +1,58 @@ +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from abc import ABC, abstractmethod +from typing import Final, Optional, TypeVar, Generic +from PyQt5.QtWidgets import QWidget + +from ..field import Field + +T = TypeVar("T") + + +class ConfigBasedWidget(ABC, Generic[T]): + """ + Wrapper of widget linked to a configuration field. + + The widget is created based on the passed field and available as a + public attribute. + + Unifies QWidgets interface to `read()` and `set()` methods. + Additionally, allows to use the configuration field to reset the + widget to the default value and save current value to config. + """ + + def __init__( + self, + config_field: Field, + parent: Optional[QWidget] = None, + pretty_name: Optional[str] = None, + ) -> None: + self._parent = parent + self.config_field: Final[Field] = config_field + self.pretty_name = self._init_pretty_name(pretty_name) + self.widget: QWidget + + @abstractmethod + def read(self) -> T: + """Return the current value of the widget.""" + ... + + @abstractmethod + def set(self, value: T): + """Replace the value of the widget with passed one.""" + ... + + def reset(self) -> None: + """Replace the value of the widget with the default one.""" + self.set(self.config_field.read()) + + def save(self) -> None: + """Save the current value of the widget to kritarc.""" + self.config_field.write(self.read()) + + def _init_pretty_name(self, pretty_name: Optional[str]) -> str: + """Pick the name of the widget. Config field name if not given.""" + if pretty_name is not None: + return pretty_name + return self.config_field.name diff --git a/shortcut_composer/config_system/ui/config_form_widget.py b/shortcut_composer/config_system/ui/config_form_widget.py new file mode 100644 index 00000000..396a9581 --- /dev/null +++ b/shortcut_composer/config_system/ui/config_form_widget.py @@ -0,0 +1,68 @@ +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from typing import List, Union +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import ( + QFormLayout, + QSplitter, + QWidget, + QLabel) + +from .config_based_widget import ConfigBasedWidget + + +class ConfigFormWidget(QWidget): + """ + Configuration Widget with a form of ConfigBasedWidgets. + + Consists of centered titles and labelled widgets added with + `add_row()` and `add_title`. + + Alternatively, it can be initialized with a list of strings and + ConfigBasedWidgets which create titles and form rows. + + Synchronizes stored ConfigBasedWidgets by allowing to refresh and + save values to config of all stored ones. + """ + + def __init__(self, elements: List[Union[ConfigBasedWidget, str]]) -> None: + super().__init__() + self._layout = QFormLayout() + self._layout.RowWrapPolicy(QFormLayout.DontWrapRows) + self._layout.setFieldGrowthPolicy(QFormLayout.FieldsStayAtSizeHint) + self._layout.setLabelAlignment(Qt.AlignRight) + self._layout.setFormAlignment( + Qt.AlignHCenter | Qt.AlignTop) # type: ignore + self.setLayout(self._layout) + + self._widgets: List[ConfigBasedWidget] = [] + for element in elements: + if isinstance(element, str): + self.add_title(element) + elif isinstance(element, ConfigBasedWidget): + self.add_row(element) + else: + raise TypeError("Unsupported arguments.") + + def add_row(self, element: ConfigBasedWidget) -> None: + """Add a ConfigBasedWidget along with a label.""" + self._widgets.append(element) + self._layout.addRow(f"{element.pretty_name}:", element.widget) + + def add_title(self, text: str): + """Add a label with given text.""" + label = QLabel(text) + label.setAlignment(Qt.AlignCenter) + self._layout.addRow(QSplitter(Qt.Horizontal)) + self._layout.addRow(label) + + def refresh(self) -> None: + """Read values from krita config and apply them to stored boxes.""" + for element in self._widgets: + element.reset() + + def apply(self) -> None: + """Write values from stored spin boxes to krita config file.""" + for element in self._widgets: + element.save() diff --git a/shortcut_composer/config_system/ui/widgets.py b/shortcut_composer/config_system/ui/widgets.py new file mode 100644 index 00000000..021aef0c --- /dev/null +++ b/shortcut_composer/config_system/ui/widgets.py @@ -0,0 +1,102 @@ +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from typing import Any, List, Final, Optional, TypeVar, Generic, Protocol +from PyQt5.QtWidgets import QDoubleSpinBox, QComboBox, QSpinBox, QWidget + +from ..field import Field +from .config_based_widget import ConfigBasedWidget + +F = TypeVar("F", bound=float) + + +class SpinBox(Protocol, Generic[F]): + """Representation of both Qt spinboxes as one generic class.""" + + def value(self) -> F: ... + def setValue(self, val: F) -> None: ... + + +class ConfigSpinBox(ConfigBasedWidget[F]): + """ + Wrapper of SpinBox linked to a configutation field. + + Based on QSpinBox or QDoubleSpinBox depending on the config type. + Works only for fields of type: `int` or `float`. + """ + + def __init__( + self, + config_field: Field[F], + parent: Optional[QWidget] = None, + pretty_name: Optional[str] = None, + step: F = 1, + max_value: F = 100, + ) -> None: + super().__init__(config_field, parent, pretty_name) + self._step = step + self._max_value = max_value + self._spin_box = self._init_spin_box() + self.widget: Final[SpinBox[F]] = self._spin_box + self.reset() + + def read(self) -> F: + """Return the current value of the spinbox widget.""" + return self._spin_box.value() + + def set(self, value: F): + """Replace the value of the spinbox widget with passed one.""" + self._spin_box.setValue(value) + + def _init_spin_box(self) -> SpinBox: + """Return the spinbox widget of type based on config field type.""" + spin_box: QDoubleSpinBox = {int: QSpinBox, float: QDoubleSpinBox}[ + type(self.config_field.default)]() + + spin_box.setMinimumWidth(90) + spin_box.setObjectName(self.config_field.name) + spin_box.setMinimum(0) + spin_box.setSingleStep(self._step) + spin_box.setMaximum(self._max_value) + return spin_box + + +class ConfigComboBox(ConfigBasedWidget[str]): + """ + Wrapper of Combobox linked to a configutation field. + + Works only for fields of type: `str`. + """ + + def __init__( + self, + config_field: Field[str], + parent: Optional[QWidget] = None, + pretty_name: Optional[str] = None, + allowed_values: List[Any] = [], + ) -> None: + super().__init__(config_field, parent, pretty_name) + self._allowed_values = allowed_values + self._combo_box = self._init_combo_box() + self.widget: Final[QComboBox] = self._combo_box + self.reset() + + def reset(self) -> None: + """Update allowed values of the combobox and pick a default one.""" + self._combo_box.clear() + self._combo_box.addItems(self._allowed_values) + self.set(self.config_field.read()) + + def read(self) -> str: + """Return the current value of the ComboBox.""" + return self._combo_box.currentText() + + def set(self, value: str): + """Replace the value of the ComboBox with passed one.""" + return self._combo_box.setCurrentText(value) + + def _init_combo_box(self) -> QComboBox: + """Return the spinbox widget.""" + combo_box = QComboBox() + combo_box.setObjectName(self.config_field.name) + return combo_box diff --git a/shortcut_composer/core_components/__init__.py b/shortcut_composer/core_components/__init__.py index 53879ed3..b7662a7f 100644 --- a/shortcut_composer/core_components/__init__.py +++ b/shortcut_composer/core_components/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later """ diff --git a/shortcut_composer/core_components/controller_base.py b/shortcut_composer/core_components/controller_base.py index 2f624619..ace7f9de 100644 --- a/shortcut_composer/core_components/controller_base.py +++ b/shortcut_composer/core_components/controller_base.py @@ -1,17 +1,34 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later -from typing import Any, Union +from typing import Optional, Union, Generic, TypeVar from PyQt5.QtGui import QPixmap, QIcon from api_krita.pyqt import Text +T = TypeVar("T") -class Controller: + +class Controller(Generic[T]): """Component that allows to get and set a specific property of krita.""" - default_value: Any = None + default_value: Optional[T] = None + + def refresh(self) -> None: + """Refresh stored krita components.""" + ... + + def get_value(self) -> T: + """Get handled value from krita.""" + ... + + def set_value(self, value: T) -> None: + """Set handled value in krita.""" + ... + + def get_label(self, value: T) -> Union[Text, QPixmap, QIcon, None]: + """Get value representation that can be displayed in GUI,""" + ... - def refresh(self) -> None: """Refresh stored krita components.""" - def get_value(self) -> Any: """Get handled value from krita.""" - def set_value(self, value: Any) -> None: """Set handled value in krita.""" - def get_label(self, value: Any) -> Union[Text, QPixmap, QIcon, None]: ... + def get_pretty_name(self, value: T) -> str: + """Get value name that can be displayed to the user in GUI.""" + return str(value) diff --git a/shortcut_composer/core_components/controllers/__init__.py b/shortcut_composer/core_components/controllers/__init__.py index 86b01026..4736ebf7 100644 --- a/shortcut_composer/core_components/controllers/__init__.py +++ b/shortcut_composer/core_components/controllers/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later """ diff --git a/shortcut_composer/core_components/controllers/canvas_controllers.py b/shortcut_composer/core_components/controllers/canvas_controllers.py index a66250f0..0ed025f6 100644 --- a/shortcut_composer/core_components/controllers/canvas_controllers.py +++ b/shortcut_composer/core_components/controllers/canvas_controllers.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from api_krita import Krita @@ -6,7 +6,7 @@ from ..controller_base import Controller -class CanvasBasedController(Controller): +class CanvasBasedController: """Family of controllers which operate on values from active document.""" def refresh(self): @@ -14,27 +14,34 @@ def refresh(self): self.canvas = Krita.get_active_canvas() -class CanvasZoomController(CanvasBasedController): +class CanvasZoomController(CanvasBasedController, Controller[float]): """ - Gives access to `zoom`. + Gives access to `zoom` in %. - Operates on `float` - - Defaults to `1.0` + - Defaults to `100` """ default_value: float = 100.0 def get_value(self) -> float: + """Get current zoom level in %""" return self.canvas.zoom def set_value(self, value: float) -> None: + """Set current zoom level in %""" self.canvas.zoom = value def get_label(self, value: float) -> Text: - return Text(f"{round(value/100, 2)}") + """Return Text with formatted canvas zoom.""" + return Text(self.get_pretty_name(value)) + def get_pretty_name(self, value: float) -> str: + """Format the canvas zoom like: `100%`.""" + return f"{round(value)}%" -class CanvasRotationController(CanvasBasedController): + +class CanvasRotationController(CanvasBasedController, Controller[float]): """ Gives access to `canvas rotation` in degrees. @@ -46,10 +53,17 @@ class CanvasRotationController(CanvasBasedController): default_value: float = 0.0 def get_value(self) -> float: + """Get canvas rotation in degrees.""" return self.canvas.rotation def set_value(self, value: float) -> None: + """Set rotation in degrees.""" self.canvas.rotation = value def get_label(self, value: float) -> Text: - return Text(f"{round(value)}°") + """Return Text with formatted canvas rotation.""" + return Text(self.get_pretty_name(value)) + + def get_pretty_name(self, value: float) -> str: + """Format the canvas rotation like: `30°`.""" + return f"{round(value)}°" diff --git a/shortcut_composer/core_components/controllers/core_controllers.py b/shortcut_composer/core_components/controllers/core_controllers.py index 730e80ea..a7daba25 100644 --- a/shortcut_composer/core_components/controllers/core_controllers.py +++ b/shortcut_composer/core_components/controllers/core_controllers.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from typing import Optional @@ -12,7 +12,7 @@ from ..controller_base import Controller -class ToolController(Controller): +class ToolController(Controller[Tool]): """ Gives access to tools from toolbox. @@ -33,10 +33,15 @@ def set_value(value: Tool) -> None: Krita.active_tool = value def get_label(self, value: Tool) -> QIcon: + """Forward the tools' icon.""" return value.icon + def get_pretty_name(self, value: Tool) -> str: + """Forward enums' pretty name.""" + return value.pretty_name -class TransformModeController(Controller): + +class TransformModeController(Controller[TransformMode]): """ Gives access to tools from toolbox. @@ -62,11 +67,16 @@ def set_value(value: Optional[TransformMode]) -> None: value.activate() def get_label(self, value: Tool) -> QIcon: + """Forward the transform mode icon.""" return value.icon + def get_pretty_name(self, value: Tool) -> str: + """Forward enums' pretty name.""" + return value.pretty_name + @dataclass -class ToggleController(Controller): +class ToggleController(Controller[bool]): """ Gives access to picked krita toggle action. @@ -79,14 +89,20 @@ class ToggleController(Controller): default_value = False def get_value(self) -> bool: + """Return whether the toggle action is on.""" return self.toggle.state def set_value(self, value: bool) -> None: + """Set the toggle action on or off using a bool.""" self.toggle.state = value + def get_pretty_name(self, value: Tool) -> str: + """Forward enums' pretty name.""" + return value.pretty_name + @dataclass -class UndoController(Controller): +class UndoController(Controller[float]): """ Gives access to `undo` and `redo` actions. @@ -99,6 +115,7 @@ class UndoController(Controller): """ state = 0 + 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 49ab0aa6..9653be6f 100644 --- a/shortcut_composer/core_components/controllers/document_controllers.py +++ b/shortcut_composer/core_components/controllers/document_controllers.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from api_krita import Krita @@ -7,7 +7,7 @@ from ..controller_base import Controller -class DocumentBasedController(Controller): +class DocumentBasedController: """Family of controllers which operate on values from active document.""" def refresh(self): @@ -15,7 +15,7 @@ def refresh(self): self.document = Krita.get_active_document() -class ActiveLayerController(DocumentBasedController): +class ActiveLayerController(DocumentBasedController, Controller[Node]): """ Gives access to nodes (layers, groups, masks...) from layer stack. @@ -32,8 +32,12 @@ def set_value(self, value: Node) -> None: """Set passed node as current.""" self.document.active_node = value + def get_pretty_name(self, value: Node) -> str: + """Forward enums' pretty name.""" + return value.name -class TimeController(DocumentBasedController): + +class TimeController(DocumentBasedController, Controller[int]): """ Gives access to animation timeline. @@ -44,10 +48,13 @@ class TimeController(DocumentBasedController): default_value = 0 def get_value(self) -> int: + """Get current frame on animation timeline.""" return self.document.current_time def set_value(self, value: int) -> None: + """Set passed frame of animation timeline as active.""" self.document.current_time = value def get_label(self, value: int) -> Text: - return Text(str(value)) + """Return Text with frame id as string.""" + return Text(self.get_pretty_name(value)) diff --git a/shortcut_composer/core_components/controllers/node_controllers.py b/shortcut_composer/core_components/controllers/node_controllers.py index e317eb1d..d72db10a 100644 --- a/shortcut_composer/core_components/controllers/node_controllers.py +++ b/shortcut_composer/core_components/controllers/node_controllers.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from api_krita import Krita @@ -7,7 +7,7 @@ from ..controller_base import Controller -class NodeBasedController(Controller): +class NodeBasedController: """Family of controllers which operate on values from active node.""" def refresh(self): @@ -16,12 +16,12 @@ def refresh(self): self.active_node = self.active_document.active_node -class LayerOpacityController(NodeBasedController): +class LayerOpacityController(NodeBasedController, Controller[int]): """ - Gives access to active layers' `blending mode`. + Gives access to active layers' `opacity` in %. - - Operates on `BlendingMode` - - Defaults to `BlendingMode.NORMAL` + - Operates on `integer` in range `0 to 100` + - Defaults to `100` """ default_value: int = 100 @@ -37,15 +37,21 @@ def set_value(self, opacity: int) -> None: self.active_document.refresh() def get_label(self, value: int) -> Text: - return Text(f"{value}%", Colorizer.percentage(value)) + """Return Text with formatted layer opacity.""" + return Text(self.get_pretty_name(value), Colorizer.percentage(value)) + def get_pretty_name(self, value: float) -> str: + """Format the layer opacity like: `100%`""" + return f"{value}%" -class LayerBlendingModeController(NodeBasedController): + +class LayerBlendingModeController(NodeBasedController, + Controller[BlendingMode]): """ - Gives access to active layers' `opacity` in %. + Gives access to active layers' `blending mode`. - - Operates on `integer` in range `0 to 100` - - Defaults to `100` + - Operates on `BlendingMode` + - Defaults to `BlendingMode.NORMAL` """ default_value = BlendingMode.NORMAL @@ -61,10 +67,15 @@ def set_value(self, blending_mode: BlendingMode) -> None: self.active_document.refresh() def get_label(self, value: BlendingMode) -> Text: + """Return Label of 3 first letters of mode name in correct color.""" return Text(value.name[:3], Colorizer.blending_mode(value)) + def get_pretty_name(self, value: BlendingMode) -> str: + """Forward enums' pretty name.""" + return value.pretty_name + -class LayerVisibilityController(NodeBasedController): +class LayerVisibilityController(NodeBasedController, Controller[bool]): """ Gives access to active layers' `visibility`. @@ -85,7 +96,8 @@ def set_value(self, visibility: bool) -> None: self.active_document.refresh() -class CreateLayerWithBlendingController(NodeBasedController): +class CreateLayerWithBlendingController(NodeBasedController, + Controller[BlendingMode]): """Creates Paint Layer with set Blending Mode.""" default_value = BlendingMode.NORMAL @@ -104,4 +116,9 @@ def set_value(self, blending_mode: BlendingMode) -> None: parent.add_child_node(layer, self.active_node) def get_label(self, value: BlendingMode) -> Text: + """Return Label of 3 first letters of mode name in correct color.""" return Text("+" + value.name[:3], Colorizer.blending_mode(value)) + + def get_pretty_name(self, value: BlendingMode) -> str: + """Forward enums' pretty name.""" + return value.pretty_name diff --git a/shortcut_composer/core_components/controllers/view_controllers.py b/shortcut_composer/core_components/controllers/view_controllers.py index c46d8d70..7ba9656c 100644 --- a/shortcut_composer/core_components/controllers/view_controllers.py +++ b/shortcut_composer/core_components/controllers/view_controllers.py @@ -1,15 +1,15 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later +from typing import Optional from PyQt5.QtGui import QPixmap, QImage - from api_krita import Krita from api_krita.enums import BlendingMode from api_krita.pyqt import Text, Colorizer from ..controller_base import Controller -class ViewBasedController(Controller): +class ViewBasedController: """Family of controllers which operate on values from active view.""" def refresh(self): @@ -17,7 +17,7 @@ def refresh(self): self.view = Krita.get_active_view() -class PresetController(ViewBasedController): +class PresetController(ViewBasedController, Controller[str]): """ Gives access to `presets`. @@ -35,12 +35,17 @@ def set_value(self, value: str) -> None: """Set a preset of passed name.""" self.view.brush_preset = value - def get_label(self, value: str) -> QPixmap: - image: QImage = Krita.get_presets()[value].image() - return QPixmap.fromImage(image) + def get_label(self, value: str) -> Optional[QPixmap]: + """Return the preset icon or None, when there preset name unknown.""" + try: + image: QImage = Krita.get_presets()[value].image() + except KeyError: + return None + else: + return QPixmap.fromImage(image) -class BrushSizeController(ViewBasedController): +class BrushSizeController(ViewBasedController, Controller[int]): """ Gives access to `brush size`. @@ -51,16 +56,23 @@ class BrushSizeController(ViewBasedController): default_value: float = 100 def get_value(self) -> float: + """Get current brush size.""" return self.view.brush_size def set_value(self, value: float) -> None: + """Set current brush size.""" self.view.brush_size = value def get_label(self, value: float) -> Text: - return Text(f"{round(value)}px") + """Return Text with formatted brush size.""" + return Text(self.get_pretty_name(value)) + + def get_pretty_name(self, value: float) -> str: + """Format the brush size like: `100px`""" + return f"{round(value)}px" -class BlendingModeController(ViewBasedController): +class BlendingModeController(ViewBasedController, Controller[BlendingMode]): """ Gives access to `brush blending mode`. @@ -79,10 +91,15 @@ def set_value(self, value: BlendingMode) -> None: self.view.blending_mode = value def get_label(self, value: BlendingMode) -> Text: + """Return Label of 3 first letters of mode name in correct color.""" return Text(value.name[:3], Colorizer.blending_mode(value)) + def get_pretty_name(self, value: BlendingMode) -> str: + """Forward enums' pretty name.""" + return value.pretty_name + -class OpacityController(ViewBasedController): +class OpacityController(ViewBasedController, Controller[int]): """ Gives access to `brush opacity` in %. @@ -101,10 +118,15 @@ def set_value(self, value: int) -> None: self.view.opacity = value def get_label(self, value: int) -> Text: - return Text(f"{value}%", Colorizer.percentage(value)) + """Return Text with formatted brush opacity.""" + return Text(self.get_pretty_name(value), Colorizer.percentage(value)) + def get_pretty_name(self, value: float) -> str: + """Format the opacity like: `100%`""" + return f"{value}%" -class FlowController(ViewBasedController): + +class FlowController(ViewBasedController, Controller[int]): """ Gives access to `brush flow` in %. @@ -115,10 +137,17 @@ class FlowController(ViewBasedController): default_value: int = 100 def get_value(self) -> int: + """Get current brush flow.""" return self.view.flow def set_value(self, value: int) -> None: + """Set passed brush flow.""" self.view.flow = value def get_label(self, value: int) -> Text: - return Text(f"{value}%", Colorizer.percentage(value)) + """Return Text with formatted brush flow.""" + return Text(self.get_pretty_name(value), Colorizer.percentage(value)) + + def get_pretty_name(self, value: float) -> str: + """Format the flow like: `100%`""" + return f"{value}%" diff --git a/shortcut_composer/core_components/instruction_base.py b/shortcut_composer/core_components/instruction_base.py index e0cc5b18..28e412d0 100644 --- a/shortcut_composer/core_components/instruction_base.py +++ b/shortcut_composer/core_components/instruction_base.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from typing import List diff --git a/shortcut_composer/core_components/instructions/__init__.py b/shortcut_composer/core_components/instructions/__init__.py index d2eca8c5..ff84e377 100644 --- a/shortcut_composer/core_components/instructions/__init__.py +++ b/shortcut_composer/core_components/instructions/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later """ diff --git a/shortcut_composer/core_components/instructions/layer_hide.py b/shortcut_composer/core_components/instructions/layer_hide.py index 15b91f9e..423dae18 100644 --- a/shortcut_composer/core_components/instructions/layer_hide.py +++ b/shortcut_composer/core_components/instructions/layer_hide.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from api_krita import Krita diff --git a/shortcut_composer/core_components/instructions/set_brush_strategy.py b/shortcut_composer/core_components/instructions/set_brush_strategy.py index dddbaadf..94884f79 100644 --- a/shortcut_composer/core_components/instructions/set_brush_strategy.py +++ b/shortcut_composer/core_components/instructions/set_brush_strategy.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from api_krita import Krita diff --git a/shortcut_composer/core_components/instructions/togglers.py b/shortcut_composer/core_components/instructions/togglers.py index af5ae185..ed11211a 100644 --- a/shortcut_composer/core_components/instructions/togglers.py +++ b/shortcut_composer/core_components/instructions/togglers.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from dataclasses import dataclass diff --git a/shortcut_composer/core_components/instructions/undo.py b/shortcut_composer/core_components/instructions/undo.py index 355ee339..7b6cf615 100644 --- a/shortcut_composer/core_components/instructions/undo.py +++ b/shortcut_composer/core_components/instructions/undo.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from api_krita import Krita @@ -16,4 +16,5 @@ class UndoOnPress(Instruction): """ def on_key_press(self) -> None: + """Trigger the undo action.""" Krita.trigger_action("edit_undo") diff --git a/shortcut_composer/data_components/__init__.py b/shortcut_composer/data_components/__init__.py index 2914129c..db2012d5 100644 --- a/shortcut_composer/data_components/__init__.py +++ b/shortcut_composer/data_components/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later """ @@ -10,16 +10,15 @@ """ from .current_layer_stack import CurrentLayerStack -from .writable_values import TagConfigValues, EnumConfigValues from .pick_strategy import PickStrategy from .slider import Slider from .range import Range +from .tag import Tag __all__ = [ "CurrentLayerStack", - "EnumConfigValues", - "TagConfigValues", "PickStrategy", "Slider", "Range", + "Tag", ] diff --git a/shortcut_composer/data_components/current_layer_stack.py b/shortcut_composer/data_components/current_layer_stack.py index 30548e3a..1db0eeaf 100644 --- a/shortcut_composer/data_components/current_layer_stack.py +++ b/shortcut_composer/data_components/current_layer_stack.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from typing import List diff --git a/shortcut_composer/data_components/pick_strategy.py b/shortcut_composer/data_components/pick_strategy.py index a5996cb3..39ac5b3a 100644 --- a/shortcut_composer/data_components/pick_strategy.py +++ b/shortcut_composer/data_components/pick_strategy.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from enum import Enum diff --git a/shortcut_composer/data_components/range.py b/shortcut_composer/data_components/range.py index b3ba93b2..87a97387 100644 --- a/shortcut_composer/data_components/range.py +++ b/shortcut_composer/data_components/range.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from dataclasses import dataclass diff --git a/shortcut_composer/data_components/slider.py b/shortcut_composer/data_components/slider.py index 6daf1e89..fbe7f5a7 100644 --- a/shortcut_composer/data_components/slider.py +++ b/shortcut_composer/data_components/slider.py @@ -1,14 +1,17 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later -from typing import Any, List, Union, Optional +from typing import List, Union, Optional, Generic, TypeVar from composer_utils import Config +from config_system import Field from core_components import Controller from .range import Range +T = TypeVar("T") -class Slider: + +class Slider(Generic[T]): """ Part of CursorTracker specifying what to do on single axis movement. @@ -60,8 +63,8 @@ class Slider: def __init__( self, - controller: Controller, - values: Union[List[Any], Range], + controller: Controller[T], + values: Union[List[T], Range], sensitivity_scale: float = 1.0, deadzone: Optional[int] = None, ) -> None: @@ -76,7 +79,7 @@ def __init__( self.deadzone = self._read(deadzone, Config.TRACKER_DEADZONE) - def _read(self, passed: Optional[int], field: Config) -> int: + def _read(self, passed: Optional[int], field: Field) -> int: if passed is not None: return passed return field.read() diff --git a/shortcut_composer/data_components/tag.py b/shortcut_composer/data_components/tag.py new file mode 100644 index 00000000..bc67a1a5 --- /dev/null +++ b/shortcut_composer/data_components/tag.py @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from typing import List +from api_krita.wrappers import Database + + +class Tag(List[str]): + """List representing names of presets in a tag of given name.""" + + def __init__(self, tag_name: str): + self.tag_name = tag_name + self.extend(self._read_presets()) + + def _read_presets(self) -> List[str]: + """Read the brush presets from the database using tag name.""" + with Database() as database: + return database.get_preset_names_from_tag(self.tag_name) diff --git a/shortcut_composer/data_components/writable_values.py b/shortcut_composer/data_components/writable_values.py deleted file mode 100644 index 91887fce..00000000 --- a/shortcut_composer/data_components/writable_values.py +++ /dev/null @@ -1,68 +0,0 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus -# SPDX-License-Identifier: GPL-3.0-or-later - -from api_krita.wrappers import Database -from composer_utils import Config -from typing import Type, TypeVar -from enum import Enum - -T = TypeVar('T', bound=Enum) - - -class TagConfigValues(list): - """ - List-based container with preset names fetched from database. - - Created using tag's name stored in passed configuration. - Uses custom tag order stored in `tag_values` config. All the tag - values not present in the order will be added to the end. - - Values which are no longer in the tag, will not be included. - - Does not update in runtime as the tag gets edited. - - ### Example usage: - - Fetch all brush presets from tag named stored in TAG_BLUE: - ```python - TagConfigValues(Config.TAG_BLUE, Config.TAG_BLUE_VALUES) - ``` - """ - - def __init__(self, tag: Config, tag_values: Config) -> None: - self.tag = tag - self.config_to_write = tag_values - - with Database() as database: - tag_presets = database.get_preset_names_from_tag(tag.read()) - - preset_string: str = tag_values.read() - preset_order = preset_string.split("\t") - preset_order = [p for p in preset_order if p in tag_presets] - - missing = [p for p in tag_presets if p not in preset_order] - self.extend(preset_order + missing) - - -class EnumConfigValues(list): - """ - List-based container with enums fetched from configuration. - - ### Example usage: - - Fetch all enums stored in `TRANSFORM_MODES_VALUES` as enum `Tool`: - ```python - EnumConfigValues(Config.TRANSFORM_MODES_VALUES, Tool) - ``` - """ - - def __init__(self, values: Config, enum_type: Type[T]) -> None: - self.config_to_write = values - value_string: str = values.read() - if value_string == '': - return - values_list = value_string.split("\t") - try: - self.extend([enum_type[value] for value in values_list]) - except KeyError: - print(f"{values_list} not in {enum_type}") diff --git a/shortcut_composer/input_adapter/__init__.py b/shortcut_composer/input_adapter/__init__.py index 1ddc97ce..00224b9c 100644 --- a/shortcut_composer/input_adapter/__init__.py +++ b/shortcut_composer/input_adapter/__init__.py @@ -1,23 +1,20 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later """ Module contains utilities that allow to connect custom actions to krita. ActionManager (public) allows to use custom action interface of -ComplexAction (public) that supports key pressing, releasing and counting -time to differentiate short and long presses. +ComplexAction (public) that supports: +- key pressing +- key releasing +- distinguishing between short and long key presses -Custom interface is achieved by using ShortcutAdapters (private) - each -instance is responsible for finding right time to call methods of the -ctions. - -Key pressing is achieved the usual way, by connecting a method to right -signal, but key releases require usage of EventFilter (private) common -for all actions. +It has no external dependencies, so that it can be copy-pasted to any +other krita plugin. """ from .action_manager import ActionManager -from .complex_action import ComplexAction +from .complex_action_interface import ComplexActionInterface -__all__ = ['ActionManager', 'ComplexAction'] +__all__ = ['ActionManager', 'ComplexActionInterface'] diff --git a/shortcut_composer/input_adapter/action_manager.py b/shortcut_composer/input_adapter/action_manager.py index 089c26f9..98a5c97a 100644 --- a/shortcut_composer/input_adapter/action_manager.py +++ b/shortcut_composer/input_adapter/action_manager.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later """ @@ -11,8 +11,8 @@ from PyQt5.QtWidgets import QWidgetAction -from api_krita import Krita -from .complex_action import ComplexAction +from .api_krita import Krita +from .complex_action_interface import ComplexActionInterface from .event_filter import ReleaseKeyEventFilter from .shortcut_adapter import ShortcutAdapter @@ -22,13 +22,12 @@ class ActionContainer: """ Holds action elements together. - - `ComplexAction` is the action implementation. + - `ComplexActionInterface` is the action implementation. - `QWidgetAction` krita representation, which ComplexAction implements. - `ShortcutAdapter` manages running elements of ComplexAction interface at right time. - """ - core_action: ComplexAction + core_action: ComplexActionInterface krita_action: QWidgetAction shortcut: ShortcutAdapter @@ -36,7 +35,7 @@ def __post_init__(self) -> None: """Bind key_press method to action 'trigger' event.""" self.krita_action.triggered.connect(self.shortcut.on_key_press) - def replace_action(self, new_action: ComplexAction) -> None: + def replace_action(self, new_action: ComplexActionInterface) -> None: """Replace plugin action managed by this container.""" self.core_action = new_action self.shortcut.action = new_action @@ -44,10 +43,13 @@ def replace_action(self, new_action: ComplexAction) -> None: class ActionManager: """ - Creates and stores `ActionContainers` from `ComplexActions`. + Creates and stores `ActionContainers` from `ComplexActionInterfaces`. + + Ensures, that methods of the action interface will be called at + appropriate keyboard input events. `QWidgetAction` and `ShortcutAdapter` are created and stored in - container along with passed `ComplexAction` by using the + container along with passed `ComplexActionInterfaces` by using the bind_action() method. """ @@ -56,7 +58,7 @@ def __init__(self, window) -> None: self._event_filter = ReleaseKeyEventFilter() self._stored_actions: Dict[str, ActionContainer] = {} - def bind_action(self, action: ComplexAction) -> None: + def bind_action(self, action: ComplexActionInterface) -> None: """ Create action components and stores them together. @@ -76,7 +78,8 @@ def bind_action(self, action: ComplexAction) -> None: ) self._stored_actions[action.name] = container - def _create_adapter(self, action: ComplexAction) -> ShortcutAdapter: + def _create_adapter(self, action: ComplexActionInterface) \ + -> ShortcutAdapter: """ Create ShortcutAdapter which runs elements of ComplexAction interface. diff --git a/shortcut_composer/input_adapter/api_krita.py b/shortcut_composer/input_adapter/api_krita.py new file mode 100644 index 00000000..de188e2d --- /dev/null +++ b/shortcut_composer/input_adapter/api_krita.py @@ -0,0 +1,53 @@ +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Required part of api_krita package, so that no dependency is needed.""" + +from krita import Krita as Api +from typing import Callable, Protocol + +from PyQt5.QtWidgets import QWidgetAction +from PyQt5.QtGui import QKeySequence + + +class KritaWindow(Protocol): + """Krita window received in createActions() of main extension file.""" + + def createAction( + self, + name: str, + description: str, + menu: str, / + ) -> QWidgetAction: ... + + +class KritaInstance: + """Wraps krita API for typing, documentation and PEP8 compatibility.""" + + def __init__(self) -> None: + self.instance = Api.instance() + + 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 create_action( + self, + window: KritaWindow, + name: str, + group: str = "", + callback: Callable[[], None] = lambda: None + ) -> QWidgetAction: + """ + Create a new action in krita. + + Requires providing a krita window received in createActions() + method of the main extension file. + """ + krita_action = window.createAction(name, name, group) + krita_action.setAutoRepeat(False) + krita_action.triggered.connect(callback) + return krita_action + + +Krita = KritaInstance() diff --git a/shortcut_composer/input_adapter/complex_action.py b/shortcut_composer/input_adapter/complex_action.py deleted file mode 100644 index 5cbf75f5..00000000 --- a/shortcut_composer/input_adapter/complex_action.py +++ /dev/null @@ -1,61 +0,0 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus -# SPDX-License-Identifier: GPL-3.0-or-later - -from typing import List, Optional - -from composer_utils import Config -from core_components import InstructionHolder, Instruction - - -class ComplexAction: - """ - Stores basic action attributes and grants main plugin action interface. - - ### Arguments: - - - `name` -- unique name of action. Must match the definition - in shortcut_composer.action file - - `instructions` -- (optional) list of additional instructions to - perform on key press and release. - - `short_vs_long_press_time` -- (optional) time [s] that specifies - if key press is short or long. - - Class is meant for creating child classes which override: - - on_key_press - - on_short_key_release - - on_long_key_release - - on_every_key_release - """ - - def __init__( - self, *, - name: str, - instructions: List[Instruction] = [], - short_vs_long_press_time: Optional[float] = None - ) -> None: - self.name = name - self.short_vs_long_press_time = _read_time(short_vs_long_press_time) - self._instructions = InstructionHolder(instructions) - - def on_key_press(self) -> None: - """Called on each press of key specified in settings.""" - self._instructions.on_key_press() - - def on_short_key_release(self) -> None: - """Called when related key was released shortly after press.""" - self._instructions.on_short_key_release() - - def on_long_key_release(self) -> None: - """Called when related key was released after a long time.""" - self._instructions.on_long_key_release() - - def on_every_key_release(self) -> None: - """Called on each release of related key, after short/long callback.""" - self._instructions.on_every_key_release() - - -def _read_time(short_vs_long_press_time: Optional[float]) -> float: - """Return the given time, or time red from krita config if not given.""" - if short_vs_long_press_time is None: - return Config.SHORT_VS_LONG_PRESS_TIME.read() - return short_vs_long_press_time diff --git a/shortcut_composer/input_adapter/complex_action_interface.py b/shortcut_composer/input_adapter/complex_action_interface.py new file mode 100644 index 00000000..6c9ac1ea --- /dev/null +++ b/shortcut_composer/input_adapter/complex_action_interface.py @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from typing import Protocol + + +class ComplexActionInterface(Protocol): + """ + Interface of action with extended keyboard events recognition. + + - `name` -- unique name of action. Must match the definition in + the .action file + - `short_vs_long_press_time` -- time [s] that specifies if key press + is short or long. + """ + + name: str + short_vs_long_press_time: float + + def on_key_press(self) -> None: + """Called on each press of key specified in settings.""" + + def on_short_key_release(self) -> None: + """Called when related key was released shortly after press.""" + + def on_long_key_release(self) -> None: + """Called when related key was released after a long time.""" + + def on_every_key_release(self) -> None: + """Called on each release of related key, after short/long callback.""" diff --git a/shortcut_composer/input_adapter/event_filter.py b/shortcut_composer/input_adapter/event_filter.py index 6879f19f..d597752e 100644 --- a/shortcut_composer/input_adapter/event_filter.py +++ b/shortcut_composer/input_adapter/event_filter.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from typing import Callable, List, Literal diff --git a/shortcut_composer/input_adapter/shortcut_adapter.py b/shortcut_composer/input_adapter/shortcut_adapter.py index c002d59b..5dcc174d 100644 --- a/shortcut_composer/input_adapter/shortcut_adapter.py +++ b/shortcut_composer/input_adapter/shortcut_adapter.py @@ -1,12 +1,12 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from time import time from PyQt5.QtGui import QKeyEvent, QKeySequence -from api_krita import Krita -from .complex_action import ComplexAction +from .api_krita import Krita +from .complex_action_interface import ComplexActionInterface class ShortcutAdapter: @@ -24,7 +24,7 @@ class ShortcutAdapter: - on_every_key_release (called after short or long release callback) """ - def __init__(self, action: ComplexAction) -> None: + def __init__(self, action: ComplexActionInterface) -> None: self.action = action self.key_released = True self.last_press_time = time() diff --git a/shortcut_composer/manual.html b/shortcut_composer/manual.html index d0537645..8a0e180d 100644 --- a/shortcut_composer/manual.html +++ b/shortcut_composer/manual.html @@ -4,333 +4,518 @@ - Shortcut Composer + Shortcut Composer -

Shortcut composer

-

Extension for painting application Krita, which allows to create custom, complex - keyboard shortcuts.

-

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 v1.1.1

-

Fixed

-
    -
  • Fix a crash when any PieMenu is empty.
  • -
  • Support multiple krita windows.
  • -
  • Fix transform modes ocasionally stopping to work.
  • -
  • Handle invalid configuration.
  • -
  • Add missing icon for transform tool.
  • -
-

Requirements

-

Shortcut Composer v1.1.1 Requires krita 5.1.0 or later.

-

OS support state:

-
    -
  • [x] Windows 10/11
  • -
  • [x] Linux
  • -
  • [ ] MacOS (Known bug of canvas losing focus after using PieMenu)
  • -
  • [ ] Android (Does not support python plugins yet)
  • -
-

Installation:

-
    -
  1. on github project page, click the green button - code and pick the download zip option. Do not extract it.
  2. -
  3. in krita's topbar, open Tools > Scripts > Import Python Plugin From File and pick the - downloaded .zip file
  4. -
  5. restart krita.
  6. -
  7. set custom shortcuts in Settings > Configure Krita > Keyboard Shortcuts under - Scripts > Shortcut Composer: Complex Actions section. By intention, there are no default - bindings.
  8. -
-

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 a icon, activates its value on key release. The action does not - recognise mouse clicks, and only requires hovering. Pie menu does nothing if the cursor is not moved out - of - the deadzone.

-

Dragging a value enters Edit mode in which the keyboard button no longer needs to be pressed. In this - mode values can be dragged accross the widget to change their order. 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 tags can be changed in Tools > Scripts > Configure Shortcut - Composer. Default tag mapping is as follows:

    -
      -
    • red: "★ My Favorites"
    • -
    • green: "RGBA"
    • -
    • blue: "Erasers"
    • -
    -

    Presets in edited tags do not reload by themselves. Use Tools > Scripts > Reload Shortcut - Composer or press apply/ok button in plugin configuration dialog.

    -
  • -
  • -

    Pick misc tools

    -

    Pie menu for picking active tools. Includes tools that are used rather sporadically, and may - not - be worth a dedicated keyboard shortcut each:

    -
      -
    • crop tool,
    • -
    • reference tool,
    • -
    • gradient tool,
    • -
    • multi_brush tool,
    • -
    • assistant tool
    • -
    -
  • -
  • -

    Pick painting blending modes

    -

    Pie menu for picking painting blending modes. Consists of most commonly used ones:

    -
      -
    • 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. Consists of most commonly used - ones:

    -
      -
    • 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.

    -
  • -
  • -

    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.

    -
  • -
  • -

    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.

    -
  • -
-

(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:

-
    -
  • Preset pie-menus mapping
      -
    • Tag (red) - tag to be used in red preset pie-menu
    • -
    • Tag (green) - tag to be used in green preset pie-menu
    • -
    • Tag (blue) - tag to be used in blue preset pie-menu
    • -
    -
  • -
  • Common settings
      -
    • Short vs long press time - Time in seconds distinguishing short key presses from long ones. + +

      Shortcut composer

      +

      Extension for painting application Krita, which allows to create custom, + complex + keyboard shortcuts. +

      +

      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.0

      +

      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.
      • +
      +

      Check out historic changelogs.

      +

      Requirements

      +
        +
      • Version of krita on plugin release: 5.1.5
      • +
      • Required version of krita: 5.1.0
      • +
      +

      OS support state:

      +
        +
      • [x] Windows (10, 11)
      • +
      • [x] Linux (Ubuntu 20.04, 22.04)
      • +
      • [ ] 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, click the green button + code and pick the download zip option. Do not extract it. +
      2. +
      3. in krita's topbar, open Tools > Scripts > Import Python Plugin From File and pick + the + downloaded .zip file
      4. +
      5. restart krita.
      6. +
      7. set custom shortcuts in Settings > Configure Krita > Keyboard Shortcuts under + Scripts > Shortcut Composer: Complex Actions section. By intention, there are no default + bindings. +
      8. +
      +

      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. +

        +
      • +
      • +

        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.

      • -
      • 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. +
      +

      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.
        • +
      • -
      -
    • -
    • 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.
      • -
      -
    • -
    -

    Changing the values in Pie menus and Multiple - assignments

    -

    Configure Shortcut Composer has a separate tab called Action values in which you can - add new values to some of the actions, as well as remove those that are not needed.

    -

    Action to modify can be selected using the combobox on the top.

    -

    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. This can also be done using the Edit mode - directly from the PieMenu (does not apply to MultipleAssignments)

    -

    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.

    -

    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.

      -
    • -
    • -

      Define an action in actions.action file by duplicating one of the existing definitions and using - an - unique name for it.

      -
    • -
    • 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.
    • -
    -

    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.
    • -
    -

    For krita plugin programmers

    -

    Alternative API

    -

    The extension consists of elements that can be reused in other krita plugins under GPL-3.0-or-later license. - Package - api_krita wrappes krita api offering PEP8 compatibility, typings, and docstring documentation. Most of - objects attributes are now available as settables properties.

    -

    Copy api_krita to the extension directory, to access syntax such as:

    -
    from .api_krita import Krita
    +        
  • 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.

+

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.

    +
  • +
  • +

    Define an action in actions.action file by duplicating one of the existing definitions and + using + an unique name for it.

    +
  • +
  • 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. +
  • +
+

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: +

+
"""
+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:

+
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.

+
from .api_krita import Krita
 from .api_krita.enums import BlendingMode, Tool, Toggle
 
 # active tool operations
@@ -359,41 +544,8 @@ 

Alternative API

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.

-

Custom keyboard shortcut interface

-

Package input_adapter consists of ActionManager and ComplexAction which - grants - extended interface for creating keyboard shortcuts.

-

While usual actions can only recognise key press, subclassing ComplexAction lets you override methods - performed on:

-
    -
  • key press
  • -
  • short key release
  • -
  • long key release
  • -
  • every key release
  • -
-

Then use ActionManager instance to bind objects of those custom actions to krita during CreateActions - phase:

-
from api_krita import Extension
-  from input_adapter import ActionManager, ComplexAction
-  
-  
-  class CustomAction(ComplexAction):
-      def on_key_press(self): ...
-      def on_short_key_release(self): ...
-      def on_long_key_release(self): ...
-      def on_every_key_release(self): ...
-  
-  
-  class ExtensionName(Extension):
-      ...
-      def createActions(self, window) -> None:
-          action = CustomAction(name="Custom action name")
-          self.manager = ActionManager(window)
-          self.manager.bind_action(action)
-  
- +

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/shortcut_composer.py b/shortcut_composer/shortcut_composer.py index 8ec3be88..8e77884c 100755 --- a/shortcut_composer/shortcut_composer.py +++ b/shortcut_composer/shortcut_composer.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from typing import List @@ -65,7 +65,6 @@ def _create_reload_action(self, window) -> QWidgetAction: return Krita.create_action( window=window, name="Reload Shortcut Composer", - group="tools/scripts", callback=self._reload_composer) def _create_settings_action( diff --git a/shortcut_composer/templates/__init__.py b/shortcut_composer/templates/__init__.py index fdcdf5b9..fcde9c77 100644 --- a/shortcut_composer/templates/__init__.py +++ b/shortcut_composer/templates/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later """ diff --git a/shortcut_composer/templates/cursor_tracker.py b/shortcut_composer/templates/cursor_tracker.py index caed504e..1093ac1e 100644 --- a/shortcut_composer/templates/cursor_tracker.py +++ b/shortcut_composer/templates/cursor_tracker.py @@ -1,19 +1,21 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later -from typing import List, Optional +from typing import List, Optional, Generic, TypeVar from core_components import Instruction from data_components import Slider -from input_adapter import ComplexAction from .mouse_tracker_utils import ( SingleAxisTracker, DoubleAxisTracker, - SliderHandler, -) + SliderHandler) +from .raw_instructions import RawInstructions +T = TypeVar("T") +U = TypeVar("U") -class CursorTracker(ComplexAction): + +class CursorTracker(Generic[T, U]): """ Switch values with horizontal or vertical mouse movement. @@ -61,10 +63,10 @@ class CursorTracker(ComplexAction): def __new__( cls, name: str, - horizontal_slider: Optional[Slider] = None, - vertical_slider: Optional[Slider] = None, + horizontal_slider: Optional[Slider[T]] = None, + vertical_slider: Optional[Slider[U]] = None, instructions: List[Instruction] = [], - ) -> ComplexAction: + ) -> RawInstructions: """ Pick and create correct ActionPlugin based on provided sliders. @@ -78,8 +80,7 @@ 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( @@ -87,8 +88,7 @@ def __new__( instructions=instructions, slider_handler=SliderHandler( slider=vertical_slider, - is_horizontal=False, - ) + is_horizontal=False) ) if horizontal_slider and vertical_slider: return DoubleAxisTracker( @@ -96,19 +96,9 @@ def __new__( instructions=instructions, horizontal_handler=SliderHandler( slider=horizontal_slider, - is_horizontal=True, - ), + is_horizontal=True), vertical_handler=SliderHandler( slider=vertical_slider, - is_horizontal=False, - ) + is_horizontal=False) ) raise ValueError("At least one slider needed.") - - def __init__( - self, - name: str, - horizontal_slider: Optional[Slider] = None, - vertical_slider: Optional[Slider] = None, - instructions: List[Instruction] = [], - ) -> None: ... diff --git a/shortcut_composer/templates/mouse_tracker_utils/__init__.py b/shortcut_composer/templates/mouse_tracker_utils/__init__.py index 5c832382..c6275236 100644 --- a/shortcut_composer/templates/mouse_tracker_utils/__init__.py +++ b/shortcut_composer/templates/mouse_tracker_utils/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later """Implementation of CursorTracker.""" diff --git a/shortcut_composer/templates/mouse_tracker_utils/axis_trackers.py b/shortcut_composer/templates/mouse_tracker_utils/axis_trackers.py index 5ec3609b..fb6a0f5d 100644 --- a/shortcut_composer/templates/mouse_tracker_utils/axis_trackers.py +++ b/shortcut_composer/templates/mouse_tracker_utils/axis_trackers.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from typing import List, Optional @@ -6,11 +6,11 @@ from api_krita import Krita from api_krita.pyqt import Timer from core_components import Instruction -from input_adapter import ComplexAction from .slider_handler import SliderHandler +from ..raw_instructions import RawInstructions -class SingleAxisTracker(ComplexAction): +class SingleAxisTracker(RawInstructions): """ Track the mouse along one axis to switch values. @@ -26,10 +26,7 @@ def __init__( instructions: List[Instruction] = [], short_vs_long_press_time: Optional[float] = None ) -> None: - super().__init__( - name=name, - short_vs_long_press_time=short_vs_long_press_time, - instructions=instructions) + super().__init__(name, instructions, short_vs_long_press_time) self._handler = slider_handler @@ -44,7 +41,7 @@ def on_every_key_release(self) -> None: self._handler.stop() -class DoubleAxisTracker(ComplexAction): +class DoubleAxisTracker(RawInstructions): """ Track the mouse along the axis which had the biggest initial movement. @@ -61,10 +58,7 @@ def __init__( instructions: List[Instruction] = [], short_vs_long_press_time: Optional[float] = None ) -> None: - super().__init__( - name=name, - short_vs_long_press_time=short_vs_long_press_time, - instructions=instructions) + super().__init__(name, instructions, short_vs_long_press_time) self._horizontal_handler = horizontal_handler self._vertical_handler = vertical_handler diff --git a/shortcut_composer/templates/mouse_tracker_utils/mouse_interpreter.py b/shortcut_composer/templates/mouse_tracker_utils/mouse_interpreter.py index 38a3aefa..b4583a13 100644 --- a/shortcut_composer/templates/mouse_tracker_utils/mouse_interpreter.py +++ b/shortcut_composer/templates/mouse_tracker_utils/mouse_interpreter.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from dataclasses import dataclass @@ -33,7 +33,7 @@ class MouseInterpreter: start_value: Interpreted min: Interpreted max: Interpreted - pixels_in_unit: int + pixels_in_unit: float def interpret(self, mouse: MouseInput) -> Interpreted: """Return value corresponding to the `mouse`.""" diff --git a/shortcut_composer/templates/mouse_tracker_utils/new_types.py b/shortcut_composer/templates/mouse_tracker_utils/new_types.py index 728f567a..7e1ea183 100644 --- a/shortcut_composer/templates/mouse_tracker_utils/new_types.py +++ b/shortcut_composer/templates/mouse_tracker_utils/new_types.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from typing import NewType diff --git a/shortcut_composer/templates/mouse_tracker_utils/slider_handler.py b/shortcut_composer/templates/mouse_tracker_utils/slider_handler.py index 89c65bef..5f701a08 100644 --- a/shortcut_composer/templates/mouse_tracker_utils/slider_handler.py +++ b/shortcut_composer/templates/mouse_tracker_utils/slider_handler.py @@ -1,7 +1,7 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later -from typing import Callable, Iterable +from typing import Callable, Iterable, Generic, TypeVar from api_krita import Krita from api_krita.pyqt import Timer @@ -15,10 +15,11 @@ SliderValues, ) +T = TypeVar("T") MouseGetter = Callable[[], MouseInput] -class SliderHandler: +class SliderHandler(Generic[T]): """ When started, tracks the mouse, interprets it and sets corresponding value. @@ -48,7 +49,7 @@ class SliderHandler: phase in which case main loop will never be started. """ - def __init__(self, slider: Slider, is_horizontal: bool) -> None: + def __init__(self, slider: Slider[T], is_horizontal: bool) -> None: """Store the slider configuration, create value adapter.""" self._slider = slider self._to_cycle = self._create_slider_values(slider) @@ -129,7 +130,7 @@ def _pick_mouse_getter(self) -> MouseGetter: return lambda: MouseInput(-cursor.y()) @staticmethod - def _create_slider_values(slider: Slider) -> SliderValues: + def _create_slider_values(slider: Slider[T]) -> SliderValues[T]: """Return the right values adapter based on passed data type.""" if isinstance(slider.values, Iterable): return ListSliderValues(slider.values) diff --git a/shortcut_composer/templates/mouse_tracker_utils/slider_values.py b/shortcut_composer/templates/mouse_tracker_utils/slider_values.py index 3d7fe67d..cbd5565f 100644 --- a/shortcut_composer/templates/mouse_tracker_utils/slider_values.py +++ b/shortcut_composer/templates/mouse_tracker_utils/slider_values.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from typing import Any, List, Generic, TypeVar diff --git a/shortcut_composer/templates/multiple_assignment.py b/shortcut_composer/templates/multiple_assignment.py index a5b5d88c..fa93db81 100644 --- a/shortcut_composer/templates/multiple_assignment.py +++ b/shortcut_composer/templates/multiple_assignment.py @@ -1,16 +1,18 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from typing import List, Iterator, TypeVar, Generic, Optional from itertools import cycle from core_components import Controller, Instruction -from input_adapter import ComplexAction +from config_system import Field +from .raw_instructions import RawInstructions +from .multiple_assignment_utils import SettingsHandler T = TypeVar('T') -class MultipleAssignment(ComplexAction, Generic[T]): +class MultipleAssignment(RawInstructions, Generic[T]): """ Cycle multiple values (short press) or return to default (long press). @@ -61,21 +63,31 @@ class MultipleAssignment(ComplexAction, Generic[T]): def __init__( self, *, name: str, - controller: Controller, + controller: Controller[T], values: List[T], default_value: Optional[T] = None, instructions: List[Instruction] = [], short_vs_long_press_time: Optional[float] = None ) -> None: - super().__init__( - name=name, - short_vs_long_press_time=short_vs_long_press_time, - instructions=instructions) + super().__init__(name, instructions, short_vs_long_press_time) self._controller = controller - self._values_to_cycle = values self._default_value = self._read_default_value(default_value) + self.config = Field( + config_group=f"ShortcutComposer: {name}", + name="Values", + default=values) + + self._settings = SettingsHandler(name, self.config, instructions) + self._values_to_cycle = self.config.read() + + def reset() -> None: + self._values_to_cycle = self.config.read() + self._reset_iterator() + + self.config.register_callback(reset) + self._last_value: Optional[T] = None self._iterator: Iterator[T] @@ -107,4 +119,7 @@ def _reset_iterator(self) -> None: def _read_default_value(self, value: Optional[T]) -> T: """Read value from controller if it was not given.""" - return value if value is not None else self._controller.default_value + 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/__init__.py b/shortcut_composer/templates/multiple_assignment_utils/__init__.py new file mode 100644 index 00000000..f4fecd97 --- /dev/null +++ b/shortcut_composer/templates/multiple_assignment_utils/__init__.py @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Widgets to display on the settings dialog.""" + +from .action_values_window import ActionValuesWindow +from .settings_handler import SettingsHandler +from .action_values import ActionValues +from .value_list import ValueList + +__all__ = [ + "ActionValuesWindow", + "SettingsHandler", + "ActionValues", + "ValueList" +] diff --git a/shortcut_composer/composer_utils/utils/action_values.py b/shortcut_composer/templates/multiple_assignment_utils/action_values.py similarity index 51% rename from shortcut_composer/composer_utils/utils/action_values.py rename to shortcut_composer/templates/multiple_assignment_utils/action_values.py index 6329d15c..e19760fc 100644 --- a/shortcut_composer/composer_utils/utils/action_values.py +++ b/shortcut_composer/templates/multiple_assignment_utils/action_values.py @@ -1,7 +1,8 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later -from typing import Set +from typing import List, Type +from enum import Enum from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( @@ -13,15 +14,28 @@ ) from api_krita import Krita -from ..config import Config +from config_system import Field from .value_list import ValueList class ActionValues(QWidget): - def __init__(self, allowed_values: Set[str], config: Config) -> None: + """ + Widget for selecting values and their order available as Enum. + + Consists of two lists next to each other. Values from the left + represent all unused Enum values. They can be moved to the list of + selected values on the right. + + Elements are moved between lists using two buttons. Widget supports + moving multiple values at once. Drag&drop allows to change order in + the list of selected values. + """ + + def __init__(self, enum_type: Type[Enum], config: Field[List[Enum]]): super().__init__() + layout = QHBoxLayout() - self.allowed_values = allowed_values + self.enum_type = enum_type self.config = config self.available_list = ValueList(movable=False, parent=self) @@ -43,13 +57,14 @@ def __init__(self, allowed_values: Set[str], config: Config) -> None: control_layout.addWidget(remove_button) control_layout.addStretch() - layout.addLayout(self._labeled_list(self.available_list, "Available:")) + layout.addLayout(self._add_label(self.available_list, "Available:")) layout.addLayout(control_layout) - layout.addLayout(self._labeled_list(self.current_list, "Selected:")) + layout.addLayout(self._add_label(self.current_list, "Selected:")) self.setLayout(layout) - def _labeled_list(self, value_list: ValueList, text: str) -> QVBoxLayout: + def _add_label(self, value_list: ValueList, text: str) -> QVBoxLayout: + """Adds a label on top of the list area.""" layout = QVBoxLayout() label = QLabel(text) label.setAlignment(Qt.AlignCenter) @@ -57,37 +72,43 @@ def _labeled_list(self, value_list: ValueList, text: str) -> QVBoxLayout: layout.addWidget(value_list) return layout - def add(self): + def add(self) -> None: + """Move mouse-selected values from the left list to the right.""" for value in self.available_list.selected: self.current_list.insert( position=self.current_list.current_row, - value=value, - ) + value=value) self.available_list.remove(value=value,) - def remove(self): + def remove(self) -> None: + """Move mouse-selected values from the right list to the left.""" selected = self.current_list.selected self.current_list.remove_selected() new_available = set(self.available_list.get_all()) | set(selected) self.available_list.clear() self.available_list.addItems(sorted(new_available)) - def apply(self): - texts = [] + def apply(self) -> None: + """Save the right list into kritarc.""" + to_write: List[Enum] = [] for row in range(self.current_list.count()): - texts.append(self.current_list.item(row).text()) - self.config.write("\t".join(texts)) + text = self.current_list.item(row).text() + to_write.append(self.enum_type[text]) + + self.config.write(to_write) - def refresh(self): + def refresh(self) -> None: + """Refresh right list with kritarc values and left one accordingly.""" self.current_list.clear() - current: str = self.config.read() - current_list = current.split("\t") - if current_list == ['']: - current_list = [] - for item in current_list: - if item in self.allowed_values: - self.current_list.addItem(item) + current_list = self.config.read() + text_list = [item.name for item in current_list] + self.current_list.addItems(text_list) self.available_list.clear() - allowed_items = sorted(self.allowed_values - set(current_list)) + allowed_items = sorted(set(self._allowed_values) - set(text_list)) self.available_list.addItems(allowed_items) + + @property + def _allowed_values(self) -> List[str]: + """Return list of all available values using the enum type.""" + return self.enum_type._member_names_ diff --git a/shortcut_composer/templates/multiple_assignment_utils/action_values_window.py b/shortcut_composer/templates/multiple_assignment_utils/action_values_window.py new file mode 100644 index 00000000..cac8fa0c --- /dev/null +++ b/shortcut_composer/templates/multiple_assignment_utils/action_values_window.py @@ -0,0 +1,57 @@ +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from typing import List, Type +from enum import Enum + +from PyQt5.QtWidgets import QVBoxLayout, QDialog +from PyQt5.QtCore import Qt + +from config_system import Field +from composer_utils import ButtonsLayout +from .action_values import ActionValues + + +class ActionValuesWindow(QDialog): + """Tab in which user can change action enums and their order.""" + + def __init__(self, enum_type: Type[Enum], config: Field[List[Enum]]): + super().__init__() + self.setWindowFlags( + self.windowFlags() | Qt.WindowStaysOnTopHint) # type: ignore + layout = QVBoxLayout() + + self._config = config + self.widget = ActionValues(enum_type, config) + layout.addWidget(self.widget) + + layout.addLayout(ButtonsLayout( + ok_callback=self._ok, + apply_callback=self._apply, + reset_callback=self._reset, + cancel_callback=self.hide)) + + self.setLayout(layout) + + def show(self) -> None: + """Refresh the widget before showing it.""" + self._refresh() + return super().show() + + def _ok(self) -> None: + """Hide the dialog after applying the changes""" + self._apply() + self.hide() + + def _reset(self) -> None: + """Reset all config values to defaults in krita and elements.""" + self._config.reset_default() + self._refresh() + + def _apply(self) -> None: + """Apply changes in held widget.""" + self.widget.apply() + + def _refresh(self) -> None: + """Refresh the held widget.""" + self.widget.refresh() diff --git a/shortcut_composer/templates/multiple_assignment_utils/settings_handler.py b/shortcut_composer/templates/multiple_assignment_utils/settings_handler.py new file mode 100644 index 00000000..67112815 --- /dev/null +++ b/shortcut_composer/templates/multiple_assignment_utils/settings_handler.py @@ -0,0 +1,82 @@ +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from typing import List +from enum import Enum + +from PyQt5.QtGui import QColor + +from api_krita import Krita +from api_krita.pyqt import RoundButton, Timer +from core_components import Instruction +from config_system import Field +from .action_values_window import ActionValuesWindow + + +class SettingsHandler: + """ + Manages showing the MA settings window and button activating it. + + Creates settings window and button, and installs a instruction in + multiple assignment action to show the button when the action key is + pressed for a short time. + + Once clicked, the button shows the window in which the MA + configuration can be modified. + """ + + def __init__( + self, + name: str, + config: Field[list], + instructions: List[Instruction], + ) -> None: + to_cycle = config.read() + if not to_cycle or not isinstance(to_cycle[0], Enum): + return + + self._settings = ActionValuesWindow(type(to_cycle[0]), config) + self._settings.setWindowTitle(f"Configure: {name}") + + self._button = RoundButton( + icon=Krita.get_icon("properties"), + icon_scale=1.1, + initial_radius=25, + background_color=QColor(75, 75, 75, 255), + active_color=QColor(100, 150, 230, 255)) + self._button.clicked.connect(self._on_button_click) + self._button.move(0, 0) + self._button.hide() + + instructions.append(HandlerInstruction(self._settings, self._button)) + + def _on_button_click(self): + """Show the settings and hide the button after it was clicked.""" + self._settings.show() + self._button.hide() + + +class HandlerInstruction(Instruction): + """Instruction installed on the MA action which activates the button.""" + + def __init__(self, settings: ActionValuesWindow, button: RoundButton): + self._settings = settings + self._button = button + self._timer = Timer(self.timer_callback, 500) + + def on_key_press(self) -> None: + """Start a timer which soon will run a callback once.""" + self._timer.start() + + def timer_callback(self): + """Show a button in top left corner of painting area.""" + if not self._settings.isVisible(): + mdiArea = Krita.get_active_mdi_area() + self._button.move(mdiArea.mapToGlobal(mdiArea.pos())) + self._button.show() + self._timer.stop() + + def on_every_key_release(self) -> None: + """Hide the button when visible, or cancel the timer if not.""" + self._button.hide() + self._timer.stop() diff --git a/shortcut_composer/composer_utils/utils/value_list.py b/shortcut_composer/templates/multiple_assignment_utils/value_list.py similarity index 93% rename from shortcut_composer/composer_utils/utils/value_list.py rename to shortcut_composer/templates/multiple_assignment_utils/value_list.py index 7af1c700..65008846 100644 --- a/shortcut_composer/composer_utils/utils/value_list.py +++ b/shortcut_composer/templates/multiple_assignment_utils/value_list.py @@ -1,14 +1,10 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from typing import Optional, List from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import ( - QAbstractItemView, - QListWidget, - QWidget, -) +from PyQt5.QtWidgets import QAbstractItemView, QListWidget, QWidget class ValueList(QListWidget): diff --git a/shortcut_composer/templates/pie_menu.py b/shortcut_composer/templates/pie_menu.py index 874aafb1..f50b9e08 100644 --- a/shortcut_composer/templates/pie_menu.py +++ b/shortcut_composer/templates/pie_menu.py @@ -1,33 +1,35 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later -from typing import List, TypeVar, Generic, Union, Optional +from typing import List, TypeVar, Generic, Optional +from enum import Enum -from PyQt5.QtGui import QColor, QPixmap, QIcon +from PyQt5.QtCore import QPoint +from PyQt5.QtGui import QColor -from api_krita.pyqt import Text -from composer_utils import Config +from api_krita import Krita from core_components import Controller, Instruction -from input_adapter import ComplexAction from .pie_menu_utils import ( + create_pie_settings_window, + create_local_config, PieManager, PieWidget, PieStyle, - Label, -) + Label) +from .pie_menu_utils.widget_utils import EditMode, PieButton +from .raw_instructions import RawInstructions T = TypeVar('T') -class PieMenu(ComplexAction, Generic[T]): +class PieMenu(RawInstructions, Generic[T]): """ Pick value by hovering over a pie menu widget. - 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 - - Dragging values activates edit mode in which pie does not hide - - Applying the changes in edit mode, saves its values to settings + - Edit button activates mode in pie does not hide and can be changed ### Arguments: @@ -49,7 +51,6 @@ class PieMenu(ComplexAction, Generic[T]): Action is meant to change opacity of current layer to one of predefined values using the pie menu widget. - ```python templates.PieMenu( name="Pick active layer opacity", @@ -62,21 +63,11 @@ class PieMenu(ComplexAction, Generic[T]): ) ``` """ - """ - Class is responsible for: - - Handling the key press/release interface - - Reading widget configuration and storing it in PieStyle - passed - to objects that can be displayed - - Creating the PieWidget - and PieManager which displays it - - Starting and stopping the PieManager on key press and release - - Creating Labels - paintable representations of handled values - - Setting a value on key release when the deadzone was reached - """ def __init__( self, *, name: str, - controller: Controller, + controller: Controller[T], values: List[T], instructions: List[Instruction] = [], pie_radius_scale: float = 1.0, @@ -85,59 +76,120 @@ def __init__( active_color: QColor = QColor(100, 150, 230, 255), short_vs_long_press_time: Optional[float] = None ) -> None: - super().__init__( - name=name, - short_vs_long_press_time=short_vs_long_press_time, - instructions=instructions) + super().__init__(name, instructions, short_vs_long_press_time) self._controller = controller - - self._labels = self._create_labels(values) - self._style = PieStyle( + self._config = create_local_config( + name=name, + values=values, pie_radius_scale=pie_radius_scale, icon_radius_scale=icon_radius_scale, - icons_amount=len(self._labels), background_color=background_color, - active_color=active_color, - ) - - related_config = self._get_config_to_write_back(values) - self._pie_widget = PieWidget(self._style, self._labels, related_config) - self._pie_manager = PieManager(self._pie_widget) + active_color=active_color) + + self._last_values: List[T] = [] + 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( + 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( + 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( + 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() + + def _move_buttons(self): + """Move accept button to center and setting button to bottom-right.""" + 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: - """Show widget under mouse and start manager which repaints it.""" + """Reload labels, start GUI manager and run instructions.""" + if self.pie_widget.isVisible(): + return + self._controller.refresh() - self._pie_manager.start() + + 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._move_buttons() + + self.pie_manager.start() super().on_key_press() def on_every_key_release(self) -> None: - """Stop the widget. Set selected value if deadzone was reached.""" + """ + Handle the key release event. + + Ignore if in edit mode. Otherwise, stop the manager and set the + selected value if deadzone was reached. + """ super().on_every_key_release() - if self._pie_widget.edit_mode: + + if self._edit_mode.get(): return - self._pie_manager.stop() - if widget := self._pie_widget.widget_holder.active: - self._controller.set_value(widget.label.value) - def _create_labels(self, values: List[T]) -> List[Label]: - """Wrap values into paintable label objects with position info.""" - label_list = [] + self.pie_manager.stop() + 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: + """Replace list values with newly created labels.""" + label_list.clear() for value in values: - if icon := self._get_icon_if_possible(value): - label_list.append(Label(value=value, display_value=icon)) - return label_list - - def _get_icon_if_possible(self, value: T) \ - -> Union[Text, QPixmap, QIcon, None]: - """Return the paintable icon of the value or None if missing.""" - try: - return self._controller.get_label(value) - except KeyError: - return None - - def _get_config_to_write_back(self, values: List[T]) -> Optional[Config]: - """Some value lists can contain metadata with config to write back.""" - try: - return values.config_to_write # type: ignore - except AttributeError: - return None + label = self._controller.get_label(value) + 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] diff --git a/shortcut_composer/templates/pie_menu_utils/__init__.py b/shortcut_composer/templates/pie_menu_utils/__init__.py index 1e9c5a27..bf6d8ab4 100644 --- a/shortcut_composer/templates/pie_menu_utils/__init__.py +++ b/shortcut_composer/templates/pie_menu_utils/__init__.py @@ -1,8 +1,9 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later """Implementation of PieMenu main elements.""" +from .dispatchers import create_local_config, create_pie_settings_window from .label_widget import LabelWidget from .pie_manager import PieManager from .pie_widget import PieWidget @@ -10,6 +11,8 @@ from .label import Label __all__ = [ + "create_pie_settings_window", + "create_local_config", "LabelWidget", "PieManager", "PieWidget", diff --git a/shortcut_composer/templates/pie_menu_utils/dispatchers.py b/shortcut_composer/templates/pie_menu_utils/dispatchers.py new file mode 100644 index 00000000..d0203aa3 --- /dev/null +++ b/shortcut_composer/templates/pie_menu_utils/dispatchers.py @@ -0,0 +1,55 @@ +# 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 a75ec460..7c5ba31f 100644 --- a/shortcut_composer/templates/pie_menu_utils/label.py +++ b/shortcut_composer/templates/pie_menu_utils/label.py @@ -1,21 +1,20 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from api_krita.pyqt import Text -from typing import Union, Any +from typing import Union, Generic, TypeVar from dataclasses import dataclass from PyQt5.QtCore import QPoint -from PyQt5.QtGui import ( - QPixmap, - QIcon, -) +from PyQt5.QtGui import QPixmap, QIcon from composer_utils import Config +T = TypeVar("T") + @dataclass -class Label: +class Label(Generic[T]): """ Data representing a single value in PieWidget. @@ -28,19 +27,26 @@ class Label: - `activation_progress` -- state of animation in range <0-1> """ - value: Any + value: T center: QPoint = QPoint(0, 0) angle: int = 0 display_value: Union[QPixmap, QIcon, Text, None] = None + pretty_name: str = "" def __post_init__(self) -> None: self.activation_progress = AnimationProgress(speed_scale=1, steep=1) - def swap_locations(self, other: 'Label') -> None: + def swap_locations(self, other: 'Label[T]') -> None: """Change position data with information Label.""" self.angle, other.angle = other.angle, self.angle self.center, other.center = other.center, self.center + def __eq__(self, other: T) -> bool: + """Consider two labels with the same value equal.""" + if not isinstance(other, Label): + return False + return self.value == other.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 15d6805f..4f4ae00c 100644 --- a/shortcut_composer/templates/pie_menu_utils/label_widget.py +++ b/shortcut_composer/templates/pie_menu_utils/label_widget.py @@ -1,7 +1,9 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later -from PyQt5.QtCore import Qt, QMimeData +from typing import Protocol + +from PyQt5.QtCore import Qt, QMimeData, QEvent from PyQt5.QtWidgets import QWidget from PyQt5.QtGui import QDrag, QPixmap, QMouseEvent @@ -10,6 +12,16 @@ from .label import Label +class WidgetInstructions(Protocol): + """Additional logic to do on entering and leaving a widget.""" + + def on_enter(self, label: Label) -> None: + """Logic to perform when mouse starts hovering over widget.""" + + def on_leave(self, label: Label) -> None: + """Logic to perform when mouse stops hovering over widget.""" + + class LabelWidget(BaseWidget): """Displays a `label` inside of `widget` using given `style`.""" @@ -18,22 +30,60 @@ def __init__( label: Label, style: PieStyle, parent: QWidget, + is_unscaled: bool = False, ) -> None: super().__init__(parent) self.setGeometry(0, 0, style.icon_radius*2, style.icon_radius*2) - self.label = label self._style = style - self.setCursor(Qt.ArrowCursor) + self._is_unscaled = is_unscaled + + self.draggable = self._draggable = True + + self._enabled = True + self._hovered = False + + self._instructions: list[WidgetInstructions] = [] + + def add_instruction(self, instruction: WidgetInstructions): + """Add additional logic to do on entering and leaving widget.""" + self._instructions.append(instruction) + + @property + def draggable(self) -> bool: + """Return whether the label can be dragged.""" + return self._draggable + + @draggable.setter + def draggable(self, value: bool) -> None: + """Make the widget accept dragging or not.""" + self._draggable = value + if value: + return self.setCursor(Qt.ArrowCursor) + self.setCursor(Qt.CrossCursor) + + @property + def enabled(self): + """Return whether the label interacts with mouse hover and drag.""" + return self._enabled + + @enabled.setter + def enabled(self, value: bool) -> None: + """Make the widget interact with mouse or not.""" + self._enabled = value + if not value: + self.draggable = False + self.repaint() def move_to_label(self) -> None: - """Move the widget by providing a new center point.""" + """Move the widget according to current center of label it holds.""" self.move_center(self.label.center) def mousePressEvent(self, e: QMouseEvent) -> None: """Initiate a drag loop for this Widget, so Widgets can be swapped.""" - if e.buttons() != Qt.LeftButton: + if e.buttons() != Qt.LeftButton or not self._draggable: return + drag = QDrag(self) drag.setMimeData(QMimeData()) @@ -42,3 +92,35 @@ def mousePressEvent(self, e: QMouseEvent) -> None: drag.setPixmap(PixmapTransform.make_pixmap_round(pixmap)) drag.exec_(Qt.MoveAction) + + def enterEvent(self, e: QEvent) -> None: + super().enterEvent(e) + """Notice that mouse moved over the widget.""" + self._hovered = True + for instruction in self._instructions: + instruction.on_enter(self.label) + self.repaint() + + def leaveEvent(self, e: QEvent) -> None: + """Notice that mouse moved out of the widget.""" + super().leaveEvent(e) + self._hovered = False + for instruction in self._instructions: + instruction.on_leave(self.label) + self.repaint() + + @property + def _border_color(self): + """Return border color which differs when enabled or hovered.""" + if not self.enabled: + return self._style.active_color_dark + if self._hovered and self.draggable: + return self._style.active_color + return self._style.border_color + + @property + def icon_radius(self): + """Return icon radius based flag passed on initialization.""" + if self._is_unscaled: + return self._style.unscaled_icon_radius + return self._style.icon_radius diff --git a/shortcut_composer/templates/pie_menu_utils/label_widget_utils/__init__.py b/shortcut_composer/templates/pie_menu_utils/label_widget_utils/__init__.py index 68760a4e..6d3e69fd 100644 --- a/shortcut_composer/templates/pie_menu_utils/label_widget_utils/__init__.py +++ b/shortcut_composer/templates/pie_menu_utils/label_widget_utils/__init__.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later """Implementation of different LabelWidget types.""" diff --git a/shortcut_composer/templates/pie_menu_utils/label_widget_utils/create_label_widget.py b/shortcut_composer/templates/pie_menu_utils/label_widget_utils/create_label_widget.py index 57b42140..9fbc5117 100644 --- a/shortcut_composer/templates/pie_menu_utils/label_widget_utils/create_label_widget.py +++ b/shortcut_composer/templates/pie_menu_utils/label_widget_utils/create_label_widget.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from typing import Type @@ -21,8 +21,9 @@ def create_label_widget( label: Label, style: PieStyle, - parent: QWidget -) -> 'LabelWidget': + parent: QWidget, + is_unscaled: bool = False, +) -> LabelWidget: """Return LabelWidget which can display this label.""" if label.display_value is None: raise ValueError(f"Label {label} is not valid") @@ -33,4 +34,4 @@ def create_label_widget( QIcon: IconLabelWidget, }[type(label.display_value)] - return painter_type(label, style, parent) + return painter_type(label, style, parent, is_unscaled) 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 d99aa262..cd7fd3ea 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 @@ -1,10 +1,7 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later -from PyQt5.QtGui import ( - QPixmap, - QIcon, -) +from PyQt5.QtGui import QPixmap, QIcon from api_krita.pyqt import PixmapTransform from .image_label_widget import ImageLabelWidget @@ -20,7 +17,7 @@ def _prepare_image(self) -> QPixmap: if not isinstance(to_display, QIcon): raise TypeError("Label supposed to be QIcon.") - size = round(self._style.icon_radius*1.1) + size = round(self.icon_radius*1.1) return PixmapTransform.scale_pixmap( pixmap=to_display.pixmap(size, size), size_px=size diff --git a/shortcut_composer/templates/pie_menu_utils/label_widget_utils/image_label_widget.py b/shortcut_composer/templates/pie_menu_utils/label_widget_utils/image_label_widget.py index fbdfd2b4..7ac52b93 100644 --- a/shortcut_composer/templates/pie_menu_utils/label_widget_utils/image_label_widget.py +++ b/shortcut_composer/templates/pie_menu_utils/label_widget_utils/image_label_widget.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from PyQt5.QtGui import ( @@ -16,8 +16,14 @@ class ImageLabelWidget(LabelWidget): """Displays a `label` which holds an image.""" - def __init__(self, label: Label, style: PieStyle, parent: QWidget) -> None: - super().__init__(label, style, parent) + def __init__( + self, + label: Label, + style: PieStyle, + parent: QWidget, + is_unscaled: bool = False, + ) -> None: + super().__init__(label, style, parent, is_unscaled) self.ready_image = self._prepare_image() def paintEvent(self, event: QPaintEvent) -> None: @@ -29,16 +35,15 @@ def paintEvent(self, event: QPaintEvent) -> None: with Painter(self, event) as painter: painter.paint_wheel( center=self.center, - outer_radius=self._style.icon_radius, - color=self._style.icon_color - ) + outer_radius=self.icon_radius, + color=self._style.icon_color) + painter.paint_wheel( center=self.center, outer_radius=( - self._style.icon_radius-self._style.border_thickness//2), - color=self._style.border_color, - thickness=self._style.border_thickness, - ) + self.icon_radius-self._style.border_thickness//2), + color=self._border_color, + thickness=self._style.border_thickness) painter.paint_pixmap(self.center, self.ready_image) def _prepare_image(self) -> QPixmap: @@ -51,5 +56,4 @@ def _prepare_image(self) -> QPixmap: rounded_image = PixmapTransform.make_pixmap_round(to_display) return PixmapTransform.scale_pixmap( pixmap=rounded_image, - size_px=round(self._style.icon_radius*1.8) - ) + size_px=round(self.icon_radius*1.8)) diff --git a/shortcut_composer/templates/pie_menu_utils/label_widget_utils/text_label_widget.py b/shortcut_composer/templates/pie_menu_utils/label_widget_utils/text_label_widget.py index 46729863..01431c14 100644 --- a/shortcut_composer/templates/pie_menu_utils/label_widget_utils/text_label_widget.py +++ b/shortcut_composer/templates/pie_menu_utils/label_widget_utils/text_label_widget.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from PyQt5.QtCore import Qt @@ -19,8 +19,14 @@ class TextLabelWidget(LabelWidget): """Displays a `label` which holds text.""" - def __init__(self, label: Label, style: PieStyle, parent: QWidget) -> None: - super().__init__(label, style, parent) + def __init__( + self, + label: Label, + style: PieStyle, + parent: QWidget, + is_unscaled: bool = False, + ) -> None: + super().__init__(label, style, parent, is_unscaled) self._pyqt_label = self._create_pyqt_label() def paintEvent(self, event: QPaintEvent) -> None: @@ -32,15 +38,13 @@ def paintEvent(self, event: QPaintEvent) -> None: with Painter(self, event) as painter: painter.paint_wheel( center=self.center, - outer_radius=self._style.icon_radius, - color=self._style.icon_color, - ) + outer_radius=self.icon_radius, + color=self._style.icon_color) painter.paint_wheel( center=self.center, - outer_radius=self._style.icon_radius, - color=self._style.border_color, - thickness=self._style.border_thickness, - ) + outer_radius=self.icon_radius, + color=self._border_color, + thickness=self._style.border_thickness) def _create_pyqt_label(self) -> QLabel: """Create and show a new Qt5 label. Does not need redrawing.""" @@ -49,7 +53,7 @@ def _create_pyqt_label(self) -> QLabel: if not isinstance(to_display, Text): raise TypeError("Label supposed to be text.") - heigth = round(self._style.icon_radius*0.8) + heigth = round(self.icon_radius*0.8) label = QLabel(self) label.setText(to_display.value) diff --git a/shortcut_composer/templates/pie_menu_utils/pie_config.py b/shortcut_composer/templates/pie_menu_utils/pie_config.py new file mode 100644 index 00000000..cea2bcb5 --- /dev/null +++ b/shortcut_composer/templates/pie_menu_utils/pie_config.py @@ -0,0 +1,99 @@ +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from abc import ABC, abstractmethod +from typing import List, Generic, TypeVar, Optional +from PyQt5.QtGui import QColor +from config_system import Field, FieldGroup +from data_components import Tag + +T = TypeVar("T") + + +class PieConfig(FieldGroup, Generic[T], ABC): + """Abstract FieldGroup representing config of PieMenu.""" + + ALLOW_REMOVE: bool + """Is it allowed to remove elements in runtime. """ + + name: str + """Name of the group in kritarc.""" + background_color: Optional[QColor] + active_color: QColor + + ORDER: Field[List[T]] + """Value order stored in kritarc.""" + PIE_RADIUS_SCALE: Field[float] + ICON_RADIUS_SCALE: Field[float] + + @abstractmethod + def values(self) -> List[T]: + """Return values to display as icons on the pie.""" + ... + + +class PresetPieConfig(PieConfig[str]): + """ + FieldGroup representing config of PieMenu of presets. + + Values are calculated according to presets belonging to handled tag + and the custom order saved by the user in kritarc. + """ + + ALLOW_REMOVE = False + + def __init__( + self, + name: str, + values: Tag, + pie_radius_scale: float, + icon_radius_scale: float, + background_color: Optional[QColor], + active_color: QColor, + ) -> None: + super().__init__(name) + + 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.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()) + + 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 + + +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, + background_color: Optional[QColor], + active_color: QColor, + ) -> None: + super().__init__(name) + + 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.background_color = background_color + self.active_color = active_color + + def values(self) -> List[T]: + """Return values to display as icons as defined be the user.""" + return self.ORDER.read() diff --git a/shortcut_composer/templates/pie_menu_utils/pie_manager.py b/shortcut_composer/templates/pie_menu_utils/pie_manager.py index 5e22dc2b..9c02d3a9 100644 --- a/shortcut_composer/templates/pie_menu_utils/pie_manager.py +++ b/shortcut_composer/templates/pie_menu_utils/pie_manager.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from typing import Optional @@ -7,8 +7,9 @@ from api_krita.pyqt import Timer from composer_utils import Config +from .settings_gui import PieSettings from .pie_widget import PieWidget -from .label_widget import LabelWidget +from .label import Label from .widget_utils import CirclePoints @@ -18,50 +19,61 @@ class PieManager: - Displays the widget between start() and stop() calls. - Starts a thread loop which checks for changes of active label. - - Asks the widget to repaint when after changing active label. """ - def __init__(self, widget: PieWidget) -> None: - self._widget = widget - self._holder = self._widget.widget_holder + def __init__(self, pie_widget: PieWidget, pie_settings: PieSettings): + self._pie_widget = pie_widget + self._pie_settings = pie_settings self._timer = Timer(self._handle_cursor, Config.get_sleep_time()) - self._animator = LabelAnimator(widget) + self._animator = LabelAnimator(pie_widget) self._circle: CirclePoints def start(self) -> None: """Show widget under the mouse and start the mouse tracking loop.""" - self._widget.move_center(QCursor().pos()) - self._widget.show() - self._circle = CirclePoints(self._widget.center_global, 0) + 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() - def stop(self) -> None: + # Make sure the pie widget is not draggable. It could have been + # broken by pie settings reloading the widgets. + self._pie_widget.set_draggable(False) + + def stop(self, hide: bool = True) -> None: """Hide the widget and stop the mouse tracking loop.""" self._timer.stop() - for label in self._widget.labels: + for label in self._pie_widget.label_holder: label.activation_progress.reset() - self._widget.hide() + if hide: + self._pie_widget.hide() def _handle_cursor(self) -> None: """Calculate zone of the cursor and mark which child is active.""" # NOTE: The widget can get hidden outside of stop() when key is # released during the drag&drop operation or when user clicked # outside the pie widget. - if not self._widget.isVisible(): + if not self._pie_widget.isVisible(): return self.stop() cursor = QCursor().pos() - if self._circle.distance(cursor) < self._widget.deadzone: - return self._set_active_widget(None) + if self._circle.distance(cursor) < self._pie_widget.deadzone: + return self._set_active_label(None) angle = self._circle.angle_from_point(cursor) - self._set_active_widget(self._holder.on_angle(angle)) + holder = self._pie_widget.label_holder.widget_holder + self._set_active_label(holder.on_angle(angle).label) - def _set_active_widget(self, widget: Optional[LabelWidget]) -> None: + def _set_active_label(self, label: Optional[Label]) -> None: """Mark label as active and start animating the change.""" - if self._holder.active != widget: - self._holder.active = widget + if self._pie_widget.active != label: + self._pie_widget.active = label self._animator.start() @@ -72,9 +84,8 @@ class LabelAnimator: Handles the whole widget at once, to prevent unnecessary repaints. """ - def __init__(self, widget: PieWidget) -> None: - self._widget = widget - self._children = widget.widget_holder + def __init__(self, pie_widget: PieWidget) -> None: + self._pie_widget = pie_widget self._timer = Timer(self._update, Config.get_sleep_time()) def start(self) -> None: @@ -83,14 +94,14 @@ def start(self) -> None: def _update(self) -> None: """Move all labels to next animation state. End animation if needed.""" - for widget in self._children: - if self._children.active == widget: - widget.label.activation_progress.up() + for label in self._pie_widget.label_holder: + if self._pie_widget.active == label: + label.activation_progress.up() else: - widget.label.activation_progress.down() + label.activation_progress.down() - self._widget.repaint() - for widget in self._children: - if widget.label.activation_progress.value not in (0, 1): + self._pie_widget.repaint() + for label in self._pie_widget.label_holder: + if label.activation_progress.value not in (0, 1): return self._timer.stop() diff --git a/shortcut_composer/templates/pie_menu_utils/pie_style.py b/shortcut_composer/templates/pie_menu_utils/pie_style.py index c281fa23..ad854052 100644 --- a/shortcut_composer/templates/pie_menu_utils/pie_style.py +++ b/shortcut_composer/templates/pie_menu_utils/pie_style.py @@ -1,10 +1,9 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later import math import platform -from typing import Optional -from dataclasses import dataclass +from typing import TYPE_CHECKING from copy import copy from PyQt5.QtGui import QColor @@ -12,95 +11,164 @@ from api_krita import Krita from composer_utils import Config +if TYPE_CHECKING: + from .pie_config import PieConfig + -@dataclass class PieStyle: """ Holds and calculates configuration of displayed elements. - All style elements are calculated based on passed base colors and - scale multipliers. + Style elements are calculated based on passed local config and + imported global config. - Using adapt_to_item_amount() allows to modify the style to make it - fit the given amount of labels. + They are also affected by length of passed items list which size can + change over time. """ def __init__( self, - pie_radius_scale: float, - icon_radius_scale: float, - icons_amount: int, - background_color: Optional[QColor], - active_color: QColor, + pie_config: 'PieConfig', + items: list, ) -> None: - self._icons_amount = icons_amount + self._items = items self._base_size = Krita.screen_size/2560 - - self.pie_radius_scale = pie_radius_scale - self.icon_radius_scale = icon_radius_scale - self.background_color = self._pick_background_color(background_color) - self.active_color = active_color - self.active_color_dark = QColor( - round(active_color.red()*0.8), - round(active_color.green()*0.8), - round(active_color.blue()*0.8)) - - self.pie_radius: int = round( + self._pie_config = pie_config + + @property + def _pie_radius_scale(self) -> float: + """Local scale of pie selected by user.""" + return self._pie_config.PIE_RADIUS_SCALE.read() + + @property + def _icon_radius_scale(self) -> float: + """Local scale of pie child selected by user.""" + return self._pie_config.ICON_RADIUS_SCALE.read() + + @property + def pie_radius(self) -> int: + """Radius in pixels at which icon centers are located.""" + return round( 165 * self._base_size - * self.pie_radius_scale + * self._pie_radius_scale * Config.PIE_GLOBAL_SCALE.read()) - self.icon_radius = self._pick_icon_radius() - self.widget_radius = self.pie_radius + self.icon_radius - self.deadzone_radius = self._pick_deadzone_radius() - - self.border_thickness = round(self.pie_radius*0.02) - self.area_thickness = round(self.pie_radius/self.pie_radius_scale*0.4) - - self.inner_edge_radius = self.pie_radius - self.area_thickness - self.no_border_radius = self.pie_radius - self.border_thickness//2 - - self.icon_color = copy(self.background_color) - self.icon_color.setAlpha(255) - - self.border_color = QColor( - min(self.icon_color.red()+15, 255), - min(self.icon_color.green()+15, 255), - min(self.icon_color.blue()+15, 255), - 255) - - self.font_multiplier = self.SYSTEM_FONT_SIZE[platform.system()] + @property + def _base_icon_radius(self) -> int: + """Radius of icons in pixels. Not affected by items amount.""" + return round( + 50 * self._base_size + * self._icon_radius_scale + * Config.PIE_ICON_GLOBAL_SCALE.read()) - def _pick_icon_radius(self) -> int: - """Icons radius depend on settings, but they have to fit in the pie.""" - icon_radius: int = round( + @property + def unscaled_icon_radius(self) -> int: + """Radius of icons in pixels. Ignores local scale and items amount.""" + return round( 50 * self._base_size - * self.icon_radius_scale * Config.PIE_ICON_GLOBAL_SCALE.read()) - - if not self._icons_amount: - return icon_radius - max_icon_size = round(self.pie_radius * math.pi / self._icons_amount) - return min(icon_radius, max_icon_size) + @property + def _max_icon_radius(self) -> int: + """Max icon radius in pixels according to items amount.""" + if not self._items: + return 1 + return round(self.pie_radius * math.pi / len(self._items)) - def _pick_deadzone_radius(self) -> float: + @property + def icon_radius(self) -> int: + """Icons radius depend on settings, but they have to fit in the pie.""" + return min(self._base_icon_radius, self._max_icon_radius) + + @property + def deadzone_radius(self) -> float: """Deadzone can be configured, but when pie is empty, becomes inf.""" - if not self._icons_amount: + if not self._items: return float("inf") return ( 40 * self._base_size - * Config.PIE_DEADZONE_GLOBAL_SCALE.read() - ) - - def _pick_background_color(self, color: Optional[QColor]) -> QColor: - """Default background color depends on the app theme lightness.""" - if color is not None: - return color + * Config.PIE_DEADZONE_GLOBAL_SCALE.read()) + + @property + def widget_radius(self) -> int: + """Radius of the entire widget, including base and the icons.""" + return self.pie_radius + self._base_icon_radius + + @property + def border_thickness(self): + """Thickness of border around icons.""" + return round(self.unscaled_icon_radius*0.06) + + @property + def area_thickness(self): + """Thickness of the base area of pie menu.""" + return round(self.pie_radius*0.4) + + @property + def inner_edge_radius(self): + """Radius at which the base area starts.""" + return self.pie_radius - self.area_thickness + + @property + def no_border_radius(self): + """Radius at which pie decoration border starts.""" + return self.pie_radius - self.border_thickness//2 + + @property + def setting_button_radius(self) -> int: + """Radius of the button which activates edit mode.""" + return round(30 * self._base_size) + + @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 + + @property + def active_color(self): + """Color of active element.""" + return self._pie_config.active_color + + @property + def background_color(self) -> QColor: + """Color of base area. Depends on the app theme lightness""" + if self._pie_config.background_color is not None: + return self._pie_config.background_color if Krita.is_light_theme_active: return QColor(210, 210, 210, 190) return QColor(75, 75, 75, 190) + @property + def active_color_dark(self): + """Color variation of active element.""" + return QColor( + round(self.active_color.red()*0.8), + round(self.active_color.green()*0.8), + round(self.active_color.blue()*0.8)) + + @property + def icon_color(self): + """Color of icon background.""" + color = copy(self.background_color) + color.setAlpha(255) + return color + + @property + def border_color(self): + """Color of icon borders.""" + return QColor( + min(self.icon_color.red()+15, 255), + min(self.icon_color.green()+15, 255), + min(self.icon_color.blue()+15, 255), + 255) + + @property + def font_multiplier(self): + """Multiplier to apply to the font depending on the used OS.""" + return self.SYSTEM_FONT_SIZE[platform.system()] + SYSTEM_FONT_SIZE = { "Linux": 0.175, "Windows": 0.11, diff --git a/shortcut_composer/templates/pie_menu_utils/pie_widget.py b/shortcut_composer/templates/pie_menu_utils/pie_widget.py index 41ad3db2..458e1a69 100644 --- a/shortcut_composer/templates/pie_menu_utils/pie_widget.py +++ b/shortcut_composer/templates/pie_menu_utils/pie_widget.py @@ -1,26 +1,31 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later -from typing import List, Optional +from typing import TypeVar, Optional, Generic, List from PyQt5.QtCore import Qt -from PyQt5.QtGui import QPaintEvent, QDragMoveEvent, QDragEnterEvent +from PyQt5.QtGui import ( + QDragEnterEvent, + QDragLeaveEvent, + QDragMoveEvent, + QPaintEvent) + from api_krita.pyqt import Painter, AnimatedWidget, BaseWidget from composer_utils import Config from .pie_style import PieStyle from .label import Label from .label_widget import LabelWidget +from .pie_config import PieConfig from .widget_utils import ( WidgetHolder, CirclePoints, - AcceptButton, - PiePainter, - EditMode, -) -from .label_widget_utils import create_label_widget + LabelHolder, + PiePainter) + +T = TypeVar('T') -class PieWidget(AnimatedWidget, BaseWidget): +class PieWidget(AnimatedWidget, BaseWidget, Generic[T]): """ PyQt5 widget with icons on ring that can be selected by hovering. @@ -29,113 +34,134 @@ class PieWidget(AnimatedWidget, BaseWidget): - hide() - hides the widget - repaint() - updates widget display after its data was changed - Contains children widgets that are draggable. When one of the - children is dragged, the widget enters the edit mode. That can be - used by whoever controls this widget to handle it differently. + It uses LabelHolder to store children widgets representing the + values user can pick. When the pie enters the edit mode, its + children become draggable. - Stores the values in three forms: - - Labels: contain bare data - - LabelWidgets: widget children displaying a single Label - - LabelHolder: container of all LabelWidgets that operate on angles - - Makes changes to LabelHolder when one of children is dragged. - When the widget is hidden while in the edit mode, changes made to - the LabelHolder are saved in the related configuration. + By dragging children, user can change their order or remove them + by moving them out of the widget. New children can be added by + dragging them from other widgets. """ - edit_mode = EditMode() - def __init__( self, style: PieStyle, - labels: List[Label], - config_to_write_back: Optional[Config] = None, + labels: List[Label[T]], + config: PieConfig, parent=None ): AnimatedWidget.__init__(self, parent, Config.PIE_ANIMATION_TIME.read()) self.setGeometry(0, 0, style.widget_radius*2, style.widget_radius*2) - self._style = style - self.config_to_write_back = config_to_write_back - self._circle_points = CirclePoints( - center=self.center, - radius=self._style.pie_radius) - - self.labels = labels - self.children_widgets = self._create_children_holder() - self.widget_holder = self._put_children_in_holder() - - self.accept_button = AcceptButton(self._style, self) - self.accept_button.move_center(self.center) - self.accept_button.clicked.connect(self.hide) - self.setAcceptDrops(True) self.setWindowFlags(( self.windowFlags() | # type: ignore - Qt.Popup | + Qt.Tool | Qt.FramelessWindowHint | Qt.NoDropShadowWindowHint)) self.setAttribute(Qt.WA_TranslucentBackground) self.setStyleSheet("background: transparent;") self.setCursor(Qt.CrossCursor) + self._style = style + self._labels = labels + self.config = config + self.config.register_callback(self._reset) + + self.active: Optional[Label] = None + self.is_edit_mode = False + self._last_widget = None + + self.label_holder = LabelHolder( + labels=self._labels, + style=self._style, + config=self.config, + owner=self) + self._circle_points: CirclePoints + + self.set_draggable(False) + self._reset() + + 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: """Return the deadzone distance.""" return self._style.deadzone_radius - def hide(self): - """Leave the edit mode when the widget is hidden.""" - self.edit_mode = False - super().hide() - def paintEvent(self, event: QPaintEvent) -> None: """Paint the entire widget using the Painter wrapper.""" with Painter(self, event) as painter: - PiePainter(painter, self.labels, self._style, self.edit_mode) + PiePainter(painter, self._labels, self._style, self.is_edit_mode) def dragEnterEvent(self, e: QDragEnterEvent) -> None: - """Start edit mode when one of the draggable children gets dragged.""" - self.edit_mode = True - self.repaint() - e.accept() + """Allow dragging the widgets while in edit mode.""" + if self.is_edit_mode: + return e.accept() + e.ignore() def dragMoveEvent(self, e: QDragMoveEvent) -> None: - """Swap children during drag when mouse is moved to another zone.""" - pos = e.pos() + """Handle all children actions - order change, add and remove.""" + e.accept() source_widget = e.source() + pos = e.pos() + distance = self._circle_points.distance(pos) - if (self._circle_points.distance(pos) < self._style.deadzone_radius - or not isinstance(source_widget, LabelWidget)): - return e.accept() + if not isinstance(source_widget, LabelWidget): + # Drag incoming from outside the PieWidget ecosystem + return + + if self.type and not isinstance(source_widget.label.value, self.type): + # Label type does not match the type of pie menu + return + + self._last_widget = source_widget + if distance > self._style.widget_radius: + # Dragged out of the PieWidget + return self.label_holder.remove(source_widget.label) + if distance < self._style.deadzone_radius: + # Do nothing in deadzone + return - # NOTE: This computation is too heavy to be done on each call angle = self._circle_points.angle_from_point(pos) - widget = self.widget_holder.on_angle(angle) - if widget == source_widget: - return e.accept() + _a = self._widget_holder.on_angle(angle) + + if source_widget.label not in self.label_holder or not self._labels: + # Dragged with unknown label + 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: + self.label_holder.swap(_a.label, _b.label) + self.repaint() + + def dragLeaveEvent(self, e: QDragLeaveEvent) -> None: + """Remove the label when its widget is dragged out.""" + if self._last_widget is not None: + self.label_holder.remove(self._last_widget.label) + return super().dragLeaveEvent(e) + + def set_draggable(self, draggable: bool): + """Change draggable state of all children.""" + for widget in self.label_holder.widget_holder: + widget.draggable = draggable - self.widget_holder.swap(widget, source_widget) - self.repaint() - e.accept() + @property + def _widget_holder(self) -> WidgetHolder: + """Return the holder with child widgets.""" + return self.label_holder.widget_holder - def _create_children_holder(self) -> List[LabelWidget]: - """Create LabelWidgets that represent the labels.""" - children: List[LabelWidget] = [] - for label in self.labels: - children.append(create_label_widget(label, self._style, self)) - return children - - def _put_children_in_holder(self) -> WidgetHolder: - """Create WidgetHolder which manages child widgets angles.""" - children = self.children_widgets - angle_iterator = self._circle_points.iterate_over_circle(len(children)) - label_holder = WidgetHolder() - - for child, (angle, point) in zip(children, angle_iterator): - child.label.angle = angle - child.label.center = point - child.move_to_label() - label_holder.add(child) - - return label_holder + @property + def type(self) -> Optional[type]: + """Return type of values stored in labels. None if no labels.""" + if not self._labels: + return None + return type(self._labels[0].value) diff --git a/shortcut_composer/templates/pie_menu_utils/settings_gui/__init__.py b/shortcut_composer/templates/pie_menu_utils/settings_gui/__init__.py new file mode 100644 index 00000000..d6677014 --- /dev/null +++ b/shortcut_composer/templates/pie_menu_utils/settings_gui/__init__.py @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from .pie_settings import PieSettings +from .enum_pie_settings import EnumPieSettings +from .preset_pie_settings import PresetPieSettings +from .numeric_pie_settings import NumericPieSettings + +__all__ = [ + "PieSettings", + "EnumPieSettings", + "PresetPieSettings", + "NumericPieSettings" +] 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 new file mode 100644 index 00000000..99b317db --- /dev/null +++ b/shortcut_composer/templates/pie_menu_utils/settings_gui/enum_pie_settings.py @@ -0,0 +1,76 @@ +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from typing import List, Optional + +from PyQt5.QtWidgets import QVBoxLayout, QTabWidget, QWidget + +from config_system.ui import ConfigFormWidget, ConfigSpinBox +from ..label import Label +from ..pie_style import PieStyle +from ..pie_config import NonPresetPieConfig +from .pie_settings import PieSettings +from .scroll_area import ScrollArea + + +class EnumPieSettings(PieSettings): + """ + Pie setting window for pie values being enums. + + Consists of two tabs: + - usual form with field values to set + - scrollable area with all available enum values to drag into pie + """ + + def __init__( + self, + values: List[Label], + used_values: List[Label], + config: NonPresetPieConfig, + style: PieStyle, + parent: Optional[QWidget] = None, + ) -> None: + super().__init__(config, style, parent) + + self._used_values = used_values + + tab_holder = QTabWidget() + + self._action_values = ScrollArea(values, self._style, 3) + self._action_values.setMinimumHeight( + round(style.unscaled_icon_radius*6.2)) + + 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") + + layout = QVBoxLayout(self) + layout.addWidget(tab_holder) + self.setLayout(layout) + + self._config.ORDER.register_callback(self.refresh) + self.refresh() + + def refresh(self): + """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() 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 new file mode 100644 index 00000000..d70b90bb --- /dev/null +++ b/shortcut_composer/templates/pie_menu_utils/settings_gui/numeric_pie_settings.py @@ -0,0 +1,44 @@ +# 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() 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 new file mode 100644 index 00000000..79eb1a13 --- /dev/null +++ b/shortcut_composer/templates/pie_menu_utils/settings_gui/pie_settings.py @@ -0,0 +1,52 @@ +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from typing import Optional + +from PyQt5.QtCore import QPoint, Qt +from PyQt5.QtGui import QCursor +from PyQt5.QtWidgets import QWidget + +from api_krita.pyqt import AnimatedWidget, BaseWidget +from composer_utils import Config +from ..pie_style import PieStyle +from ..pie_config import PieConfig + + +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. + """ + + 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)) + self.setAcceptDrops(True) + self.setWindowFlags(( + self.windowFlags() | # type: ignore + Qt.Tool | + Qt.FramelessWindowHint)) + self.setCursor(Qt.ArrowCursor) + + self._style = style + self._config = config + self._config.register_callback(self._reset) + self._reset() + + 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 _reset(self): + """React to change in pie size.""" + self.setMinimumHeight(self._style.widget_radius*2) 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 new file mode 100644 index 00000000..4b8a6bc9 --- /dev/null +++ b/shortcut_composer/templates/pie_menu_utils/settings_gui/preset_pie_settings.py @@ -0,0 +1,59 @@ +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from typing import List, Optional + +from PyQt5.QtWidgets import QVBoxLayout +from PyQt5.QtWidgets import QWidget + +from api_krita.wrappers import Database +from config_system.ui import ( + ConfigFormWidget, + ConfigComboBox, + ConfigSpinBox) +from ..pie_style import PieStyle +from ..pie_config import PresetPieConfig +from .pie_settings import PieSettings + + +class PresetPieSettings(PieSettings): + """Pie setting window for pie values being brush presets.""" + + 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)) 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 new file mode 100644 index 00000000..5807dfa1 --- /dev/null +++ b/shortcut_composer/templates/pie_menu_utils/settings_gui/scroll_area.py @@ -0,0 +1,165 @@ +# 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, + QScrollArea, + QLabel, + QGridLayout, + QVBoxLayout) + +from ..label import Label +from ..label_widget import LabelWidget +from ..label_widget_utils import create_label_widget +from ..pie_style import PieStyle + + +class ChildInstruction: + """Logic of displaying widget text in passed QLabel.""" + + def __init__(self, display_label: QLabel) -> None: + self._display_label = display_label + + def on_enter(self, label: Label) -> None: + """Set text of label which was entered with mouse.""" + self._display_label.setText(str(label.pretty_name)) + + def on_leave(self, label: Label) -> None: + """Reset text after mouse leaves the widget.""" + self._display_label.setText("") + + +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). + + ScrollArea comes with embedded QLabel showing the name of the + children widget over which mouse was hovered. + """ + + def __init__( + self, + labels: List[Label], + style: PieStyle, + columns: int, + parent=None + ) -> None: + super().__init__(parent) + self._style = style + self._labels = labels + + 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) + + layout = QVBoxLayout() + layout.addWidget(area) + self._active_label_display = QLabel(self) + layout.addWidget(self._active_label_display) + self.setLayout(layout) + + self.children_list = self._create_children() + + def _create_children(self) -> List[LabelWidget]: + """Create LabelWidgets that represent the labels.""" + children: List[LabelWidget] = [] + + 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) + + self._scroll_area_layout.extend(children) + return children + + +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): + 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) diff --git a/shortcut_composer/templates/pie_menu_utils/widget_utils/__init__.py b/shortcut_composer/templates/pie_menu_utils/widget_utils/__init__.py index fd578206..bfd3d700 100644 --- a/shortcut_composer/templates/pie_menu_utils/widget_utils/__init__.py +++ b/shortcut_composer/templates/pie_menu_utils/widget_utils/__init__.py @@ -1,18 +1,20 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later """Additional classes used by pie menu components.""" from .circle_points import CirclePoints from .widget_holder import WidgetHolder -from .accept_button import AcceptButton +from .label_holder import LabelHolder from .pie_painter import PiePainter +from .pie_button import PieButton from .edit_mode import EditMode __all__ = [ "CirclePoints", "WidgetHolder", - "AcceptButton", + "LabelHolder", "PiePainter", + "PieButton", "EditMode", ] diff --git a/shortcut_composer/templates/pie_menu_utils/widget_utils/accept_button.py b/shortcut_composer/templates/pie_menu_utils/widget_utils/accept_button.py deleted file mode 100644 index f4cffbc3..00000000 --- a/shortcut_composer/templates/pie_menu_utils/widget_utils/accept_button.py +++ /dev/null @@ -1,45 +0,0 @@ -from PyQt5.QtWidgets import QWidget, QPushButton -from PyQt5.QtGui import QColor -from PyQt5.QtCore import Qt - -from ..pie_style import PieStyle -from api_krita.pyqt import BaseWidget -from api_krita import Krita - - -class AcceptButton(QPushButton, BaseWidget): - """Round button with a tick icon which uses provided PieStyle.""" - - def __init__(self, style: PieStyle, parent: QWidget) -> None: - QPushButton.__init__(self, Krita.get_icon("dialog-ok"), "", parent) - - self._style = style - self.hide() - if self._style.deadzone_radius != float("inf"): - self.radius = int(self._style.deadzone_radius) - else: - self.radius = 1 - self.setCursor(Qt.ArrowCursor) - self.setGeometry( - 0, 0, - self.radius*2, - self.radius*2) - - self.setStyleSheet(f""" - QPushButton [ - border: {self._style.border_thickness}px - {self._color_to_str(self._style.border_color)}; - border-radius: {self.radius}px; - border-style: outset; - background: {self._color_to_str(self._style.background_color)}; - qproperty-iconSize:{round(self.radius*1.5)}px; - ] - QPushButton:hover [ - background: {self._color_to_str(self._style.active_color)}; - ] - """.replace('[', '{').replace(']', '}') - ) - - @staticmethod - def _color_to_str(color: QColor) -> str: return f'''rgba( - {color.red()}, {color.green()}, {color.blue()}, {color.alpha()})''' 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 7c8739fe..3272870d 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 @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later import math 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 d0f0c73a..56c05195 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 @@ -1,44 +1,71 @@ -from enum import Enum -from composer_utils import Config +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + from typing import TYPE_CHECKING + if TYPE_CHECKING: - from ..pie_widget import PieWidget + from ...pie_menu import PieMenu class EditMode: """ - Descriptor that handles the edit mode of PieWidget. + Handles the edit mode of the PieMenu action. - When red from, it returns a bool telling whether the Pie is in edit mode. - When mode is changed, changes the Pie's components to reflect that. - Whe edit mode is turned off, saves the current values to settings. + Changing its state to between False and True performs actions on + PieMenu widgets and components. """ - def __init__(self) -> None: + def __init__(self, obj: 'PieMenu') -> None: self._edit_mode = False + self._obj = obj - def __get__(self, *_) -> bool: + def get(self) -> bool: """Return whether the Pie is in edit mode""" return self._edit_mode - def __set__(self, obj: 'PieWidget', mode_to_set: bool) -> None: + 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(obj) + self._write_settings() + + if not self._edit_mode ^ mode_to_set: + return - self._edit_mode = mode_to_set if mode_to_set: - obj.accept_button.show() + self.set_edit_mode_true() else: - obj.accept_button.hide() + self.set_edit_mode_false() + self._edit_mode = mode_to_set - def _write_settings(self, obj: 'PieWidget') -> None: + def set_edit_mode_true(self): + """Set the edit mode on.""" + self._obj.pie_manager.stop(hide=False) + self._obj.pie_widget.set_draggable(True) + self._obj.pie_widget.is_edit_mode = True + self._obj.pie_widget.repaint() + self._obj.pie_settings.show() + self._obj.accept_button.show() + self._obj.settings_button.hide() + + def set_edit_mode_false(self): + """Set the edit mode off.""" + self._obj.pie_widget.hide() + self._obj.pie_widget.set_draggable(False) + self._obj.pie_widget.is_edit_mode = False + self._obj.pie_settings.hide() + self._obj.accept_button.hide() + self._obj.settings_button.show() + + 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.""" - if not obj.labels or obj.config_to_write_back is None: + widget = self._obj.pie_widget + + if not widget.label_holder or widget.config is None: return - values = [widget.label.value for widget in obj.widget_holder] - if isinstance(values[0], Enum): - obj.config_to_write_back.write(Config.format_enums(values)) - else: - obj.config_to_write_back.write('\t'.join(values)) + 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 new file mode 100644 index 00000000..293238c3 --- /dev/null +++ b/shortcut_composer/templates/pie_menu_utils/widget_utils/label_holder.py @@ -0,0 +1,113 @@ +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from typing import List +from functools import partial + +from api_krita.pyqt import BaseWidget +from ..pie_style import PieStyle +from ..label import Label +from ..label_widget import LabelWidget +from ..label_widget_utils import create_label_widget +from ..pie_config import PieConfig +from .widget_holder import WidgetHolder +from .circle_points import CirclePoints + + +class LabelHolder: + """ + Represents the pie icons as a positional label container. + + Creates and controls the publically available WidgetHolder with + actual pie widgets. Is responsible for making sure that WidgetHolder + state always reflect the internal state of this container. + """ + + def __init__( + self, + labels: List[Label], + style: PieStyle, + config: PieConfig, + owner: BaseWidget, + ) -> None: + self._labels = labels + self._style = style + self._config = config + # Refresh itself when config changed, but do not notify change + # in config as holder was not their cause + self._config.register_callback(partial(self.reset, notify=False)) + self._owner = owner + + 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() + + def insert(self, index: int, label: Label): + """Insert the new label to the holder at given index.""" + 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): + self._labels.remove(label) + self.reset() + + def index(self, label: Label): + """Return the index at which the label is stored.""" + return self._labels.index(label) + + def swap(self, _a: Label, _b: Label): + """Swap positions of two labels from the holder.""" + _a.swap_locations(_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() + + def __iter__(self): + """Iterate over all labels in the holder.""" + return iter(self._labels) + + def reset(self, notify: bool = True) -> None: + """ + Ensure the icon widgets properly represet 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. + """ + for child in self.widget_holder: + child.setParent(None) # type: ignore + self.widget_holder.clear() + + children_widgets: List[LabelWidget] = [] + for label in self._labels: + children_widgets.append( + create_label_widget(label, self._style, self._owner)) + + circle_points = CirclePoints( + center=self._owner.center, + radius=self._style.pie_radius) + angles = circle_points.iterate_over_circle(len(self._labels)) + for child, (angle, point) in zip(children_widgets, angles): + child.setParent(self._owner) + child.show() + child.label.angle = angle + child.label.center = point + child.move_to_label() + child.draggable = True + self.widget_holder.add(child) + + if notify: + values = [label.value for label in self._labels] + self._config.ORDER.write(values) diff --git a/shortcut_composer/templates/pie_menu_utils/widget_utils/pie_button.py b/shortcut_composer/templates/pie_menu_utils/widget_utils/pie_button.py new file mode 100644 index 00000000..e41af8a4 --- /dev/null +++ b/shortcut_composer/templates/pie_menu_utils/widget_utils/pie_button.py @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from typing import Optional, Callable + +from PyQt5.QtWidgets import QWidget +from PyQt5.QtGui import QIcon + +from api_krita.pyqt import RoundButton +from ..pie_style import PieStyle +from ..pie_config import PieConfig + + +class PieButton(RoundButton): + """ + Round button with custom icon, which uses provided PieStyle. + + `radius_callback` defines how the button radius is determined. Each + change in passed `config` results in resetting the button size. + """ + + def __init__( + self, + icon: QIcon, + icon_scale: float, + radius_callback: Callable[[], int], + style: PieStyle, + config: PieConfig, + parent: Optional[QWidget] = None, + ) -> None: + super().__init__( + icon=icon, + icon_scale=icon_scale, + initial_radius=radius_callback(), + background_color=style.background_color, + active_color=style.active_color, + parent=parent) + + config.register_callback(lambda: self.resize(radius_callback())) diff --git a/shortcut_composer/templates/pie_menu_utils/widget_utils/pie_painter.py b/shortcut_composer/templates/pie_menu_utils/widget_utils/pie_painter.py index 44cc70da..fe7be481 100644 --- a/shortcut_composer/templates/pie_menu_utils/widget_utils/pie_painter.py +++ b/shortcut_composer/templates/pie_menu_utils/widget_utils/pie_painter.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from typing import List @@ -42,14 +42,13 @@ def _paint_deadzone_indicator(self) -> None: center=self._center, outer_radius=self.style.deadzone_radius, color=QColor(128, 255, 128, 120), - thickness=1, - ) + thickness=1) + self.painter.paint_wheel( center=self._center, outer_radius=self.style.deadzone_radius-1, color=QColor(255, 128, 128, 120), - thickness=1, - ) + thickness=1) def _paint_base_wheel(self) -> None: """Paint a base circle.""" @@ -57,15 +56,14 @@ def _paint_base_wheel(self) -> None: # of the widget, so a low opacity circle allows to trick it. self.painter.paint_wheel( center=self._center, - outer_radius=self.style.no_border_radius, - color=QColor(128, 128, 128, 1), - ) + outer_radius=self.style.widget_radius, + color=QColor(128, 128, 128, 1)) + self.painter.paint_wheel( center=self._center, outer_radius=self.style.no_border_radius, color=self.style.background_color, - thickness=self.style.area_thickness, - ) + thickness=self.style.area_thickness) def _paint_base_border(self) -> None: """Paint a border on the inner edge of base circle.""" @@ -73,8 +71,7 @@ def _paint_base_border(self) -> None: center=self._center, outer_radius=self.style.inner_edge_radius, color=self.style.border_color, - thickness=self.style.border_thickness, - ) + thickness=self.style.border_thickness) def _paint_active_pie(self) -> None: """Paint a pie behind a label which is active or during animation.""" @@ -92,25 +89,13 @@ def _paint_active_pie(self) -> None: angle=label.angle, span=360//len(self.labels), color=self._pick_pie_color(label), - thickness=self.style.area_thickness + thickness_addition, - ) + thickness=self.style.area_thickness + thickness_addition) def _pick_pie_color(self, label: Label) -> QColor: - """ - Pick color of pie based on widget mode and animation progress. - - In edit mode color is different to create visual distinction. - Two similar colors are merged with animated opacity to - distinguish two consequtive pies from each other. - """ - if not self.edit_mode: - return self._overlay_colors( - self.style.active_color_dark, - self.style.active_color, - opacity=label.activation_progress.value) + """Pick color of pie based on widget mode and animation progress.""" return self._overlay_colors( - self.style.icon_color, - self.style.border_color, + self.style.active_color_dark, + self.style.active_color, opacity=label.activation_progress.value) @staticmethod 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 b1061f75..11a5601f 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 @@ -1,56 +1,52 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later -from typing import Dict, Iterator, Optional +from typing import Dict, Iterator from ..label_widget import LabelWidget +from ..label import Label class WidgetHolder: - """ - Holds LabelWidgets and in relation to their angles on PieWidget. - - Holds which of them is active (One of them None), but does not - handle this attribute by itself - this is done by PieManager. - """ + """Holds LabelWidgets in relation to their angles on PieWidget.""" def __init__(self): self._widgets: Dict[int, LabelWidget] = {} - self.active: Optional[LabelWidget] = None def add(self, widget: LabelWidget) -> None: """Add a new LabelWidget to the holder.""" self._widgets[widget.label.angle] = widget - def angles(self) -> Iterator[int]: - """Iterate over all angles at which LabelWidgets are.""" - return iter(self._widgets.keys()) - def on_angle(self, angle: float) -> LabelWidget: """Return LabelWidget which is the closest to given `angle`.""" - def angle_difference(label_angle: float) -> float: """Return the smallest difference between two angles.""" - nonlocal angle raw_difference = label_angle - angle return abs((raw_difference + 180) % 360 - 180) closest = min(self.angles(), key=angle_difference) return self._widgets[closest] + def on_label(self, label: Label): + """Return widget wrapping the label of the same value as given.""" + for widget in self._widgets.values(): + if widget.label == label: + return widget + raise ValueError(f"{label} not in holder.") + def angle(self, widget: LabelWidget) -> int: - """Return at which angle angle is the given LabelWidget.""" + """Return at which angle is the given LabelWidget.""" for angle, held_widget in self._widgets.items(): if widget == held_widget: return angle raise ValueError(f"{widget} not in holder.") - def swap(self, _a: LabelWidget, _b: LabelWidget) -> None: - """Swap two LabelWidgets that are already in the holder.""" - _a.label.swap_locations(_b.label) - key_a, key_b = self.angle(_a), self.angle(_b) - self._widgets[key_a], self._widgets[key_b] = _b, _a - _a.move_to_label() - _b.move_to_label() + def clear(self): + """Remove all widgets from the holder.""" + self._widgets = {} + + def angles(self) -> Iterator[int]: + """Iterate over all angles at which LabelWidgets are.""" + return iter(self._widgets.keys()) def __iter__(self) -> Iterator[LabelWidget]: """Iterate over all held LabelWidgets.""" diff --git a/shortcut_composer/templates/raw_instructions.py b/shortcut_composer/templates/raw_instructions.py index 067affdf..5444ed25 100644 --- a/shortcut_composer/templates/raw_instructions.py +++ b/shortcut_composer/templates/raw_instructions.py @@ -1,15 +1,18 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later -from input_adapter import ComplexAction +from typing import List, Optional +from composer_utils import Config +from core_components import InstructionHolder, Instruction +from input_adapter import ComplexActionInterface -class RawInstructions(ComplexAction): + +class RawInstructions(ComplexActionInterface): """ - Temporarily toggle plugin instructions. + ShortcutComposer action base. - Action starts all the instructions on key press, and ends them on - release. + Handles passed instructions, fulfilling the ComplexActionInterface. ### Arguments: @@ -35,3 +38,36 @@ class RawInstructions(ComplexAction): ) ``` """ + + def __init__( + self, + name: str, + instructions: List[Instruction] = [], + short_vs_long_press_time: Optional[float] = None + ) -> None: + self.name = name + self.short_vs_long_press_time = _read_time(short_vs_long_press_time) + self._instructions = InstructionHolder(instructions) + + def on_key_press(self) -> None: + """Run instructions meant for key press event.""" + self._instructions.on_key_press() + + def on_short_key_release(self) -> None: + """Run instructions meant for key release event.""" + self._instructions.on_short_key_release() + + def on_long_key_release(self) -> None: + """Run instructions meant for key release event after long time.""" + self._instructions.on_long_key_release() + + def on_every_key_release(self) -> None: + """Run instructions meant for key release event after short time.""" + self._instructions.on_every_key_release() + + +def _read_time(short_vs_long_press_time: Optional[float]) -> float: + """Return the given time, or time red from krita config if not given.""" + if short_vs_long_press_time is None: + return Config.SHORT_VS_LONG_PRESS_TIME.read() + return short_vs_long_press_time diff --git a/shortcut_composer/templates/temporary_key.py b/shortcut_composer/templates/temporary_key.py index ace46f6c..f5e9d641 100644 --- a/shortcut_composer/templates/temporary_key.py +++ b/shortcut_composer/templates/temporary_key.py @@ -1,15 +1,15 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from typing import List, TypeVar, Generic, Optional from core_components import Controller, Instruction -from input_adapter import ComplexAction +from .raw_instructions import RawInstructions T = TypeVar('T') -class TemporaryKey(ComplexAction, Generic[T]): +class TemporaryKey(RawInstructions, Generic[T]): """ Temporarily activate (long press) a value or toggle it (short press). @@ -59,17 +59,13 @@ class TemporaryKey(ComplexAction, Generic[T]): def __init__( self, *, name: str, - controller: Controller, + controller: Controller[T], high_value: T, low_value: Optional[T] = None, instructions: List[Instruction] = [], short_vs_long_press_time: Optional[float] = None ) -> None: - super().__init__( - name=name, - short_vs_long_press_time=short_vs_long_press_time, - instructions=instructions) - + super().__init__(name, instructions, short_vs_long_press_time) self._controller = controller self._high_value = high_value self._low_value = self._read_default_value(low_value) @@ -106,6 +102,9 @@ def on_long_key_release(self) -> None: super().on_long_key_release() self._set_low() - def _read_default_value(self, value: Optional[T]): + def _read_default_value(self, value: Optional[T]) -> T: """Read value from controller if it was not given.""" - return value if value else self._controller.default_value + 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