# 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:
-
- - on github project page, click the green button
- code and pick the download zip option. Do not extract it.
- - in krita's topbar, open Tools > Scripts > Import Python Plugin From File and pick the
- downloaded .zip file
- - restart krita.
- - set custom shortcuts in Settings > Configure Krita > Keyboard Shortcuts under
- Scripts > Shortcut Composer: Complex Actions section. By intention, there are no default
- bindings.
-
- Pre-made actions
- While Shortcut-Composer is highly configurable and extendable, the add-on comes with pre-made, plug-and-play
- actions.
-
-
- Pie 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.
-
- -
-
-
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
-
- -
-
-
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
-
-
- -
-
-
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
-
-
- -
-
-
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: -
-
-
- -
-
-
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)
-
-
- -
-
-
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.
-
- -
-
-
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
):
-
- -
-
-
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:
+
+ - on github project page, click the green button
+ code and pick the download zip option. Do not extract it.
+
+ - in krita's topbar, open Tools > Scripts > Import Python Plugin From File and pick
+ the
+ downloaded .zip file
+ - restart krita.
+ - set custom shortcuts in Settings > Configure Krita > Keyboard Shortcuts under
+ Scripts > Shortcut Composer: Complex Actions section. By intention, there are no default
+ bindings.
+
+
+ Pre-made actions
+ While Shortcut-Composer is highly configurable and extendable, the add-on comes with pre-made, plug-and-play
+ actions.
+
+
+ Pie 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"
+
+
+ -
+
+
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
+
+ -
+
+
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
+
+
+ -
+
+
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
+
+
+ -
+
+
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: -
+
+
+ -
+
+
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)
+
+
+ -
+
+
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.
+
+ -
+
+
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
):
+
+ -
+
+
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:
-
- 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:
+
+ 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
+
+
+group = FieldGroup("MyGroup")
+
+group.register_callback(lambda: print("any field changed"))
+
+
+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])
+
+
+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"))
+
+
+str_field.write("Digital")
+
+enums_field_1.write([EnumMock.MODE_A, EnumMock.MODE_B])
+
+enums_field_2.write([EnumMock.MODE_A])
+
+
+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
@@ -359,41 +544,8 @@ Alternative API
Toggle.SOFT_PROOFING.state = False
Toggle.PRESERVE_ALPHA.switch_state()
- 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