diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..308f30ed --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Change Log + +## [1.1.0] - 2023-01-20 +### Added +- Edit mode for PieMenus - click or drag the value icon to enter it. While in edit mode, the values can be dragged across the PieMenu to change their order. +- Action values tab in Configure Shortcut Composer for adding and removing values in PieMenus and MultipleAssignment actions. +- PieValues backtrounds are now animated. +- New PieMenu to create a layer with chosen blending mode. + +### Fixed +- Allow scrolling through masks while using layey mouse trackers +- Make sure that presets in PieMenu are displayed only once. +- Allow using "Enclose and fill" tool +- Fix icon of "Colorize Mask" and "Color Sampler" tools +- Fix scaling issues of Text labels in PieMenu + +## [1.0.1] - 2023-01-09 +Fixes following issues: +- Fix displaying inactive presets and tags +- Detect and support light krita theme, switch between them when changed +- PieWidget as popup - pies no longer recognized by OS as windows +- Allow different parameters for each configuration value +- Use default font to fix MacOS helvetica issue +- Allow using actions when active layer is locked +- Work around krita bug: zoom being dependent on document dpi diff --git a/README.md b/README.md index 23191199..6ef0e7da 100644 --- a/README.md +++ b/README.md @@ -12,20 +12,25 @@ The plugin adds new shortcuts of the following types: [![PIE MENUS - introducing Shortcut Composer](http://img.youtube.com/vi/hrjBycVYFZM/0.jpg)](https://www.youtube.com/watch?v=hrjBycVYFZM "PIE MENUS - introducing Shortcut Composer") -## What's new in v1.0.1 -Fixes following issues: -- Fix displaying inactive presets and tags -- Detect and support light krita theme, switch between them when changed -- PieWidget as popup - pies no longer recognized by OS as windows -- Allow different parameters for each configuration value -- Use default font to fix MacOS helvetica issue -- Allow using actions when active layer is locked -- Work around krita bug: zoom being dependent on document dpi +## What's new in v1.1.0 +### Added +- Edit mode for PieMenus - click or drag the value icon to enter it. While in edit mode, the values can be dragged across the PieMenu to change their order. +- Action values tab in Configure Shortcut Composer for adding and removing values in PieMenus and MultipleAssignment actions. +- PieValues backtrounds are now animated. +- New PieMenu to create a layer with chosen blending mode. + +### Fixed +- Allow scrolling through masks while using layey mouse trackers +- Make sure that presets in PieMenu are displayed only once. +- Allow using "Enclose and fill" tool +- Fix icon of "Colorize Mask" tool +- Fix icon of "Colorize Mask" and "Color Sampler" tools +- Fix scaling issues of Text labels in PieMenu ## Requirements -Shortcut Composer **v1.0.1** Requires krita **5.1.0** or later. +Shortcut Composer **v1.1.0** Requires krita **5.1.0** or later. -Supported operating systems: +OS support state: - [x] Windows 10/11 - [x] Linux - [ ] MacOS (Known bug of canvas losing focus after using PieMenu) @@ -43,6 +48,8 @@ While Shortcut-Composer is highly configurable and extendable, the add-on comes ### (`Pie menus`): Pie menu is a widget displayed on the canvas while a key is pressed. It will disappear as soon, as the key is released. Moving cursor in a direction of a icon, activates its value on key release. **The action does not recognise mouse clicks, and only requires hovering**. Pie menu does nothing if the cursor is not moved out of the deadzone. +Dragging a value enters `Edit mode` in which the keyboard button no longer needs to be pressed. In this mode values can be dragged accross the widget to change their order. When done, press the tick button to apply the changes. + - ### Pick brush presets (red, green, blue) Three color coded pie menus that let you pick a **brush preset** from related **tag** with brush presets. Used tags can be changed in **Tools > Scripts > Configure Shortcut Composer**. Default tag mapping is as follows: - red: "★ My Favorites" @@ -69,6 +76,18 @@ Pie menu is a widget displayed on the canvas while a key is pressed. It will dis - 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** @@ -159,6 +178,16 @@ Shortcut-Composer comes with a settings dialog available from krita topbar: **To - `Pie deadzone global scale` - Global scale factor for the deadzone area of every pie menu. - `Pie animation time` - Time (in seconds) for fade-in animation when showing the pie menu. +### Changing the values in Pie menus and Multiple assignments +**Configure Shortcut Composer** has a separate tab called `Action values` in which you can add new values to some of the actions, as well as remove those that are not needed. + +Action to modify can be selected using the combobox on the top. + +Values are added by selecting the list value(s) on the left and pressing the green **add** button. Analogically, selecting values on the right, and pressing the **remove** button, removes the values from action. + +Dragging values on the right, allow to change their order. This can also be done using the `Edit mode` directly from the PieMenu (does not apply to MultipleAssignments) + + ### Modifying actions and creating custom ones While the settings dialog allows to tweak the values common for plugin actions, it does not allow to modify the behaviour of the actions or create new ones. diff --git a/shortcut_composer/actions.action b/shortcut_composer/actions.action index f2097746..1112e449 100755 --- a/shortcut_composer/actions.action +++ b/shortcut_composer/actions.action @@ -97,6 +97,10 @@ 1 + + 1 + + Shortcut Composer: Utilities diff --git a/shortcut_composer/actions.py b/shortcut_composer/actions.py index 3cc834f1..0149509b 100644 --- a/shortcut_composer/actions.py +++ b/shortcut_composer/actions.py @@ -14,16 +14,17 @@ from PyQt5.QtGui import QColor -from api_krita.enums import BlendingMode, Tool, Toggle +from api_krita.enums import BlendingMode, Tool, Toggle, TransformMode from core_components import instructions, controllers from composer_utils import Config from input_adapter import ComplexAction from data_components import ( CurrentLayerStack, PickStrategy, + EnumConfigValues, + TagConfigValues, Slider, Range, - Tag, ) infinity = float("inf") @@ -94,11 +95,7 @@ def create_actions() -> List[ComplexAction]: return [ name="Cycle selection tools", controller=controllers.ToolController(), default_value=Tool.FREEHAND_BRUSH, - values=[ - Tool.FREEHAND_SELECTION, - Tool.RECTANGULAR_SELECTION, - Tool.CONTIGUOUS_SELECTION, - ], + values=EnumConfigValues(Config.SELECTION_TOOLS_VALUES, Tool), ), # Control undo and redo actions by sliding the cursor horizontally @@ -186,13 +183,7 @@ def create_actions() -> List[ComplexAction]: return [ templates.PieMenu( name="Pick misc tools", controller=controllers.ToolController(), - values=[ - Tool.CROP, - Tool.REFERENCE, - Tool.GRADIENT, - Tool.MULTI_BRUSH, - Tool.ASSISTANTS, - ], + values=EnumConfigValues(Config.MISC_TOOLS_VALUES, Tool), pie_radius_scale=0.9 ), @@ -202,30 +193,22 @@ def create_actions() -> List[ComplexAction]: return [ name="Pick painting blending modes", controller=controllers.BlendingModeController(), instructions=[instructions.SetBrushOnNonPaintable()], - values=[ - BlendingMode.NORMAL, - BlendingMode.OVERLAY, - BlendingMode.COLOR, - BlendingMode.MULTIPLY, - BlendingMode.ADD, - BlendingMode.SCREEN, - BlendingMode.DARKEN, - BlendingMode.LIGHTEN, - ], + values=EnumConfigValues(Config.BLENDING_MODES_VALUES, BlendingMode), + ), + + # Use pie menu to create painting layer with selected blending mode. + templates.PieMenu( + name="Create painting layer with blending mode", + controller=controllers.CreateLayerWithBlendingController(), + values=EnumConfigValues( + Config.CREATE_BLENDING_LAYER_VALUES, BlendingMode), ), # Pick one of the transform tool modes. templates.PieMenu( name="Pick transform tool modes", - controller=controllers.ToolController(), - values=[ - Tool.TRANSFORM_FREE, - Tool.TRANSFORM_PERSPECTIVE, - Tool.TRANSFORM_WARP, - Tool.TRANSFORM_CAGE, - Tool.TRANSFORM_LIQUIFY, - Tool.TRANSFORM_MESH, - ], + controller=controllers.TransformModeController(), + values=EnumConfigValues(Config.TRANSFORM_MODES_VALUES, TransformMode), ), # Use pie menu to pick one of presets from tag specified in settings. @@ -234,7 +217,7 @@ def create_actions() -> List[ComplexAction]: return [ name="Pick brush presets (red)", controller=controllers.PresetController(), instructions=[instructions.SetBrushOnNonPaintable()], - values=Tag(Config.TAG_RED.read()), + values=TagConfigValues(Config.TAG_RED, Config.TAG_RED_VALUES), background_color=QColor(95, 65, 65, 190), active_color=QColor(200, 70, 70), ), @@ -245,7 +228,7 @@ def create_actions() -> List[ComplexAction]: return [ name="Pick brush presets (green)", controller=controllers.PresetController(), instructions=[instructions.SetBrushOnNonPaintable()], - values=Tag(Config.TAG_GREEN.read()), + values=TagConfigValues(Config.TAG_GREEN, Config.TAG_GREEN_VALUES), background_color=QColor(65, 95, 65, 190), active_color=QColor(70, 200, 70), ), @@ -256,7 +239,7 @@ def create_actions() -> List[ComplexAction]: return [ name="Pick brush presets (blue)", controller=controllers.PresetController(), instructions=[instructions.SetBrushOnNonPaintable()], - values=Tag(Config.TAG_BLUE.read()), + values=TagConfigValues(Config.TAG_BLUE, Config.TAG_BLUE_VALUES), background_color=QColor(70, 70, 105, 190), active_color=QColor(110, 160, 235), ), diff --git a/shortcut_composer/api_krita/actions/__init__.py b/shortcut_composer/api_krita/actions/__init__.py index aef5ce7d..349c2560 100644 --- a/shortcut_composer/api_krita/actions/__init__.py +++ b/shortcut_composer/api_krita/actions/__init__.py @@ -1,6 +1,6 @@ # SPDX-FileCopyrightText: © 2022 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later -from .transform_actions import TransformModeActions +from .transform_actions import TransformModeActions, TransformModeFinder -__all__ = ["TransformModeActions"] +__all__ = ["TransformModeActions", "TransformModeFinder"] diff --git a/shortcut_composer/api_krita/actions/transform_actions.py b/shortcut_composer/api_krita/actions/transform_actions.py index a808e8f5..29942828 100644 --- a/shortcut_composer/api_krita/actions/transform_actions.py +++ b/shortcut_composer/api_krita/actions/transform_actions.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: © 2022 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later -from typing import Dict, Literal +from typing import Dict, Optional from functools import partial, partialmethod from PyQt5.QtCore import QTimer @@ -12,19 +12,10 @@ QWidget, ) -from ..enums import Tool +from ..enums import Tool, TransformMode from ..core_api import KritaInstance Krita = KritaInstance() -TransformMode = Literal[ - Tool.TRANSFORM_FREE, - Tool.TRANSFORM_PERSPECTIVE, - Tool.TRANSFORM_WARP, - Tool.TRANSFORM_CAGE, - Tool.TRANSFORM_LIQUIFY, - Tool.TRANSFORM_MESH, -] - class TransformModeActions: """ @@ -33,26 +24,25 @@ class TransformModeActions: Tools are available as krita actions, but not added to the toolbar. """ - def __init__(self, window) -> None: - self._finder = self.TransformModeFinder() - self._actions: Dict[str, QWidgetAction] = {} - self._create_actions(window) + def __init__(self) -> None: + self._finder = TransformModeFinder() + self._actions: Dict[TransformMode, QWidgetAction] = {} - def _create_actions(self, window) -> None: + def create_actions(self, window) -> None: """Create krita actions which activate new tools.""" _ACTION_MAP = { - "Transform tool: free": self.set_free, - "Transform tool: perspective": self.set_perspective, - "Transform tool: warp": self.set_warp, - "Transform tool: cage": self.set_cage, - "Transform tool: liquify": self.set_liquify, - "Transform tool: mesh": self.set_mesh, + TransformMode.FREE: self.set_free, + TransformMode.PERSPECTIVE: self.set_perspective, + TransformMode.WARP: self.set_warp, + TransformMode.CAGE: self.set_cage, + TransformMode.LIQUIFY: self.set_liquify, + TransformMode.MESH: self.set_mesh, } - for action_name, implementation in _ACTION_MAP.items(): - self._actions[action_name] = Krita.create_action( + for action, implementation in _ACTION_MAP.items(): + self._actions[action] = Krita.create_action( window=window, - name=action_name, + name=action.value, callback=implementation ) @@ -70,79 +60,76 @@ def _delayed_click(self, mode: TransformMode) -> None: method = partial(self._finder.activate_mode, mode=mode, apply=False) QTimer.singleShot(40, method) - set_free = partialmethod(_set_mode, Tool.TRANSFORM_FREE) - set_perspective = partialmethod(_set_mode, Tool.TRANSFORM_PERSPECTIVE) - set_warp = partialmethod(_set_mode, Tool.TRANSFORM_WARP) - set_cage = partialmethod(_set_mode, Tool.TRANSFORM_CAGE) - set_liquify = partialmethod(_set_mode, Tool.TRANSFORM_LIQUIFY) - set_mesh = partialmethod(_set_mode, Tool.TRANSFORM_MESH) - - class TransformModeFinder: - """ - Helper class for finding components related to transform modes. - - Stores elements of krita needed to control transform modes: - - Widget transform tool options - - Buttons of every transform modes from the tool options widget - - Button used to apply the changes of transform tool - - As the widget do not exist during plugin intialization phase, - fetching the elements needs to happen at runtime. - """ - - def __init__(self) -> None: - self._mode_buttons: Dict[TransformMode, QToolButton] = {} - self._initialized = False - - self._transform_options: QWidget - self._apply_button: QPushButton - - def ensure_initialized(self, mode: TransformMode) -> None: - """Fetch widget, apply and mode buttons if not done already.""" - if not self._initialized: - last_tool = Krita.active_tool - Krita.active_tool = Tool.TRANSFORM - self._transform_options = self._fetch_transform_options() - self._apply_button = self._fetch_apply_button() - self._initialized = True - Krita.active_tool = last_tool - - if mode not in self._mode_buttons: - self._mode_buttons[mode] = self._fetch_mode_button(mode) - - def activate_mode(self, mode: TransformMode, apply: bool) -> None: - """Apply transform if requested and activate given mode.""" - if apply: - self._apply_button.click() - self._mode_buttons[mode].click() - - def _fetch_transform_options(self) -> QWidget: - """Fetch widget with transform tool options.""" - for qobj in Krita.get_active_qwindow().findChildren(QWidget): - if qobj.objectName() == "KisToolTransform option widget": - return qobj # type: ignore - raise RuntimeError("Transform options not found.") - - def _fetch_mode_button(self, mode: TransformMode) -> QToolButton: - """Fetch a button that activates a given mode.""" - for qobj in self._transform_options.findChildren(QToolButton): - if qobj.objectName() == self._BUTTONS_MAP[mode]: - return qobj # type: ignore - raise RuntimeError(f"Could not find the {mode.name} button.") - - def _fetch_apply_button(self) -> QPushButton: - """Fetch a button that applies the transformation.""" - buttons = self._transform_options.findChildren(QPushButton) - if not buttons: - raise RuntimeError("Could not find the apply button.") - return max(buttons, key=lambda button: button.x()) # type: ignore - - _BUTTONS_MAP = { - Tool.TRANSFORM_FREE: "freeTransformButton", - Tool.TRANSFORM_PERSPECTIVE: "perspectiveTransformButton", - Tool.TRANSFORM_WARP: "warpButton", - Tool.TRANSFORM_CAGE: "cageButton", - Tool.TRANSFORM_LIQUIFY: "liquifyButton", - Tool.TRANSFORM_MESH: "meshButton", - } - """Maps the TransformMode Tools to their buttons from the widget.""" + set_free = partialmethod(_set_mode, TransformMode.FREE) + set_perspective = partialmethod(_set_mode, TransformMode.PERSPECTIVE) + set_warp = partialmethod(_set_mode, TransformMode.WARP) + set_cage = partialmethod(_set_mode, TransformMode.CAGE) + set_liquify = partialmethod(_set_mode, TransformMode.LIQUIFY) + set_mesh = partialmethod(_set_mode, TransformMode.MESH) + + +class TransformModeFinder: + """ + Helper class for finding components related to transform modes. + + Stores elements of krita needed to control transform modes: + - Widget transform tool options + - Buttons of every transform modes from the tool options widget + - Button used to apply the changes of transform tool + + As the widget do not exist during plugin intialization phase, + fetching the elements needs to happen at runtime. + """ + + def __init__(self) -> None: + self._mode_buttons: Dict[TransformMode, QToolButton] = {} + self._initialized = False + + self._transform_options: QWidget + self._apply_button: QPushButton + + def ensure_initialized(self, mode: TransformMode) -> None: + """Fetch widget, apply and mode buttons if not done already.""" + if not self._initialized: + last_tool = Krita.active_tool + Krita.active_tool = Tool.TRANSFORM + self._transform_options = self._fetch_transform_options() + self._apply_button = self._fetch_apply_button() + self._initialized = True + Krita.active_tool = last_tool + + if mode not in self._mode_buttons: + self._mode_buttons[mode] = self._fetch_mode_button(mode) + + def activate_mode(self, mode: TransformMode, apply: bool) -> None: + """Apply transform if requested and activate given mode.""" + if apply: + self._apply_button.click() + self._mode_buttons[mode].click() + + def get_active_mode(self) -> Optional[TransformMode]: + for mode, button in self._mode_buttons.items(): + if button.isChecked(): + return mode + return None + + def _fetch_transform_options(self) -> QWidget: + """Fetch widget with transform tool options.""" + for qobj in Krita.get_active_qwindow().findChildren(QWidget): + if qobj.objectName() == "KisToolTransform option widget": + return qobj # type: ignore + raise RuntimeError("Transform options not found.") + + def _fetch_mode_button(self, mode: TransformMode) -> QToolButton: + """Fetch a button that activates a given mode.""" + for qobj in self._transform_options.findChildren(QToolButton): + if qobj.objectName() == mode.button_name: + return qobj # type: ignore + raise RuntimeError(f"Could not find the {mode.name} button.") + + def _fetch_apply_button(self) -> QPushButton: + """Fetch a button that applies the transformation.""" + buttons = self._transform_options.findChildren(QPushButton) + if not buttons: + raise RuntimeError("Could not find the apply button.") + return max(buttons, key=lambda button: button.x()) # type: ignore diff --git a/shortcut_composer/api_krita/core_api.py b/shortcut_composer/api_krita/core_api.py index 372fab85..5c5dbf74 100644 --- a/shortcut_composer/api_krita/core_api.py +++ b/shortcut_composer/api_krita/core_api.py @@ -5,7 +5,7 @@ from typing import Callable, Protocol, Any from PyQt5.QtWidgets import QMainWindow, QDesktopWidget, QWidgetAction -from PyQt5.QtGui import QKeySequence, QColor +from PyQt5.QtGui import QKeySequence, QColor, QIcon from PyQt5.QtCore import QTimer from .wrappers import ( @@ -61,6 +61,9 @@ def get_active_qwindow(self) -> QMainWindow: """Return qt window of krita. Don't use on plugin init phase.""" return self.instance.activeWindow().qwindow() + def get_icon(self, icon_name: str) -> QIcon: + return self.instance.icon(icon_name) + def read_setting(self, group: str, name: str, default: str) -> str: """Read setting from .kritarc file as string.""" return self.instance.readSetting(group, name, default) diff --git a/shortcut_composer/api_krita/enums/__init__.py b/shortcut_composer/api_krita/enums/__init__.py index cf611723..901335d8 100644 --- a/shortcut_composer/api_krita/enums/__init__.py +++ b/shortcut_composer/api_krita/enums/__init__.py @@ -3,8 +3,10 @@ """Enumerated values used in krita api wrappers.""" +from .transform_mode import TransformMode from .blending_mode import BlendingMode +from .node_types import NodeType from .toggle import Toggle from .tool import Tool -__all__ = ["BlendingMode", "Toggle", "Tool"] +__all__ = ["TransformMode", "BlendingMode", "NodeType", "Toggle", "Tool"] diff --git a/shortcut_composer/api_krita/enums/blending_mode.py b/shortcut_composer/api_krita/enums/blending_mode.py index 4e16ae02..686614ab 100644 --- a/shortcut_composer/api_krita/enums/blending_mode.py +++ b/shortcut_composer/api_krita/enums/blending_mode.py @@ -141,6 +141,7 @@ class BlendingMode(Enum): - `REFLECT` - `REFLECT_FREEZE` """ + NORMAL = "normal" ADD = "add" BURN = "burn" diff --git a/shortcut_composer/api_krita/enums/node_types.py b/shortcut_composer/api_krita/enums/node_types.py new file mode 100644 index 00000000..c6c554a0 --- /dev/null +++ b/shortcut_composer/api_krita/enums/node_types.py @@ -0,0 +1,63 @@ +# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from krita import Krita as Api +from enum import Enum + +from PyQt5.QtGui import QIcon + +class NodeType(Enum): + """ + Contains all known node types in krita. + + Example usage: `NodeType.PAINT_LAYER` + + Available node types: + - `PAINT_LAYER` + - `GROUP_LAYER` + - `FILE_LAYER` + - `FILTER_LAYER` + - `FILL_LAYER` + - `CLONE_LAYER` + - `VECTOR_LAYER` + - `TRANSPARENCY_MASK` + - `FILTER_MASK` + - `TRANSFORM_MASK` + - `SELECTION_MASK` + - `COLORIZE_MASK` + """ + + PAINT_LAYER = "paintlayer" + GROUP_LAYER = "grouplayer" + FILE_LAYER = "filelayer" + FILTER_LAYER = "filterlayer" + FILL_LAYER = "filllayer" + CLONE_LAYER = "clonelayer" + VECTOR_LAYER = "vectorlayer" + TRANSPARENCY_MASK = "transparencymask" + FILTER_MASK = "filtermask" + TRANSFORM_MASK = "transformmask" + SELECTION_MASK = "selectionmask" + COLORIZE_MASK = "colorizemask" + + @property + def icon(self) -> QIcon: + """Return the icon of this node type.""" + icon_name = _ICON_NAME_MAP.get(self, "edit-delete") + return Api.instance().icon(icon_name) + +_ICON_NAME_MAP = { + NodeType.PAINT_LAYER: "paintLayer", + NodeType.GROUP_LAYER: "groupLayer", + NodeType.FILE_LAYER: "fileLayer", + NodeType.FILTER_LAYER: "filterLayer", + NodeType.FILL_LAYER: "fillLayer", + NodeType.CLONE_LAYER: "cloneLayer", + NodeType.VECTOR_LAYER: "vectorLayer", + NodeType.TRANSPARENCY_MASK: "transparencyMask", + NodeType.FILTER_MASK: "filterMask", + NodeType.TRANSFORM_MASK: "transformMask", + NodeType.SELECTION_MASK: "selectionMask", + NodeType.COLORIZE_MASK: "colorizeMask" +} +"""Maps node types to names of their icons.""" \ No newline at end of file diff --git a/shortcut_composer/api_krita/enums/toggle.py b/shortcut_composer/api_krita/enums/toggle.py index 5da9aabd..4c0a48ee 100644 --- a/shortcut_composer/api_krita/enums/toggle.py +++ b/shortcut_composer/api_krita/enums/toggle.py @@ -26,6 +26,7 @@ class Toggle(Enum): - `SNAP_ASSISTANT` - `SNAP_TO_GRID` """ + ERASER = "erase_action" PRESERVE_ALPHA = "preserve_alpha" MIRROR_CANVAS = "mirror_canvas" diff --git a/shortcut_composer/api_krita/enums/tool.py b/shortcut_composer/api_krita/enums/tool.py index 7e50f577..0d20ce56 100644 --- a/shortcut_composer/api_krita/enums/tool.py +++ b/shortcut_composer/api_krita/enums/tool.py @@ -21,12 +21,6 @@ class Tool(Enum): - `GRADIENT` - `LINE` - `TRANSFORM` - - `TRANSFORM_FREE` - - `TRANSFORM_PERSPECTIVE` - - `TRANSFORM_WARP` - - `TRANSFORM_CAGE` - - `TRANSFORM_LIQUIFY` - - `TRANSFORM_MESH` - `MOVE` - `RECTANGULAR_SELECTION` - `CONTIGUOUS_SELECTION` @@ -64,12 +58,6 @@ class Tool(Enum): GRADIENT = "KritaFill/KisToolGradient" LINE = "KritaShape/KisToolLine" TRANSFORM = "KisToolTransform" - TRANSFORM_FREE = "Transform tool: free" - TRANSFORM_PERSPECTIVE = "Transform tool: perspective" - TRANSFORM_WARP = "Transform tool: warp" - TRANSFORM_CAGE = "Transform tool: cage" - TRANSFORM_LIQUIFY = "Transform tool: liquify" - TRANSFORM_MESH = "Transform tool: mesh" MOVE = "KritaTransform/KisToolMove" RECTANGULAR_SELECTION = "KisToolSelectRectangular" CONTIGUOUS_SELECTION = "KisToolSelectContiguous" @@ -86,6 +74,7 @@ class Tool(Enum): TEXT = "SvgTextTool" ELLIPSE = "KritaShape/KisToolEllipse" FILL = "KritaFill/KisToolFill" + ENCLOSE_AND_FILL = "KisToolEncloseAndFill" BEZIER_SELECTION = "KisToolSelectPath" DYNAMIC_BRUSH = "KritaShape/KisToolDyna" RECTANGLE = "KritaShape/KisToolRectangle" @@ -115,27 +104,6 @@ def icon(self) -> QIcon: icon_name = _ICON_NAME_MAP.get(self, "edit-delete") return Api.instance().icon(icon_name) - def __eq__(self, other) -> bool: - """All subtools of transform tool are technically the same tool.""" - if self in _TRANSFORMS and other in _TRANSFORMS: - return True - return Enum.__eq__(self, other) - - def __hash__(self) -> int: - """Identify tool by its krita name.""" - return hash(self.value) - - -_TRANSFORMS = { - Tool.TRANSFORM, - Tool.TRANSFORM_FREE, - Tool.TRANSFORM_PERSPECTIVE, - Tool.TRANSFORM_WARP, - Tool.TRANSFORM_CAGE, - Tool.TRANSFORM_LIQUIFY, - Tool.TRANSFORM_MESH, -} -"""Set of all subtools that are in fact the transform tool.""" _PAINTABLE = { Tool.FREEHAND_BRUSH, @@ -153,12 +121,6 @@ def __hash__(self) -> int: Tool.FREEHAND_SELECTION: "tool_outline_selection", Tool.GRADIENT: "krita_tool_gradient", Tool.LINE: "krita_tool_line", - Tool.TRANSFORM_FREE: "krita_tool_transform", - Tool.TRANSFORM_PERSPECTIVE: "transform_icons_perspective", - Tool.TRANSFORM_WARP: "transform_icons_warp", - Tool.TRANSFORM_CAGE: "transform_icons_cage", - Tool.TRANSFORM_LIQUIFY: "transform_icons_liquify_main", - Tool.TRANSFORM_MESH: "transform_icons_mesh", Tool.MOVE: "krita_tool_move", Tool.RECTANGULAR_SELECTION: "tool_rect_selection", Tool.CONTIGUOUS_SELECTION: "tool_contiguous_selection", @@ -169,12 +131,13 @@ def __hash__(self) -> int: Tool.POLYLINE: "polyline", Tool.SHAPE_SELECT: "select", Tool.ASSISTANTS: "krita_tool_assistant", - Tool.COLOR_SAMPLER: "krita_tool_color_picker", + Tool.COLOR_SAMPLER: "krita_tool_color_sampler", Tool.POLYGON: "krita_tool_polygon", Tool.MEASUREMENT: "krita_tool_measure", Tool.TEXT: "draw-text", Tool.ELLIPSE: "krita_tool_ellipse", Tool.FILL: "krita_tool_color_fill", + Tool.ENCLOSE_AND_FILL: "krita_tool_enclose_and_fill", Tool.BEZIER_SELECTION: "tool_path_selection", Tool.DYNAMIC_BRUSH: "krita_tool_dyna", Tool.RECTANGLE: "krita_tool_rectangle", @@ -183,7 +146,7 @@ def __hash__(self) -> int: Tool.EDIT_SHAPES: "shape_handling", Tool.ELIPTICAL_SELECTION: "tool_elliptical_selection", Tool.SMART_PATCH: "krita_tool_smart_patch", - Tool.COLORIZE_MASK: "colorizeMask", + Tool.COLORIZE_MASK: "krita_tool_lazybrush", Tool.SIMILAR_COLOR_SELECTION: "tool_similar_selection", Tool.ZOOM: "tool_zoom", Tool.MAGNETIC_SELECTION: "tool_magnetic_selection", diff --git a/shortcut_composer/api_krita/enums/transform_mode.py b/shortcut_composer/api_krita/enums/transform_mode.py new file mode 100644 index 00000000..eb0328a7 --- /dev/null +++ b/shortcut_composer/api_krita/enums/transform_mode.py @@ -0,0 +1,68 @@ +# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from krita import Krita as Api +from enum import Enum + +from PyQt5.QtGui import QIcon + + +class TransformMode(Enum): + """ + Contains all known tools from krita toolbox. + + Extended with modes of the transform tool. + + Example usage: `Tool.FREEHAND_BRUSH` + + Available modes: + - `FREE` + - `PERSPECTIVE` + - `WARP` + - `CAGE` + - `LIQUIFY` + - `MESH` + """ + + FREE = "Transform tool: free" + PERSPECTIVE = "Transform tool: perspective" + WARP = "Transform tool: warp" + CAGE = "Transform tool: cage" + LIQUIFY = "Transform tool: liquify" + MESH = "Transform tool: mesh" + + def activate(self) -> None: + """Use krita action created by TransformModeActions to set mode.""" + Api.instance().action(self.value).trigger() + + @property + def button_name(self) -> str: + """Return name of the button related to the mode.""" + return _BUTTONS_MAP[self] + + @property + def icon(self) -> QIcon: + """Return the icon of this transform mode.""" + icon_name = _ICON_NAME_MAP[self] + return Api.instance().icon(icon_name) + + +_ICON_NAME_MAP = { + TransformMode.FREE: "krita_tool_transform", + TransformMode.PERSPECTIVE: "transform_icons_perspective", + TransformMode.WARP: "transform_icons_warp", + TransformMode.CAGE: "transform_icons_cage", + TransformMode.LIQUIFY: "transform_icons_liquify_main", + TransformMode.MESH: "transform_icons_mesh", +} +"""Maps the TransformMode Tools to their icons.""" + +_BUTTONS_MAP = { + TransformMode.FREE: "freeTransformButton", + TransformMode.PERSPECTIVE: "perspectiveTransformButton", + TransformMode.WARP: "warpButton", + TransformMode.CAGE: "cageButton", + TransformMode.LIQUIFY: "liquifyButton", + TransformMode.MESH: "meshButton", +} +"""Maps the TransformMode Tools to their buttons from the widget.""" diff --git a/shortcut_composer/api_krita/pyqt/__init__.py b/shortcut_composer/api_krita/pyqt/__init__.py index e991f0e4..812be5ac 100644 --- a/shortcut_composer/api_krita/pyqt/__init__.py +++ b/shortcut_composer/api_krita/pyqt/__init__.py @@ -3,8 +3,8 @@ """Wrappers and utilities based on PyQt5 objects.""" +from .custom_widgets import AnimatedWidget, BaseWidget from .pixmap_transform import PixmapTransform -from .animated_widget import AnimatedWidget from .colorizer import Colorizer from .painter import Painter from .timer import Timer @@ -13,6 +13,7 @@ __all__ = [ "PixmapTransform", "AnimatedWidget", + "BaseWidget", "Colorizer", "Painter", "Timer", diff --git a/shortcut_composer/api_krita/pyqt/colorizer.py b/shortcut_composer/api_krita/pyqt/colorizer.py index ebfbcaa4..62de22dd 100644 --- a/shortcut_composer/api_krita/pyqt/colorizer.py +++ b/shortcut_composer/api_krita/pyqt/colorizer.py @@ -73,6 +73,7 @@ def _percentage(percent: int) -> Color: BLENDING_MODES_DARK = defaultdict(lambda: Color.LIGHT_GRAY, { BlendingMode.NORMAL: Color.WHITE, + BlendingMode.ERASE: Color.VIOLET, BlendingMode.OVERLAY: Color.RED, BlendingMode.SCREEN: Color.GREEN, BlendingMode.COLOR: Color.YELLOW, @@ -82,6 +83,7 @@ def _percentage(percent: int) -> Color: """Mapping of blending modes to custom colors in dark theme.""" BLENDING_MODES_LIGHT = defaultdict(lambda: Color.DARK_GRAY, { BlendingMode.NORMAL: Color.BLACK, + BlendingMode.ERASE: Color.VIOLET, BlendingMode.OVERLAY: Color.RED, BlendingMode.SCREEN: Color.ORANGE, BlendingMode.COLOR: Color.VIOLET, diff --git a/shortcut_composer/api_krita/pyqt/animated_widget.py b/shortcut_composer/api_krita/pyqt/custom_widgets.py similarity index 60% rename from shortcut_composer/api_krita/pyqt/animated_widget.py rename to shortcut_composer/api_krita/pyqt/custom_widgets.py index e0eba597..11d42d31 100644 --- a/shortcut_composer/api_krita/pyqt/animated_widget.py +++ b/shortcut_composer/api_krita/pyqt/custom_widgets.py @@ -1,8 +1,30 @@ from PyQt5.QtWidgets import QWidget +from PyQt5.QtCore import QPoint from .timer import Timer +class BaseWidget(QWidget): + """Adds base convenience methods to the widget.""" + + def __init__(self, parent, *args, **kwargs) -> None: + super().__init__(parent) + + @property + def center(self) -> QPoint: + """Return point with center widget's point in its coordinates.""" + return QPoint(self.size().width()//2, self.size().height()//2) + + @property + def center_global(self) -> QPoint: + """Return point with center widget's point in screen coordinates.""" + return self.pos() + self.center # type: ignore + + def move_center(self, new_center: QPoint) -> None: + """Move the widget by providing a new center point.""" + self.move(new_center-self.center) # type: ignore + + class AnimatedWidget(QWidget): """Adds the fade-in animation when the widget is shown (60 FPS).""" @@ -12,6 +34,12 @@ def __init__(self, parent, animation_time: float = 0) -> None: self._animation_interval = self._read_animation_interval() self._animation_timer = Timer(self._increase_opacity, 17) + def show(self): + """Decrease opacity to 0, and start a timer which animates it.""" + self.setWindowOpacity(0) + self._animation_timer.start() + super().show() + def _increase_opacity(self): """Add interval to current opacity, stop the timer when full.""" current_opacity = self.windowOpacity() @@ -19,12 +47,6 @@ def _increase_opacity(self): if current_opacity >= 1: self._animation_timer.stop() - def show(self): - """Decrease opacity to 0, and start a timer which animates it.""" - self.setWindowOpacity(0) - self._animation_timer.start() - super().show() - def _read_animation_interval(self): """Return how much opacity (0-1) should be increased on each frame.""" if time := self._animation_time: diff --git a/shortcut_composer/api_krita/wrappers/database.py b/shortcut_composer/api_krita/wrappers/database.py index 6fc9730f..80ca6ae0 100644 --- a/shortcut_composer/api_krita/wrappers/database.py +++ b/shortcut_composer/api_krita/wrappers/database.py @@ -46,7 +46,7 @@ def _single_column_query(self, sql_query: str, value: str) -> List[Any]: def get_preset_names_from_tag(self, tag_name: str) -> List[str]: """Return list of all preset names that belong to given tag.""" sql_query = f''' - SELECT r.name AS preset + SELECT DISTINCT r.name AS preset FROM tags t JOIN resource_tags rt ON t.id=rt.tag_id @@ -61,7 +61,7 @@ def get_preset_names_from_tag(self, tag_name: str) -> List[str]: def get_brush_tags(self) -> List[str]: "Return list of all tag names." sql_query = ''' - SELECT t.name AS tag + SELECT DISTINCT t.name AS tag FROM tags t WHERE t.active = 1 diff --git a/shortcut_composer/api_krita/wrappers/document.py b/shortcut_composer/api_krita/wrappers/document.py index 7e371896..7617b98d 100644 --- a/shortcut_composer/api_krita/wrappers/document.py +++ b/shortcut_composer/api_krita/wrappers/document.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from typing import List, Protocol +from ..enums import NodeType from .node import Node, KritaNode @@ -12,6 +13,7 @@ class KritaDocument(Protocol): def activeNode(self) -> KritaNode: ... def setActiveNode(self, node: KritaNode): ... + def createNode(self, name: str, node_type: NodeType) -> KritaNode: ... def topLevelNodes(self) -> List[KritaNode]: ... def resolution(self) -> int: ... def currentTime(self) -> int: ... @@ -35,6 +37,21 @@ def active_node(self, node: Node) -> None: """Set active `Node`.""" self.document.setActiveNode(node.node) + def create_node(self, name: str, node_type: NodeType) -> Node: + """ + Create a Node. + + IMPORTANT: Created node must be then added to node tree to be usable from Krita. + For example with add_child_node() method of Node Class. + + When relevant, the new Node will have the colorspace of the image by default; + that can be changed with Node::setColorSpace. + + The settings and selections for relevant layer and mask types can also be set + after the Node has been created. + """ + return Node(self.document.createNode(name, node_type.value)) + @property def current_time(self) -> int: """Settable property with this `Document`'s current frame number.""" @@ -53,7 +70,7 @@ def get_all_nodes(self) -> List[Node]: """Return a list of all `Nodes` in this document bottom to top.""" def recursive_search(nodes: List[Node], found_so_far: List[Node]): for node in nodes: - if node.is_group_layer and not node.collapsed: + if not node.collapsed: recursive_search(node.get_child_nodes(), found_so_far) found_so_far.append(node) return found_so_far diff --git a/shortcut_composer/api_krita/wrappers/node.py b/shortcut_composer/api_krita/wrappers/node.py index ad1ae285..4b17113c 100644 --- a/shortcut_composer/api_krita/wrappers/node.py +++ b/shortcut_composer/api_krita/wrappers/node.py @@ -9,6 +9,7 @@ class KritaNode(Protocol): """Krita `Node` object API.""" + def addChildNode(self, child: 'KritaNode', above: 'KritaNode') -> bool: ... def name(self) -> str: ... def setName(self, name: str) -> None: ... def visible(self) -> bool: ... @@ -34,6 +35,18 @@ class Node(): node: KritaNode + def add_child_node(self, child: 'Node', above: 'Node') -> bool: + """ + Add the given node in the list of children. + + Parameters: + child - the node to be added + above - the node above which this node will be placed + + Returns false if adding the node failed. + """ + return self.node.addChildNode(child.node, above.node) + @property def name(self) -> str: """Settable property with this node's name.""" diff --git a/shortcut_composer/composer_utils/config.py b/shortcut_composer/composer_utils/config.py index bb6b12cf..b6f957c8 100644 --- a/shortcut_composer/composer_utils/config.py +++ b/shortcut_composer/composer_utils/config.py @@ -1,10 +1,13 @@ # SPDX-FileCopyrightText: © 2022 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later -from typing import Union, Any +from typing import Union, Any, TypeVar, List from enum import Enum from api_krita import Krita +from api_krita.enums import Tool, BlendingMode, TransformMode + +T = TypeVar('T', bound=Enum) class Config(Enum): @@ -23,13 +26,26 @@ class Config(Enum): - `TAG_RED` = "★ My Favorites" - `TAG_GREEN` = "RGBA" - `TAG_BLUE` = "Erasers" + - `TAG_RED_VALUES` = ... + - `TAG_GREEN_VALUES` = ... + - `TAG_BLUE_VALUES` = ... + - `BLENDING_MODES_VALUES` = ... + - `MISC_TOOLS_VALUES` = ... + - `SELECTION_TOOLS_VALUES` = ... + - `TRANSFORM_MODES_VALUES` = ... + - `CREATE_BLENDING_LAYER_VALUES` = ... Each field can: - return its default value - - read current value from krita config file + - read current value from krita config file in correct type - write given value to krita config file Class holds a staticmethod which resets all config files. + + VALUES configs are string representations of lists. They hold values + to use in given action with elements separated with tabulators. + These are needed to be further parsed using TagConfigValues or + EnumConfigValues. """ SHORT_VS_LONG_PRESS_TIME = "Short vs long press time" @@ -40,12 +56,23 @@ class Config(Enum): PIE_ICON_GLOBAL_SCALE = "Pie icon global scale" PIE_DEADZONE_GLOBAL_SCALE = "Pie deadzone global scale" PIE_ANIMATION_TIME = "pie animation time" + TAG_RED = "Tag (red)" TAG_GREEN = "Tag (green)" TAG_BLUE = "Tag (blue)" + TAG_RED_VALUES = "Tag (red) values" + TAG_GREEN_VALUES = "Tag (green) values" + TAG_BLUE_VALUES = "Tag (blue) values" + + BLENDING_MODES_VALUES = "Blending modes values" + MISC_TOOLS_VALUES = "Misc tools values" + SELECTION_TOOLS_VALUES = "Selection tools values" + TRANSFORM_MODES_VALUES = "Transform modes values" + CREATE_BLENDING_LAYER_VALUES = "Create blending layer values" + @property - def default(self) -> Union[float, int]: + def default(self) -> Union[float, int, str]: """Return default value of the field.""" return _defaults[self] @@ -77,6 +104,10 @@ def get_sleep_time() -> int: fps_limit = Config.FPS_LIMIT.read() return round(1000/fps_limit) if fps_limit else 1 + @staticmethod + def format_enums(enums: List[Enum]) -> str: + return "\t".join([enum.name for enum in enums]) + _defaults = { Config.SHORT_VS_LONG_PRESS_TIME: 0.3, @@ -87,8 +118,55 @@ def get_sleep_time() -> int: Config.PIE_ICON_GLOBAL_SCALE: 1.0, Config.PIE_DEADZONE_GLOBAL_SCALE: 1.0, Config.PIE_ANIMATION_TIME: 0.2, + Config.TAG_RED: "★ My Favorites", Config.TAG_GREEN: "RGBA", Config.TAG_BLUE: "Erasers", + + Config.TAG_RED_VALUES: "", + Config.TAG_GREEN_VALUES: "", + Config.TAG_BLUE_VALUES: "", + + Config.SELECTION_TOOLS_VALUES: Config.format_enums([ + Tool.FREEHAND_SELECTION, + Tool.RECTANGULAR_SELECTION, + Tool.CONTIGUOUS_SELECTION, + ]), + Config.MISC_TOOLS_VALUES: Config.format_enums([ + Tool.CROP, + Tool.REFERENCE, + Tool.GRADIENT, + Tool.MULTI_BRUSH, + Tool.ASSISTANTS, + ]), + Config.BLENDING_MODES_VALUES: Config.format_enums([ + BlendingMode.NORMAL, + BlendingMode.OVERLAY, + BlendingMode.COLOR, + BlendingMode.MULTIPLY, + BlendingMode.ADD, + BlendingMode.SCREEN, + BlendingMode.DARKEN, + BlendingMode.LIGHTEN, + ]), + Config.CREATE_BLENDING_LAYER_VALUES: Config.format_enums([ + BlendingMode.NORMAL, + BlendingMode.ERASE, + BlendingMode.OVERLAY, + BlendingMode.COLOR, + BlendingMode.MULTIPLY, + BlendingMode.ADD, + BlendingMode.SCREEN, + BlendingMode.DARKEN, + BlendingMode.LIGHTEN, + ]), + Config.TRANSFORM_MODES_VALUES: Config.format_enums([ + TransformMode.FREE, + TransformMode.PERSPECTIVE, + TransformMode.WARP, + TransformMode.CAGE, + TransformMode.LIQUIFY, + TransformMode.MESH, + ]) } """Maps default values to config fields.""" diff --git a/shortcut_composer/composer_utils/settings_dialog_utils/__init__.py b/shortcut_composer/composer_utils/layouts/__init__.py similarity index 100% rename from shortcut_composer/composer_utils/settings_dialog_utils/__init__.py rename to shortcut_composer/composer_utils/layouts/__init__.py diff --git a/shortcut_composer/composer_utils/settings_dialog_utils/buttons_layout.py b/shortcut_composer/composer_utils/layouts/buttons_layout.py similarity index 100% rename from shortcut_composer/composer_utils/settings_dialog_utils/buttons_layout.py rename to shortcut_composer/composer_utils/layouts/buttons_layout.py diff --git a/shortcut_composer/composer_utils/settings_dialog_utils/combo_boxes_layout.py b/shortcut_composer/composer_utils/layouts/combo_boxes_layout.py similarity index 100% rename from shortcut_composer/composer_utils/settings_dialog_utils/combo_boxes_layout.py rename to shortcut_composer/composer_utils/layouts/combo_boxes_layout.py diff --git a/shortcut_composer/composer_utils/settings_dialog_utils/spin_boxes_layout.py b/shortcut_composer/composer_utils/layouts/spin_boxes_layout.py similarity index 100% rename from shortcut_composer/composer_utils/settings_dialog_utils/spin_boxes_layout.py rename to shortcut_composer/composer_utils/layouts/spin_boxes_layout.py diff --git a/shortcut_composer/composer_utils/settings_dialog.py b/shortcut_composer/composer_utils/settings_dialog.py index 14a939d4..8395bd5f 100644 --- a/shortcut_composer/composer_utils/settings_dialog.py +++ b/shortcut_composer/composer_utils/settings_dialog.py @@ -1,21 +1,22 @@ # SPDX-FileCopyrightText: © 2022 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later -from PyQt5.QtWidgets import QVBoxLayout, QDialog +from PyQt5.QtWidgets import ( + QVBoxLayout, + QTabWidget, + QDialog, +) from PyQt5.QtCore import QSize from PyQt5.QtGui import QCursor from api_krita import Krita from .config import Config -from .settings_dialog_utils import ( - ComboBoxesLayout, - SpinBoxesLayout, - ButtonsLayout -) +from .layouts import ButtonsLayout +from .tabs import GeneralSettingsTab, ActionValuesTab class SettingsDialog(QDialog): - """Dialog which allows to change global settings of the plugin.""" + """Dialog which allows to configure plugin elements.""" def __init__(self) -> None: super().__init__() @@ -23,44 +24,48 @@ def __init__(self) -> None: self.setMinimumSize(QSize(300, 200)) self.setWindowTitle("Configure Shortcut Composer") - self._combo_boxes_layout = ComboBoxesLayout() - self._spin_boxes_layout = SpinBoxesLayout() - self._buttons_layout = ButtonsLayout( - ok_callback=self._ok, - apply_callback=self._apply, - reset_callback=self._reset, - cancel_callback=self.hide, - ) + self._tab_dict = { + "General": GeneralSettingsTab(), + "Action values": ActionValuesTab(), + } + tab_holder = QTabWidget() + for name, tab in self._tab_dict.items(): + tab_holder.addTab(tab, name) - full_layout = QVBoxLayout() - full_layout.addLayout(self._combo_boxes_layout) - full_layout.addLayout(self._spin_boxes_layout) - full_layout.addLayout(self._buttons_layout) + full_layout = QVBoxLayout(self) + full_layout.addWidget(tab_holder) + full_layout.addLayout(ButtonsLayout( + ok_callback=self.ok, + apply_callback=self.apply, + reset_callback=self.reset, + cancel_callback=self.hide, + )) self.setLayout(full_layout) - def _apply(self) -> None: + def show(self) -> None: + """Show the dialog after refreshing all its elements.""" + self.refresh() + self.move(QCursor.pos()) + return super().show() + + def apply(self) -> None: """Ask all dialog zones to apply themselves.""" - self._combo_boxes_layout.apply() - self._spin_boxes_layout.apply() + for tab in self._tab_dict.values(): + tab.apply() Krita.trigger_action("Reload Shortcut Composer") - def _refresh(self) -> None: - """Ask all dialog zones to refresh themselves. """ - self._combo_boxes_layout.refresh() - self._spin_boxes_layout.refresh() - - def _ok(self) -> None: + def ok(self) -> None: """Hide the dialog after applying the changes""" - self._apply() + self.apply() self.hide() - def _reset(self) -> None: + def reset(self) -> None: """Reset all config values to defaults in krita and elements.""" Config.reset_defaults() - self._refresh() + self.refresh() + Krita.trigger_action("Reload Shortcut Composer") - def show(self) -> None: - """Show the dialog after refreshing all its elements.""" - self._refresh() - self.move(QCursor.pos()) - return super().show() + def refresh(self): + """Ask all tabs to refresh themselves. """ + for tab in self._tab_dict.values(): + tab.refresh() diff --git a/shortcut_composer/composer_utils/tabs/__init__.py b/shortcut_composer/composer_utils/tabs/__init__.py new file mode 100644 index 00000000..4e7a0132 --- /dev/null +++ b/shortcut_composer/composer_utils/tabs/__init__.py @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from .general_settings_tab import GeneralSettingsTab +from .action_values_tab import ActionValuesTab + + +__all__ = ["GeneralSettingsTab", "ActionValuesTab"] diff --git a/shortcut_composer/composer_utils/tabs/action_values_tab.py b/shortcut_composer/composer_utils/tabs/action_values_tab.py new file mode 100644 index 00000000..87578597 --- /dev/null +++ b/shortcut_composer/composer_utils/tabs/action_values_tab.py @@ -0,0 +1,69 @@ +# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from PyQt5.QtWidgets import ( + QVBoxLayout, + QComboBox, + QWidget, +) + +from api_krita.enums import BlendingMode, Tool, TransformMode +from ..utils import ActionValues +from ..config import Config + + +class ActionValuesTab(QWidget): + """Tab in which user can change values used in actions and their order.""" + + def __init__(self) -> None: + super().__init__() + layout = QVBoxLayout() + self.combo_widget_picker = QComboBox() + self.widgets = { + "Blending modes": ActionValues( + allowed_values=set(BlendingMode._member_names_), + config=Config.BLENDING_MODES_VALUES + ), + "Create layer with blending": ActionValues( + allowed_values=set(BlendingMode._member_names_), + config=Config.CREATE_BLENDING_LAYER_VALUES + ), + "Selection tools": ActionValues( + allowed_values=set(Tool._member_names_), + config=Config.SELECTION_TOOLS_VALUES + ), + "Misc tools": ActionValues( + allowed_values=set(Tool._member_names_), + config=Config.MISC_TOOLS_VALUES + ), + "Transform modes": ActionValues( + allowed_values=set(TransformMode._member_names_), + config=Config.TRANSFORM_MODES_VALUES + ), + } + self.combo_widget_picker.addItems(self.widgets.keys()) + self.combo_widget_picker.currentTextChanged.connect( + self._change_widget) + + layout.addWidget(self.combo_widget_picker) + for widget in self.widgets.values(): + widget.hide() + layout.addWidget(widget) + self.widgets["Blending modes"].show() + self.setLayout(layout) + + def _change_widget(self): + """Show a selectable list for a different action.""" + for widget in self.widgets.values(): + widget.hide() + self.widgets[self.combo_widget_picker.currentText()].show() + + def apply(self) -> None: + """Save values and their order for each selectable list.""" + for widget in self.widgets.values(): + widget.apply() + + def refresh(self) -> None: + """Refresh each selectable list by loading values from settings.""" + for widget in self.widgets.values(): + widget.refresh() diff --git a/shortcut_composer/composer_utils/tabs/general_settings_tab.py b/shortcut_composer/composer_utils/tabs/general_settings_tab.py new file mode 100644 index 00000000..b326b547 --- /dev/null +++ b/shortcut_composer/composer_utils/tabs/general_settings_tab.py @@ -0,0 +1,44 @@ +# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from PyQt5.QtWidgets import ( + QVBoxLayout, + QHBoxLayout, + QWidget, +) + +from ..layouts import ( + ComboBoxesLayout, + SpinBoxesLayout, +) + + +class GeneralSettingsTab(QWidget): + """Dialog which allows to change global settings of the plugin.""" + + def __init__(self) -> None: + super().__init__() + + self._layouts_dict = { + "ComboBoxes": ComboBoxesLayout(), + "SpinBoxes": SpinBoxesLayout(), + } + layout = QVBoxLayout() + for layout_part in self._layouts_dict.values(): + layout.addLayout(layout_part) + + stretched = QHBoxLayout() + stretched.addStretch() + stretched.addLayout(layout) + stretched.addStretch() + self.setLayout(stretched) + + def apply(self) -> None: + """Ask all dialog zones to apply themselves.""" + for layout in self._layouts_dict.values(): + layout.apply() + + def refresh(self) -> None: + """Ask all dialog zones to refresh themselves. """ + for layout in self._layouts_dict.values(): + layout.refresh() diff --git a/shortcut_composer/composer_utils/utils/__init__.py b/shortcut_composer/composer_utils/utils/__init__.py new file mode 100644 index 00000000..2164e89c --- /dev/null +++ b/shortcut_composer/composer_utils/utils/__init__.py @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Widgets to display on the settings dialog.""" + +from .action_values import ActionValues +from .value_list import ValueList + +__all__ = ["ActionValues", "ValueList"] diff --git a/shortcut_composer/composer_utils/utils/action_values.py b/shortcut_composer/composer_utils/utils/action_values.py new file mode 100644 index 00000000..ce453cb5 --- /dev/null +++ b/shortcut_composer/composer_utils/utils/action_values.py @@ -0,0 +1,91 @@ +# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from typing import Set + +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import ( + QVBoxLayout, + QPushButton, + QHBoxLayout, + QWidget, + QLabel, +) + +from api_krita import Krita +from ..config import Config +from .value_list import ValueList + + +class ActionValues(QWidget): + def __init__(self, allowed_values: Set[str], config: Config) -> None: + super().__init__() + layout = QHBoxLayout() + self.allowed_values = set(allowed_values) + self.config = config + + self.available_list = ValueList(movable=False, parent=self) + self.current_list = ValueList(movable=True, parent=self) + + add_button = QPushButton(Krita.get_icon("list-add"), "") + add_button.setStyleSheet("background-color : green") + add_button.setFixedHeight(add_button.sizeHint().height()*3) + add_button.clicked.connect(self.add) + + remove_button = QPushButton(Krita.get_icon("deletelayer"), "") + remove_button.setStyleSheet("background-color : red") + remove_button.setFixedHeight(remove_button.sizeHint().height()*3) + remove_button.clicked.connect(self.remove) + + control_layout = QVBoxLayout() + control_layout.addStretch() + control_layout.addWidget(add_button) + control_layout.addWidget(remove_button) + control_layout.addStretch() + + layout.addLayout(self._labeled_list(self.available_list, "Available:")) + layout.addLayout(control_layout) + layout.addLayout(self._labeled_list(self.current_list, "Selected:")) + + self.setLayout(layout) + + def _labeled_list(self, value_list: ValueList, text: str) -> QVBoxLayout: + layout = QVBoxLayout() + label = QLabel(text) + label.setAlignment(Qt.AlignCenter) + layout.addWidget(label) + layout.addWidget(value_list) + return layout + + def add(self): + for value in self.available_list.selected: + self.current_list.insert( + position=self.current_list.current_row, + value=value, + ) + self.available_list.remove(value=value,) + + def remove(self): + 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 = [] + for row in range(self.current_list.count()): + texts.append(self.current_list.item(row).text()) + self.config.write("\t".join(texts)) + + def refresh(self): + self.current_list.clear() + current: str = self.config.read() + current_list = current.split("\t") + if current_list == ['']: + current_list = [] + self.current_list.addItems(current_list) + + self.available_list.clear() + allowed_items = sorted(self.allowed_values - set(current_list)) + self.available_list.addItems(allowed_items) diff --git a/shortcut_composer/composer_utils/utils/value_list.py b/shortcut_composer/composer_utils/utils/value_list.py new file mode 100644 index 00000000..7af1c700 --- /dev/null +++ b/shortcut_composer/composer_utils/utils/value_list.py @@ -0,0 +1,67 @@ +# SPDX-FileCopyrightText: © 2022 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, +) + + +class ValueList(QListWidget): + """ + List widget with multiselection and convenience methods. + + When movable, elements of list can be reordered with drag and drop. + """ + + def __init__(self, movable: bool, parent: Optional[QWidget] = None): + super().__init__(parent) + self.horizontalScrollBar().setStyleSheet("QScrollBar {height:0px;}") + self.setMaximumWidth(200) + self.setSelectionMode(QAbstractItemView.ExtendedSelection) + if movable: + self.setDragDropMode(QAbstractItemView.InternalMove) + + @property + def current_row(self) -> int: + """Return selected row id, or the last id if none is selected.""" + if not self.selectedIndexes(): + return self.count() + return self.currentRow() + + @property + def selected(self) -> List[str]: + """Return a list of all selected string values.""" + selected = self.selectedIndexes() + indices = [item.row() for item in selected] + items = [self.item(index) for index in indices] + return [item.text() for item in items] + + def insert(self, position: int, value: str): + """Add new string `value` after the item at given `position`.""" + self.insertItem(position+1, value) + self.clearSelection() + self.setCurrentRow(position+1) + + def get_all(self): + """Get list of all the strings in the list.""" + items: List[str] = [] + for i in range(self.count()): + items.append(self.item(i).text()) + return items + + def remove(self, value: str): + """Remove strings by passed value and select the previous one.""" + for item in self.findItems(value, Qt.MatchExactly): + index = self.row(item) + self.takeItem(index) + self.setCurrentRow(index-1) + + def remove_selected(self): + """Remove all the selected values.""" + for item in self.selected: + self.remove(item) diff --git a/shortcut_composer/core_components/controllers/__init__.py b/shortcut_composer/core_components/controllers/__init__.py index d0fa0f07..86b01026 100644 --- a/shortcut_composer/core_components/controllers/__init__.py +++ b/shortcut_composer/core_components/controllers/__init__.py @@ -19,6 +19,7 @@ - `CanvasRotationController` - `CanvasZoomController` - `ToggleController` + - `CreateLayerWithBlendingController` """ from .document_controllers import ( @@ -40,8 +41,10 @@ LayerBlendingModeController, LayerVisibilityController, LayerOpacityController, + CreateLayerWithBlendingController, ) from .core_controllers import ( + TransformModeController, ToggleController, ToolController, UndoController, @@ -49,6 +52,7 @@ __all__ = [ "LayerBlendingModeController", + "TransformModeController", "LayerVisibilityController", "CanvasRotationController", "BlendingModeController", @@ -63,4 +67,5 @@ "ToolController", "UndoController", "FlowController", + "CreateLayerWithBlendingController", ] diff --git a/shortcut_composer/core_components/controllers/core_controllers.py b/shortcut_composer/core_components/controllers/core_controllers.py index 5a7fdbe8..730e80ea 100644 --- a/shortcut_composer/core_components/controllers/core_controllers.py +++ b/shortcut_composer/core_components/controllers/core_controllers.py @@ -1,12 +1,14 @@ # SPDX-FileCopyrightText: © 2022 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later +from typing import Optional from dataclasses import dataclass from PyQt5.QtGui import QIcon from api_krita import Krita -from api_krita.enums import Tool, Toggle +from api_krita.enums import Tool, Toggle, TransformMode +from api_krita.actions import TransformModeFinder from ..controller_base import Controller @@ -34,6 +36,35 @@ def get_label(self, value: Tool) -> QIcon: return value.icon +class TransformModeController(Controller): + """ + Gives access to tools from toolbox. + + - Operates on `TransformMode` + - Defaults to `TransformMode.FREE` + """ + + default_value: TransformMode = TransformMode.FREE + + def __init__(self) -> None: + self.button_finder = TransformModeFinder() + + def get_value(self) -> Optional[TransformMode]: + """Get currently active tool.""" + for mode in TransformMode._member_map_.values(): + self.button_finder.ensure_initialized(mode) # type: ignore + return self.button_finder.get_active_mode() + + @staticmethod + def set_value(value: Optional[TransformMode]) -> None: + """Set a passed tool.""" + if value is not None: + value.activate() + + def get_label(self, value: Tool) -> QIcon: + return value.icon + + @dataclass class ToggleController(Controller): """ diff --git a/shortcut_composer/core_components/controllers/node_controllers.py b/shortcut_composer/core_components/controllers/node_controllers.py index 0c66ce30..e317eb1d 100644 --- a/shortcut_composer/core_components/controllers/node_controllers.py +++ b/shortcut_composer/core_components/controllers/node_controllers.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from api_krita import Krita -from api_krita.enums import BlendingMode +from api_krita.enums import BlendingMode, NodeType from api_krita.pyqt import Text, Colorizer from ..controller_base import Controller @@ -83,3 +83,25 @@ def set_value(self, visibility: bool) -> None: if self.active_node.visible != visibility: self.active_node.visible = visibility self.active_document.refresh() + + +class CreateLayerWithBlendingController(NodeBasedController): + """Creates Paint Layer with set Blending Mode.""" + + default_value = BlendingMode.NORMAL + + def get_value(self) -> BlendingMode: + """Get current layer blending mode.""" + raise NotImplementedError("Can't use this controller to get value") + + def set_value(self, blending_mode: BlendingMode) -> None: + """Create new paint layer and set blending mode.""" + layer = self.active_document.create_node( + name=str(blending_mode.value).capitalize() + " Paint Layer", + node_type=NodeType.PAINT_LAYER) + layer.blending_mode = blending_mode + parent = self.active_node.get_parent_node() + parent.add_child_node(layer, self.active_node) + + def get_label(self, value: BlendingMode) -> Text: + return Text("+" + value.name[:3], Colorizer.blending_mode(value)) diff --git a/shortcut_composer/data_components/__init__.py b/shortcut_composer/data_components/__init__.py index 08f211f4..2914129c 100644 --- a/shortcut_composer/data_components/__init__.py +++ b/shortcut_composer/data_components/__init__.py @@ -10,15 +10,16 @@ """ from .current_layer_stack import CurrentLayerStack +from .writable_values import TagConfigValues, EnumConfigValues from .pick_strategy import PickStrategy from .slider import Slider from .range import Range -from .tag import Tag __all__ = [ "CurrentLayerStack", + "EnumConfigValues", + "TagConfigValues", "PickStrategy", "Slider", "Range", - "Tag", ] diff --git a/shortcut_composer/data_components/tag.py b/shortcut_composer/data_components/tag.py deleted file mode 100644 index c9c35604..00000000 --- a/shortcut_composer/data_components/tag.py +++ /dev/null @@ -1,27 +0,0 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus -# SPDX-License-Identifier: GPL-3.0-or-later - -from api_krita.wrappers import Database - - -class Tag(list): - """ - List-like container representating presets in a tag. - - Created using tag's name, gets filled with preset names. - Does not update in runtime as the tag gets edited. - - ### Example usage: - - Fetch all brush presets from tag named `Digital`: - ```python - Tag("Digital") - ``` - """ - - def __init__(self, name: str) -> None: - self.name = name - with Database() as database: - preset_names = database.get_preset_names_from_tag(name) - self.extend(set(preset_names)) - self.sort() diff --git a/shortcut_composer/data_components/writable_values.py b/shortcut_composer/data_components/writable_values.py new file mode 100644 index 00000000..17c406ba --- /dev/null +++ b/shortcut_composer/data_components/writable_values.py @@ -0,0 +1,65 @@ +# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from api_krita.wrappers import Database +from composer_utils import Config +from typing import Type, TypeVar +from enum import Enum + +T = TypeVar('T', bound=Enum) + + +class TagConfigValues(list): + """ + List-based container with preset names fetched from database. + + Created using tag's name stored in passed configuration. + Uses custom tag order stored in `tag_values` config. All the tag + values not present in the order will be added to the end. + + Values which are no longer in the tag, will not be included. + + Does not update in runtime as the tag gets edited. + + ### Example usage: + + Fetch all brush presets from tag named stored in TAG_BLUE: + ```python + TagConfigValues(Config.TAG_BLUE, Config.TAG_BLUE_VALUES) + ``` + """ + + def __init__(self, tag: Config, tag_values: Config) -> None: + self.tag = tag + self.config_to_write = tag_values + + with Database() as database: + tag_presets = database.get_preset_names_from_tag(tag.read()) + + preset_string: str = tag_values.read() + preset_order = preset_string.split("\t") + preset_order = [p for p in preset_order if p in tag_presets] + + missing = [p for p in tag_presets if p not in preset_order] + self.extend(preset_order + missing) + + +class EnumConfigValues(list): + """ + List-based container with enums fetched from configuration. + + ### Example usage: + + Fetch all enums stored in `TRANSFORM_MODES_VALUES` as enum `Tool`: + ```python + EnumConfigValues(Config.TRANSFORM_MODES_VALUES, Tool) + ``` + """ + + def __init__(self, values: Config, enum_type: Type[T]) -> None: + self.config_to_write = values + value_string: str = values.read() + if value_string == '': + return + values_list = value_string.split("\t") + self.extend([enum_type[value] for value in values_list]) diff --git a/shortcut_composer/manual.html b/shortcut_composer/manual.html index bc433337..27822975 100644 --- a/shortcut_composer/manual.html +++ b/shortcut_composer/manual.html @@ -16,13 +16,41 @@

Shortcut composer

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

    +

    Added

    +
      +
    • Edit mode for PieMenus - click or drag the value icon to enter it. While in edit mode, the values can be dragged + across the PieMenu to change their order.
    • +
    • Action values tab in Configure Shortcut Composer for adding and removing values in PieMenus and + MultipleAssignment + actions.
    • +
    • PieValues backtrounds are now animated.
    • +
    +

    Fixed

    +
      +
    • Allow scrolling through masks while using layey mouse trackers
    • +
    • Make sure that presets in PieMenu are displayed only once.
    • +
    • Allow using "Enclose and fill" tool
    • +
    • Fix icon of "Colorize Mask" tool
    • +
    • Fix scaling issues of Text labels in PieMenu
    • +
    +

    Requirements

    +

    Shortcut Composer v1.1.0 Requires krita 5.1.0 or later.

    +

    OS support state:

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

    Installation:

    1. on github project page, click the green button @@ -36,12 +64,17 @@

      Installation:

    Pre-made actions

    While Shortcut-Composer is highly configurable and extendable, the add-on comes with pre-made, plug-and-play - actions.

    + actions. +

    (Pie menus):

    Pie menu is a widget displayed on the canvas while a key is pressed. It will disappear as soon, as the key is released. Moving cursor in a direction of a icon, activates its value on key release. The action does not recognise mouse clicks, and only requires hovering. Pie menu does nothing if the cursor is not moved out - of the deadzone.

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

      @@ -59,7 +92,8 @@

      Pick brush presets (red, green, blue
    • Pick misc tools

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

      + not + be worth a dedicated keyboard shortcut each:

      • crop tool,
      • reference tool,
      • @@ -82,17 +116,35 @@

        Pick painting blending modes

      • 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

    + changes + a single krita property according to the cursor movement along horizontal or vertical axis. The action does + not recognise mouse clicks, and only requires hovering

    • Scroll isolated layers

      Scrolls the layers by sliding the cursor vertically. Can be used for picking the active layer and analizing the layer stack. While the key is pressed, isolates the active layer to give better response of which layer is - active.

      + active. +

      • Key press: isloate active layer
      • Horizontal: -
      • @@ -133,7 +185,8 @@

        Scroll brush size or opacity

      • Scroll canvas zoom or rotation

        Allows to control both canvas zoom or canvas rotation with a single key. Does not - block the ability to paint.

        + block + the ability to paint.

        • Key press: -
        • Horizontal: canvas rotation (contiguous)
        • @@ -158,7 +211,8 @@

          Preview projection below

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

          + to + belong the the list.

          • Cycle selection tools

            @@ -194,13 +248,15 @@

            Temporary eraser

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

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

          + 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
            • @@ -228,6 +284,17 @@

              Tweaking the global parameters

          +

          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.

          @@ -241,19 +308,23 @@

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

          + an + unique name for it.

        • Implement an action in actions.py file. Once again, duplicate one of the existing implementations. - It is best to pick the one that feels closest to desired action. Fill its arguments, making sure the name is - exactly the same as defined earlier.
        • + It + is best to pick the one that feels closest to desired action. Fill its arguments, making sure the name is exactly + the same as defined earlier.

        Worth noting

        • Key bindings with modifiers like ctrl or shift are supported. When assigned to a key combination, the key is considered released when the main key in sequence (non-modifier) is released.
        • Multiple shortcuts from this plugin can be used at the same time, unless bindings make it technically - impossible. For example holding both keys for Temporary eraser and Cycle painting - opacity result in an eraser with 70% opacity.
        • + impossible. + For example holding both keys for Temporary eraser and Cycle painting opacity result in + an + eraser with 70% opacity.

        Known limitations

          @@ -264,43 +335,45 @@

          Known limitations

          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.

          + 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
          -  from .api_krita.enums import BlendingMode, Tool, Toggle
          -  
          -  # active tool operations
          -  tool = Krita.active_tool  # get current tool
          -  Krita.active_tool = Tool.FREEHAND_BRUSH  # set current tool
          -  Tool.FREEHAND_BRUSH.activate()  # set current tool (alternative way)
          -  
          -  # operations on a document
          -  document = Krita.get_active_document()
          -  all_nodes = document.get_all_nodes()  # all nodes with flattened structure
          -  picked_node = all_nodes[3]
          -  
          -  picked_node.name = "My layer name"
          -  picked_node.visible = True
          -  picked_node.opacity = 50  # remapped from 0-255 go 0-100 [%]
          -  document.active_node = picked_node 
          -  document.refresh()
          -  
          -  # Operations on a view
          -  view = Krita.get_active_view()
          -  view.brush_size = 100
          -  view.blending_mode = BlendingMode.NORMAL  # Enumerated blending modes
          -  
          -  # Handling checkable actions
          -  mirror_state = Toggle.MIRROR_CANVAS.state  # get mirror state
          -  Toggle.SOFT_PROOFING.state = False  # turn off soft proofing
          -  Toggle.PRESERVE_ALPHA.switch_state()  # change state of preserve alpha
          -  
          +from .api_krita.enums import BlendingMode, Tool, Toggle + +# active tool operations +tool = Krita.active_tool # get current tool +Krita.active_tool = Tool.FREEHAND_BRUSH # set current tool +Tool.FREEHAND_BRUSH.activate() # set current tool (alternative way) + +# operations on a document +document = Krita.get_active_document() +all_nodes = document.get_all_nodes() # all nodes with flattened structure +picked_node = all_nodes[3] + +picked_node.name = "My layer name" +picked_node.visible = True +picked_node.opacity = 50 # remapped from 0-255 go 0-100 [%] +document.active_node = picked_node +document.refresh() + +# Operations on a view +view = Krita.get_active_view() +view.brush_size = 100 +view.blending_mode = BlendingMode.NORMAL # Enumerated blending modes + +# Handling checkable actions +mirror_state = Toggle.MIRROR_CANVAS.state # get mirror state +Toggle.SOFT_PROOFING.state = False # turn off soft proofing +Toggle.PRESERVE_ALPHA.switch_state() # change state of preserve alpha +

          Only functionalities that were needed during this plugin development are wrapped, so some of them are not yet available. The syntax can also change over time.

          Custom keyboard shortcut interface

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

          + grants + extended interface for creating keyboard shortcuts.

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

            diff --git a/shortcut_composer/shortcut_composer.py b/shortcut_composer/shortcut_composer.py index 96efc94e..b480e258 100755 --- a/shortcut_composer/shortcut_composer.py +++ b/shortcut_composer/shortcut_composer.py @@ -29,7 +29,8 @@ def createActions(self, window) -> None: - Add a callback to reload plugin when krita theme changes - Create complex action manager which holds and binds them to krita """ - self._transform_modes = TransformModeActions(window) + self._transform_modes = TransformModeActions() + self._transform_modes.create_actions(window) self._pie_settings_dialog = SettingsDialog() self._reload_action = self._create_reload_action(window) diff --git a/shortcut_composer/templates/multiple_assignment.py b/shortcut_composer/templates/multiple_assignment.py index aa28b28f..a5b5d88c 100644 --- a/shortcut_composer/templates/multiple_assignment.py +++ b/shortcut_composer/templates/multiple_assignment.py @@ -85,7 +85,10 @@ def on_key_press(self) -> None: super().on_key_press() if self._controller.get_value() != self._last_value: self._reset_iterator() - self._set_value(next(self._iterator)) + + # NOTE: When there are no values to cycle, iterator is invalid + if self._values_to_cycle: + self._set_value(next(self._iterator)) def on_long_key_release(self) -> None: """Long releases set default value.""" @@ -104,4 +107,4 @@ 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 else self._controller.default_value + return value if value is not None else self._controller.default_value diff --git a/shortcut_composer/templates/pie_menu.py b/shortcut_composer/templates/pie_menu.py index 501c0472..874aafb1 100644 --- a/shortcut_composer/templates/pie_menu.py +++ b/shortcut_composer/templates/pie_menu.py @@ -4,14 +4,12 @@ from typing import List, TypeVar, Generic, Union, Optional from PyQt5.QtGui import QColor, QPixmap, QIcon -from PyQt5.QtCore import QPoint from api_krita.pyqt import Text +from composer_utils import Config from core_components import Controller, Instruction from input_adapter import ComplexAction from .pie_menu_utils import ( - CirclePoints, - LabelHolder, PieManager, PieWidget, PieStyle, @@ -28,6 +26,8 @@ class PieMenu(ComplexAction, Generic[T]): - Widget is displayed under the cursor between key press and release - Moving mouse in a direction of a value activates in on key release - When the mouse was not moved past deadzone, value is not changed + - Dragging values activates edit mode in which pie does not hide + - Applying the changes in edit mode, saves its values to settings ### Arguments: @@ -37,12 +37,12 @@ class PieMenu(ComplexAction, Generic[T]): - `values` -- list of values compatibile with controller to cycle - `instructions` -- (optional) list of additional instructions to perform on key press and release - - `short_vs_long_press_time` -- (optional) time [s] that specifies - if key press is short or long - `pie_radius_scale` -- (optional) widget size multiplier - `icon_radius_scale` -- (optional) icons size multiplier - `background_color` -- (optional) rgba color of background - `active_color` -- (optional) rgba color of active pie + - `short_vs_long_press_time` -- (optional) time [s] that specifies + if key press is short or long ### Action implementation example: @@ -70,8 +70,6 @@ class PieMenu(ComplexAction, Generic[T]): - 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 - - Storing created labels in LabelHolder which allows to fetch them - by the angle on a pie - Setting a value on key release when the deadzone was reached """ @@ -93,16 +91,18 @@ def __init__( instructions=instructions) self._controller = controller + self._labels = self._create_labels(values) self._style = PieStyle( 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, ) - self._labels = self._create_labels(values) - self._style.adapt_to_item_amount(len(self._labels)) - self._pie_manager = PieManager(PieWidget(self._labels, self._style)) + 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) def on_key_press(self) -> None: """Show widget under mouse and start manager which repaints it.""" @@ -113,30 +113,19 @@ def on_key_press(self) -> None: def on_every_key_release(self) -> None: """Stop the widget. Set selected value if deadzone was reached.""" super().on_every_key_release() + if self._pie_widget.edit_mode: + return self._pie_manager.stop() - if label := self._labels.active: - self._controller.set_value(label.value) + if widget := self._pie_widget.widget_holder.active: + self._controller.set_value(widget.label.value) - def _create_labels(self, values: List[T]) -> LabelHolder: + def _create_labels(self, values: List[T]) -> List[Label]: """Wrap values into paintable label objects with position info.""" label_list = [] for value in values: if icon := self._get_icon_if_possible(value): label_list.append(Label(value=value, display_value=icon)) - - center = QPoint(self._style.widget_radius, self._style.widget_radius) - circle_points = CirclePoints( - center=center, - radius=self._style.pie_radius) - angle_iterator = circle_points.iterate_over_circle(len(label_list)) - - label_holder = LabelHolder() - for label, (angle, point) in zip(label_list, angle_iterator): - label.angle = angle - label.center = point - label_holder.add(label) - - return label_holder + return label_list def _get_icon_if_possible(self, value: T) \ -> Union[Text, QPixmap, QIcon, None]: @@ -145,3 +134,10 @@ def _get_icon_if_possible(self, value: T) \ 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 diff --git a/shortcut_composer/templates/pie_menu_utils/__init__.py b/shortcut_composer/templates/pie_menu_utils/__init__.py index 35afe128..1e9c5a27 100644 --- a/shortcut_composer/templates/pie_menu_utils/__init__.py +++ b/shortcut_composer/templates/pie_menu_utils/__init__.py @@ -1,18 +1,16 @@ # SPDX-FileCopyrightText: © 2022 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later -"""Implementation of PieMenu.""" +"""Implementation of PieMenu main elements.""" -from .circle_points import CirclePoints -from .label_holder import LabelHolder +from .label_widget import LabelWidget from .pie_manager import PieManager from .pie_widget import PieWidget from .pie_style import PieStyle from .label import Label __all__ = [ - "CirclePoints", - "LabelHolder", + "LabelWidget", "PieManager", "PieWidget", "PieStyle", diff --git a/shortcut_composer/templates/pie_menu_utils/label.py b/shortcut_composer/templates/pie_menu_utils/label.py index a837dcf4..a75ec460 100644 --- a/shortcut_composer/templates/pie_menu_utils/label.py +++ b/shortcut_composer/templates/pie_menu_utils/label.py @@ -1,22 +1,23 @@ # SPDX-FileCopyrightText: © 2022 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later -from typing import Union, Type, Any +from api_krita.pyqt import Text +from typing import Union, Any from dataclasses import dataclass -from abc import ABC, abstractmethod -from PyQt5.QtCore import QPoint, Qt -from PyQt5.QtGui import QFont, QPixmap, QColor, QIcon, QFontDatabase -from PyQt5.QtWidgets import QLabel, QWidget +from PyQt5.QtCore import QPoint +from PyQt5.QtGui import ( + QPixmap, + QIcon, +) -from api_krita.pyqt import Painter, Text, PixmapTransform -from .pie_style import PieStyle +from composer_utils import Config @dataclass class Label: """ - Paintable representation of value in PieWidget. + Data representing a single value in PieWidget. - `value` -- Value to set using the controller - `center -- Label position in widget coordinates @@ -24,12 +25,7 @@ class Label: counted clockwise with 0 being the top of widget - `display_value` -- `value` representation to display. Can be either a colored text or an image - - `display_value` can also be accessed using `text` and `image` - properties, which return None when the value does not exist or is - not of required type. - - Label can be displayed with LabelPainter, returned by get_painter(). + - `activation_progress` -- state of animation in range <0-1> """ value: Any @@ -37,138 +33,49 @@ class Label: angle: int = 0 display_value: Union[QPixmap, QIcon, Text, None] = None - def get_painter(self, widget: QWidget, style: PieStyle) -> 'LabelPainter': - """Return LabelPainter which can display this label.""" - if self.display_value is None: - raise ValueError(f"Label {self} is not valid") - - painter_type: Type[LabelPainter] = { - QPixmap: ImageLabelPainter, - Text: TextLabelPainter, - QIcon: IconPainter, - }[type(self.display_value)] + def __post_init__(self) -> None: + self.activation_progress = AnimationProgress(speed_scale=1, steep=1) - return painter_type(self, widget, style) + def swap_locations(self, other: 'Label') -> None: + """Change position data with information Label.""" + self.angle, other.angle = other.angle, self.angle + self.center, other.center = other.center, self.center -@dataclass -class LabelPainter(ABC): - """Displays a `label` inside of `widget` using given `style`.""" +class AnimationProgress: + """ + Grants interface to track progress of two-way steep animation. - label: Label - widget: QWidget - style: PieStyle + Holds the state of animation as float in range <0-1> which can be + obtained with `value` property. - @abstractmethod - def paint(self, painter: Painter) -> None: """Paint a label.""" + Animation state can be altered with `up()` and `down()` methods. + The change is the fastest when the animation starts, and then slows + down near the end (controlled by `steep` argument). + There is a `reset()` method to cancel the animation immediatelly. + """ -@dataclass -class TextLabelPainter(LabelPainter): - """Displays a `label` which holds text.""" - - def __post_init__(self): - self._pyqt_label = self._create_pyqt_label() - - def paint(self, painter: Painter): - """Paint a background behind a label and its border.""" - painter.paint_wheel( - center=self.label.center, - outer_radius=self.style.icon_radius, - color=self.style.icon_color, - ) - painter.paint_wheel( - center=self.label.center, - outer_radius=self.style.icon_radius, - color=self.style.border_color, - thickness=self.style.border_thickness, - ) - - def _create_pyqt_label(self) -> QLabel: - """Create and show a new Qt5 label. Does not need redrawing.""" - to_display = self.label.display_value - - if not isinstance(to_display, Text): - raise TypeError("Label supposed to be text.") - - heigth = round(self.style.icon_radius*0.8) - - label = QLabel(self.widget) - label.setText(to_display.value) - label.setFont(self._font) - label.setAlignment(Qt.AlignCenter) - label.setGeometry(0, 0, round(heigth*2), round(heigth)) - label.move(self.label.center.x()-heigth, - self.label.center.y()-heigth//2) - label.setStyleSheet(f''' - background-color:rgba({self._color_to_str(self.style.icon_color)}); - color:rgba({self._color_to_str(to_display.color)}); - ''') - - label.show() - return label + def __init__(self, speed_scale: float = 1.0, steep: float = 1.0) -> None: + self._value = 0 + self._speed = 0.004*Config.get_sleep_time()*speed_scale + self._steep = steep - @property - def _font(self) -> QFont: - """Return font which to use in pyqt label.""" - font = QFontDatabase.systemFont(QFontDatabase.TitleFont) - font.setPointSize(self.style.font_size) - font.setBold(True) - return font + def up(self) -> None: + """Increase the animation progress.""" + difference = (1+self._steep-self._value) * self._speed + self._value = min(self._value + difference, 1) - @staticmethod - def _color_to_str(color: QColor) -> str: return f''' - {color.red()}, {color.green()}, {color.blue()}, {color.alpha()}''' + def down(self) -> None: + """Decrease the animation progress.""" + difference = (self._value+self._steep) * self._speed + self._value = max(self._value - difference, 0) + @property + def value(self) -> float: + """Get current state of animation. It ss in range <0-1>.""" + return self._value -@dataclass -class ImageLabelPainter(LabelPainter): - """Displays a `label` which holds an image.""" - - def __post_init__(self): - self.ready_image = self._prepare_image() - - def paint(self, painter: Painter): - """Paint a background behind a label its border, and image itself.""" - painter.paint_wheel( - center=self.label.center, - outer_radius=self.style.icon_radius, - color=self.style.icon_color - ) - painter.paint_wheel( - center=self.label.center, - outer_radius=self.style.icon_radius-self.style.border_thickness//2, - color=self.style.border_color, - thickness=self.style.border_thickness, - ) - painter.paint_pixmap(self.label.center, self.ready_image) - - def _prepare_image(self) -> QPixmap: - """Return image after scaling and reshaping it to circle.""" - to_display = self.label.display_value - - if not isinstance(to_display, QPixmap): - raise TypeError("Label supposed to be 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) - ) - - -class IconPainter(ImageLabelPainter): - """Displays a `label` which holds an icon.""" - - def _prepare_image(self) -> QPixmap: - """Return icon after scaling it to fix QT_SCALE_FACTOR.""" - to_display = self.label.display_value - - if not isinstance(to_display, QIcon): - raise TypeError("Label supposed to be QIcon.") - - size = round(self.style.icon_radius*1.1) - return PixmapTransform.scale_pixmap( - pixmap=to_display.pixmap(size, size), - size_px=size - ) + def reset(self) -> None: + """Arbitralily set a value to 0""" + self._value = 0 diff --git a/shortcut_composer/templates/pie_menu_utils/label_holder.py b/shortcut_composer/templates/pie_menu_utils/label_holder.py deleted file mode 100644 index c08c2ac8..00000000 --- a/shortcut_composer/templates/pie_menu_utils/label_holder.py +++ /dev/null @@ -1,49 +0,0 @@ -# SPDX-FileCopyrightText: © 2022 Wojciech Trybus -# SPDX-License-Identifier: GPL-3.0-or-later - -from typing import Dict, Iterator, Optional - -from .label import Label - - -class LabelHolder: - """ - Holds Labels and allows fetching them using their angle. - - Allows one of the labels to be active, but does not handle this - attribute by itself. - - Allows iterating over Labels (default) and over angles (angles()) - """ - - def __init__(self): - self._labels: Dict[int, Label] = {} - self.active: Optional[Label] = None - - def add(self, label: Label) -> None: - """Add a new label to the holder.""" - self._labels[label.angle] = label - - def angles(self) -> Iterator[int]: - """Iterate over all angles of held Labels.""" - return iter(self._labels.keys()) - - def from_angle(self, angle: int) -> Label: - """Return Label which is the closest to given `angle`.""" - - def angle_difference(label_angle: int): - """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._labels[closest] - - def __iter__(self) -> Iterator[Label]: - """Iterate over all held labels.""" - return iter(self._labels.values()) - - def __len__(self) -> int: - """Return amount of held labels.""" - return len(self._labels) diff --git a/shortcut_composer/templates/pie_menu_utils/label_widget.py b/shortcut_composer/templates/pie_menu_utils/label_widget.py new file mode 100644 index 00000000..15d6805f --- /dev/null +++ b/shortcut_composer/templates/pie_menu_utils/label_widget.py @@ -0,0 +1,44 @@ +# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from PyQt5.QtCore import Qt, QMimeData +from PyQt5.QtWidgets import QWidget +from PyQt5.QtGui import QDrag, QPixmap, QMouseEvent + +from api_krita.pyqt import PixmapTransform, BaseWidget +from .pie_style import PieStyle +from .label import Label + + +class LabelWidget(BaseWidget): + """Displays a `label` inside of `widget` using given `style`.""" + + def __init__( + self, + label: Label, + style: PieStyle, + parent: QWidget, + ) -> 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) + + def move_to_label(self) -> None: + """Move the widget by providing a new center point.""" + 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: + return + drag = QDrag(self) + drag.setMimeData(QMimeData()) + + pixmap = QPixmap(self.size()) + self.render(pixmap) + drag.setPixmap(PixmapTransform.make_pixmap_round(pixmap)) + + drag.exec_(Qt.MoveAction) 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 new file mode 100644 index 00000000..68760a4e --- /dev/null +++ b/shortcut_composer/templates/pie_menu_utils/label_widget_utils/__init__.py @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Implementation of different LabelWidget types.""" + +from .create_label_widget import create_label_widget + +__all__ = ["create_label_widget"] 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 new file mode 100644 index 00000000..57b42140 --- /dev/null +++ b/shortcut_composer/templates/pie_menu_utils/label_widget_utils/create_label_widget.py @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from typing import Type + +from PyQt5.QtGui import ( + QPixmap, + QIcon, +) +from PyQt5.QtWidgets import QWidget + +from api_krita.pyqt import Text +from ..pie_style import PieStyle +from ..label import Label +from ..label_widget import LabelWidget +from .icon_label_widget import IconLabelWidget +from .text_label_widget import TextLabelWidget +from .image_label_widget import ImageLabelWidget + + +def create_label_widget( + label: Label, + style: PieStyle, + parent: QWidget +) -> 'LabelWidget': + """Return LabelWidget which can display this label.""" + if label.display_value is None: + raise ValueError(f"Label {label} is not valid") + + painter_type: Type[LabelWidget] = { + QPixmap: ImageLabelWidget, + Text: TextLabelWidget, + QIcon: IconLabelWidget, + }[type(label.display_value)] + + return painter_type(label, style, parent) 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 new file mode 100644 index 00000000..d99aa262 --- /dev/null +++ b/shortcut_composer/templates/pie_menu_utils/label_widget_utils/icon_label_widget.py @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from PyQt5.QtGui import ( + QPixmap, + QIcon, +) + +from api_krita.pyqt import PixmapTransform +from .image_label_widget import ImageLabelWidget + + +class IconLabelWidget(ImageLabelWidget): + """Displays a `label` which holds an icon.""" + + def _prepare_image(self) -> QPixmap: + """Return icon after scaling it to fix QT_SCALE_FACTOR.""" + to_display = self.label.display_value + + if not isinstance(to_display, QIcon): + raise TypeError("Label supposed to be QIcon.") + + size = round(self._style.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 new file mode 100644 index 00000000..fbdfd2b4 --- /dev/null +++ b/shortcut_composer/templates/pie_menu_utils/label_widget_utils/image_label_widget.py @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from PyQt5.QtGui import ( + QPixmap, + QPaintEvent, +) +from PyQt5.QtWidgets import QWidget + +from api_krita.pyqt import Painter, PixmapTransform +from ..pie_style import PieStyle +from ..label import Label +from ..label_widget import LabelWidget + + +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) + self.ready_image = self._prepare_image() + + def paintEvent(self, event: QPaintEvent) -> None: + """ + Paint the entire widget using the Painter wrapper. + + Paint a background behind a label its border, and image itself. + """ + with Painter(self, event) as painter: + painter.paint_wheel( + center=self.center, + outer_radius=self._style.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, + ) + painter.paint_pixmap(self.center, self.ready_image) + + def _prepare_image(self) -> QPixmap: + """Return image after scaling and reshaping it to circle.""" + to_display = self.label.display_value + + if not isinstance(to_display, QPixmap): + raise TypeError("Label supposed to be 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) + ) 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 new file mode 100644 index 00000000..46729863 --- /dev/null +++ b/shortcut_composer/templates/pie_menu_utils/label_widget_utils/text_label_widget.py @@ -0,0 +1,79 @@ +# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from PyQt5.QtCore import Qt +from PyQt5.QtGui import ( + QFont, + QColor, + QFontDatabase, + QPaintEvent, +) +from PyQt5.QtWidgets import QLabel, QWidget + +from api_krita.pyqt import Painter, Text +from ..pie_style import PieStyle +from ..label import Label +from ..label_widget import LabelWidget + + +class TextLabelWidget(LabelWidget): + """Displays a `label` which holds text.""" + + def __init__(self, label: Label, style: PieStyle, parent: QWidget) -> None: + super().__init__(label, style, parent) + self._pyqt_label = self._create_pyqt_label() + + def paintEvent(self, event: QPaintEvent) -> None: + """ + Paint the entire widget using the Painter wrapper. + + Paint a background behind a label and its border. + """ + with Painter(self, event) as painter: + painter.paint_wheel( + center=self.center, + outer_radius=self._style.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, + ) + + def _create_pyqt_label(self) -> QLabel: + """Create and show a new Qt5 label. Does not need redrawing.""" + to_display = self.label.display_value + + if not isinstance(to_display, Text): + raise TypeError("Label supposed to be text.") + + heigth = round(self._style.icon_radius*0.8) + + label = QLabel(self) + label.setText(to_display.value) + label.setAlignment(Qt.AlignCenter) + label.setGeometry(0, 0, round(heigth*2), round(heigth)) + label.setFont(self._font) + label.move(self.center.x()-heigth, + self.center.y()-heigth//2) + label.setStyleSheet(f''' + background-color:rgba({self._color_to_str(self._style.icon_color)}); + color:rgba({self._color_to_str(to_display.color)}); + ''') + + label.show() + return label + + @property + def _font(self) -> QFont: + """Return font to use in pyqt label.""" + font = QFontDatabase.systemFont(QFontDatabase.TitleFont) + font.setPointSize(round(self._style.font_multiplier*self.width())) + font.setBold(True) + return font + + @staticmethod + def _color_to_str(color: QColor) -> str: return f''' + {color.red()}, {color.green()}, {color.blue()}, {color.alpha()}''' diff --git a/shortcut_composer/templates/pie_menu_utils/pie_manager.py b/shortcut_composer/templates/pie_menu_utils/pie_manager.py index 675988fe..5e22dc2b 100644 --- a/shortcut_composer/templates/pie_menu_utils/pie_manager.py +++ b/shortcut_composer/templates/pie_menu_utils/pie_manager.py @@ -8,8 +8,8 @@ from api_krita.pyqt import Timer from composer_utils import Config from .pie_widget import PieWidget -from .label import Label -from .circle_points import CirclePoints +from .label_widget import LabelWidget +from .widget_utils import CirclePoints class PieManager: @@ -23,34 +23,74 @@ class PieManager: def __init__(self, widget: PieWidget) -> None: self._widget = widget - self._timer = Timer(self._track_angle, Config.get_sleep_time()) + self._holder = self._widget.widget_holder + self._timer = Timer(self._handle_cursor, Config.get_sleep_time()) + self._animator = LabelAnimator(widget) self._circle: CirclePoints - def start(self): + 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._timer.start() - def stop(self): + def stop(self) -> None: """Hide the widget and stop the mouse tracking loop.""" self._timer.stop() + for label in self._widget.labels: + label.activation_progress.reset() self._widget.hide() - def _track_angle(self): - """Block a thread contiguously setting an active label.""" + 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(): + return self.stop() + cursor = QCursor().pos() if self._circle.distance(cursor) < self._widget.deadzone: - label = None - else: - angle = self._circle.angle_from_point(cursor) - label = self._widget.labels.from_angle(round(angle)) - self._set_active_label(label) - - def _set_active_label(self, label: Optional[Label]): - """Mark label as active and ask the widget to repaint.""" - if self._widget.labels.active != label: - self._widget.labels.active = label - self._widget.repaint() + return self._set_active_widget(None) + + angle = self._circle.angle_from_point(cursor) + self._set_active_widget(self._holder.on_angle(angle)) + + def _set_active_widget(self, widget: Optional[LabelWidget]) -> None: + """Mark label as active and start animating the change.""" + if self._holder.active != widget: + self._holder.active = widget + self._animator.start() + + +class LabelAnimator: + """ + Controls the animation of background under pie labels. + + Handles the whole widget at once, to prevent unnecessary repaints. + """ + + def __init__(self, widget: PieWidget) -> None: + self._widget = widget + self._children = widget.widget_holder + self._timer = Timer(self._update, Config.get_sleep_time()) + + def start(self) -> None: + """Start animating. The animation will stop automatically.""" + self._timer.start() + + 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() + else: + widget.label.activation_progress.down() + + self._widget.repaint() + for widget in self._children: + if widget.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 4f09ace8..0561fefa 100644 --- a/shortcut_composer/templates/pie_menu_utils/pie_style.py +++ b/shortcut_composer/templates/pie_menu_utils/pie_style.py @@ -29,32 +29,30 @@ def __init__( self, pie_radius_scale: float, icon_radius_scale: float, + icons_amount: int, background_color: Optional[QColor], active_color: QColor, ) -> None: + self._icons_amount = icons_amount + 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)) - base_size = Krita.screen_size/2560 - - self.pie_radius = round( - 165 * base_size + self.pie_radius: int = round( + 165 * self._base_size * self.pie_radius_scale - * Config.PIE_GLOBAL_SCALE.read() - ) - self.icon_radius = round( - 50 * base_size - * self.icon_radius_scale - * Config.PIE_ICON_GLOBAL_SCALE.read() - ) - self.deadzone_radius: float = ( - 40 * base_size - * Config.PIE_DEADZONE_GLOBAL_SCALE.read() - ) + * 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) @@ -69,21 +67,31 @@ def __init__( min(self.icon_color.red()+15, 255), min(self.icon_color.green()+15, 255), min(self.icon_color.blue()+15, 255), - 255 - ) + 255) - font_multiplier = self.SYSTEM_FONT_SIZE[platform.system()] - self.font_size: int = round(self.icon_radius*font_multiplier) + self.font_multiplier = self.SYSTEM_FONT_SIZE[platform.system()] - def adapt_to_item_amount(self, amount: int) -> None: - """Modify the style to make it fit the given amount of labels.""" - if not amount: - self.deadzone_radius = float("inf") - return - max_icon_size = round(self.pie_radius * math.pi / amount) - self.icon_radius = min(self.icon_radius, max_icon_size) + def _pick_icon_radius(self) -> int: + """Icons radius depend on settings, but they have to fit in the pie.""" + icon_radius: int = round( + 50 * self._base_size + * self.icon_radius_scale + * Config.PIE_ICON_GLOBAL_SCALE.read() + ) + max_icon_size = round(self.pie_radius * math.pi / self._icons_amount) + return min(icon_radius, max_icon_size) + + def _pick_deadzone_radius(self) -> float: + """Deadzone can be configured, but when pie is empty, becomes inf.""" + if not self._icons_amount: + 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 if Krita.is_light_theme_active: @@ -91,8 +99,9 @@ def _pick_background_color(self, color: Optional[QColor]) -> QColor: return QColor(75, 75, 75, 190) SYSTEM_FONT_SIZE = { - "Linux": 0.40, - "Windows": 0.25, - "Darwin": 0.6, - "": 0.25, + "Linux": 0.175, + "Windows": 0.11, + "Darwin": 0.265, + "": 0.125, } + """Scale to fix different font sizes each OS..""" diff --git a/shortcut_composer/templates/pie_menu_utils/pie_widget.py b/shortcut_composer/templates/pie_menu_utils/pie_widget.py index 969251c2..41ad3db2 100644 --- a/shortcut_composer/templates/pie_menu_utils/pie_widget.py +++ b/shortcut_composer/templates/pie_menu_utils/pie_widget.py @@ -1,19 +1,26 @@ # SPDX-FileCopyrightText: © 2022 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later -from typing import List +from typing import List, Optional -from PyQt5.QtCore import Qt, QPoint -from PyQt5.QtGui import QColor, QPaintEvent - -from api_krita.pyqt import AnimatedWidget, Painter +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QPaintEvent, QDragMoveEvent, QDragEnterEvent +from api_krita.pyqt import Painter, AnimatedWidget, BaseWidget from composer_utils import Config from .pie_style import PieStyle -from .label import LabelPainter -from .label_holder import LabelHolder - - -class PieWidget(AnimatedWidget): +from .label import Label +from .label_widget import LabelWidget +from .widget_utils import ( + WidgetHolder, + CirclePoints, + AcceptButton, + PiePainter, + EditMode, +) +from .label_widget_utils import create_label_widget + + +class PieWidget(AnimatedWidget, BaseWidget): """ PyQt5 widget with icons on ring that can be selected by hovering. @@ -22,25 +29,47 @@ class PieWidget(AnimatedWidget): - hide() - hides the widget - repaint() - updates widget display after its data was changed - Overrides paintEvent(QPaintEvent) which tells how the widget looks + 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. + + 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 - - Paints the widget: its base, and active pie and deadzone indicator - - Wraps Labels with LabelPainter which activated, paint them - - Extends widget interface to allow moving the widget on screen by - providing the widget center. + 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. """ + edit_mode = EditMode() + def __init__( self, - labels: LabelHolder, style: PieStyle, + labels: List[Label], + config_to_write_back: Optional[Config] = None, parent=None ): - super().__init__(parent, Config.PIE_ANIMATION_TIME.read()) - self.labels = labels + 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._label_painters = self._create_label_painters() + 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 | @@ -50,94 +79,63 @@ def __init__( self.setStyleSheet("background: transparent;") self.setCursor(Qt.CrossCursor) - size = self._style.widget_radius*2 - self.setGeometry(0, 0, size, size) - - @property - def center(self) -> QPoint: - """Return point with center widget's point in its coordinates.""" - return QPoint(self._style.widget_radius, self._style.widget_radius) - - @property - def center_global(self) -> QPoint: - """Return point with center widget's point in screen coordinates.""" - return self.pos() + self.center # type: ignore - @property def deadzone(self) -> float: """Return the deadzone distance.""" return self._style.deadzone_radius - def move_center(self, new_center: QPoint) -> None: - """Move the widget by providing a new center point.""" - self.move(new_center-self.center) # type: ignore + def 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: - self._paint_deadzone_indicator(painter) - self._paint_base_wheel(painter) - self._paint_active_pie(painter) - self._paint_base_border(painter) - - for label_painter in self._label_painters: - label_painter.paint(painter) - - def _paint_base_wheel(self, painter: Painter) -> None: - """Paint a base circle and low opacity background to trick Windows.""" - painter.paint_wheel( - center=self.center, - outer_radius=self._style.no_border_radius, - color=QColor(128, 128, 128, 1), - ) - painter.paint_wheel( - center=self.center, - outer_radius=self._style.no_border_radius, - color=self._style.background_color, - thickness=self._style.area_thickness, - ) - - def _paint_base_border(self, painter: Painter) -> None: - """Paint a border on the inner edge of base circle.""" - painter.paint_wheel( - center=self.center, - outer_radius=self._style.inner_edge_radius, - color=self._style.border_color, - thickness=self._style.border_thickness, - ) - - def _paint_deadzone_indicator(self, painter: Painter) -> None: - """Paint the circle representing deadzone, when its valid.""" - if self.deadzone == float("inf"): - return - - painter.paint_wheel( - center=self.center, - outer_radius=self.deadzone, - color=QColor(128, 255, 128, 120), - thickness=1, - ) - painter.paint_wheel( - center=self.center, - outer_radius=self.deadzone-1, - color=QColor(255, 128, 128, 120), - thickness=1, - ) - - def _paint_active_pie(self, painter: Painter) -> None: - """Paint a pie representing active label if there is one.""" - if not self.labels.active: - return - - painter.paint_pie( - center=self.center, - outer_radius=self._style.no_border_radius, - angle=self.labels.active.angle, - span=360//len(self._label_painters), - color=self._style.active_color, - thickness=self._style.area_thickness, - ) - - def _create_label_painters(self) -> List[LabelPainter]: - """Wrap all labels with LabelPainter which can paint it.""" - return [label.get_painter(self, self._style) for label in self.labels] + PiePainter(painter, self.labels, self._style, self.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() + + def dragMoveEvent(self, e: QDragMoveEvent) -> None: + """Swap children during drag when mouse is moved to another zone.""" + pos = e.pos() + source_widget = e.source() + + if (self._circle_points.distance(pos) < self._style.deadzone_radius + or not isinstance(source_widget, LabelWidget)): + return e.accept() + + # 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() + + self.widget_holder.swap(widget, source_widget) + self.repaint() + e.accept() + + 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 diff --git a/shortcut_composer/templates/pie_menu_utils/widget_utils/__init__.py b/shortcut_composer/templates/pie_menu_utils/widget_utils/__init__.py new file mode 100644 index 00000000..fd578206 --- /dev/null +++ b/shortcut_composer/templates/pie_menu_utils/widget_utils/__init__.py @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: © 2022 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 .pie_painter import PiePainter +from .edit_mode import EditMode + +__all__ = [ + "CirclePoints", + "WidgetHolder", + "AcceptButton", + "PiePainter", + "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 new file mode 100644 index 00000000..f4cffbc3 --- /dev/null +++ b/shortcut_composer/templates/pie_menu_utils/widget_utils/accept_button.py @@ -0,0 +1,45 @@ +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/circle_points.py b/shortcut_composer/templates/pie_menu_utils/widget_utils/circle_points.py similarity index 94% rename from shortcut_composer/templates/pie_menu_utils/circle_points.py rename to shortcut_composer/templates/pie_menu_utils/widget_utils/circle_points.py index 6a136911..7c8739fe 100644 --- a/shortcut_composer/templates/pie_menu_utils/circle_points.py +++ b/shortcut_composer/templates/pie_menu_utils/widget_utils/circle_points.py @@ -21,11 +21,11 @@ class CirclePoints: - iterate over points, when the circle is divided into even parts """ - def __init__(self, center: QPoint, radius: int): + def __init__(self, center: QPoint, radius: int) -> None: self._center = center self._radius = radius - def distance(self, point: QPoint): + def distance(self, point: QPoint) -> float: """Count distance between pie center and cursor position.""" distance = (self._center.x() - point.x()) ** 2 distance += (self._center.y() - point.y()) ** 2 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 new file mode 100644 index 00000000..d0f0c73a --- /dev/null +++ b/shortcut_composer/templates/pie_menu_utils/widget_utils/edit_mode.py @@ -0,0 +1,44 @@ +from enum import Enum +from composer_utils import Config +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from ..pie_widget import PieWidget + + +class EditMode: + """ + Descriptor that handles the edit mode of PieWidget. + + 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. + """ + + def __init__(self) -> None: + self._edit_mode = False + + 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: + """Update the mode and change Pie's content accordingly.""" + if not mode_to_set and self._edit_mode: + self._write_settings(obj) + + self._edit_mode = mode_to_set + if mode_to_set: + obj.accept_button.show() + else: + obj.accept_button.hide() + + def _write_settings(self, obj: 'PieWidget') -> None: + """If values were not hardcoded, but from config, write them back.""" + if not obj.labels or obj.config_to_write_back 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)) 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 new file mode 100644 index 00000000..44cc70da --- /dev/null +++ b/shortcut_composer/templates/pie_menu_utils/widget_utils/pie_painter.py @@ -0,0 +1,123 @@ +# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from typing import List +from dataclasses import dataclass + +from PyQt5.QtCore import QPoint +from PyQt5.QtGui import QColor + +from api_krita.pyqt import Painter +from ..pie_style import PieStyle +from ..label import Label + + +@dataclass +class PiePainter: + """Uses provided painter and parts of widget information to paint it.""" + + painter: Painter + labels: List[Label] + style: PieStyle + edit_mode: bool + + def __post_init__(self): + """Paint the widget which created the passed painter.""" + self._paint_deadzone_indicator() + self._paint_base_wheel() + self._paint_active_pie() + self._paint_base_border() + + @property + def _center(self) -> QPoint: + """Return point with center widget's point in its coordinates.""" + return QPoint(self.style.widget_radius, self.style.widget_radius) + + def _paint_deadzone_indicator(self) -> None: + """Paint the circle representing deadzone, when its valid.""" + if self.style.deadzone_radius == float("inf"): + return + + self.painter.paint_wheel( + center=self._center, + outer_radius=self.style.deadzone_radius, + color=QColor(128, 255, 128, 120), + thickness=1, + ) + self.painter.paint_wheel( + center=self._center, + outer_radius=self.style.deadzone_radius-1, + color=QColor(255, 128, 128, 120), + thickness=1, + ) + + def _paint_base_wheel(self) -> None: + """Paint a base circle.""" + # NOTE: Windows10 does not treat the transparent center as part + # 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), + ) + self.painter.paint_wheel( + center=self._center, + outer_radius=self.style.no_border_radius, + color=self.style.background_color, + thickness=self.style.area_thickness, + ) + + def _paint_base_border(self) -> None: + """Paint a border on the inner edge of base circle.""" + self.painter.paint_wheel( + center=self._center, + outer_radius=self.style.inner_edge_radius, + color=self.style.border_color, + thickness=self.style.border_thickness, + ) + + def _paint_active_pie(self) -> None: + """Paint a pie behind a label which is active or during animation.""" + for label in self.labels: + if not label.activation_progress.value: + continue + + thickness_addition = round( + 0.15 * label.activation_progress.value + * self.style.area_thickness) + + self.painter.paint_pie( + center=self._center, + outer_radius=self.style.no_border_radius + thickness_addition, + angle=label.angle, + span=360//len(self.labels), + color=self._pick_pie_color(label), + 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) + return self._overlay_colors( + self.style.icon_color, + self.style.border_color, + opacity=label.activation_progress.value) + + @staticmethod + def _overlay_colors(base: QColor, over: QColor, opacity: float) -> QColor: + """Merge two colors by overlaying one on another with given opacity.""" + opacity_negation = 1-opacity + return QColor( + round(base.red()*opacity_negation + over.red()*opacity), + round(base.green()*opacity_negation + over.green()*opacity), + round(base.blue()*opacity_negation + over.blue()*opacity)) 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 new file mode 100644 index 00000000..b1061f75 --- /dev/null +++ b/shortcut_composer/templates/pie_menu_utils/widget_utils/widget_holder.py @@ -0,0 +1,62 @@ +# SPDX-FileCopyrightText: © 2022 Wojciech Trybus +# SPDX-License-Identifier: GPL-3.0-or-later + +from typing import Dict, Iterator, Optional +from ..label_widget import LabelWidget + + +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. + """ + + 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 angle(self, widget: LabelWidget) -> int: + """Return at which angle 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 __iter__(self) -> Iterator[LabelWidget]: + """Iterate over all held LabelWidgets.""" + for angle in sorted(self.angles()): + yield self._widgets[angle] + + def __len__(self) -> int: + """Return amount of held LabelWidgets.""" + return len(self._widgets)