# SPDX-FileCopyrightText: © 2022-2023 Wojciech Trybus # SPDX-License-Identifier: GPL-3.0-or-later from typing import List, Type, TypeVar, Generic, Optional from functools import cached_property from enum import Enum from PyQt5.QtCore import QPoint from PyQt5.QtGui import QColor from api_krita import Krita from core_components import Controller, Instruction from .pie_menu_utils.settings_gui import ( PieSettings, NumericPieSettings, PresetPieSettings, EnumPieSettings) from .pie_menu_utils import ( NonPresetPieConfig, PresetPieConfig, PieManager, PieConfig, PieWidget, PieStyle, Label) from .pie_menu_utils.widget_utils import EditMode, PieButton from .raw_instructions import RawInstructions T = TypeVar('T') class PieMenu(RawInstructions, Generic[T]): """ Pick value by hovering over a pie menu widget. - Widget is displayed under the cursor between key press and release - Moving mouse in a direction of a value activates in on key release - When the mouse was not moved past deadzone, value is not changed - Edit button activates mode in which pie does not hide on key release and can be configured ### Arguments: - `name` -- unique name of action. Must match the definition in shortcut_composer.action file - `controller` -- defines which krita property will be modified - `values` -- default list of values to display in pie - `instructions` -- (optional) list of additional instructions to perform on key press and release - `pie_radius_scale` -- (optional) default widget size multiplier - `icon_radius_scale` -- (optional) default icons size multiplier - `background_color` -- (optional) default rgba color of background - `active_color` -- (optional) default rgba color of active pie - `short_vs_long_press_time` -- (optional) time [s] that specifies if key press is short or long ### Action implementation example: Action is meant to change opacity of current layer to one of predefined values using the pie menu widget. ```python templates.PieMenu( name="Pick active layer opacity", controller=controllers.LayerOpacityController(), values=[100, 90, 80, 70, 60, 50, 40, 30, 20, 10], pie_radius_scale=1.3 # 30% larger menu icon_radius_scale=0.9 # 10% smaller icons background_color=QColor(255, 0, 0, 128) # 50% red active_color=QColor(0, 0, 255) # 100% blue ) ``` """ def __init__( self, *, name: str, controller: Controller[T], values: List[T], instructions: Optional[List[Instruction]] = None, pie_radius_scale: float = 1.0, icon_radius_scale: float = 1.0, background_color: Optional[QColor] = None, active_color: QColor = QColor(100, 150, 230, 255), save_local: bool = False, short_vs_long_press_time: Optional[float] = None ) -> None: super().__init__(name, instructions, short_vs_long_press_time) self._controller = controller def _dispatch_config_type() -> Type[PieConfig[T]]: if issubclass(self._controller.TYPE, str): return PresetPieConfig # type: ignore return NonPresetPieConfig self._config = _dispatch_config_type()(**{ "name": f"ShortcutComposer: {name}", "values": values, "pie_radius_scale": pie_radius_scale, "icon_radius_scale": icon_radius_scale, "save_local": save_local, "background_color": background_color, "active_color": active_color}) self._config.ORDER.register_callback(self._reset_labels) self._labels: List[Label] = [] self._edit_mode = EditMode(self) self._style = PieStyle(items=self._labels, pie_config=self._config) @cached_property def pie_widget(self) -> PieWidget: """Qwidget of the Pie for selecting values.""" return PieWidget( style=self._style, labels=self._labels, config=self._config) @cached_property def pie_settings(self) -> PieSettings: """Create and return the right settings based on labels type.""" if issubclass(self._controller.TYPE, str): return PresetPieSettings(self._config, self._style) # type: ignore elif issubclass(self._controller.TYPE, float): return NumericPieSettings(self._config, self._style) elif issubclass(self._controller.TYPE, Enum): return EnumPieSettings( self._controller, self._config, self._style) # type: ignore raise ValueError(f"Unknown pie config {self._config}") @cached_property def pie_manager(self) -> PieManager: """Manager which shows, hides and moves Pie widget and its settings.""" return PieManager(pie_widget=self.pie_widget) @cached_property def settings_button(self): """Button with which user can enter the edit mode.""" settings_button = PieButton( icon=Krita.get_icon("properties"), icon_scale=1.1, parent=self.pie_widget, radius_callback=lambda: self._style.setting_button_radius, style=self._style, config=self._config) settings_button.clicked.connect(lambda: self._edit_mode.set(True)) return settings_button @cached_property def accept_button(self): """Button displayed in edit mode, which allows to hide the pie.""" accept_button = PieButton( icon=Krita.get_icon("dialog-ok"), icon_scale=1.5, parent=self.pie_widget, radius_callback=lambda: self._style.accept_button_radius, style=self._style, config=self._config) accept_button.clicked.connect(lambda: self._edit_mode.set(False)) accept_button.hide() return accept_button def _move_buttons(self): """Move accept and setting buttons to their correct positions.""" self.accept_button.move_center(self.pie_widget.center) self.settings_button.move(QPoint( self.pie_widget.width()-self.settings_button.width(), self.pie_widget.height()-self.settings_button.height())) def on_key_press(self) -> None: """Handle the event of user pressing the action key.""" super().on_key_press() if self.pie_widget.isVisible(): return self._controller.refresh() self._reset_labels() self.pie_widget.label_holder.reset() # HACK: should be automatic self._move_buttons() self.pie_manager.start() def on_every_key_release(self) -> None: """ Handle the key release event. Ignore if in edit mode. Otherwise, stop the manager and set the selected value if deadzone was reached. """ super().on_every_key_release() if self._edit_mode.get(): return self.pie_manager.stop() if label := self.pie_widget.active: self._controller.set_value(label.value) INVALID_VALUES: 'set[T]' = set() def _reset_labels(self) -> None: """Replace list values with newly created labels.""" values = self._config.values() # Workaround of krita tags sometimes returning invalid presets # Bad values are remembered in class attribute and filtered out filtered_values = [v for v in values if v not in self.INVALID_VALUES] current_values = [label.value for label in self._labels] # Method is expensive, and should not be performed when values # did not in fact change. if filtered_values == current_values: return self._labels.clear() for value in values: label = Label.from_value(value, self._controller) if label is not None: self._labels.append(label) else: self.INVALID_VALUES.add(value) self._config.refresh_order()