From c2ecc428342e894f23e6bfee8c206859d293535f Mon Sep 17 00:00:00 2001 From: DS_Litvinov Date: Thu, 20 Nov 2025 15:59:15 +0300 Subject: [PATCH 1/5] new features --- .gitignore | 6 +- GIT_SYNC_FIX.md | 73 ++ beeref/__main__.py | 6 +- beeref/actions/actions.py | 8 +- beeref/actions/menu_structure.py | 1 + beeref/assets/__init__.py | 27 +- beeref/assets/icons/close.svg | 4 + beeref/assets/icons/crop.svg | 3 + beeref/assets/icons/drag-and-drop.svg | 5 + beeref/assets/icons/draw-line.svg | 3 + beeref/assets/icons/flip_h.svg | 3 + beeref/assets/icons/flip_v.svg | 3 + beeref/assets/icons/fonts.svg | 3 + beeref/assets/icons/format-bold.svg | 3 + beeref/assets/icons/format-color-text.svg | 3 + beeref/assets/icons/format-italic.svg | 3 + beeref/assets/icons/format-strikethrough.svg | 4 + beeref/assets/icons/format-underline.svg | 3 + beeref/assets/icons/frames.svg | 3 + beeref/assets/icons/gamut.svg | 3 + beeref/assets/icons/grayscale.svg | 4 + beeref/assets/icons/line-arrow-both.svg | 4 + beeref/assets/icons/line-arrow-left.svg | 3 + beeref/assets/icons/line-arrow.svg | 3 + beeref/assets/icons/line-dashed.svg | 5 + beeref/assets/icons/line-solid.svg | 3 + beeref/assets/icons/next-frame.svg | 3 + beeref/assets/icons/opacity.svg | 3 + beeref/assets/icons/palette.svg | 3 + beeref/assets/icons/pause.svg | 3 + beeref/assets/icons/play.svg | 3 + beeref/assets/icons/prev-frame.svg | 3 + beeref/assets/icons/small-down.svg | 3 + beeref/assets/icons/trash-alt.svg | 3 + beeref/assets/icons/undo.svg | 3 + beeref/constants.py | 166 ++++ beeref/fileio/__init__.py | 23 +- beeref/fileio/image.py | 16 + beeref/fileio/sql.py | 44 +- beeref/gif_item.py | 303 +++++++ beeref/items.py | 470 ++++++++++- beeref/scene.py | 13 +- beeref/view.py | 805 ++++++++++++++++++- beeref/widgets/__init__.py | 7 + beeref/widgets/color_gamut.py | 64 +- beeref/widgets/color_picker.py | 351 ++++++++ beeref/widgets/controls/mouse.py | 1 + beeref/widgets/draw_floating_menu.py | 205 +++++ beeref/widgets/floating_menu.py | 293 +++++++ beeref/widgets/gif_floating_menu.py | 231 ++++++ beeref/widgets/gif_frames_menu.py | 407 ++++++++++ beeref/widgets/image_floating_menu.py | 79 ++ beeref/widgets/text_floating_menu.py | 185 +++++ beeref/widgets/welcome_overlay.py | 153 +++- 54 files changed, 3957 insertions(+), 75 deletions(-) create mode 100644 GIT_SYNC_FIX.md create mode 100644 beeref/assets/icons/close.svg create mode 100644 beeref/assets/icons/crop.svg create mode 100644 beeref/assets/icons/drag-and-drop.svg create mode 100644 beeref/assets/icons/draw-line.svg create mode 100644 beeref/assets/icons/flip_h.svg create mode 100644 beeref/assets/icons/flip_v.svg create mode 100644 beeref/assets/icons/fonts.svg create mode 100644 beeref/assets/icons/format-bold.svg create mode 100644 beeref/assets/icons/format-color-text.svg create mode 100644 beeref/assets/icons/format-italic.svg create mode 100644 beeref/assets/icons/format-strikethrough.svg create mode 100644 beeref/assets/icons/format-underline.svg create mode 100644 beeref/assets/icons/frames.svg create mode 100644 beeref/assets/icons/gamut.svg create mode 100644 beeref/assets/icons/grayscale.svg create mode 100644 beeref/assets/icons/line-arrow-both.svg create mode 100644 beeref/assets/icons/line-arrow-left.svg create mode 100644 beeref/assets/icons/line-arrow.svg create mode 100644 beeref/assets/icons/line-dashed.svg create mode 100644 beeref/assets/icons/line-solid.svg create mode 100644 beeref/assets/icons/next-frame.svg create mode 100644 beeref/assets/icons/opacity.svg create mode 100644 beeref/assets/icons/palette.svg create mode 100644 beeref/assets/icons/pause.svg create mode 100644 beeref/assets/icons/play.svg create mode 100644 beeref/assets/icons/prev-frame.svg create mode 100644 beeref/assets/icons/small-down.svg create mode 100644 beeref/assets/icons/trash-alt.svg create mode 100644 beeref/assets/icons/undo.svg create mode 100644 beeref/gif_item.py create mode 100644 beeref/widgets/color_picker.py create mode 100644 beeref/widgets/draw_floating_menu.py create mode 100644 beeref/widgets/floating_menu.py create mode 100644 beeref/widgets/gif_floating_menu.py create mode 100644 beeref/widgets/gif_frames_menu.py create mode 100644 beeref/widgets/image_floating_menu.py create mode 100644 beeref/widgets/text_floating_menu.py diff --git a/.gitignore b/.gitignore index f0b0c63..6d1fb86 100644 --- a/.gitignore +++ b/.gitignore @@ -134,4 +134,8 @@ dmypy.json # github pages Gemfile.lock _site -.bundle \ No newline at end of file +.bundle + +# macOS +.DS_Store +**/.DS_Store \ No newline at end of file diff --git a/GIT_SYNC_FIX.md b/GIT_SYNC_FIX.md new file mode 100644 index 0000000..ce0007f --- /dev/null +++ b/GIT_SYNC_FIX.md @@ -0,0 +1,73 @@ +# Решение проблемы синхронизации Git + +## Проблема +При попытке выполнить `git push origin colorpicker` возникает ошибка: +``` +remote: Internal Server Error +! [remote rejected] colorpicker -> colorpicker (Internal Server Error) +``` + +## Текущий статус +- Локальная ветка опережает удаленную на **2 коммита** +- Все изменения сохранены локально +- Проблема на стороне GitHub сервера + +## Решения + +### Вариант 1: Повторить попытку позже (рекомендуется) +Это временная проблема GitHub. Попробуйте через 10-15 минут: +```bash +git push origin colorpicker +``` + +### Вариант 2: Проверить настройки репозитория на GitHub +1. Откройте https://github.com/DSLitvinov/beeref/settings +2. Проверьте раздел "Branches" - возможно ветка `colorpicker` защищена +3. Если ветка защищена, может потребоваться создать Pull Request вместо прямого push + +### Вариант 3: Использовать веб-интерфейс GitHub +1. Откройте https://github.com/DSLitvinov/beeref +2. Создайте Pull Request из локальной ветки через веб-интерфейс +3. Или используйте GitHub Desktop приложение + +### Вариант 4: Проверить права доступа +Убедитесь, что у вас есть права на запись в репозиторий: +- Проверьте, что вы авторизованы в GitHub +- Проверьте настройки доступа к репозиторию + +### Вариант 5: Использовать другой метод аутентификации +Если используете HTTPS, попробуйте настроить SSH ключи: +```bash +# Генерация SSH ключа (если еще нет) +ssh-keygen -t ed25519 -C "your_email@example.com" + +# Добавление ключа в ssh-agent +eval "$(ssh-agent -s)" +ssh-add ~/.ssh/id_ed25519 + +# Копирование публичного ключа для добавления в GitHub +cat ~/.ssh/id_ed25519.pub + +# Затем добавьте ключ в GitHub Settings > SSH and GPG keys +# И измените remote URL: +git remote set-url origin git@github.com:DSLitvinov/beeref.git +``` + +## Текущие локальные коммиты +1. `84c6c5a` - Fix critical code issues: assertions, division by zero, validation +2. `181350a` - Add code review documentation + +Все изменения сохранены и будут отправлены при успешном push. + +## Проверка статуса +```bash +# Проверить статус +git status + +# Посмотреть коммиты, которые нужно отправить +git log origin/colorpicker..HEAD --oneline + +# Попробовать push +git push origin colorpicker +``` + diff --git a/beeref/__main__.py b/beeref/__main__.py index a3653b5..bc9dd58 100755 --- a/beeref/__main__.py +++ b/beeref/__main__.py @@ -109,9 +109,11 @@ def main(): logger.info(f'Logging to: {logfile_name()}') settings.on_startup() args = CommandlineArgs(with_check=True) # Force checking - assert not args.debug_raise_error, args.debug_raise_error + if args.debug_raise_error: + raise RuntimeError(args.debug_raise_error) - os.environ["QT_DEBUG_PLUGINS"] = "1" + if args.loglevel == 'DEBUG': + os.environ["QT_DEBUG_PLUGINS"] = "1" app = BeeRefApplication(sys.argv) palette = create_palette_from_dict(constants.COLORS) app.setPalette(palette) diff --git a/beeref/actions/actions.py b/beeref/actions/actions.py index a9b7770..874026a 100644 --- a/beeref/actions/actions.py +++ b/beeref/actions/actions.py @@ -165,6 +165,12 @@ actions = ActionList([ shortcuts=['Ctrl+T'], callback='on_action_insert_text', ), + Action( + id='insert_draw', + text='&Draw', + shortcuts=['Ctrl+D'], + callback='on_action_insert_draw', + ), Action( id='undo', text='&Undo', @@ -202,7 +208,7 @@ actions = ActionList([ Action( id='delete', text='&Delete', - shortcuts=['Del'], + shortcuts=['Del', 'Backspace'], callback='on_action_delete_items', group='active_when_selection', ), diff --git a/beeref/actions/menu_structure.py b/beeref/actions/menu_structure.py index 9c70966..0822984 100644 --- a/beeref/actions/menu_structure.py +++ b/beeref/actions/menu_structure.py @@ -72,6 +72,7 @@ menu_structure = [ 'items': [ 'insert_images', 'insert_text', + 'insert_draw', ], }, { diff --git a/beeref/assets/__init__.py b/beeref/assets/__init__.py index 2507233..237c089 100644 --- a/beeref/assets/__init__.py +++ b/beeref/assets/__init__.py @@ -18,7 +18,7 @@ from importlib.resources import files as rsc_files import logging -from PyQt6 import QtGui, QtWidgets +from PyQt6 import QtGui, QtWidgets, QtSvg logger = logging.getLogger(__name__) @@ -45,6 +45,8 @@ class BeeAssets: 'cursor_flip_h.png', (20, 20)) self.cursor_flip_v = self.cursor_from_image( 'cursor_flip_v.png', (20, 20)) + self.cursor_draw_line = self.cursor_from_svg( + 'icons/draw-line.svg', (12, 12)) def cursor_from_image(self, filename, hotspot): app = QtWidgets.QApplication.instance() @@ -55,3 +57,26 @@ class BeeAssets: pixmap.setDevicePixelRatio(scaling) return QtGui.QCursor( pixmap, int(hotspot[0]/scaling), int(hotspot[1]/scaling)) + + def cursor_from_svg(self, filename, hotspot, size=24): + """Создает курсор из SVG файла.""" + app = QtWidgets.QApplication.instance() + scaling = app.primaryScreen().devicePixelRatio() + + # Загружаем SVG + svg_path = str(self.PATH.joinpath(filename)) + renderer = QtSvg.QSvgRenderer(svg_path) + + # Создаем pixmap нужного размера + pixmap_size = int(size * scaling) + pixmap = QtGui.QPixmap(pixmap_size, pixmap_size) + pixmap.fill(QtGui.QColor(0, 0, 0, 0)) # Прозрачный фон + + # Рендерим SVG в pixmap + painter = QtGui.QPainter(pixmap) + renderer.render(painter) + painter.end() + + pixmap.setDevicePixelRatio(scaling) + return QtGui.QCursor( + pixmap, int(hotspot[0]/scaling), int(hotspot[1]/scaling)) diff --git a/beeref/assets/icons/close.svg b/beeref/assets/icons/close.svg new file mode 100644 index 0000000..ad0c27f --- /dev/null +++ b/beeref/assets/icons/close.svg @@ -0,0 +1,4 @@ + + + + diff --git a/beeref/assets/icons/crop.svg b/beeref/assets/icons/crop.svg new file mode 100644 index 0000000..2b00313 --- /dev/null +++ b/beeref/assets/icons/crop.svg @@ -0,0 +1,3 @@ + + + diff --git a/beeref/assets/icons/drag-and-drop.svg b/beeref/assets/icons/drag-and-drop.svg new file mode 100644 index 0000000..fadd459 --- /dev/null +++ b/beeref/assets/icons/drag-and-drop.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/beeref/assets/icons/draw-line.svg b/beeref/assets/icons/draw-line.svg new file mode 100644 index 0000000..a1f6d8d --- /dev/null +++ b/beeref/assets/icons/draw-line.svg @@ -0,0 +1,3 @@ + + + diff --git a/beeref/assets/icons/flip_h.svg b/beeref/assets/icons/flip_h.svg new file mode 100644 index 0000000..e5688f3 --- /dev/null +++ b/beeref/assets/icons/flip_h.svg @@ -0,0 +1,3 @@ + + + diff --git a/beeref/assets/icons/flip_v.svg b/beeref/assets/icons/flip_v.svg new file mode 100644 index 0000000..fd3e332 --- /dev/null +++ b/beeref/assets/icons/flip_v.svg @@ -0,0 +1,3 @@ + + + diff --git a/beeref/assets/icons/fonts.svg b/beeref/assets/icons/fonts.svg new file mode 100644 index 0000000..b3ee539 --- /dev/null +++ b/beeref/assets/icons/fonts.svg @@ -0,0 +1,3 @@ + + + diff --git a/beeref/assets/icons/format-bold.svg b/beeref/assets/icons/format-bold.svg new file mode 100644 index 0000000..d92dbca --- /dev/null +++ b/beeref/assets/icons/format-bold.svg @@ -0,0 +1,3 @@ + + + diff --git a/beeref/assets/icons/format-color-text.svg b/beeref/assets/icons/format-color-text.svg new file mode 100644 index 0000000..e40f5de --- /dev/null +++ b/beeref/assets/icons/format-color-text.svg @@ -0,0 +1,3 @@ + + + diff --git a/beeref/assets/icons/format-italic.svg b/beeref/assets/icons/format-italic.svg new file mode 100644 index 0000000..ddf0ee6 --- /dev/null +++ b/beeref/assets/icons/format-italic.svg @@ -0,0 +1,3 @@ + + + diff --git a/beeref/assets/icons/format-strikethrough.svg b/beeref/assets/icons/format-strikethrough.svg new file mode 100644 index 0000000..7f5ba27 --- /dev/null +++ b/beeref/assets/icons/format-strikethrough.svg @@ -0,0 +1,4 @@ + + + + diff --git a/beeref/assets/icons/format-underline.svg b/beeref/assets/icons/format-underline.svg new file mode 100644 index 0000000..f210ba6 --- /dev/null +++ b/beeref/assets/icons/format-underline.svg @@ -0,0 +1,3 @@ + + + diff --git a/beeref/assets/icons/frames.svg b/beeref/assets/icons/frames.svg new file mode 100644 index 0000000..5db745e --- /dev/null +++ b/beeref/assets/icons/frames.svg @@ -0,0 +1,3 @@ + + + diff --git a/beeref/assets/icons/gamut.svg b/beeref/assets/icons/gamut.svg new file mode 100644 index 0000000..3383273 --- /dev/null +++ b/beeref/assets/icons/gamut.svg @@ -0,0 +1,3 @@ + + + diff --git a/beeref/assets/icons/grayscale.svg b/beeref/assets/icons/grayscale.svg new file mode 100644 index 0000000..7675bb8 --- /dev/null +++ b/beeref/assets/icons/grayscale.svg @@ -0,0 +1,4 @@ + + + + diff --git a/beeref/assets/icons/line-arrow-both.svg b/beeref/assets/icons/line-arrow-both.svg new file mode 100644 index 0000000..3557e1d --- /dev/null +++ b/beeref/assets/icons/line-arrow-both.svg @@ -0,0 +1,4 @@ + + + + diff --git a/beeref/assets/icons/line-arrow-left.svg b/beeref/assets/icons/line-arrow-left.svg new file mode 100644 index 0000000..278850f --- /dev/null +++ b/beeref/assets/icons/line-arrow-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/beeref/assets/icons/line-arrow.svg b/beeref/assets/icons/line-arrow.svg new file mode 100644 index 0000000..59e5350 --- /dev/null +++ b/beeref/assets/icons/line-arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/beeref/assets/icons/line-dashed.svg b/beeref/assets/icons/line-dashed.svg new file mode 100644 index 0000000..cc0c2b3 --- /dev/null +++ b/beeref/assets/icons/line-dashed.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/beeref/assets/icons/line-solid.svg b/beeref/assets/icons/line-solid.svg new file mode 100644 index 0000000..853d485 --- /dev/null +++ b/beeref/assets/icons/line-solid.svg @@ -0,0 +1,3 @@ + + + diff --git a/beeref/assets/icons/next-frame.svg b/beeref/assets/icons/next-frame.svg new file mode 100644 index 0000000..f9ee17c --- /dev/null +++ b/beeref/assets/icons/next-frame.svg @@ -0,0 +1,3 @@ + + + diff --git a/beeref/assets/icons/opacity.svg b/beeref/assets/icons/opacity.svg new file mode 100644 index 0000000..64709e0 --- /dev/null +++ b/beeref/assets/icons/opacity.svg @@ -0,0 +1,3 @@ + + + diff --git a/beeref/assets/icons/palette.svg b/beeref/assets/icons/palette.svg new file mode 100644 index 0000000..9538b21 --- /dev/null +++ b/beeref/assets/icons/palette.svg @@ -0,0 +1,3 @@ + + + diff --git a/beeref/assets/icons/pause.svg b/beeref/assets/icons/pause.svg new file mode 100644 index 0000000..786bf5b --- /dev/null +++ b/beeref/assets/icons/pause.svg @@ -0,0 +1,3 @@ + + + diff --git a/beeref/assets/icons/play.svg b/beeref/assets/icons/play.svg new file mode 100644 index 0000000..b87b2c6 --- /dev/null +++ b/beeref/assets/icons/play.svg @@ -0,0 +1,3 @@ + + + diff --git a/beeref/assets/icons/prev-frame.svg b/beeref/assets/icons/prev-frame.svg new file mode 100644 index 0000000..0d45e98 --- /dev/null +++ b/beeref/assets/icons/prev-frame.svg @@ -0,0 +1,3 @@ + + + diff --git a/beeref/assets/icons/small-down.svg b/beeref/assets/icons/small-down.svg new file mode 100644 index 0000000..21dca97 --- /dev/null +++ b/beeref/assets/icons/small-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/beeref/assets/icons/trash-alt.svg b/beeref/assets/icons/trash-alt.svg new file mode 100644 index 0000000..ed1423a --- /dev/null +++ b/beeref/assets/icons/trash-alt.svg @@ -0,0 +1,3 @@ + + + diff --git a/beeref/assets/icons/undo.svg b/beeref/assets/icons/undo.svg new file mode 100644 index 0000000..b8da845 --- /dev/null +++ b/beeref/assets/icons/undo.svg @@ -0,0 +1,3 @@ + + + diff --git a/beeref/constants.py b/beeref/constants.py index ae8988a..269d075 100644 --- a/beeref/constants.py +++ b/beeref/constants.py @@ -45,3 +45,169 @@ COLORS = { 'Scene:Canvas': (60, 60, 60), 'Scene:Text': (200, 200, 200), } + + +def get_welcome_overlay_icon_style(): + return 'padding: 12px; margin-bottom: 12px;' + + +def get_standard_button_style(): + highlight = COLORS['Active:Highlight'] + text = COLORS['Active:ButtonText'] + return ( + 'QPushButton {' + f'color: rgb({text[0]}, {text[1]}, {text[2]});' + f'background-color: rgba({highlight[0]}, {highlight[1]}, {highlight[2]}, 0.25);' + 'padding: 0.6em 2em;' + 'border: none;' + 'border-radius: 6px;' + '}' + 'QPushButton:hover {' + f'background-color: rgba({highlight[0]}, {highlight[1]}, {highlight[2]}, 0.4);' + '}' + 'QPushButton:pressed {' + f'background-color: rgba({highlight[0]}, {highlight[1]}, {highlight[2]}, 0.6);' + '}' + ) + + +# Floating Menu Styles + +def _css_color(color): + """Return a CSS-compatible rgb/rgba string for the given color tuple.""" + length = len(color) + if length == 3: + r, g, b = color + return f"rgb({r}, {g}, {b})" + if length == 4: + r, g, b, a = color + return f"rgba({r}, {g}, {b}, {a})" + raise ValueError('Color tuples must have 3 (RGB) or 4 (RGBA) components.') + + +def get_floating_menu_base_style(): + """Base container style for floating menus.""" + bg = COLORS['Active:Window'] + border = COLORS['Active:Base'] + return f""" + QWidget#FloatingMenu {{ + background-color: {_css_color(bg)}; + border-radius: 8px; + border: 1px solid {_css_color(border)}; + }} + """ + + +def get_floating_menu_button_style(): + """Push button styling for floating menus.""" + background_color = (255, 255, 255, 30) + border_color = (255, 255, 255, 10) + inactive = COLORS['Disabled:Text'] + active = COLORS['Active:Text'] + accent = COLORS['Active:Highlight'] + + return f""" + QWidget#FloatingMenu QPushButton[floatingButton="true"] {{ + background-color: {_css_color(background_color)}; + color: {_css_color(inactive)}; + border: 1px solid {_css_color(border_color)}; + border-radius: 4px; + padding: 4px 10px; + font-weight: 600; + min-width: 12px; + min-height: 28px; + max-height: 28px; + }} + + QWidget#FloatingMenu QPushButton[floatingButton="true"]:hover, + QWidget#FloatingMenu QPushButton[floatingButton="true"]:checked, + QWidget#FloatingMenu QPushButton[floatingButton="true"][active="true"] {{ + color: {_css_color(active)}; + }} + + QWidget#FloatingMenu QPushButton[floatingButton="true"]:checked, + QWidget#FloatingMenu QPushButton[floatingButton="true"][active="true"] {{ + border-bottom: 2px solid {_css_color(accent)}; + }} + """ + + +def get_floating_menu_separator_style(): + """Separator styling for floating menus.""" + border_color = (255, 255, 255, 10) + r, g, b = border_color[:3] + + return f""" + QWidget#FloatingMenu QFrame#FloatingMenuSeparator {{ + background-color: rgba({r}, {g}, {b}, 30); + min-height: 28px; + max-height: 28px; + }} + """ + + +def get_floating_menu_combo_style(): + """Combo-box styling for floating menus.""" + from beeref.assets import BeeAssets + + bg = COLORS['Active:Button'] + active_color = COLORS['Active:Text'] + border_color = (255, 255, 255, 10) + border_r, border_g, border_b = border_color[:3] + + # Получаем путь к иконке стрелки + assets = BeeAssets() + arrow_icon_path = assets.PATH.joinpath('icons', 'small-down.svg') + # Используем прямой путь, экранируя обратные слеши для Windows + arrow_icon_path_str = str(arrow_icon_path).replace('\\', '/') + + return f""" + QWidget#FloatingMenu QComboBox, + QWidget#FloatingMenu QFontComboBox {{ + background-color: {_css_color(bg)}; + color: {_css_color(active_color)}; + border: 1px solid rgba({border_r}, {border_g}, {border_b}, 160); + border-radius: 4px; + padding: 4px 12px; + min-width: 40px; + min-height: 28px; + max-height: 28px; + }} + + QWidget#FloatingMenu QComboBox::drop-down, + QWidget#FloatingMenu QFontComboBox::drop-down {{ + border: none; + width: 16px; + }} + + QWidget#FloatingMenu QComboBox::down-arrow, + QWidget#FloatingMenu QFontComboBox::down-arrow {{ + image: url({arrow_icon_path_str}); + width: 12px; + height: 12px; + margin-right: 4px; + }} + + QWidget#FloatingMenu QComboBox QAbstractItemView, + QWidget#FloatingMenu QFontComboBox QAbstractItemView {{ + min-width: 152px; + }} + + QWidget#FloatingMenu QComboBox#FloatingMenuFontSize QAbstractItemView {{ + min-width: 80px; + }} + + QWidget#FloatingMenu QComboBox#FloatingMenuGifSpeed QAbstractItemView {{ + min-width: 80px; + }} + """ + + +def get_floating_menu_style(): + """Aggregate stylesheet for floating menus.""" + return ''.join([ + get_floating_menu_base_style(), + get_floating_menu_button_style(), + get_floating_menu_separator_style(), + get_floating_menu_combo_style(), + ]) diff --git a/beeref/fileio/__init__.py b/beeref/fileio/__init__.py index 5834d97..0e8ac72 100644 --- a/beeref/fileio/__init__.py +++ b/beeref/fileio/__init__.py @@ -19,7 +19,7 @@ from PyQt6 import QtCore from beeref import commands from beeref.fileio.errors import BeeFileIOError -from beeref.fileio.image import load_image +from beeref.fileio.image import load_image, is_gif_file from beeref.fileio.sql import SQLiteIO, is_bee_file from beeref.items import BeePixmapItem @@ -62,15 +62,24 @@ def load_images(filenames, pos, scene, worker): logger.info(f'Loading image from file {filename}') img, filename = load_image(filename) worker.progress.emit(i) - if img.isNull(): + + # Check if file is a GIF + if is_gif_file(filename): + from beeref.gif_item import BeeGifItem + item = BeeGifItem(filename=filename) + item.set_pos_center(pos) + scene.add_item_later({'item': item, 'type': 'gif'}, selected=True) + items.append(item) + elif img.isNull(): logger.info(f'Could not load file {filename}') errors.append(filename) continue - - item = BeePixmapItem(img, filename) - item.set_pos_center(pos) - scene.add_item_later({'item': item, 'type': 'pixmap'}, selected=True) - items.append(item) + else: + item = BeePixmapItem(img, filename) + item.set_pos_center(pos) + scene.add_item_later({'item': item, 'type': 'pixmap'}, selected=True) + items.append(item) + if worker.canceled: break # Give main thread time to process items: diff --git a/beeref/fileio/image.py b/beeref/fileio/image.py index 9e6449c..c18f0d6 100644 --- a/beeref/fileio/image.py +++ b/beeref/fileio/image.py @@ -29,6 +29,17 @@ import plum logger = logging.getLogger(__name__) +def is_gif_file(path): + """Проверяет, является ли файл GIF.""" + if isinstance(path, str): + ext = os.path.splitext(path)[1].lower() + return ext == '.gif' + elif hasattr(path, 'isLocalFile') and path.isLocalFile(): + ext = os.path.splitext(path.toLocalFile())[1].lower() + return ext == '.gif' + return False + + def exif_rotated_image(path=None): """Returns a QImage that is transformed according to the source's orientation EXIF data. @@ -84,9 +95,14 @@ def exif_rotated_image(path=None): def load_image(path): if isinstance(path, str): path = os.path.normpath(path) + # Проверяем, является ли файл GIF + if is_gif_file(path): + return (None, path) # Возвращаем None для изображения, путь для GIF return (exif_rotated_image(path), path) if path.isLocalFile(): path = os.path.normpath(path.toLocalFile()) + if is_gif_file(path): + return (None, path) return (exif_rotated_image(path), path) url = bytes(path.toEncoded()).decode() diff --git a/beeref/fileio/sql.py b/beeref/fileio/sql.py index 9e2a2b5..5882b6d 100644 --- a/beeref/fileio/sql.py +++ b/beeref/fileio/sql.py @@ -34,7 +34,7 @@ import tempfile from PyQt6 import QtGui from beeref import constants -from beeref.items import BeePixmapItem, BeeErrorItem +from beeref.items import BeePixmapItem, BeeErrorItem, BeeDrawItem from .errors import BeeFileIOError, IMG_LOADING_ERROR_MSG from .schema import SCHEMA, USER_VERSION, MIGRATIONS, APPLICATION_ID @@ -81,6 +81,7 @@ class SQLiteIO: self.readonly = readonly self.worker = worker self.retry = False + self._migration_retry_count = 0 def __del__(self): self._close_connection() @@ -115,6 +116,10 @@ class SQLiteIO: except Exception: # Updating a file failed; try creating it from scratch instead logger.exception('Error migrating bee file') + self._migration_retry_count += 1 + if self._migration_retry_count > 1: + # Prevent infinite recursion + raise self.create_new = True self._establish_connection() @@ -200,6 +205,12 @@ class SQLiteIO: ' items.data, null as data ' 'FROM items ' 'WHERE items.type = "text"')) + # Fetch draw items separately + rows.extend(self.fetchall( + 'SELECT items.id, type, x, y, z, scale, rotation, flip, ' + ' items.data, null as data ' + 'FROM items ' + 'WHERE items.type = "draw"')) if self.worker: self.worker.begin_processing.emit(len(rows)) @@ -225,7 +236,28 @@ class SQLiteIO: + IMG_LOADING_ERROR_MSG) data['type'] = BeeErrorItem.TYPE data['item'] = item - + elif data['type'] == 'gif': + from beeref.gif_item import BeeGifItem + import tempfile + # Save GIF data to temporary file + if row[9]: # If there's data in sqlar + with tempfile.NamedTemporaryFile( + suffix='.gif', delete=False) as tmp_file: + tmp_file.write(row[9]) + tmp_filename = tmp_file.name + item = BeeGifItem(filename=tmp_filename) + # Save original filename if available + if 'filename' in data['data']: + item.filename = data['data']['filename'] + else: + # If no data, use original file + filename = data['data'].get('filename') + item = BeeGifItem(filename=filename) + data['item'] = item + elif data['type'] == 'draw': + # Create drawing item from saved data + item = BeeDrawItem.create_from_data(**data) + data['item'] = item self.scene.add_item_later(data) if self.worker: @@ -311,6 +343,14 @@ class SQLiteIO: 'INSERT INTO sqlar (item_id, name, mode, sz, data) ' 'VALUES (?, ?, ?, ?, ?)', (item.save_id, name, 0o644, len(pixmap), pixmap)) + elif hasattr(item, 'gif_to_bytes'): + gif_data = item.gif_to_bytes() + if gif_data: + name = item.get_filename_for_export('gif') + self.ex( + 'INSERT INTO sqlar (item_id, name, mode, sz, data) ' + 'VALUES (?, ?, ?, ?, ?)', + (item.save_id, name, 0o644, len(gif_data), gif_data)) self.connection.commit() def update_item(self, item): diff --git a/beeref/gif_item.py b/beeref/gif_item.py new file mode 100644 index 0000000..2191c73 --- /dev/null +++ b/beeref/gif_item.py @@ -0,0 +1,303 @@ +# This file is part of BeeRef. +# +# BeeRef is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# BeeRef is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with BeeRef. If not, see . + +"""Class for animated GIF images.""" + +import logging +import os.path +from typing import Optional + +from PyQt6 import QtCore, QtGui, QtWidgets + +from beeref.config import BeeSettings +from beeref.items import BeeItemMixin, register_item + + +logger = logging.getLogger(__name__) + + +@register_item +class BeeGifItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem): + """Class for animated GIF images.""" + + TYPE = 'gif' + + def __init__(self, filename=None, **kwargs): + super().__init__() + self.save_id = None + self.filename = filename + self.is_image = True + self.init_selectable() + self.settings = BeeSettings() + + # GIF animation control + self.movie = None + self.is_playing = False + self.current_frame = 0 + self.frame_count = 0 + self.speed = 1.0 + + # Timer for automatic playback (using singleShot instead of regular timer) + self._animation_timer_id = None + + # Frame cache + self._frame_cache: dict[int, QtGui.QPixmap] = {} + self._frame_delays: dict[int, int] = {} + + if filename: + self.load_gif(filename) + + logger.debug(f'Initialized {self}') + + def load_gif(self, filename): + """Loads GIF file and initializes animation.""" + if not filename or not os.path.exists(filename): + logger.warning(f'GIF file not found: {filename}') + return + + self.movie = QtGui.QMovie(filename) + self.movie.setCacheMode(QtGui.QMovie.CacheMode.CacheAll) + + # Get frame count + self.frame_count = self.movie.frameCount() + logger.debug(f'Loaded GIF: {filename}, frames: {self.frame_count}') + + if self.frame_count > 0: + # Load and cache all frames + self._cache_all_frames() + + # Show first frame + self._show_frame(0) + + # Start animation automatically by default + if self.frame_count > 1: + self.play_animation() + + def _cache_all_frames(self): + """Caches all GIF frames and their delays.""" + if not self.movie: + return + + current_frame = self.current_frame + + for frame_num in range(self.frame_count): + if self.movie.jumpToFrame(frame_num): + pixmap = self.movie.currentPixmap() + if not pixmap.isNull(): + self._frame_cache[frame_num] = pixmap + self._frame_delays[frame_num] = self.movie.nextFrameDelay() + + # Restore original frame + if current_frame >= 0 and current_frame < self.frame_count: + self.movie.jumpToFrame(current_frame) + + def _show_frame(self, frame_num: int): + """Shows the specified frame.""" + if frame_num in self._frame_cache: + self.current_frame = frame_num + # Use setPixmap and update, but this should be fast with cached frames + self.setPixmap(self._frame_cache[frame_num]) + self.update() + + def _on_animation_tick(self): + """Animation timer tick handler.""" + if not self.is_playing or self.frame_count <= 1: + return + + next_frame = (self.current_frame + 1) % self.frame_count + + # Defer frame update to give UI time to process events + def show_next(): + if self.is_playing: # Check again, as pause may have occurred + self._show_frame(next_frame) + # Set next timeout with speed consideration + if next_frame in self._frame_delays: + delay = max(10, int(self._frame_delays[next_frame] / self.speed)) + # Use singleShot instead of regular timer for better responsiveness + if self.is_playing: # Check again before starting + QtCore.QTimer.singleShot(delay, self._on_animation_tick) + + QtCore.QTimer.singleShot(0, show_next) + + def toggle_animation(self): + """Toggles animation play/pause.""" + if self.is_playing: + self.pause_animation() + else: + self.play_animation() + + def play_animation(self): + """Starts automatic animation playback.""" + if not self.movie or self.frame_count <= 1: + return + + if not self.is_playing: + self.is_playing = True + + # Start timer for next frame using singleShot + next_frame = (self.current_frame + 1) % self.frame_count + if next_frame in self._frame_delays: + delay = max(10, int(self._frame_delays[next_frame] / self.speed)) + self._animation_timer_id = QtCore.QTimer.singleShot(delay, self._on_animation_tick) + + logger.debug(f'Started playing GIF: {self.filename}') + + def pause_animation(self): + """Pauses animation playback.""" + if self.is_playing: + self.is_playing = False + # QTimer.singleShot doesn't return ID for cancellation, but we can simply + # set is_playing = False flag, and _on_animation_tick will check it + self._animation_timer_id = None + logger.debug(f'Paused GIF: {self.filename}') + + def set_speed(self, speed): + """Sets animation playback speed.""" + self.speed = speed + + # If animation is playing, restart timer with new speed + if self.is_playing: + self.pause_animation() + self.play_animation() + + logger.debug(f'Set GIF speed to {speed}x: {self.filename}') + + def get_speed(self): + """Returns current playback speed.""" + return self.speed + + def previous_frame(self): + """Goes to previous frame.""" + if self.frame_count == 0: + return + + # Stop automatic playback + if self.is_playing: + self.pause_animation() + + new_frame = (self.current_frame - 1) % self.frame_count + self._show_frame(new_frame) + + def next_frame(self): + """Goes to next frame.""" + if self.frame_count == 0: + return + + # Stop automatic playback + if self.is_playing: + self.pause_animation() + + new_frame = (self.current_frame + 1) % self.frame_count + self._show_frame(new_frame) + + def bounding_rect_unselected(self): + """Returns item bounds without selection.""" + return QtWidgets.QGraphicsPixmapItem.boundingRect(self) + + def paint(self, painter, option, widget): + """Paints GIF item with selection border.""" + if abs(painter.combinedTransform().m11()) < 2: + # Smooth image rendering at low zoom + painter.setRenderHint(painter.RenderHint.SmoothPixmapTransform) + + # Draw current frame + pm = self.pixmap() + if not pm.isNull(): + painter.drawPixmap(0, 0, pm) + + # Draw selection border and handles + self.paint_selectable(painter, option, widget) + + def __str__(self): + if self.movie: + size = self.movie.scaledSize() + return (f'GIF "{self.filename}" {size.width()} x {size.height()}, ' + f'{self.frame_count} frames') + return f'GIF "{self.filename}"' + + @classmethod + def create_from_data(cls, **kwargs): + item = kwargs.pop('item', None) + data = kwargs.pop('data', {}) + filename = data.get('filename') or (item.filename if item else None) + + gif_item = cls(filename=filename) + return gif_item + + def update_from_data(self, **kwargs): + """Updates item from data.""" + super().update_from_data(**kwargs) + + def get_extra_save_data(self): + """Returns additional data for saving.""" + return { + 'filename': self.filename, + 'opacity': self.opacity(), + 'current_frame': self.current_frame, + } + + def gif_to_bytes(self): + """Reads GIF file and returns its bytes.""" + if not self.filename or not os.path.exists(self.filename): + return None + try: + with open(self.filename, 'rb') as f: + return f.read() + except IOError as e: + logger.error(f'Error reading GIF file {self.filename}: {e}') + return None + + def get_filename_for_export(self, imgformat='gif', save_id_default=None): + """Returns filename for export.""" + save_id = self.save_id or save_id_default + if save_id is None: + raise ValueError("save_id must be provided for export") + + if self.filename: + basename = os.path.splitext(os.path.basename(self.filename))[0] + return f'{save_id:04}-{basename}.{imgformat}' + else: + return f'{save_id:04}.{imgformat}' + + def create_copy(self): + """Creates a copy of the item.""" + item = BeeGifItem(self.filename) + item.setPos(self.pos()) + item.setZValue(self.zValue()) + item.setScale(self.scale()) + item.setRotation(self.rotation()) + item.setOpacity(self.opacity()) + if self.flip() == -1: + item.do_flip() + + # Copy animation state + item.current_frame = self.current_frame + item._show_frame(self.current_frame) + + return item + + def get_frame_pixmap(self, frame_number: int): + """Gets pixmap of specified frame.""" + return self._frame_cache.get(frame_number) + + def get_frame_delay(self, frame_number: int): + """Gets delay of specified frame in milliseconds.""" + return self._frame_delays.get(frame_number, 100) + + def copy_to_clipboard(self, clipboard): + """Copies current frame to clipboard.""" + pixmap = self.pixmap() + if not pixmap.isNull(): + clipboard.setPixmap(pixmap) diff --git a/beeref/items.py b/beeref/items.py index c42044a..8c56ead 100644 --- a/beeref/items.py +++ b/beeref/items.py @@ -28,7 +28,7 @@ from PyQt6.QtCore import Qt from beeref import commands from beeref.config import BeeSettings from beeref.constants import COLORS -from beeref.selection import SelectableMixin +from beeref.selection import SelectableMixin, SELECT_COLOR logger = logging.getLogger(__name__) @@ -156,7 +156,7 @@ class BeePixmapItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem): @grayscale.setter def grayscale(self, value): - logger.debug('Setting grayscale for {self} to {value}') + logger.debug(f'Setting grayscale for {self} to {value}') self._grayscale = value if value is True: # Using the grayscale image format to convert to grayscale @@ -226,7 +226,8 @@ class BeePixmapItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem): def get_filename_for_export(self, imgformat, save_id_default=None): save_id = self.save_id or save_id_default - assert save_id is not None + if save_id is None: + raise ValueError("save_id must be provided for export") if self.filename: basename = os.path.splitext(os.path.basename(self.filename))[0] @@ -648,6 +649,7 @@ class BeeTextItem(BeeItemMixin, QtWidgets.QGraphicsTextItem): self.is_editable = True self.edit_mode = False self.setDefaultTextColor(QtGui.QColor(*COLORS['Scene:Text'])) + self.background_color = QtGui.QColor(0, 0, 0, 0) # Transparent by default @classmethod def create_from_data(cls, **kwargs): @@ -667,14 +669,27 @@ class BeeTextItem(BeeItemMixin, QtWidgets.QGraphicsTextItem): def paint(self, painter, option, widget): painter.setPen(Qt.PenStyle.NoPen) - color = QtGui.QColor(0, 0, 0) - color.setAlpha(40) - brush = QtGui.QBrush(color) + rect = QtWidgets.QGraphicsTextItem.boundingRect(self) + + # Use background_color if set, otherwise use default semi-transparent black + if hasattr(self, 'background_color') and self.background_color.alpha() > 0: + brush = QtGui.QBrush(self.background_color) + else: + color = QtGui.QColor(0, 0, 0) + color.setAlpha(40) + brush = QtGui.QBrush(color) + painter.setBrush(brush) - painter.drawRect(QtWidgets.QGraphicsTextItem.boundingRect(self)) + # Draw rounded rectangle with 2px radius + painter.drawRoundedRect(rect, 2, 2) option.state = QtWidgets.QStyle.StateFlag.State_Enabled super().paint(painter, option, widget) self.paint_selectable(painter, option, widget) + + def set_background_color(self, color: QtGui.QColor): + """Set the background color for the text item.""" + self.background_color = color + self.update() def create_copy(self): item = BeeTextItem(self.toPlainText()) @@ -684,6 +699,13 @@ class BeeTextItem(BeeItemMixin, QtWidgets.QGraphicsTextItem): item.setRotation(self.rotation()) if self.flip() == -1: item.do_flip() + # Copy text color + item.setDefaultTextColor(self.defaultTextColor()) + # Copy font + item.setFont(self.font()) + # Copy background color + if hasattr(self, 'background_color'): + item.set_background_color(self.background_color) return item def enter_edit_mode(self): @@ -714,23 +736,426 @@ class BeeTextItem(BeeItemMixin, QtWidgets.QGraphicsTextItem): def has_selection_handles(self): return super().has_selection_handles() and not self.edit_mode - def keyPressEvent(self, event): - if (event.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return) - and event.modifiers() == Qt.KeyboardModifier.NoModifier): - self.exit_edit_mode() - event.accept() - return - if (event.key() == Qt.Key.Key_Escape - and event.modifiers() == Qt.KeyboardModifier.NoModifier): - self.exit_edit_mode(commit=False) - event.accept() - return - super().keyPressEvent(event) - def copy_to_clipboard(self, clipboard): clipboard.setText(self.toPlainText()) +@register_item +class BeeDrawItem(BeeItemMixin, QtWidgets.QGraphicsPathItem): + """Class for freehand drawing items.""" + + TYPE = 'draw' + CLICKABLE_PADDING = 8.0 # Отступ вокруг линии для увеличения кликабельной области + + def __init__(self, path=None, **kwargs): + super().__init__() + self.save_id = None + logger.debug(f'Initialized {self}') + self.is_image = False + self.init_selectable() + self.is_editable = False # Рисование не редактируется через двойной клик + + # Настройки пера по умолчанию + self.pen_color = QtGui.QColor(*COLORS['Scene:Text']) + self.pen_width = 8 + self.pen_style = 'solid' # 'solid', 'dashed', 'arrow', '<-', '<->' + self._update_pen() + self.setBrush(QtGui.QBrush(QtCore.Qt.BrushStyle.NoBrush)) + + if path: + self.setPath(path) + + def setPath(self, path): + """Устанавливает путь и обновляет геометрию.""" + self.prepareGeometryChange() + super().setPath(path) + self.update() + + @classmethod + def create_from_data(cls, **kwargs): + data = kwargs.get('data', {}) + item = cls() + + # Восстановление пути из данных + if 'path' in data and data['path']: + path = QtGui.QPainterPath() + path_data = data['path'] + for i, point_data in enumerate(path_data): + if i == 0: + path.moveTo(point_data['x'], point_data['y']) + else: + path.lineTo(point_data['x'], point_data['y']) + item.setPath(path) + + if 'pen_color' in data: + item.pen_color = QtGui.QColor(data['pen_color']) + if 'pen_width' in data: + item.pen_width = data['pen_width'] + if 'pen_style' in data: + item.pen_style = data['pen_style'] + item._update_pen() + return item + + def __str__(self): + return f'Drawing ({self.path().elementCount()} points)' + + def get_extra_save_data(self): + """Сохраняет данные рисунка для сериализации.""" + path = self.path() + path_data = [] + for i in range(path.elementCount()): + elem = path.elementAt(i) + path_data.append({'x': elem.x, 'y': elem.y}) + + return { + 'path': path_data, + 'pen_color': self.pen_color.name(), + 'pen_width': self.pen_width, + 'pen_style': self.pen_style, + } + + def _update_pen(self): + """Обновляет перо с текущими настройками.""" + if self.pen_style == 'dashed': + pen_style = QtCore.Qt.PenStyle.DashLine + else: + pen_style = QtCore.Qt.PenStyle.SolidLine + + pen = QtGui.QPen(self.pen_color, self.pen_width, + pen_style, + QtCore.Qt.PenCapStyle.RoundCap, + QtCore.Qt.PenJoinStyle.RoundJoin) + self.setPen(pen) + + def set_pen_color(self, color: QtGui.QColor): + """Устанавливает цвет пера.""" + self.pen_color = color + self._update_pen() + self.update() + + def set_pen_width(self, width: int): + """Устанавливает толщину пера.""" + self.pen_width = max(1, min(width, 50)) # Ограничение 1-50 + self._update_pen() + self.update() + + def set_pen_style(self, style: str): + """Устанавливает стиль линии: 'solid', 'dashed', 'arrow', '<-', '<->'.""" + if style in ('solid', 'dashed', 'arrow', '<-', '<->'): + self.pen_style = style + self._update_pen() + self.update() + + def create_copy(self): + item = BeeDrawItem() + item.setPath(self.path()) + item.setPos(self.pos()) + item.setZValue(self.zValue()) + item.setScale(self.scale()) + item.setRotation(self.rotation()) + item.set_pen_color(self.pen_color) + item.set_pen_width(self.pen_width) + item.set_pen_style(self.pen_style) + if self.flip() == -1: + item.do_flip() + return item + + def bounding_rect_unselected(self): + """Возвращает границы элемента без учета выделения.""" + path = self.path() + if path.isEmpty(): + return QtCore.QRectF() + + # Получаем boundingRect напрямую из пути + base_rect = path.boundingRect() + + # Добавляем отступ для толщины пера и кликабельной области + margin = (self.pen_width / 2.0) + self.CLICKABLE_PADDING + return base_rect.marginsAdded( + QtCore.QMarginsF(margin, margin, margin, margin)) + + def shape(self): + """Возвращает прямоугольную кликабельную область, как в PureRef.""" + path = QtGui.QPainterPath() + rect = self.bounding_rect_unselected() + + # Если элемент выделен и есть ручки, добавляем области для ручек + if self.has_selection_handles(): + margin = self.select_resize_size / 2 + rect = rect.marginsAdded( + QtCore.QMarginsF(margin, margin, margin, margin)) + path.addRect(rect) + # Добавляем области для ручек поворота в углах + for corner in self.corners: + path.addPath(self.get_rotate_bounds(corner)) + else: + path.addRect(rect) + + return path + + def contains(self, point): + """Проверяет, попадает ли точка в прямоугольную область линии.""" + # Используем boundingRect для прямоугольной области клика + return self.bounding_rect_unselected().contains(point) + + + def _get_path_end_points(self, path): + """Получает последние две точки пути для определения направления стрелки.""" + if path.elementCount() < 2: + return None, None + + # Получаем последнюю точку пути + last_point = path.pointAtPercent(1.0) + + # Получаем предпоследнюю точку (близко к концу) + if path.elementCount() >= 2: + prev_point = path.pointAtPercent(0.95) # 95% от пути + else: + prev_point = path.pointAtPercent(0.0) + + return prev_point, last_point + + def _get_path_start_points(self, path): + """Получает первые две точки пути для определения направления стрелки.""" + if path.elementCount() < 2: + return None, None + + # Получаем первую точку пути + first_point = path.pointAtPercent(0.0) + + # Получаем вторую точку (близко к началу) + if path.elementCount() >= 2: + second_point = path.pointAtPercent(0.05) # 5% от пути + else: + second_point = path.pointAtPercent(1.0) + + return first_point, second_point + + def _draw_arrow_right(self, painter, path): + """Рисует стрелку вправо в конце линии.""" + if path.elementCount() < 2: + return + + # Получаем последние две точки для определения направления + prev_point, last_point = self._get_path_end_points(path) + if prev_point is None or last_point is None: + return + + # Вычисляем направление стрелки + dx = last_point.x() - prev_point.x() + dy = last_point.y() - prev_point.y() + length = (dx * dx + dy * dy) ** 0.5 + if length == 0: + return + + # Нормализуем вектор направления + dx /= length + dy /= length + + # Размер стрелки зависит от толщины линии + arrow_size = max(self.pen_width * 3, 8) + # Угол стрелки + angle = 0.5 # примерно 30 градусов + + # Координаты конца стрелки + end_x = last_point.x() + end_y = last_point.y() + + # Координаты боковых точек стрелки + perp_x = -dy + perp_y = dx + arrow_x1 = end_x - arrow_size * dx + arrow_size * angle * perp_x + arrow_y1 = end_y - arrow_size * dy + arrow_size * angle * perp_y + arrow_x2 = end_x - arrow_size * dx - arrow_size * angle * perp_x + arrow_y2 = end_y - arrow_size * dy - arrow_size * angle * perp_y + + # Рисуем стрелку + arrow_path = QtGui.QPainterPath() + arrow_path.moveTo(end_x, end_y) + arrow_path.lineTo(arrow_x1, arrow_y1) + arrow_path.moveTo(end_x, end_y) + arrow_path.lineTo(arrow_x2, arrow_y2) + + painter.setPen(QtGui.QPen(self.pen_color, self.pen_width, + QtCore.Qt.PenStyle.SolidLine, + QtCore.Qt.PenCapStyle.RoundCap, + QtCore.Qt.PenJoinStyle.RoundJoin)) + painter.drawPath(arrow_path) + + def _draw_arrow_left(self, painter, path): + """Рисует стрелку влево в начале линии.""" + if path.elementCount() < 2: + return + + # Получаем первые две точки для определения направления + first_point, second_point = self._get_path_start_points(path) + if first_point is None or second_point is None: + return + + # Вычисляем направление стрелки (от второй точки к первой) + dx = first_point.x() - second_point.x() + dy = first_point.y() - second_point.y() + length = (dx * dx + dy * dy) ** 0.5 + if length == 0: + return + + # Нормализуем вектор направления + dx /= length + dy /= length + + # Размер стрелки зависит от толщины линии + arrow_size = max(self.pen_width * 3, 8) + # Угол стрелки + angle = 0.5 # примерно 30 градусов + + # Координаты начала стрелки + start_x = first_point.x() + start_y = first_point.y() + + # Координаты боковых точек стрелки + perp_x = -dy + perp_y = dx + arrow_x1 = start_x - arrow_size * dx + arrow_size * angle * perp_x + arrow_y1 = start_y - arrow_size * dy + arrow_size * angle * perp_y + arrow_x2 = start_x - arrow_size * dx - arrow_size * angle * perp_x + arrow_y2 = start_y - arrow_size * dy - arrow_size * angle * perp_y + + # Рисуем стрелку + arrow_path = QtGui.QPainterPath() + arrow_path.moveTo(start_x, start_y) + arrow_path.lineTo(arrow_x1, arrow_y1) + arrow_path.moveTo(start_x, start_y) + arrow_path.lineTo(arrow_x2, arrow_y2) + + painter.setPen(QtGui.QPen(self.pen_color, self.pen_width, + QtCore.Qt.PenStyle.SolidLine, + QtCore.Qt.PenCapStyle.RoundCap, + QtCore.Qt.PenJoinStyle.RoundJoin)) + painter.drawPath(arrow_path) + + def _draw_arrow_both(self, painter, path): + """Рисует стрелки в обе стороны линии.""" + if path.elementCount() < 2: + return + + # Рисуем стрелку вправо (в конце) + prev_point, last_point = self._get_path_end_points(path) + if prev_point is not None and last_point is not None: + dx = last_point.x() - prev_point.x() + dy = last_point.y() - prev_point.y() + length = (dx * dx + dy * dy) ** 0.5 + if length > 0: + dx /= length + dy /= length + arrow_size = max(self.pen_width * 3, 8) + angle = 0.5 + end_x = last_point.x() + end_y = last_point.y() + perp_x = -dy + perp_y = dx + arrow_x1 = end_x - arrow_size * dx + arrow_size * angle * perp_x + arrow_y1 = end_y - arrow_size * dy + arrow_size * angle * perp_y + arrow_x2 = end_x - arrow_size * dx - arrow_size * angle * perp_x + arrow_y2 = end_y - arrow_size * dy - arrow_size * angle * perp_y + + arrow_path = QtGui.QPainterPath() + arrow_path.moveTo(end_x, end_y) + arrow_path.lineTo(arrow_x1, arrow_y1) + arrow_path.moveTo(end_x, end_y) + arrow_path.lineTo(arrow_x2, arrow_y2) + + painter.setPen(QtGui.QPen(self.pen_color, self.pen_width, + QtCore.Qt.PenStyle.SolidLine, + QtCore.Qt.PenCapStyle.RoundCap, + QtCore.Qt.PenJoinStyle.RoundJoin)) + painter.drawPath(arrow_path) + + # Рисуем стрелку влево (в начале) + first_point, second_point = self._get_path_start_points(path) + if first_point is not None and second_point is not None: + dx = first_point.x() - second_point.x() + dy = first_point.y() - second_point.y() + length = (dx * dx + dy * dy) ** 0.5 + if length > 0: + dx /= length + dy /= length + arrow_size = max(self.pen_width * 3, 8) + angle = 0.5 + start_x = first_point.x() + start_y = first_point.y() + perp_x = -dy + perp_y = dx + arrow_x1 = start_x - arrow_size * dx + arrow_size * angle * perp_x + arrow_y1 = start_y - arrow_size * dy + arrow_size * angle * perp_y + arrow_x2 = start_x - arrow_size * dx - arrow_size * angle * perp_x + arrow_y2 = start_y - arrow_size * dy - arrow_size * angle * perp_y + + arrow_path = QtGui.QPainterPath() + arrow_path.moveTo(start_x, start_y) + arrow_path.lineTo(arrow_x1, arrow_y1) + arrow_path.moveTo(start_x, start_y) + arrow_path.lineTo(arrow_x2, arrow_y2) + + painter.setPen(QtGui.QPen(self.pen_color, self.pen_width, + QtCore.Qt.PenStyle.SolidLine, + QtCore.Qt.PenCapStyle.RoundCap, + QtCore.Qt.PenJoinStyle.RoundJoin)) + painter.drawPath(arrow_path) + + def paint(self, painter, option, widget): + """Отрисовка пути с рамкой выделения.""" + # Отключаем стандартную отрисовку Qt для выделенных элементов + option.state &= ~QtWidgets.QStyle.StateFlag.State_Selected + option.state &= ~QtWidgets.QStyle.StateFlag.State_HasFocus + # Рисуем основную линию + super().paint(painter, option, widget) + + path = self.path() + # Рисуем стрелку в зависимости от стиля + if self.pen_style == 'arrow': + self._draw_arrow_right(painter, path) + elif self.pen_style == '<-': + self._draw_arrow_left(painter, path) + elif self.pen_style == '<->': + self._draw_arrow_both(painter, path) + + self.paint_selectable(painter, option, widget) + + def paint_debug(self, painter, option, widget): + """Переопределяем для полного отключения отладочной информации.""" + # Полностью отключаем отладочную информацию для линий + pass + + def paint_selectable(self, painter, option, widget): + """Переопределяем для убирания пунктирной обводки (отладочной информации).""" + # Не вызываем paint_debug, чтобы убрать пунктирную обводку + # self.paint_debug(painter, option, widget) + + if not self.has_selection_outline(): + return + + pen = QtGui.QPen(SELECT_COLOR) + pen.setWidth(self.SELECT_LINE_WIDTH) + pen.setCosmetic(True) + painter.setPen(pen) + painter.setBrush(QtGui.QBrush()) + + # Draw the main selection rectangle + painter.drawRect(self.bounding_rect_unselected()) + + # If it's a single selection, draw the handles: + if self.has_selection_handles(): + pen.setWidth(self.SELECT_HANDLE_SIZE) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + painter.setPen(pen) + for corner in self.corners: + painter.drawPoint(corner) + + def copy_to_clipboard(self, clipboard): + """Для рисования копирование не поддерживается.""" + pass + + @register_item class BeeErrorItem(BeeItemMixin, QtWidgets.QGraphicsTextItem): """Class for displaying error messages when an item can't be loaded @@ -804,3 +1229,8 @@ class BeeErrorItem(BeeItemMixin, QtWidgets.QGraphicsTextItem): def copy_to_clipboard(self, clipboard): clipboard.setText(self.toPlainText()) + + +# Импортируем GIF item для регистрации в item_registry +from beeref.gif_item import BeeGifItem # noqa: E402, F401 + diff --git a/beeref/scene.py b/beeref/scene.py index 56325c2..cb78dd9 100644 --- a/beeref/scene.py +++ b/beeref/scene.py @@ -141,7 +141,12 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene): scale_factors = [] for item in items: rect = self.itemsBoundingRect(items=[item]) - scale_factors.append(avg / getattr(rect, mode)()) + dimension = getattr(rect, mode)() + if dimension > 0: + scale_factors.append(avg / dimension) + else: + # Skip items with zero width or height + scale_factors.append(1.0) self.undo_stack.push( commands.NormalizeItems(items, scale_factors)) @@ -175,7 +180,11 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene): scale_factors = [] for item in items: rect = self.itemsBoundingRect(items=[item]) - scale_factors.append(math.sqrt(avg / rect.width() / rect.height())) + if rect.width() > 0 and rect.height() > 0: + scale_factors.append(math.sqrt(avg / rect.width() / rect.height())) + else: + # Skip items with zero width or height + scale_factors.append(1.0) self.undo_stack.push( commands.NormalizeItems(items, scale_factors)) diff --git a/beeref/view.py b/beeref/view.py index 9e40532..757e97d 100644 --- a/beeref/view.py +++ b/beeref/view.py @@ -14,6 +14,7 @@ # along with BeeRef. If not, see . from functools import partial +from typing import Optional import logging import os import os.path @@ -23,13 +24,19 @@ from PyQt6.QtCore import Qt from beeref.actions import ActionsMixin, actions from beeref import commands +from beeref.assets import BeeAssets from beeref.config import CommandlineArgs, BeeSettings, KeyboardSettings from beeref import constants from beeref import fileio from beeref.fileio.errors import IMG_LOADING_ERROR_MSG from beeref.fileio.export import exporter_registry, ImagesToDirectoryExporter from beeref import widgets -from beeref.items import BeePixmapItem, BeeTextItem +from beeref.widgets.text_floating_menu import TextFloatingMenu +from beeref.widgets.image_floating_menu import ImageFloatingMenu +from beeref.widgets.gif_floating_menu import GifFloatingMenu +from beeref.widgets.draw_floating_menu import DrawFloatingMenu +from beeref.items import BeePixmapItem, BeeTextItem, BeeDrawItem +from beeref.gif_item import BeeGifItem from beeref.main_controls import MainControlsMixin from beeref.scene import BeeGraphicsScene from beeref.utils import get_file_extension_from_format, qcolor_to_hex @@ -46,6 +53,7 @@ class BeeGraphicsView(MainControlsMixin, PAN_MODE = 1 ZOOM_MODE = 2 SAMPLE_COLOR_MODE = 3 + DRAW_MODE = 4 def __init__(self, app, parent=None): super().__init__(parent) @@ -55,6 +63,17 @@ class BeeGraphicsView(MainControlsMixin, self.keyboard_settings = KeyboardSettings() self.welcome_overlay = widgets.welcome_overlay.WelcomeOverlay(self) + self.image_floating_menu: Optional[ImageFloatingMenu] = None + self.text_floating_menu: Optional[TextFloatingMenu] = None + self.gif_floating_menu: Optional[GifFloatingMenu] = None + self.draw_floating_menu: Optional[DrawFloatingMenu] = None + + # Initialize drawing mode + self.drawing_mode = False + self.current_draw_item = None + self.drawing_path = None + self.drawing_points = [] # For line smoothing + self.setBackgroundBrush( QtGui.QBrush(QtGui.QColor(*constants.COLORS['Scene:Canvas']))) self.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing) @@ -82,6 +101,9 @@ class BeeGraphicsView(MainControlsMixin, self.control_target = self self.init_main_controls(main_window=parent) + if parent is not None: + self._init_floating_menus(parent) + # Load files given via command line if commandline_args.filenames: fn = commandline_args.filenames[0] @@ -107,6 +129,7 @@ class BeeGraphicsView(MainControlsMixin, def cancel_active_modes(self): self.scene.cancel_active_modes() self.cancel_sample_color_mode() + self.cancel_drawing_mode() self.active_mode = None def cancel_sample_color_mode(self): @@ -119,6 +142,22 @@ class BeeGraphicsView(MainControlsMixin, if self.scene.has_multi_selection(): self.scene.multi_select_item.bring_to_front() + def cancel_drawing_mode(self): + """Cancels drawing mode.""" + if self.drawing_mode: + logger.debug('Cancel drawing mode') + self.drawing_mode = False + if self.current_draw_item and self.drawing_path: + # If item is empty, remove it + if self.drawing_path.elementCount() <= 1: + self.scene.removeItem(self.current_draw_item) + self.current_draw_item = None + self.drawing_path = None + self.viewport().unsetCursor() + # Hide drawing menu + if self.draw_floating_menu: + self.draw_floating_menu.hide_menu() + def update_window_title(self): clean = self.undo_stack.isClean() if clean and not self.filename: @@ -129,6 +168,78 @@ class BeeGraphicsView(MainControlsMixin, title = f'{name}{clean} - {constants.APPNAME}' self.parent.setWindowTitle(title) + def _init_floating_menus(self, parent: QtWidgets.QWidget) -> None: + self.image_floating_menu = ImageFloatingMenu(parent, self) + self.text_floating_menu = TextFloatingMenu(parent, self) + self.gif_floating_menu = GifFloatingMenu(parent, self) + self.draw_floating_menu = DrawFloatingMenu(parent, self) + + def _floating_menus(self): + return [ + menu + for menu in (self.image_floating_menu, self.text_floating_menu, + self.gif_floating_menu, self.draw_floating_menu) + if menu is not None + ] + + def _hide_all_floating_menus(self) -> None: + for menu in self._floating_menus(): + menu.hide_menu() + + def _update_floating_menus_on_selection(self) -> None: + if not self._floating_menus(): + return + + if self.scene.has_single_selection(): + item = self.scene.selectedItems(user_only=True)[0] + if isinstance(item, BeeTextItem) and self.text_floating_menu: + self._hide_other_menus(self.text_floating_menu) + self.text_floating_menu.show_for_item(item) + elif isinstance(item, BeeGifItem) and self.gif_floating_menu: + self._hide_other_menus(self.gif_floating_menu) + self.gif_floating_menu.show_for_item(item) + elif isinstance(item, BeeDrawItem) and self.draw_floating_menu: + self._hide_other_menus(self.draw_floating_menu) + self.draw_floating_menu.show_for_item(item) + elif getattr(item, 'is_image', False) and self.image_floating_menu: + self._hide_other_menus(self.image_floating_menu) + self.image_floating_menu.show_for_item(item) + else: + self._hide_all_floating_menus() + else: + self._hide_all_floating_menus() + + def _hide_other_menus(self, current_menu): + """Hides all menus except current one.""" + for menu in self._floating_menus(): + if menu != current_menu: + menu.hide_menu() + + def update_floating_menus_position(self) -> None: + for menu in self._floating_menus(): + menu.update_position() + + def _refresh_visible_floating_menu(self) -> None: + if not self.scene.has_single_selection(): + return + item = self.scene.selectedItems(user_only=True)[0] + if (isinstance(item, BeeTextItem) + and self.text_floating_menu + and self.text_floating_menu.isVisible()): + self.text_floating_menu.show_for_item(item) + elif (isinstance(item, BeeGifItem) + and self.gif_floating_menu + and self.gif_floating_menu.isVisible()): + self.gif_floating_menu.show_for_item(item) + elif (isinstance(item, BeeDrawItem) + and self.draw_floating_menu + and self.draw_floating_menu.isVisible()): + self.draw_floating_menu.show_for_item(item) + elif (getattr(item, 'is_image', False) + and self.image_floating_menu + and self.image_floating_menu.isVisible()): + self.image_floating_menu.show_for_item(item) + def on_scene_changed(self, region): if not self.scene.items(): logger.debug('No items in scene') @@ -137,6 +248,7 @@ class BeeGraphicsView(MainControlsMixin, self.clearFocus() self.welcome_overlay.show() self.actiongroup_set_enabled('active_when_items_in_scene', False) + self._hide_all_floating_menus() else: self.setFocus() self.welcome_overlay.clearFocus() @@ -383,6 +495,606 @@ class BeeGraphicsView(MainControlsMixin, def on_action_show_color_gamut(self): widgets.color_gamut.GamutDialog(self, self.scene.selectedItems()[0]) + # ------------------------------------------------------------------ + # Text helpers used by floating menus + def _selected_text_items(self): + return [ + item for item in self.scene.selectedItems(user_only=True) + if isinstance(item, BeeTextItem) + ] + + def change_selected_text_color(self): + items = self._selected_text_items() + if not items: + return + initial = items[0].defaultTextColor() + dialog = widgets.color_picker.ColorPickerDialog(self, initial) + if dialog.exec() != QtWidgets.QDialog.DialogCode.Accepted: + return + color = dialog.selectedColor() + for item in items: + item.setDefaultTextColor(color) + item.update() + self._refresh_visible_floating_menu() + + def change_selected_text_background(self): + items = self._selected_text_items() + if not items: + return + initial = getattr(items[0], 'background_color', QtGui.QColor(0, 0, 0, 0)) + dialog = widgets.color_picker.ColorPickerDialog(self, initial) + if dialog.exec() != QtWidgets.QDialog.DialogCode.Accepted: + return + bg_color = dialog.selectedColor() + + # Calculate text color based on background color + text_color = self._calculate_text_color_from_background(bg_color) + + for item in items: + if hasattr(item, 'set_background_color'): + item.set_background_color(bg_color) + # Automatically change text color + item.setDefaultTextColor(text_color) + item.update() + self._refresh_visible_floating_menu() + + def cancel_drawing_mode(self): + """Cancels drawing mode.""" + if self.drawing_mode: + logger.debug('Cancel drawing mode') + self.drawing_mode = False + if self.current_draw_item and self.drawing_path: + # If item is empty, remove it + if self.drawing_path.elementCount() <= 1: + self.scene.removeItem(self.current_draw_item) + self.current_draw_item = None + self.drawing_path = None + self.drawing_points = [] + self.viewport().unsetCursor() + + def enter_drawing_mode(self): + """Activates drawing mode.""" + logger.debug('Entering drawing mode') + self.cancel_active_modes() + self.drawing_mode = True + assets = BeeAssets() + self.viewport().setCursor(assets.cursor_draw_line) + self.current_draw_item = None + self.drawing_path = None + self.drawing_points = [] + + def _start_drawing(self, pos: QtCore.QPointF): + """Starts drawing at specified position.""" + if not self.drawing_mode: + return + + # Create new drawing item on first click + if not self.current_draw_item: + self.current_draw_item = BeeDrawItem() + self.drawing_path = QtGui.QPainterPath() + # Add item to scene + self.undo_stack.push(commands.InsertItems(self.scene, [self.current_draw_item], pos)) + + # Start new path + self.drawing_path = QtGui.QPainterPath() + self.drawing_points = [] # Reset points for smoothing + local_pos = self.current_draw_item.mapFromScene(pos) + self.drawing_path.moveTo(local_pos) + self.drawing_points.append(local_pos) # Save first point + self.current_draw_item.setPath(self.drawing_path) + + def _continue_drawing(self, pos: QtCore.QPointF): + """Continues drawing to specified position with improved smoothing.""" + if (not self.drawing_mode or not self.current_draw_item + or not self.drawing_path): + return + + local_pos = self.current_draw_item.mapFromScene(pos) + self.drawing_points.append(local_pos) + + # Apply improved smoothing through cubic Bezier curves + if len(self.drawing_points) >= 2: + if len(self.drawing_points) == 2: + # For first two points, just draw a line + self.drawing_path.lineTo(local_pos) + elif len(self.drawing_points) == 3: + # For three points, use quadratic curve + p0 = self.drawing_points[0] + p1 = self.drawing_points[1] + p2 = self.drawing_points[2] + + # Control point is middle between p1 and p2 + cp_x = (p1.x() + p2.x()) / 2.0 + cp_y = (p1.y() + p2.y()) / 2.0 + + self.drawing_path.quadTo( + QtCore.QPointF(cp_x, cp_y), + p2 + ) + else: + # For four or more points, use improved smoothing algorithm + # Use averaging of multiple points for smoother curves + p1 = self.drawing_points[-2] # Previous point + p2 = self.drawing_points[-1] # Current point + + # Calculate velocity (movement vector) based on several previous points + if len(self.drawing_points) >= 4: + # Use vector averaging for smoother movement + p0 = self.drawing_points[-3] + p_prev = self.drawing_points[-4] if len(self.drawing_points) >= 5 else p0 + + # Movement vector 1 (from p_prev to p0) + v1_x = p0.x() - p_prev.x() + v1_y = p0.y() - p_prev.y() + + # Movement vector 2 (from p0 to p1) + v2_x = p1.x() - p0.x() + v2_y = p1.y() - p0.y() + + # Movement vector 3 (from p1 to p2) + v3_x = p2.x() - p1.x() + v3_y = p2.y() - p1.y() + + # Average vectors for smoothness + avg_v1_x = (v1_x + v2_x) / 2.0 + avg_v1_y = (v1_y + v2_y) / 2.0 + avg_v2_x = (v2_x + v3_x) / 2.0 + avg_v2_y = (v2_y + v3_y) / 2.0 + + # Control points calculated considering movement direction + # Smoothing factor (can be adjusted from 0.3 to 0.7) + smooth_factor = 0.5 + + cp1_x = p1.x() - avg_v1_x * smooth_factor + cp1_y = p1.y() - avg_v1_y * smooth_factor + cp2_x = p1.x() + avg_v2_x * smooth_factor + cp2_y = p1.y() + avg_v2_y * smooth_factor + else: + # For fewer points, use simple algorithm + p0 = self.drawing_points[-3] + + # Control points closer to p1 for smoother transition + cp1_x = p0.x() + (p1.x() - p0.x()) * 0.7 + cp1_y = p0.y() + (p1.y() - p0.y()) * 0.7 + cp2_x = p1.x() + (p2.x() - p1.x()) * 0.3 + cp2_y = p1.y() + (p2.y() - p1.y()) * 0.3 + + # Use cubic Bezier curve for smooth transition + self.drawing_path.cubicTo( + QtCore.QPointF(cp1_x, cp1_y), # Control point 1 + QtCore.QPointF(cp2_x, cp2_y), # Control point 2 + p2 # End point + ) + + self.current_draw_item.setPath(self.drawing_path) + + def _finish_drawing(self, pos: QtCore.QPointF): + """Finishes drawing and applies path simplification.""" + if (not self.drawing_mode or not self.current_draw_item + or not self.drawing_path): + return + + # Finish path + self._continue_drawing(pos) + + # If path is too short, remove item + if self.drawing_path.elementCount() <= 1: + self.scene.removeItem(self.current_draw_item) + self.current_draw_item = None + self.drawing_path = None + self.drawing_points = [] + return + + # Apply path simplification to remove unnecessary points + if len(self.drawing_points) > 2: + # Adaptive epsilon: use smaller value for complex curves + adaptive_epsilon = self._calculate_adaptive_epsilon(self.drawing_points) + simplified_points = self._simplify_path(self.drawing_points, epsilon=adaptive_epsilon) + if len(simplified_points) >= 2: + # Recreate path from simplified points with smart smoothing + simplified_path = self._create_smooth_path(simplified_points) + self.current_draw_item.setPath(simplified_path) + + # Clear current item, but drawing mode remains active + # for next stroke (like in PureRef) + self.current_draw_item = None + self.drawing_path = None + self.drawing_points = [] + + def _simplify_path(self, points, epsilon=2.0): + """Simplifies path by removing unnecessary points using Ramer-Douglas-Peucker algorithm. + + Args: + points: List of QPointF points + epsilon: Maximum distance from point to line (in pixels) + + Returns: + Simplified list of points + """ + if len(points) <= 2: + return points + + # Find point with maximum distance from line between first and last point + max_dist = 0 + max_index = 0 + start = points[0] + end = points[-1] + + # Calculate segment length + dx = end.x() - start.x() + dy = end.y() - start.y() + segment_length_sq = dx * dx + dy * dy + + for i in range(1, len(points) - 1): + p = points[i] + # Distance from point to line + if segment_length_sq > 0: + # Vector from start to p + vx = p.x() - start.x() + vy = p.y() - start.y() + # Projection onto segment + t = max(0, min(1, (vx * dx + vy * dy) / segment_length_sq)) + # Closest point on segment + proj_x = start.x() + t * dx + proj_y = start.y() + t * dy + # Distance from point to projection + dist_sq = (p.x() - proj_x) ** 2 + (p.y() - proj_y) ** 2 + dist = (dist_sq) ** 0.5 + else: + # If segment has zero length, use distance to start + dist = ((p.x() - start.x()) ** 2 + (p.y() - start.y()) ** 2) ** 0.5 + + if dist > max_dist: + max_dist = dist + max_index = i + + # If maximum distance is greater than epsilon, recursively simplify + if max_dist > epsilon: + # Recursively simplify left and right parts + left = self._simplify_path(points[:max_index + 1], epsilon) + right = self._simplify_path(points[max_index:], epsilon) + + # Combine results (remove duplicate in middle) + return left[:-1] + right + else: + # All points between start and end can be removed + return [start, end] + + def _calculate_adaptive_epsilon(self, points): + """Calculates adaptive epsilon based on curve complexity. + + For complex curves with sharp turns, uses smaller epsilon + to preserve more details. + """ + if len(points) < 3: + return 2.0 + + # Calculate average turn angle (using approximation without math) + total_angle_change = 0.0 + angle_count = 0 + + for i in range(1, len(points) - 1): + p0 = points[i - 1] + p1 = points[i] + p2 = points[i + 1] + + # Vectors + v1_x = p1.x() - p0.x() + v1_y = p1.y() - p0.y() + v2_x = p2.x() - p1.x() + v2_y = p2.y() - p1.y() + + # Vector lengths + len1_sq = v1_x * v1_x + v1_y * v1_y + len2_sq = v2_x * v2_x + v2_y * v2_y + + if len1_sq > 0.01 and len2_sq > 0.01: # Avoid division by zero + len1 = len1_sq ** 0.5 + len2 = len2_sq ** 0.5 + + # Normalize vectors + v1_x /= len1 + v1_y /= len1 + v2_x /= len2 + v2_y /= len2 + + # Angle between vectors via dot product + # Use approximation: for small angles cos(angle) ≈ 1 - angle²/2 + dot = v1_x * v2_x + v1_y * v2_y + dot = max(-1.0, min(1.0, dot)) # Clamp for safety + + # Approximate angle calculation without math.acos + # For small angles: angle ≈ sqrt(2 * (1 - dot)) + # For larger angles use more accurate approximation + if dot > 0.9: + # Small angle, use approximation + angle_sq = 2.0 * (1.0 - dot) + angle = angle_sq ** 0.5 + else: + # Larger angle, use more accurate approximation + # acos(x) ≈ π/2 - x for x close to 0, but we need different approximation + # Use polynomial approximation + angle = 1.5708 - dot * (1.5708 - 0.2146 * dot * dot) + if angle < 0: + angle = -angle + + total_angle_change += angle + angle_count += 1 + + if angle_count > 0: + avg_angle = total_angle_change / angle_count + # For sharp turns (large angles) decrease epsilon + # For smooth curves increase epsilon + if avg_angle > 0.5: # Sharp turns (>28 degrees) + return 1.0 + elif avg_angle > 0.3: # Medium turns + return 1.5 + else: # Smooth curves + return 2.5 + + return 2.0 + + def _create_smooth_path(self, points): + """Creates smooth path from points considering turn angles for complex curves.""" + path = QtGui.QPainterPath() + path.moveTo(points[0]) + + if len(points) == 2: + path.lineTo(points[1]) + elif len(points) == 3: + # Quadratic curve for three points + p0, p1, p2 = points + cp_x = (p1.x() + p2.x()) / 2.0 + cp_y = (p1.y() + p2.y()) / 2.0 + path.quadTo(QtCore.QPointF(cp_x, cp_y), p2) + else: + # For complex curves use adaptive approach + for i in range(len(points) - 1): + p0 = points[i] + p1 = points[i + 1] + + # Determine previous and next points for context + p_prev = points[i - 1] if i > 0 else p0 + p_next = points[i + 2] if i + 2 < len(points) else p1 + + # Calculate turn angles (approximately) + angle_before = self._calculate_angle_approx(p_prev, p0, p1) + angle_after = self._calculate_angle_approx(p0, p1, p_next) + + # For sharp turns use simpler curves + # For smooth sections - more complex Bezier curves + if angle_before > 0.5 or angle_after > 0.5: # Sharp turn + # For sharp turns use quadratic curve + if i == 0: + path.lineTo(p1) + else: + # Small quadratic curve for smoothness + mid_x = (p0.x() + p1.x()) / 2.0 + mid_y = (p0.y() + p1.y()) / 2.0 + path.quadTo(QtCore.QPointF(mid_x, mid_y), p1) + else: + # Smooth section - use cubic Bezier curve + # Adaptive control points based on movement direction + if i == 0: + # First segment + v1_x = p1.x() - p0.x() + v1_y = p1.y() - p0.y() + v2_x = p_next.x() - p1.x() if i + 2 < len(points) else v1_x + v2_y = p_next.y() - p1.y() if i + 2 < len(points) else v1_y + + # Control points considering direction + tension = 0.3 # Curve tension + cp1_x = p0.x() + v1_x * tension + cp1_y = p0.y() + v1_y * tension + cp2_x = p1.x() - v2_x * tension + cp2_y = p1.y() - v2_y * tension + + path.cubicTo( + QtCore.QPointF(cp1_x, cp1_y), + QtCore.QPointF(cp2_x, cp2_y), + p1 + ) + elif i == len(points) - 2: + # Last segment + v1_x = p0.x() - p_prev.x() + v1_y = p0.y() - p_prev.y() + v2_x = p1.x() - p0.x() + v2_y = p1.y() - p0.y() + + tension = 0.3 + cp1_x = p0.x() + v1_x * tension + cp1_y = p0.y() + v1_y * tension + cp2_x = p1.x() - v2_x * tension + cp2_y = p1.y() - v2_y * tension + + path.cubicTo( + QtCore.QPointF(cp1_x, cp1_y), + QtCore.QPointF(cp2_x, cp2_y), + p1 + ) + else: + # Middle segments - use Catmull-Rom splines + # Convert to Bezier curves + cp1_x, cp1_y, cp2_x, cp2_y = self._catmull_rom_to_bezier( + p_prev, p0, p1, p_next + ) + path.cubicTo( + QtCore.QPointF(cp1_x, cp1_y), + QtCore.QPointF(cp2_x, cp2_y), + p1 + ) + + return path + + def _calculate_angle_approx(self, p0, p1, p2): + """Calculates approximate turn angle at point p1 (in radians) without math.""" + v1_x = p1.x() - p0.x() + v1_y = p1.y() - p0.y() + v2_x = p2.x() - p1.x() + v2_y = p2.y() - p1.y() + + len1_sq = v1_x * v1_x + v1_y * v1_y + len2_sq = v2_x * v2_x + v2_y * v2_y + + if len1_sq < 0.01 or len2_sq < 0.01: + return 0.0 + + len1 = len1_sq ** 0.5 + len2 = len2_sq ** 0.5 + + # Normalize + v1_x /= len1 + v1_y /= len1 + v2_x /= len2 + v2_y /= len2 + + # Angle via dot product (approximately) + dot = v1_x * v2_x + v1_y * v2_y + dot = max(-1.0, min(1.0, dot)) + + # Approximate angle calculation without math.acos + if dot > 0.9: + # Small angle: use approximation sqrt(2 * (1 - dot)) + angle_sq = 2.0 * (1.0 - dot) + return angle_sq ** 0.5 + else: + # Polynomial approximation for acos + # acos(x) ≈ π/2 - x - x³/6 for x close to 0 + # Use more accurate approximation + angle = 1.5708 - dot * (1.5708 - 0.2146 * dot * dot) + return abs(angle) + + def _catmull_rom_to_bezier(self, p0, p1, p2, p3, tension=0.5): + """Converts Catmull-Rom spline points to Bezier control points. + + Args: + p0, p1, p2, p3: Four points for Catmull-Rom spline + tension: Curve tension (0.0 = very smooth, 1.0 = sharper) + + Returns: + (cp1_x, cp1_y, cp2_x, cp2_y) - control points for cubicTo + """ + # Catmull-Rom spline passes through p1 and p2 + # Control points calculated based on neighboring points + cp1_x = p1.x() + (p2.x() - p0.x()) * tension / 6.0 + cp1_y = p1.y() + (p2.y() - p0.y()) * tension / 6.0 + cp2_x = p2.x() - (p3.x() - p1.x()) * tension / 6.0 + cp2_y = p2.y() - (p3.y() - p1.y()) * tension / 6.0 + + return cp1_x, cp1_y, cp2_x, cp2_y + + def _calculate_text_color_from_background(self, bg_color: QtGui.QColor) -> QtGui.QColor: + """Calculates text color based on background color. + + Rules: + - If background is black (brightness < 30) - white text + - If background is white (brightness > 230) - black text + - Otherwise - text two shades darker than background + """ + # Get RGB components (0-255) + r, g, b = bg_color.red(), bg_color.green(), bg_color.blue() + + # Calculate brightness using formula: 0.299*R + 0.587*G + 0.114*B + brightness = 0.299 * r + 0.587 * g + 0.114 * b + + if brightness < 30: + # Very dark background - white text + return QtGui.QColor(255, 255, 255) + elif brightness > 230: + # Very light background - black text + return QtGui.QColor(0, 0, 0) + else: + # Medium brightness - make text two shades darker + # Decrease each component by 40 units (two shades) + new_r = max(0, r - 40) + new_g = max(0, g - 40) + new_b = max(0, b - 40) + return QtGui.QColor(new_r, new_g, new_b) + + def change_selected_text_size(self, size: int): + if size <= 0 or size > 1000: # Reasonable upper limit + return + items = self._selected_text_items() + for item in items: + font = item.font() + font.setPointSize(size) + item.setFont(font) + self._refresh_visible_floating_menu() + + def change_selected_text_font(self, family: str): + items = self._selected_text_items() + for item in items: + new_font = item.font() + new_font.setFamily(family) + item.setFont(new_font) + self._refresh_visible_floating_menu() + + def toggle_selected_text_bold(self): + items = self._selected_text_items() + if not items: + return + first_font = items[0].font() + is_bold = first_font.weight() >= QtGui.QFont.Weight.Bold + target_weight = (QtGui.QFont.Weight.Normal + if is_bold else QtGui.QFont.Weight.Bold) + for item in items: + font = item.font() + font.setWeight(target_weight) + item.setFont(font) + self._refresh_visible_floating_menu() + + def toggle_selected_text_italic(self): + items = self._selected_text_items() + if not items: + return + is_italic = items[0].font().italic() + for item in items: + font = item.font() + font.setItalic(not is_italic) + item.setFont(font) + self._refresh_visible_floating_menu() + + def toggle_selected_text_underline(self): + items = self._selected_text_items() + if not items: + return + is_underline = items[0].font().underline() + for item in items: + font = item.font() + font.setUnderline(not is_underline) + item.setFont(font) + self._refresh_visible_floating_menu() + + def toggle_selected_text_strikethrough(self): + items = self._selected_text_items() + if not items: + return + is_strikethrough = items[0].font().strikeOut() + for item in items: + font = item.font() + font.setStrikeOut(not is_strikethrough) + item.setFont(font) + self._refresh_visible_floating_menu() + + def reset_selected_text_format(self): + """Reset text formatting to default values.""" + from beeref import constants + items = self._selected_text_items() + if not items: + return + default_color = QtGui.QColor(*constants.COLORS['Scene:Text']) + default_font = QtGui.QFont() + for item in items: + # Reset text color + item.setDefaultTextColor(default_color) + # Reset background color if attribute exists + if hasattr(item, 'set_background_color'): + item.set_background_color(QtGui.QColor(0, 0, 0, 0)) + # Reset font to default + item.setFont(default_font) + item.update() + self._refresh_visible_floating_menu() + def on_action_sample_color(self): self.cancel_active_modes() logger.debug('Entering sample color mode') @@ -659,6 +1371,12 @@ class BeeGraphicsView(MainControlsMixin, item.setScale(1 / self.get_scale()) self.undo_stack.push(commands.InsertItems(self.scene, [item], pos)) + def on_action_insert_draw(self): + """Activates drawing mode via context menu.""" + self.cancel_active_modes() + self.scene.clearSelection() + self.enter_drawing_mode() + def on_action_copy(self): logger.debug('Copying to clipboard...') self.cancel_active_modes() @@ -668,7 +1386,8 @@ class BeeGraphicsView(MainControlsMixin, # At the moment, we can only copy one image to the global # clipboard. (Later, we might create an image of the whole # selection for external copying.) - items[0].copy_to_clipboard(clipboard) + if items: + items[0].copy_to_clipboard(clipboard) # However, we can copy all items to the internal clipboard: self.scene.copy_selection_to_internal_clipboard() @@ -718,18 +1437,36 @@ class BeeGraphicsView(MainControlsMixin, QtCore.QUrl.fromLocalFile(dirname)) def on_selection_changed(self): - logger.debug('Currently selected items: %s', - len(self.scene.selectedItems(user_only=True))) - self.actiongroup_set_enabled('active_when_selection', - self.scene.has_selection()) - self.actiongroup_set_enabled('active_when_single_image', - self.scene.has_single_image_selection()) + # Check that scene still exists and hasn't been deleted + if not hasattr(self, 'scene') or not self.scene: + return + + try: + # Check that scene object is still valid + _ = self.scene.selectedItems() + except RuntimeError: + # Scene was deleted, ignore + logger.debug('Scene was deleted, ignoring selection change') + return + + try: + logger.debug('Currently selected items: %s', + len(self.scene.selectedItems(user_only=True))) + self.actiongroup_set_enabled('active_when_selection', + self.scene.has_selection()) + self.actiongroup_set_enabled('active_when_single_image', + self.scene.has_single_image_selection()) - if self.scene.has_selection(): - item = self.scene.selectedItems(user_only=True)[0] - grayscale = getattr(item, 'grayscale', False) - actions.actions['grayscale'].qaction.setChecked(grayscale) - self.viewport().repaint() + if self.scene.has_selection(): + item = self.scene.selectedItems(user_only=True)[0] + grayscale = getattr(item, 'grayscale', False) + actions.actions['grayscale'].qaction.setChecked(grayscale) + self.viewport().repaint() + self._update_floating_menus_on_selection() + except (RuntimeError, AttributeError): + # Scene was deleted or object unavailable, ignore + logger.debug('Scene was deleted or unavailable, ignoring selection change') + return def on_cursor_changed(self, cursor): if self.active_mode is None: @@ -855,6 +1592,13 @@ class BeeGraphicsView(MainControlsMixin, return def mousePressEvent(self, event): + # Handle drawing + if self.drawing_mode and event.button() == QtCore.Qt.MouseButton.LeftButton: + pos = self.mapToScene(event.position().toPoint()) + self._start_drawing(pos) + event.accept() + return + if self.mousePressEventMainControls(event): return @@ -897,9 +1641,19 @@ class BeeGraphicsView(MainControlsMixin, event.accept() return + if self.mousePressEventMainControls(event): + return super().mousePressEvent(event) def mouseMoveEvent(self, event): + # Handle drawing + if self.drawing_mode and self.current_draw_item: + if event.buttons() & QtCore.Qt.MouseButton.LeftButton: + pos = self.mapToScene(event.position().toPoint()) + self._continue_drawing(pos) + event.accept() + return + if self.active_mode == self.PAN_MODE: self.reset_previous_transform() pos = event.position() @@ -931,6 +1685,13 @@ class BeeGraphicsView(MainControlsMixin, super().mouseMoveEvent(event) def mouseReleaseEvent(self, event): + # Handle drawing + if self.drawing_mode and event.button() == QtCore.Qt.MouseButton.LeftButton: + pos = self.mapToScene(event.position().toPoint()) + self._finish_drawing(pos) + event.accept() + return + if self.active_mode == self.PAN_MODE: logger.trace('End pan') self.viewport().unsetCursor() @@ -949,8 +1710,26 @@ class BeeGraphicsView(MainControlsMixin, super().resizeEvent(event) self.recalc_scene_rect() self.welcome_overlay.resize(self.size()) + self.update_floating_menus_position() def keyPressEvent(self, event): + # Handle Escape to exit drawing mode (priority) + if event.key() == Qt.Key.Key_Escape: + if self.drawing_mode: + self.cancel_drawing_mode() + event.accept() + return + + # Handle Backspace/Delete for deleting items BEFORE other handlers + # This ensures it works even if QAction is disabled + if (event.key() in (Qt.Key.Key_Backspace, Qt.Key.Key_Delete) + and event.modifiers() == Qt.KeyboardModifier.NoModifier): + selected = self.scene.selectedItems(user_only=True) + if selected and not self.scene.edit_item: + self.on_action_delete_items() + event.accept() + return + if self.keyPressEventMainControls(event): return if self.active_mode == self.SAMPLE_COLOR_MODE: diff --git a/beeref/widgets/__init__.py b/beeref/widgets/__init__.py index 9ef8564..62f8f19 100644 --- a/beeref/widgets/__init__.py +++ b/beeref/widgets/__init__.py @@ -26,6 +26,13 @@ from beeref.widgets import ( # noqa: F401 settings, welcome_overlay, color_gamut, + floating_menu, + text_floating_menu, + image_floating_menu, + gif_floating_menu, + gif_frames_menu, + draw_floating_menu, + color_picker, ) diff --git a/beeref/widgets/color_gamut.py b/beeref/widgets/color_gamut.py index b3ff889..0e10da6 100644 --- a/beeref/widgets/color_gamut.py +++ b/beeref/widgets/color_gamut.py @@ -34,6 +34,44 @@ class GamutPainterThread(QtCore.QThread): self.item = item self.parent = parent + def draw_color_wheel_gradient(self, painter, center, radius): + """Draw a circular HSV color wheel gradient.""" + # Draw gradient by iterating through pixels + for y in range(2 * radius): + for x in range(2 * radius): + # Calculate distance from center + dx = x - center.x() + dy = y - center.y() + distance = math.sqrt(dx * dx + dy * dy) + + # Only draw inside the circle + if distance <= radius: + # Calculate angle in radians (same coordinate system as points) + angle_rad = math.atan2(dx, dy) + + # Convert to hue using the same transformation as points + # Points use: angle = math.radians(-90 - hue) + # So for gradient: hue = -90 - math.degrees(angle_rad) + angle_deg = math.degrees(angle_rad) + hue = int(-90 - angle_deg) % 360 + + # Calculate saturation based on distance from center + # At center (distance=0): saturation=0 (white) + # At edge (distance=radius): saturation=255 (full color) + saturation = int((distance / radius) * 255) + saturation = min(255, max(0, saturation)) + + # Value is always maximum for bright colors + value = 255 + + # Create color from HSV + color = QtGui.QColor() + color.setHsv(hue, saturation, value) + + # Draw pixel + painter.setPen(QtGui.QPen(color, 1)) + painter.drawPoint(x, y) + def run(self): logger.debug('Start drawing gamut image...') self.image = QtGui.QImage( @@ -43,10 +81,18 @@ class GamutPainterThread(QtCore.QThread): painter = QtGui.QPainter(self.image) painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing) - painter.setBrush(QtGui.QBrush(QtGui.QColor(0, 0, 0))) - painter.setPen(Qt.PenStyle.NoPen) center = QtCore.QPoint(self.radius, self.radius) - painter.drawEllipse(center, self.radius, self.radius) + + # Draw color wheel gradient if enabled + if self.parent.show_color_gamut: + logger.debug('Drawing color wheel gradient...') + self.draw_color_wheel_gradient(painter, center, self.radius) + else: + # Draw black circle background + painter.setBrush(QtGui.QBrush(QtGui.QColor(0, 0, 0))) + painter.setPen(Qt.PenStyle.NoPen) + painter.drawEllipse(center, self.radius, self.radius) + logger.debug(f'Threshold: {self.parent.threshold}') for (hue, saturation), count in self.item.color_gamut.items(): @@ -71,6 +117,7 @@ class GamutWidget(QtWidgets.QWidget): super().__init__(parent) self.item = item self.image = None + self.show_color_gamut = False # Default: gradient disabled self.worker = GamutPainterThread(self, item) self.worker.finished.connect(self.on_gamut_finished) self.worker.start() @@ -111,6 +158,12 @@ class GamutDialog(QtWidgets.QDialog): # The input controls on the right controls_layout = QtWidgets.QVBoxLayout() + # Add "View color gamut" checkbox + self.view_gamut_checkbox = QtWidgets.QCheckBox('View color gamut', self) + self.view_gamut_checkbox.setChecked(False) + self.view_gamut_checkbox.stateChanged.connect(self.on_gamut_checkbox_changed) + controls_layout.addWidget(self.view_gamut_checkbox) + label = QtWidgets.QLabel('Threshold:', self) controls_layout.addWidget(label) self.threshold_input = QtWidgets.QSlider(self) @@ -136,5 +189,10 @@ class GamutDialog(QtWidgets.QDialog): layout.addLayout(controls_layout, stretch=0) self.show() + def on_gamut_checkbox_changed(self, state): + """Handle checkbox state change.""" + self.gamut_widget.show_color_gamut = self.view_gamut_checkbox.isChecked() + self.gamut_widget.update_values() + def on_value_changed(self, value): self.gamut_widget.update_values() diff --git a/beeref/widgets/color_picker.py b/beeref/widgets/color_picker.py new file mode 100644 index 0000000..3ff013e --- /dev/null +++ b/beeref/widgets/color_picker.py @@ -0,0 +1,351 @@ +from __future__ import annotations + +import math +from typing import Optional + +from PyQt6 import QtCore, QtGui, QtWidgets + + +class HSVColorWheel(QtWidgets.QWidget): + """Interactive HSV color wheel for selecting hue and saturation.""" + + colorChanged = QtCore.pyqtSignal(int, int) # hue, saturation + + def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None: + super().__init__(parent) + self.setMouseTracking(True) + self._hue = 0 + self._saturation = 0 + self._value = 255 + self._wheel_image: Optional[QtGui.QImage] = None + self._wheel_side = 0 + self._highlight_radius = 8 + + def sizeHint(self) -> QtCore.QSize: # noqa: D401 + return QtCore.QSize(260, 260) + + def setValue(self, value: int) -> None: + value = max(0, min(255, value)) + if self._value != value: + self._value = value + self.update() + + def setHueSaturation(self, hue: int, saturation: int, emit: bool = False) -> None: + hue = int(hue) % 360 + saturation = max(0, min(255, int(saturation))) + if self._hue == hue and self._saturation == saturation: + return + self._hue = hue + self._saturation = saturation + if emit: + self.colorChanged.emit(self._hue, self._saturation) + self.update() + + def setColor(self, color: QtGui.QColor) -> None: + hue, saturation, value, _ = color.getHsv() + if hue == -1: # grayscale + hue = self._hue + self.setHueSaturation(hue, saturation) + self.setValue(value) + + def hue(self) -> int: + return self._hue + + def saturation(self) -> int: + return self._saturation + + def color(self) -> QtGui.QColor: + return QtGui.QColor.fromHsv(self._hue, self._saturation, self._value) + + def _generate_wheel_image(self, side: int) -> QtGui.QImage: + image = QtGui.QImage(side, side, QtGui.QImage.Format.Format_ARGB32) + image.fill(QtGui.QColor(0, 0, 0, 0)) + + center = side / 2.0 + radius = side / 2.0 + + for y in range(side): + for x in range(side): + dx = x - center + 0.5 + dy = y - center + 0.5 + distance = math.hypot(dx, dy) + if distance > radius: + continue + + hue = int(math.degrees(math.atan2(-dy, dx))) % 360 + saturation = int(min(1.0, distance / radius) * 255) + color = QtGui.QColor() + color.setHsv(hue, saturation, 255) + image.setPixelColor(x, y, color) + + return image + + def _ensure_wheel_image(self) -> None: + side = min(self.width(), self.height()) + if side <= 0: + return + if self._wheel_image is None or self._wheel_side != side: + self._wheel_side = side + self._wheel_image = self._generate_wheel_image(side) + + def resizeEvent(self, event: QtGui.QResizeEvent) -> None: # noqa: D401 + super().resizeEvent(event) + self._wheel_image = None + self.update() + + def paintEvent(self, event: QtGui.QPaintEvent) -> None: # noqa: D401 + painter = QtGui.QPainter(self) + painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing) + + self._ensure_wheel_image() + if not self._wheel_image: + return + + side = self._wheel_side + rect_side = min(self.width(), self.height()) + x = (self.width() - rect_side) / 2 + y = (self.height() - rect_side) / 2 + target_rect = QtCore.QRectF(x, y, rect_side, rect_side) + painter.drawImage(target_rect, self._wheel_image) + + # Draw marker representing the selected color + radius = rect_side / 2.0 + center = QtCore.QPointF(target_rect.center()) + angle_rad = math.radians(self._hue) + distance = (self._saturation / 255.0) * radius + marker_x = center.x() + math.cos(angle_rad) * distance + marker_y = center.y() - math.sin(angle_rad) * distance + marker_center = QtCore.QPointF(marker_x, marker_y) + + marker_color = QtGui.QColor.fromHsv(self._hue, self._saturation, self._value) + marker_radius = self._highlight_radius + + painter.setPen(QtGui.QPen(self.palette().window(), marker_radius / 2)) + painter.setBrush(self.palette().window()) + painter.drawEllipse(marker_center, marker_radius + 2, marker_radius + 2) + + painter.setPen(QtGui.QPen(self.palette().windowText(), 1.5)) + painter.setBrush(QtGui.QBrush(marker_color)) + painter.drawEllipse(marker_center, marker_radius, marker_radius) + + # Interaction utilities + def _set_from_position(self, pos: QtCore.QPointF) -> None: + rect_side = min(self.width(), self.height()) + x = (self.width() - rect_side) / 2 + y = (self.height() - rect_side) / 2 + center = QtCore.QPointF(x + rect_side / 2.0, y + rect_side / 2.0) + dx = pos.x() - center.x() + dy = center.y() - pos.y() + angle_deg = (math.degrees(math.atan2(dy, dx)) + 360) % 360 + distance = math.hypot(dx, dy) + radius = rect_side / 2.0 + distance = min(distance, radius) + saturation = int((distance / radius) * 255) + + hue_changed = (self._hue != int(angle_deg)) + sat_changed = (self._saturation != saturation) + if hue_changed or sat_changed: + self.setHueSaturation(int(angle_deg), saturation, emit=True) + + def mousePressEvent(self, event: QtGui.QMouseEvent) -> None: # noqa: D401 + if event.button() == QtCore.Qt.MouseButton.LeftButton: + self._set_from_position(event.position()) + self.setCursor(QtCore.Qt.CursorShape.CrossCursor) + super().mousePressEvent(event) + + def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None: # noqa: D401 + if event.buttons() & QtCore.Qt.MouseButton.LeftButton: + self._set_from_position(event.position()) + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event: QtGui.QMouseEvent) -> None: # noqa: D401 + if event.button() == QtCore.Qt.MouseButton.LeftButton: + self.unsetCursor() + super().mouseReleaseEvent(event) + + +class BrightnessSlider(QtWidgets.QWidget): + """Horizontal brightness selector with gradient background.""" + + valueChanged = QtCore.pyqtSignal(int) + + def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None: + super().__init__(parent) + self.setMouseTracking(True) + self._value = 255 + self._hue = 0 + self._saturation = 0 + self._handle_radius = 8 + + def sizeHint(self) -> QtCore.QSize: # noqa: D401 + return QtCore.QSize(260, 36) + + def minimumSizeHint(self) -> QtCore.QSize: # noqa: D401 + return QtCore.QSize(180, 32) + + def setHueSaturation(self, hue: int, saturation: int) -> None: + if self._hue != hue or self._saturation != saturation: + self._hue = hue + self._saturation = saturation + self.update() + + def setValue(self, value: int) -> None: + value = max(0, min(255, value)) + if self._value != value: + self._value = value + self.valueChanged.emit(self._value) + self.update() + + def value(self) -> int: + return self._value + + def _color_for_value(self, value: int) -> QtGui.QColor: + return QtGui.QColor.fromHsv(self._hue, self._saturation, value) + + def paintEvent(self, event: QtGui.QPaintEvent) -> None: # noqa: D401 + painter = QtGui.QPainter(self) + painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing) + + margin = 10 + rect = self.rect().adjusted(margin, margin // 2, -margin, -margin // 2) + rect = rect.normalized() + + gradient = QtGui.QLinearGradient(rect.left(), rect.center().y(), rect.right(), rect.center().y()) + gradient.setColorAt(0.0, self._color_for_value(0)) + gradient.setColorAt(0.5, self._color_for_value(128)) + gradient.setColorAt(1.0, self._color_for_value(255)) + + painter.setPen(QtGui.QPen(self.palette().mid(), 1)) + painter.setBrush(QtGui.QBrush(gradient)) + radius = rect.height() / 2 + painter.drawRoundedRect(rect, radius, radius) + + handle_x = rect.left() + (self._value / 255.0) * rect.width() + handle_center = QtCore.QPointF(handle_x, rect.center().y()) + handle_color = self._color_for_value(self._value) + + painter.setPen(QtGui.QPen(self.palette().window(), self._handle_radius / 2)) + painter.setBrush(self.palette().window()) + painter.drawEllipse(handle_center, self._handle_radius + 2, self._handle_radius + 2) + + painter.setPen(QtGui.QPen(self.palette().windowText(), 1.5)) + painter.setBrush(QtGui.QBrush(handle_color)) + painter.drawEllipse(handle_center, self._handle_radius, self._handle_radius) + + def _set_from_position(self, pos: QtCore.QPointF) -> None: + margin = 10 + rect = self.rect().adjusted(margin, margin // 2, -margin, -margin // 2) + rect = rect.normalized() + if rect.width() <= 0: + return + x = min(rect.right(), max(rect.left(), pos.x())) + ratio = (x - rect.left()) / rect.width() + value = int(ratio * 255) + if value != self._value: + self._value = value + self.valueChanged.emit(self._value) + self.update() + + def mousePressEvent(self, event: QtGui.QMouseEvent) -> None: # noqa: D401 + if event.button() == QtCore.Qt.MouseButton.LeftButton: + self._set_from_position(event.position()) + self.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + super().mousePressEvent(event) + + def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None: # noqa: D401 + if event.buttons() & QtCore.Qt.MouseButton.LeftButton: + self._set_from_position(event.position()) + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event: QtGui.QMouseEvent) -> None: # noqa: D401 + if event.button() == QtCore.Qt.MouseButton.LeftButton: + self.unsetCursor() + super().mouseReleaseEvent(event) + + +class ColorPreview(QtWidgets.QFrame): + """Simple frame showing the currently selected color.""" + + def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None: + super().__init__(parent) + self.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel) + self.setFixedHeight(36) + self.setAutoFillBackground(True) + + def setColor(self, color: QtGui.QColor) -> None: + palette = self.palette() + palette.setColor(QtGui.QPalette.ColorRole.Window, color) + self.setPalette(palette) + + +class ColorPickerDialog(QtWidgets.QDialog): + """Dialog for choosing a color via HSV color wheel and brightness slider.""" + + def __init__(self, parent: Optional[QtWidgets.QWidget] = None, initial: Optional[QtGui.QColor] = None) -> None: + super().__init__(parent) + self.setWindowTitle("Color Picker") + self.setModal(True) + + self._wheel = HSVColorWheel(self) + self._slider = BrightnessSlider(self) + self._preview = ColorPreview(self) + self._current_color = QtGui.QColor.fromHsv(0, 0, 255) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(self._wheel, alignment=QtCore.Qt.AlignmentFlag.AlignHCenter) + layout.addWidget(self._slider) + layout.addWidget(self._preview) + + buttons = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.StandardButton.Ok | QtWidgets.QDialogButtonBox.StandardButton.Cancel, + parent=self, + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + self._wheel.colorChanged.connect(self._on_wheel_changed) + self._slider.valueChanged.connect(self._on_value_changed) + + if initial is not None: + self.setColor(initial) + else: + self._update_preview() + + def selectedColor(self) -> QtGui.QColor: + return self._current_color + + def setColor(self, color: QtGui.QColor) -> None: + color = QtGui.QColor(color) + hue, saturation, value, _ = color.getHsv() + if hue == -1: + hue = 0 + self._wheel.setHueSaturation(hue, saturation) + self._wheel.setValue(value) + self._slider.setHueSaturation(hue, saturation) + self._slider.setValue(value) + self._current_color = QtGui.QColor.fromHsv(hue, saturation, value) + self._update_preview() + + def _on_wheel_changed(self, hue: int, saturation: int) -> None: + self._slider.setHueSaturation(hue, saturation) + self._wheel.setHueSaturation(hue, saturation) + value = self._slider.value() + self._wheel.setValue(value) + self._current_color = QtGui.QColor.fromHsv(hue, saturation, value) + self._update_preview() + + def _on_value_changed(self, value: int) -> None: + self._wheel.setValue(value) + self._current_color = QtGui.QColor.fromHsv(self._wheel.hue(), self._wheel.saturation(), value) + self._update_preview() + + def _update_preview(self) -> None: + self._preview.setColor(self._current_color) + + +__all__ = [ + "ColorPickerDialog", + "HSVColorWheel", + "BrightnessSlider", +] diff --git a/beeref/widgets/controls/mouse.py b/beeref/widgets/controls/mouse.py index e36118f..c043fa5 100644 --- a/beeref/widgets/controls/mouse.py +++ b/beeref/widgets/controls/mouse.py @@ -38,6 +38,7 @@ class MouseControlsEditor(MouseControlsEditorBase): self.layout.addWidget(QtWidgets.QLabel('Mouse Button:')) self.button_input = QtWidgets.QComboBox(parent=parent) + self.button_input.setMinimumWidth(40) self.button_input.insertItems(0, self.action.BUTTON_MAP.keys()) values = list(self.action.BUTTON_MAP.keys()) self.button_input.setCurrentIndex(values.index(self.old_button)) diff --git a/beeref/widgets/draw_floating_menu.py b/beeref/widgets/draw_floating_menu.py new file mode 100644 index 0000000..9d9ce04 --- /dev/null +++ b/beeref/widgets/draw_floating_menu.py @@ -0,0 +1,205 @@ +"""Floating menu for drawing items.""" + +from __future__ import annotations + +import logging +from typing import Optional, TYPE_CHECKING + +from PyQt6 import QtCore, QtGui, QtWidgets + +from beeref import constants +from beeref import widgets +from beeref.assets import BeeAssets +from beeref.widgets.floating_menu import FloatingMenu + +logger = logging.getLogger(__name__) + +if TYPE_CHECKING: # pragma: no cover - type checking only + from beeref.view import BeeGraphicsView + from beeref.items import BeeDrawItem + + +class UpwardComboBox(QtWidgets.QComboBox): + """QComboBox that opens its dropdown menu upward.""" + + def showPopup(self): + """Override to show popup above the combobox.""" + super().showPopup() + # Используем QTimer для получения popup после его создания + QtCore.QTimer.singleShot(0, self._reposition_popup) + + def _reposition_popup(self): + """Перемещает popup выше combobox.""" + # Ищем активный popup виджет + popup = QtWidgets.QApplication.activePopupWidget() + if not popup: + # Альтернативный способ - найти через view + view = self.view() + if view: + popup = view.parent() + while popup and not isinstance(popup, QtWidgets.QFrame): + popup = popup.parent() + + if popup: + # Получаем глобальную позицию combobox + global_pos = self.mapToGlobal(QtCore.QPoint(0, 0)) + # Вычисляем новую позицию выше combobox + popup_height = popup.height() + new_y = global_pos.y() - popup_height + popup.move(global_pos.x(), new_y) + + +class DrawFloatingMenu(FloatingMenu): + """Floating menu for drawing tools.""" + + def __init__(self, parent: QtWidgets.QWidget, view: "BeeGraphicsView"): + super().__init__(parent, view) + self.current_item: Optional["BeeDrawItem"] = None + + # Кнопка выбора цвета через colorpicker + assets = BeeAssets() + icons_path = assets.PATH.joinpath('icons') + palette_icon = QtGui.QIcon(str(icons_path.joinpath('palette.svg'))) + + self.color_btn = self.add_button( + "", + icon=palette_icon, + callback=self._on_color_clicked, + ) + self.color_btn.setToolTip("Color") + + # Combobox для выбора стиля линии + self.add_separator() + self.style_combo = self.add_style_combobox() + + # Селектор толщины пера + self.add_separator() + self.width_slider = self.add_width_slider() + + # Кнопка закрытия меню (отмена режима рисования) + self.add_separator() + close_icon = QtGui.QIcon(str(icons_path.joinpath('close.svg'))) + self.close_button = self.add_button( + "", + icon=close_icon, + callback=self._on_close_clicked + ) + self.close_button.setToolTip("Close") + + def add_style_combobox(self): + """Добавляет combobox для выбора стиля линии.""" + assets = BeeAssets() + icons_path = assets.PATH.joinpath('icons') + + # Загружаем иконки для стилей + solid_icon = QtGui.QIcon(str(icons_path.joinpath('line-solid.svg'))) + dashed_icon = QtGui.QIcon(str(icons_path.joinpath('line-dashed.svg'))) + arrow_icon = QtGui.QIcon(str(icons_path.joinpath('line-arrow.svg'))) + arrow_left_icon = QtGui.QIcon(str(icons_path.joinpath('line-arrow-left.svg'))) + arrow_both_icon = QtGui.QIcon(str(icons_path.joinpath('line-arrow-both.svg'))) + + combo = UpwardComboBox(self) + combo.setObjectName("FloatingMenuLineStyle") + combo.setMinimumWidth(40) + + # Добавляем элементы с иконками + combo.addItem(solid_icon, "", 'solid') + combo.addItem(dashed_icon, "", 'dashed') + combo.addItem(arrow_icon, "", 'arrow') + combo.addItem(arrow_left_icon, "", '<-') + combo.addItem(arrow_both_icon, "", '<->') + # Устанавливаем иконки для элементов + combo.setItemIcon(0, solid_icon) + combo.setItemIcon(1, dashed_icon) + combo.setItemIcon(2, arrow_icon) + combo.setItemIcon(3, arrow_left_icon) + combo.setItemIcon(4, arrow_both_icon) + + combo.setCurrentIndex(0) # По умолчанию solid + combo.currentIndexChanged.connect(self._on_style_changed) + combo.setToolTip("Line style") + + self.add_widget(combo) + return combo + + def _on_color_clicked(self) -> None: + """Открывает диалог выбора цвета.""" + if not self.current_item: + return + + initial = self.current_item.pen_color + dialog = widgets.color_picker.ColorPickerDialog(self, initial) + if dialog.exec() != QtWidgets.QDialog.DialogCode.Accepted: + return + color = dialog.selectedColor() + self.set_pen_color(color) + + def _on_style_changed(self, index: int) -> None: + """Обработчик изменения стиля линии.""" + if not self.current_item: + return + style = self.style_combo.itemData(index) + if style: + self.set_pen_style(style) + + def add_width_slider(self): + """Добавляет слайдер для выбора толщины пера.""" + container = QtWidgets.QWidget() + layout = QtWidgets.QHBoxLayout(container) + layout.setContentsMargins(0, 0, 0, 0) + + label = QtWidgets.QLabel("Width:") + layout.addWidget(label) + + slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) + slider.setRange(1, 20) + slider.setValue(8) + slider.valueChanged.connect(self.set_pen_width) + layout.addWidget(slider) + + self.add_widget(container) + return slider + + def show_for_item(self, item: "BeeDrawItem") -> None: + """Показывает меню для выбранного элемента рисования.""" + self.current_item = item + if item: + self.width_slider.setValue(item.pen_width) + # Устанавливаем текущий стиль в combobox + style_index = self.style_combo.findData(item.pen_style) + if style_index >= 0: + self.style_combo.blockSignals(True) + self.style_combo.setCurrentIndex(style_index) + self.style_combo.blockSignals(False) + super().show_for_item(item) + + def set_pen_color(self, color: QtGui.QColor): + """Устанавливает цвет пера для выбранного элемента.""" + if self.current_item: + self.current_item.set_pen_color(color) + + def set_pen_width(self, width: int): + """Устанавливает толщину пера для выбранного элемента.""" + if self.current_item: + self.current_item.set_pen_width(width) + + def set_pen_style(self, style: str): + """Устанавливает стиль линии для выбранного элемента.""" + if self.current_item: + self.current_item.set_pen_style(style) + + def _on_close_clicked(self) -> None: + """Закрывает меню и отменяет режим рисования.""" + self.view.cancel_drawing_mode() + self.hide_menu() + + def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: + """Обрабатывает нажатия клавиш.""" + if event.key() == QtCore.Qt.Key.Key_Escape: + # ESC закрывает меню и отменяет режим рисования + self.view.cancel_drawing_mode() + self.hide_menu() + event.accept() + return + super().keyPressEvent(event) + diff --git a/beeref/widgets/floating_menu.py b/beeref/widgets/floating_menu.py new file mode 100644 index 0000000..de0cd1d --- /dev/null +++ b/beeref/widgets/floating_menu.py @@ -0,0 +1,293 @@ +"""Floating menu widgets shown for single item selections.""" + +from __future__ import annotations + +from typing import Optional, TYPE_CHECKING, Callable + +from PyQt6 import QtCore, QtGui, QtWidgets + +from beeref import constants + + +if TYPE_CHECKING: # pragma: no cover - type checking only + from beeref.view import BeeGraphicsView + + +class FloatingMenu(QtWidgets.QWidget): + """Base widget for floating menus pinned to the bottom centre.""" + + BOTTOM_MARGIN = 8 + CORNER_RADIUS = 8 + + def __init__(self, parent: QtWidgets.QWidget, view: "BeeGraphicsView"): + # Create as independent window to prevent event blocking + super().__init__(None) + self.setObjectName("FloatingMenu") + self.setAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground, True) + self.setAttribute(QtCore.Qt.WidgetAttribute.WA_ShowWithoutActivating, True) + # Make it a tool window that stays on top + self.setWindowFlag(QtCore.Qt.WindowType.FramelessWindowHint, True) + self.setWindowFlag(QtCore.Qt.WindowType.Tool, True) + self.setWindowFlag(QtCore.Qt.WindowType.WindowStaysOnTopHint, True) + # Store parent for positioning + self._parent_widget = parent + self.view = view + self.current_item: Optional[QtWidgets.QGraphicsItem] = None + + # Cache for update_position optimization + self._cached_position: Optional[QtCore.QPoint] = None + self._cached_viewport_size: Optional[QtCore.QSize] = None + self._cached_window_pos: Optional[QtCore.QPoint] = None + + # Таймер для отслеживания перемещения главного окна + self._position_timer = QtCore.QTimer(self) + self._position_timer.timeout.connect(self._check_window_position) + self._position_timer.setInterval(50) # Проверка каждые 50мс + + # Main layout with uniform spacing + self._layout = QtWidgets.QHBoxLayout(self) + self._layout.setContentsMargins(8, 6, 8, 6) + self._layout.setSpacing(6) + self._layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + + self.setFocusPolicy(QtCore.Qt.FocusPolicy.NoFocus) + self.setStyleSheet(constants.get_floating_menu_style()) + + self.hide() + + def _check_window_position(self) -> None: + """Проверяет позицию viewport и обновляет позицию меню при необходимости.""" + if not self.isVisible(): + return + + view = self.view + if view is None: + return + + viewport = view.viewport() + if viewport is None: + return + + # Получаем позицию viewport в глобальных координатах + view_rect = viewport.rect() + current_viewport_pos = viewport.mapToGlobal(view_rect.topLeft()) + + # Если позиция viewport изменилась, обновляем позицию меню + if self._cached_window_pos is not None and self._cached_window_pos != current_viewport_pos: + self.update_position() + + self._cached_window_pos = current_viewport_pos + + def _apply_rounded_mask(self) -> None: + """ + Применяет закругленную маску к виджету с антиалиасингом. + Реализация на основе подхода из статьи VK Teams. + """ + size = self.size() + if size.width() == 0 or size.height() == 0: + return + + # Для более плавного сглаживания используем QBitmap с антиалиасингом + # Создаем изображение с увеличенным разрешением (как в статье) + scale_factor = 2 + scaled_size = QtCore.QSize( + int(size.width() * scale_factor), + int(size.height() * scale_factor) + ) + + # Создаем QPixmap для рисования с антиалиасингом + pixmap = QtGui.QPixmap(scaled_size) + pixmap.fill(QtCore.Qt.GlobalColor.transparent) + + painter = QtGui.QPainter(pixmap) + painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, True) + painter.setPen(QtCore.Qt.PenStyle.NoPen) + painter.setBrush(QtCore.Qt.GlobalColor.black) # Черный для маски + + # Рисуем закругленный прямоугольник на увеличенном разрешении + scaled_rect = QtCore.QRectF(0, 0, scaled_size.width(), scaled_size.height()) + scaled_radius = self.CORNER_RADIUS * scale_factor + painter.drawRoundedRect(scaled_rect, scaled_radius, scaled_radius) + painter.end() + + # Масштабируем обратно с сглаживанием + pixmap = pixmap.scaled( + size, + QtCore.Qt.AspectRatioMode.IgnoreAspectRatio, + QtCore.Qt.TransformationMode.SmoothTransformation + ) + + # Преобразуем в QBitmap для маски + # В маске: непрозрачные пиксели = видимые области, прозрачные = невидимые + image = pixmap.toImage() + # Создаем маску из непрозрачных пикселей + bitmap = QtGui.QBitmap.fromImage(image.createAlphaMask()) + + # Применяем маску + self.setMask(bitmap) + + def add_widget(self, widget: QtWidgets.QWidget) -> QtWidgets.QWidget: + widget.setParent(self) + self._layout.addWidget(widget) + return widget + + def add_button( + self, + text: str, + icon: Optional[QtGui.QIcon] = None, + callback: Optional[Callable[[], None]] = None, + checkable: bool = False, + ) -> QtWidgets.QPushButton: + button = QtWidgets.QPushButton(text, self) + button.setCheckable(checkable) + if icon: + button.setIcon(icon) + button.setProperty("floatingButton", True) + if callback: + # Connect directly - button events are handled independently + button.clicked.connect(callback) + self._layout.addWidget(button) + return button + + def add_separator(self) -> None: + separator = QtWidgets.QFrame(self) + separator.setObjectName("FloatingMenuSeparator") + separator.setFrameShape(QtWidgets.QFrame.Shape.VLine) + separator.setFrameShadow(QtWidgets.QFrame.Shadow.Plain) + separator.setFixedWidth(1) + self._layout.addWidget(separator) + + def show_for_item(self, item: QtWidgets.QGraphicsItem) -> None: + self.current_item = item + self.show_menu() + + def show_menu(self) -> None: + self.adjustSize() + self._apply_rounded_mask() + self.show() + self.raise_() + # Reset cache when showing menu to ensure position update + self._cached_position = None + self._cached_viewport_size = None + self._cached_window_pos = None + self.update_position() + # Запускаем таймер для отслеживания перемещения окна + self._position_timer.start() + # Ensure menu is on top after positioning + self.raise_() + # Возвращаем фокус view, чтобы события клавиатуры обрабатывались правильно + # FloatingMenu не должен перехватывать фокус, так как он имеет NoFocus + if self.view: + self.view.setFocus() + + def hide_menu(self) -> None: + self.current_item = None + # Останавливаем таймер + self._position_timer.stop() + # Clear cache when hiding menu + self._cached_position = None + self._cached_viewport_size = None + self._cached_window_pos = None + self.hide() + + def update_position(self) -> None: + if not self.isVisible(): + return + + parent = self._parent_widget + view = self.view + if parent is None or view is None: + return + + viewport = view.viewport() + if viewport is None: + return + + view_rect = viewport.rect() + viewport_size = view_rect.size() + + # Получаем текущую позицию viewport для проверки изменений + current_viewport_pos = viewport.mapToGlobal(view_rect.topLeft()) + + # Check if viewport size and position changed + if (self._cached_viewport_size is not None and + self._cached_viewport_size == viewport_size and + self._cached_window_pos is not None and + self._cached_window_pos == current_viewport_pos and + self._cached_position is not None): + # If viewport size and position haven't changed, check menu position + current_pos = self.pos() + if current_pos == self._cached_position: + # Position hasn't changed, skip update + return + + size = self.sizeHint() + width = self.width() or size.width() + height = self.height() or size.height() + + # Calculate position in global coordinates (independent window) + top_left_global = viewport.mapToGlobal(view_rect.topLeft()) + bottom_left_global = viewport.mapToGlobal(view_rect.bottomLeft()) + bottom_right_global = viewport.mapToGlobal(view_rect.bottomRight()) + top_right_global = viewport.mapToGlobal(view_rect.topRight()) + + # Получаем границы viewport в глобальных координатах + viewport_left = top_left_global.x() + viewport_right = top_right_global.x() + viewport_top = top_left_global.y() + viewport_bottom = bottom_left_global.y() + viewport_width = viewport_right - viewport_left + + # Вычисляем позицию по X (центрируем, но не выходим за границы) + x = viewport_left + max(0, (viewport_width - width) // 2) + # Ограничиваем, чтобы меню не выходило за левую и правую границы + x = max(viewport_left, min(x, viewport_right - width)) + + # Вычисляем позицию по Y (снизу с отступом) + y = viewport_bottom - height - self.BOTTOM_MARGIN + # Ограничиваем, чтобы меню не выходило за верхнюю границу + # Если меню не помещается снизу, размещаем его сверху + if y < viewport_top: + y = viewport_top + self.BOTTOM_MARGIN + # Также проверяем, что меню не выходит за нижнюю границу + if y + height > viewport_bottom: + y = viewport_bottom - height - self.BOTTOM_MARGIN + # Если и так не помещается, размещаем сверху + if y < viewport_top: + y = viewport_top + self.BOTTOM_MARGIN + + new_position = QtCore.QPoint(x, y) + + # Update position only if it actually changed + if self._cached_position != new_position: + self.move(new_position) + self._cached_position = new_position + self._cached_viewport_size = viewport_size + self._cached_window_pos = current_viewport_pos + # Обновляем маску после изменения размера/позиции + self._apply_rounded_mask() + + def parentWidget(self): + """Override to return stored parent widget for compatibility.""" + return self._parent_widget + + def mousePressEvent(self, event: QtGui.QMouseEvent) -> None: + """Intercept mouse events to prevent them from reaching view.""" + # Accept event to prevent propagation + event.accept() + super().mousePressEvent(event) + + def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None: + """Intercept mouse events to prevent them from reaching view.""" + event.accept() + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event: QtGui.QMouseEvent) -> None: + """Intercept mouse events to prevent them from reaching view.""" + event.accept() + super().mouseReleaseEvent(event) + + def resizeEvent(self, event: QtGui.QResizeEvent) -> None: + """Обновляет маску при изменении размера виджета.""" + super().resizeEvent(event) + self._apply_rounded_mask() diff --git a/beeref/widgets/gif_floating_menu.py b/beeref/widgets/gif_floating_menu.py new file mode 100644 index 0000000..85c9978 --- /dev/null +++ b/beeref/widgets/gif_floating_menu.py @@ -0,0 +1,231 @@ +"""Floating menu shown when a single GIF item is selected.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Optional + +from PyQt6 import QtCore, QtGui, QtWidgets + +from beeref.assets import BeeAssets +from beeref.widgets.floating_menu import FloatingMenu + +logger = logging.getLogger(__name__) + +if TYPE_CHECKING: # pragma: no cover + from beeref.view import BeeGraphicsView + from beeref.gif_item import BeeGifItem + from beeref.widgets.gif_frames_menu import GifFramesMenu + + +class GifFloatingMenu(FloatingMenu): + """Contextual floating toolbar for GIF items.""" + + SPEED_VALUES = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0] + + def __init__(self, parent: QtWidgets.QWidget, view: "BeeGraphicsView"): + super().__init__(parent, view) + + self._init_frames_menu(parent, view) + self._init_icons() + self._init_speed_combobox() + self._init_control_buttons() + + def _init_frames_menu( + self, + parent: QtWidgets.QWidget, + view: "BeeGraphicsView" + ) -> None: + """Initializes frames menu.""" + self.frames_menu: Optional["GifFramesMenu"] = None + try: + from beeref.widgets.gif_frames_menu import GifFramesMenu + self.frames_menu = GifFramesMenu(parent, view, self) + except ImportError as e: + logger.warning(f'Failed to import frames menu: {e}') + except Exception as e: + logger.warning(f'Failed to initialize frames menu: {e}') + + def _init_icons(self) -> None: + """Loads icons for control buttons.""" + assets = BeeAssets() + icons_path = assets.PATH.joinpath('icons') + + self.prev_frame_icon = QtGui.QIcon( + str(icons_path.joinpath('prev-frame.svg'))) + self.play_icon = QtGui.QIcon(str(icons_path.joinpath('play.svg'))) + self.pause_icon = QtGui.QIcon(str(icons_path.joinpath('pause.svg'))) + self.next_frame_icon = QtGui.QIcon( + str(icons_path.joinpath('next-frame.svg'))) + self.frames_icon = QtGui.QIcon(str(icons_path.joinpath('frames.svg'))) + + def _init_speed_combobox(self) -> None: + """Initializes combobox for playback speed selection.""" + self.speed_combo = QtWidgets.QComboBox(self) + self.speed_combo.setObjectName("FloatingMenuGifSpeed") + self.speed_combo.setMinimumWidth(40) + + for speed in self.SPEED_VALUES: + label = self._format_speed_label(speed) + self.speed_combo.addItem(label, speed) + + # Set default value (1.0x) + default_index = self.SPEED_VALUES.index(1.0) + self.speed_combo.setCurrentIndex(default_index) + self.speed_combo.currentIndexChanged.connect(self._on_speed_changed) + self.speed_combo.setToolTip("Playback speed") + self.add_widget(self.speed_combo) + + def _format_speed_label(self, speed: float) -> str: + """Formats speed value for display in combobox.""" + if speed < 1.0: + return f"{speed:.2f}x" + else: + speed_str = f"{speed:.2f}".rstrip('0').rstrip('.') + return f"{speed_str}x" + + def _init_control_buttons(self) -> None: + """Initializes playback control buttons.""" + self.prev_frame_btn = self.add_button( + "", + icon=self.prev_frame_icon, + callback=self.on_previous_frame, + ) + self.prev_frame_btn.setToolTip("Previous frame") + + self.play_pause_btn = self.add_button( + "", + icon=self.play_icon, + callback=self.on_toggle_play_pause, + ) + self.play_pause_btn.setToolTip("Play/Pause") + + self.next_frame_btn = self.add_button( + "", + icon=self.next_frame_icon, + callback=self.on_next_frame, + ) + self.next_frame_btn.setToolTip("Next frame") + + self.frames_btn = self.add_button( + "", + icon=self.frames_icon, + callback=self.on_toggle_frames_menu, + ) + self.frames_btn.setToolTip("Show frames timeline") + + def show_for_item(self, item: "BeeGifItem") -> None: + """Shows menu for specified GIF item.""" + super().show_for_item(item) + self.update_play_pause_button() + self.update_speed_combo() + self._hide_frames_menu() + + def _hide_frames_menu(self) -> None: + """Hides frames menu if it's open.""" + if self.frames_menu: + self.frames_menu.hide_menu() + + def update_speed_combo(self) -> None: + """Updates speed value in combobox.""" + if not self._has_gif_item(): + return + + current_speed = self.current_item.get_speed() + closest_speed = min( + self.SPEED_VALUES, + key=lambda x: abs(x - current_speed) + ) + index = self.SPEED_VALUES.index(closest_speed) + + self.speed_combo.blockSignals(True) + self.speed_combo.setCurrentIndex(index) + self.speed_combo.blockSignals(False) + + def _on_speed_changed(self, index: int) -> None: + """Handler for playback speed change.""" + if not self._has_gif_item(): + return + + speed = self.speed_combo.currentData() + if speed is not None: + self.current_item.set_speed(speed) + + def update_play_pause_button(self) -> None: + """Updates Play/Pause button icon.""" + if not self._has_gif_item(): + return + + if self.current_item.is_playing: + self.play_pause_btn.setIcon(self.pause_icon) + self.play_pause_btn.setToolTip("Pause") + else: + self.play_pause_btn.setIcon(self.play_icon) + self.play_pause_btn.setToolTip("Play") + + def on_toggle_play_pause(self) -> None: + """Toggles GIF play/pause.""" + if not self._has_gif_item(): + return + + # Defer execution so button can process event + def do_toggle(): + self.current_item.toggle_animation() + self.update_play_pause_button() + + QtCore.QTimer.singleShot(0, do_toggle) + + def on_previous_frame(self) -> None: + """Goes to previous frame.""" + if not self._has_gif_item(): + return + + # Defer execution so button can process event + def do_previous(): + self.current_item.previous_frame() + self.update_play_pause_button() + self._update_frames_menu_if_visible() + + QtCore.QTimer.singleShot(0, do_previous) + + def on_next_frame(self) -> None: + """Goes to next frame.""" + if not self._has_gif_item(): + return + + # Defer execution so button can process event + def do_next(): + self.current_item.next_frame() + self.update_play_pause_button() + self._update_frames_menu_if_visible() + + QtCore.QTimer.singleShot(0, do_next) + + def on_toggle_frames_menu(self) -> None: + """Toggles frames menu visibility.""" + if not self._has_gif_item() or not self.frames_menu: + return + + self.frames_menu.toggle_menu(self.current_item) + + def hide_menu(self) -> None: + """Hides menu and frames menu.""" + self._hide_frames_menu() + super().hide_menu() + + def update_position(self) -> None: + """Updates position of menu and frames menu.""" + super().update_position() + + if self.frames_menu and self.frames_menu.isVisible(): + self.frames_menu.update_position() + + def _has_gif_item(self) -> bool: + """Checks if current item is a GIF item.""" + return (self.current_item is not None and + hasattr(self.current_item, 'is_playing')) + + def _update_frames_menu_if_visible(self) -> None: + """Updates frames menu if it's visible.""" + if self.frames_menu and self.frames_menu.isVisible(): + self.frames_menu.load_frames(self.current_item) diff --git a/beeref/widgets/gif_frames_menu.py b/beeref/widgets/gif_frames_menu.py new file mode 100644 index 0000000..3feb37d --- /dev/null +++ b/beeref/widgets/gif_frames_menu.py @@ -0,0 +1,407 @@ +"""Floating menu showing GIF frames timeline.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Optional + +from PyQt6 import QtCore, QtGui, QtWidgets + +from beeref import constants + +if TYPE_CHECKING: # pragma: no cover + from beeref.view import BeeGraphicsView + from beeref.gif_item import BeeGifItem + +logger = logging.getLogger(__name__) + + +class GifFrameThumbnail(QtWidgets.QWidget): + """Widget for displaying a single GIF frame.""" + + FRAME_SIZE = 96 # Thumbnail size + + def __init__(self, frame_number: int, pixmap: QtGui.QPixmap, + delay_ms: int, frames_menu: "GifFramesMenu", parent=None): + super().__init__(parent) + self.frame_number = frame_number + self.pixmap = pixmap + self.delay_ms = delay_ms + self.is_selected = False + self.frames_menu = frames_menu # Keep reference to frames menu + + self.setFixedSize(self.FRAME_SIZE + 8, self.FRAME_SIZE + 24) + self.setToolTip(f"Frame: {frame_number}\nDelay: {delay_ms / 1000:.2f}s") + self.drag_start_position = None + self.is_dragging = False + + def paintEvent(self, event): + painter = QtGui.QPainter(self) + painter.setRenderHint(QtGui.QPainter.RenderHint.SmoothPixmapTransform) + + # Selection border + if self.is_selected: + pen = QtGui.QPen(QtGui.QColor(*constants.COLORS['Scene:Selection'])) + pen.setWidth(3) + painter.setPen(pen) + painter.setBrush(QtGui.QBrush()) + painter.drawRect(2, 2, self.FRAME_SIZE + 4, self.FRAME_SIZE + 4) + + # Frame thumbnail + scaled_pixmap = self.pixmap.scaled( + self.FRAME_SIZE, self.FRAME_SIZE, + QtCore.Qt.AspectRatioMode.KeepAspectRatio, + QtCore.Qt.TransformationMode.SmoothTransformation + ) + x = (self.width() - scaled_pixmap.width()) // 2 + y = 4 + painter.drawPixmap(x, y, scaled_pixmap) + + # Frame number + painter.setPen(QtGui.QPen(QtGui.QColor(255, 255, 255))) + font = painter.font() + font.setPointSize(8) + painter.setFont(font) + text_rect = QtCore.QRect(0, self.FRAME_SIZE + 8, self.width(), 16) + painter.drawText(text_rect, QtCore.Qt.AlignmentFlag.AlignCenter, + str(self.frame_number)) + + def mousePressEvent(self, event): + if event.button() == QtCore.Qt.MouseButton.LeftButton: + # Save drag start position + self.drag_start_position = event.position().toPoint() + self.is_dragging = False + super().mousePressEvent(event) + + def mouseMoveEvent(self, event): + """Handles start of frame dragging.""" + if not (event.buttons() & QtCore.Qt.MouseButton.LeftButton): + return + + if self.drag_start_position is None: + return + + # Check if mouse moved enough to start drag + if ((event.position().toPoint() - self.drag_start_position).manhattanLength() + < QtWidgets.QApplication.startDragDistance()): + return + + # Mark that dragging has started + self.is_dragging = True + + # Create QDrag object + drag = QtGui.QDrag(self) + mime_data = QtCore.QMimeData() + + # Convert pixmap to QImage for drag transfer + image = self.pixmap.toImage() + mime_data.setImageData(image) + + drag.setMimeData(mime_data) + + # Set visual representation during drag + # Use original pixmap but scaled down for preview + preview_pixmap = self.pixmap.scaled( + 128, 128, + QtCore.Qt.AspectRatioMode.KeepAspectRatio, + QtCore.Qt.TransformationMode.SmoothTransformation + ) + drag.setPixmap(preview_pixmap) + drag.setHotSpot(event.position().toPoint() - self.drag_start_position) + + # Start drag operation + drag.exec(QtCore.Qt.DropAction.CopyAction) + + # Reset state after drag completion + self.drag_start_position = None + self.is_dragging = False + + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event): + """Handles frame click (if no dragging occurred).""" + if (event.button() == QtCore.Qt.MouseButton.LeftButton + and not self.is_dragging + and self.drag_start_position is not None): + # If no dragging occurred, select frame + if self.frames_menu: + self.frames_menu.select_frame(self.frame_number) + + self.drag_start_position = None + self.is_dragging = False + super().mouseReleaseEvent(event) + + +class GifFramesMenu(QtWidgets.QWidget): + """Floating menu with GIF frames.""" + + def __init__(self, parent: QtWidgets.QWidget, view: "BeeGraphicsView", + gif_menu: "GifFloatingMenu"): + # Create as independent window to match gif_floating_menu + super().__init__(None) + self.setObjectName("GifFramesMenu") + self.setAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground, True) + self.setAttribute(QtCore.Qt.WidgetAttribute.WA_ShowWithoutActivating, True) + # Make it a tool window that stays on top + self.setWindowFlag(QtCore.Qt.WindowType.FramelessWindowHint, True) + self.setWindowFlag(QtCore.Qt.WindowType.Tool, True) + self.setWindowFlag(QtCore.Qt.WindowType.WindowStaysOnTopHint, True) + # Store parent for compatibility + self._parent_widget = parent + self.view = view + self.gif_menu = gif_menu + self.current_item: Optional["BeeGifItem"] = None + + # Cache for update_position optimization + self._cached_position: Optional[QtCore.QPoint] = None + self._cached_viewport_size: Optional[QtCore.QSize] = None + self._cached_gif_menu_pos: Optional[QtCore.QPoint] = None + + # Layout + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(8, 8, 8, 8) + layout.setSpacing(4) + + # Scroll area for frames + scroll_area = QtWidgets.QScrollArea(self) + scroll_area.setWidgetResizable(True) + scroll_area.setHorizontalScrollBarPolicy( + QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded) + scroll_area.setVerticalScrollBarPolicy( + QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + scroll_area.setStyleSheet(""" + QScrollArea { + border: 1px solid rgba(255, 255, 255, 40); + border-radius: 4px; + background-color: rgba(40, 40, 40, 240); + } + QScrollBar:horizontal { + height: 8px; + background: rgba(60, 60, 60, 200); + border-radius: 4px; + } + QScrollBar::handle:horizontal { + background: rgba(140, 140, 140, 200); + border-radius: 4px; + min-width: 20px; + } + QScrollBar::handle:horizontal:hover { + background: rgba(180, 180, 180, 200); + } + """) + + # Container for frames + self.frames_container = QtWidgets.QWidget() + self.frames_layout = QtWidgets.QHBoxLayout(self.frames_container) + self.frames_layout.setContentsMargins(4, 4, 4, 4) + self.frames_layout.setSpacing(4) + + scroll_area.setWidget(self.frames_container) + layout.addWidget(scroll_area) + + # Apply rounded style like other floating panels + bg = constants.COLORS['Active:Window'] + border = constants.COLORS['Active:Base'] + bg_r, bg_g, bg_b = bg[:3] + border_r, border_g, border_b = border[:3] + self.setStyleSheet(f""" + QWidget#GifFramesMenu {{ + background-color: rgba({bg_r}, {bg_g}, {bg_b}, 255); + border-radius: 8px; + border: 1px solid rgba({border_r}, {border_g}, {border_b}, 255); + }} + """) + self.hide() + + self.frame_widgets = [] + + def load_frames(self, item: "BeeGifItem"): + """Loads all frames from GIF.""" + self.current_item = item + self.frame_widgets.clear() + + # Clear old widgets + while self.frames_layout.count(): + child = self.frames_layout.takeAt(0) + if child.widget(): + child.widget().deleteLater() + + if not item.movie or item.frame_count == 0: + return + + # Save current frame + current_frame = item.current_frame + was_playing = item.is_playing + if was_playing: + item.pause_animation() + + # Load all frames + for frame_num in range(item.frame_count): + if item.movie.jumpToFrame(frame_num): + pixmap = item.movie.currentPixmap() + if not pixmap.isNull(): + # Get frame delay (in milliseconds) + delay_ms = item.movie.nextFrameDelay() + if delay_ms <= 0: + delay_ms = 100 # Default value + + frame_widget = GifFrameThumbnail( + frame_num + 1, pixmap, delay_ms, self, self.frames_container) + frame_widget.is_selected = (frame_num == current_frame) + self.frames_layout.addWidget(frame_widget) + self.frame_widgets.append(frame_widget) + + # Restore current frame + if item.movie.jumpToFrame(current_frame): + item.current_frame = current_frame + pixmap = item.movie.currentPixmap() + if not pixmap.isNull(): + item.setPixmap(pixmap) + + if was_playing: + item.play_animation() + + self.frames_container.adjustSize() + self.adjustSize() + + def select_frame(self, frame_number: int): + """Selects frame and displays it in item.""" + if not self.current_item or not self.current_item.movie: + return + + frame_index = frame_number - 1 # Frame numbers start from 1 + + # Update selection in widgets + for i, widget in enumerate(self.frame_widgets): + widget.is_selected = (i == frame_index) + widget.update() + + # Go to selected frame in item + was_playing = self.current_item.is_playing + if was_playing: + self.current_item.pause_animation() + + if self.current_item.movie.jumpToFrame(frame_index): + self.current_item.current_frame = frame_index + pixmap = self.current_item.movie.currentPixmap() + if not pixmap.isNull(): + self.current_item.setPixmap(pixmap) + self.current_item.update() + + if was_playing: + self.current_item.play_animation() + + def show_menu(self): + """Shows menu above main GIF menu.""" + if not self.current_item: + return + + # Reset cache when showing menu to ensure position update + self._cached_position = None + self._cached_viewport_size = None + self._cached_gif_menu_pos = None + + self.adjustSize() + self.show() + self.raise_() + self.update_position() + self.raise_() + # Activate window to ensure it receives events + self.activateWindow() + + def update_position(self): + """Updates menu position above main GIF menu with optimization.""" + if not self.isVisible(): + return + + view = self.view + if view is None: + return + + viewport = view.viewport() + if viewport is None: + return + + view_rect = viewport.rect() + viewport_size = view_rect.size() + + # Get GIF menu position in global coordinates (both are independent windows) + gif_menu_pos = None + if self.gif_menu.isVisible(): + gif_menu_pos = self.gif_menu.pos() # Already in global coordinates + + # Check if viewport size or GIF menu position changed + if (self._cached_viewport_size is not None and + self._cached_viewport_size == viewport_size and + self._cached_gif_menu_pos == gif_menu_pos and + self._cached_position is not None): + # If nothing changed, check current position + current_pos = self.pos() + if current_pos == self._cached_position: + # Position hasn't changed, skip update + return + + # Calculate available width from viewport (in global coordinates) + bottom_left_global = viewport.mapToGlobal(view_rect.bottomLeft()) + bottom_right_global = viewport.mapToGlobal(view_rect.bottomRight()) + available_width = bottom_right_global.x() - bottom_left_global.x() + + # Set full width only if it changed + if self.width() != available_width: + self.setFixedWidth(available_width) + + # Get natural content height + self.adjustSize() + height = self.height() + + # Position above main GIF menu with 8px offset + if self.gif_menu.isVisible() and gif_menu_pos is not None: + # Center frames menu above gif_menu + gif_menu_width = self.gif_menu.width() + frames_menu_width = self.width() + # Center frames menu relative to gif_menu + x = gif_menu_pos.x() + (gif_menu_width - frames_menu_width) // 2 + y = gif_menu_pos.y() - height - 8 # Above with 8px gap + else: + # If main menu is not visible, position relative to viewport bottom + y = bottom_left_global.y() - height - 8 + x = bottom_left_global.x() + + # Check if it goes beyond screen bounds (top of screen) + if y < 0: + if self.gif_menu.isVisible() and gif_menu_pos is not None: + # If doesn't fit above, position below gif_menu + y = gif_menu_pos.y() + self.gif_menu.height() + 8 + else: + y = 8 + + new_position = QtCore.QPoint(x, y) + + # Update position only if it actually changed + if self._cached_position != new_position: + self.move(new_position) + self._cached_position = new_position + self._cached_viewport_size = viewport_size + self._cached_gif_menu_pos = gif_menu_pos + + def parentWidget(self): + """Override to return stored parent widget for compatibility.""" + return self._parent_widget + + def hide_menu(self): + """Hides menu.""" + self.current_item = None + # Clear cache when hiding menu + self._cached_position = None + self._cached_viewport_size = None + self._cached_gif_menu_pos = None + self.hide() + + def toggle_menu(self, item: "BeeGifItem"): + """Toggles menu visibility.""" + if self.isVisible(): + self.hide_menu() + else: + self.load_frames(item) + self.show_menu() + diff --git a/beeref/widgets/image_floating_menu.py b/beeref/widgets/image_floating_menu.py new file mode 100644 index 0000000..2e5400a --- /dev/null +++ b/beeref/widgets/image_floating_menu.py @@ -0,0 +1,79 @@ +"""Floating menu shown when a single image item is selected.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PyQt6 import QtGui, QtWidgets + +from beeref.assets import BeeAssets +from beeref.widgets.floating_menu import FloatingMenu + +if TYPE_CHECKING: # pragma: no cover + from beeref.view import BeeGraphicsView + from beeref.items import BeePixmapItem + + +class ImageFloatingMenu(FloatingMenu): + """Contextual floating toolbar for image items.""" + + def __init__(self, parent: QtWidgets.QWidget, view: "BeeGraphicsView"): + super().__init__(parent, view) + + # Load icons + assets = BeeAssets() + icons_path = assets.PATH.joinpath('icons') + opacity_icon = QtGui.QIcon(str(icons_path.joinpath('opacity.svg'))) + grayscale_icon = QtGui.QIcon(str(icons_path.joinpath('grayscale.svg'))) + gamut_icon = QtGui.QIcon(str(icons_path.joinpath('gamut.svg'))) + flip_h_icon = QtGui.QIcon(str(icons_path.joinpath('flip_h.svg'))) + flip_v_icon = QtGui.QIcon(str(icons_path.joinpath('flip_v.svg'))) + crop_icon = QtGui.QIcon(str(icons_path.joinpath('crop.svg'))) + + self.opacity_btn = self.add_button( + "", + icon=opacity_icon, + callback=self.view.on_action_change_opacity, + ) + self.opacity_btn.setToolTip("Opacity") + + + self.grayscale_btn = self.add_button( + "", + icon=grayscale_icon, + callback=self.view.on_action_grayscale, + checkable=True, + ) + self.grayscale_btn.setToolTip("Grayscale") + gamut_btn = self.add_button( + "", + icon=gamut_icon, + callback=self.view.on_action_show_color_gamut, + ) + gamut_btn.setToolTip("Color Gamut") + self.add_separator() + + crop_btn = self.add_button( + "", + icon=crop_icon, + callback=self.view.on_action_crop, + ) + crop_btn.setToolTip("Crop") + self.add_separator() + + flip_h_btn = self.add_button( + "", + icon=flip_h_icon, + callback=self.view.on_action_flip_horizontally, + ) + flip_h_btn.setToolTip("Flip H") + flip_v_btn = self.add_button( + "", + icon=flip_v_icon, + callback=self.view.on_action_flip_vertically, + ) + flip_v_btn.setToolTip("Flip V") + + def show_for_item(self, item: "BeePixmapItem") -> None: + self.grayscale_btn.setChecked(getattr(item, "grayscale", False)) + super().show_for_item(item) diff --git a/beeref/widgets/text_floating_menu.py b/beeref/widgets/text_floating_menu.py new file mode 100644 index 0000000..952b874 --- /dev/null +++ b/beeref/widgets/text_floating_menu.py @@ -0,0 +1,185 @@ +"""Floating menu shown when a single text item is selected.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PyQt6 import QtGui, QtWidgets + +from beeref.assets import BeeAssets +from beeref.widgets.floating_menu import FloatingMenu + +if TYPE_CHECKING: + from beeref.view import BeeGraphicsView + from beeref.items import BeeTextItem + + +class TextFloatingMenu(FloatingMenu): + """Contextual floating toolbar for text items.""" + + FONT_SIZES = [8, 10, 12, 14, 16, 18, 24, 32, 48] + + def __init__(self, parent: QtWidgets.QWidget, view: "BeeGraphicsView"): + super().__init__(parent, view) + + # Load icons + assets = BeeAssets() + icons_path = assets.PATH.joinpath('icons') + text_color_icon = QtGui.QIcon(str(icons_path.joinpath('format-color-text.svg'))) + palette_icon = QtGui.QIcon(str(icons_path.joinpath('palette.svg'))) + bold_icon = QtGui.QIcon(str(icons_path.joinpath('format-bold.svg'))) + italic_icon = QtGui.QIcon(str(icons_path.joinpath('format-italic.svg'))) + underline_icon = QtGui.QIcon(str(icons_path.joinpath('format-underline.svg'))) + strikethrough_icon = QtGui.QIcon(str(icons_path.joinpath('format-strikethrough.svg'))) + reset_icon = QtGui.QIcon(str(icons_path.joinpath('undo.svg'))) + fonts_icon = QtGui.QIcon(str(icons_path.joinpath('fonts.svg'))) + + self.text_color_btn = self.add_button( + "", + icon=text_color_icon, + callback=self._on_text_color_clicked, + ) + self.background_btn = self.add_button( + "", + icon=palette_icon, + callback=self._on_background_clicked, + ) + self.add_separator() + + self.bold_btn = self.add_button( + "", + icon=bold_icon, + callback=self._on_bold_clicked, + checkable=True, + ) + self.italic_btn = self.add_button( + "", + icon=italic_icon, + callback=self._on_italic_clicked, + checkable=True, + ) + self.underline_btn = self.add_button( + "", + icon=underline_icon, + callback=self._on_underline_clicked, + checkable=True, + ) + self.strikethrough_btn = self.add_button( + "", + icon=strikethrough_icon, + callback=self._on_strikethrough_clicked, + checkable=True, + ) + self.add_separator() + + self.size_combo = QtWidgets.QComboBox(self) + self.size_combo.setObjectName("FloatingMenuFontSize") + self.size_combo.setMinimumWidth(40) + for size in self.FONT_SIZES: + self.size_combo.addItem(str(size), size) + self.size_combo.currentIndexChanged.connect(self._on_size_changed) + self.add_widget(self.size_combo) + + self.font_combo = QtWidgets.QComboBox(self) + self.font_combo.setObjectName("FloatingMenuFontFamily") + self.font_combo.setMinimumWidth(40) + self.font_combo.setEditable(False) + self.font_combo.setInsertPolicy( + QtWidgets.QComboBox.InsertPolicy.NoInsert) + families = QtGui.QFontDatabase.families() + self.font_combo.addItems(families) + # Add icon to all font items + for i in range(self.font_combo.count()): + self.font_combo.setItemIcon(i, fonts_icon) + self.font_combo.currentTextChanged.connect(self._on_font_changed) + self.add_widget(self.font_combo) + + self.add_separator() + + self.add_button( + "", + icon=reset_icon, + callback=self.view.reset_selected_text_format, + ) + + # ------------------------------------------------------------------ + def show_for_item(self, item: "BeeTextItem") -> None: + font = item.font() + self._update_font_controls(font) + self._update_colors(item) + super().show_for_item(item) + + # UI updates ------------------------------------------------------- + def _update_font_controls(self, font: QtGui.QFont) -> None: + self.bold_btn.setChecked(font.weight() >= QtGui.QFont.Weight.Bold) + self.italic_btn.setChecked(font.italic()) + self.underline_btn.setChecked(font.underline()) + self.strikethrough_btn.setChecked(font.strikeOut()) + + size = font.pointSize() + if size == -1: + size = int(font.pointSizeF()) + + try: + index = self.FONT_SIZES.index(size) + except ValueError: + index = -1 + self.size_combo.blockSignals(True) + if index >= 0: + self.size_combo.setCurrentIndex(index) + else: + self.size_combo.setCurrentText(str(size)) + self.size_combo.blockSignals(False) + + self.font_combo.blockSignals(True) + family = font.family() + index = self.font_combo.findText(family) + if index >= 0: + self.font_combo.setCurrentIndex(index) + self.font_combo.blockSignals(False) + + def _update_colors(self, item: "BeeTextItem") -> None: + text_color = item.defaultTextColor() + self.text_color_btn.setProperty( + "active", "true" if text_color else "false") + self.text_color_btn.style().unpolish(self.text_color_btn) + self.text_color_btn.style().polish(self.text_color_btn) + + bg_color = getattr(item, "background_color", None) + self.background_btn.setProperty( + "active", "true" if bg_color and bg_color.alpha() > 0 else "false") + self.background_btn.style().unpolish(self.background_btn) + self.background_btn.style().polish(self.background_btn) + + # Slots ------------------------------------------------------------ + def _on_text_color_clicked(self) -> None: + self.view.change_selected_text_color() + + def _on_background_clicked(self) -> None: + self.view.change_selected_text_background() + + def _on_bold_clicked(self) -> None: + self.view.toggle_selected_text_bold() + + def _on_italic_clicked(self) -> None: + self.view.toggle_selected_text_italic() + + def _on_underline_clicked(self) -> None: + self.view.toggle_selected_text_underline() + + def _on_strikethrough_clicked(self) -> None: + self.view.toggle_selected_text_strikethrough() + + def _on_size_changed(self, index: int) -> None: + size = self.size_combo.currentData() + if size is None: + try: + size = int(self.size_combo.currentText()) + except ValueError: + return + self.view.change_selected_text_size(size) + + def _on_font_changed(self, family: str) -> None: + if not family: + return + self.view.change_selected_text_font(family) diff --git a/beeref/widgets/welcome_overlay.py b/beeref/widgets/welcome_overlay.py index e40f772..66790a3 100644 --- a/beeref/widgets/welcome_overlay.py +++ b/beeref/widgets/welcome_overlay.py @@ -14,11 +14,15 @@ # along with BeeRef. If not, see . import logging +import os import os.path from PyQt6 import QtCore, QtGui, QtWidgets from PyQt6.QtCore import Qt +from beeref import constants +from beeref import fileio +from beeref.assets import BeeAssets from beeref.config import BeeSettings from beeref.main_controls import MainControlsMixin @@ -59,12 +63,14 @@ class RecentFilesView(QtWidgets.QListView): self.view.open_from_file(self.files[index.row()]) def update_files(self, files): - self.files = files - self.model().files = files - self.reset() + self.files = files or [] + self.setModel(RecentFilesModel(self.files)) + self.updateGeometry() def sizeHint(self): size = QtCore.QSize() + if not self.files: + return size height = sum( (self.sizeHintForRow(i) + 2) for i in range(len(self.files))) width = max(self.sizeHintForColumn(i) for i in range(len(self.files))) @@ -87,58 +93,106 @@ class RecentFilesView(QtWidgets.QListView): class WelcomeOverlay(MainControlsMixin, QtWidgets.QWidget): """Some basic info to be displayed when the scene is empty.""" - txt = """

Paste or drop images here.

-

Right-click for more options.

""" - def __init__(self, parent): super().__init__(parent) self.control_target = parent self.setAutoFillBackground(True) self.init_main_controls(main_window=parent.parent) + # Icon + icon_path = BeeAssets.PATH.joinpath('icons','drag-and-drop.svg') + icon_pixmap = QtGui.QPixmap(str(icon_path)) + self.icon_label = QtWidgets.QLabel(self) + self.icon_label.setPixmap(icon_pixmap) + self.icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.icon_label.setStyleSheet(constants.get_welcome_overlay_icon_style()) + + # Help text + self.label = QtWidgets.QLabel('

Paste or drop images here.

', self) + self.label.setAlignment(Qt.AlignmentFlag.AlignVCenter + | Qt.AlignmentFlag.AlignCenter) + + # Right-click text + self.right_click_label = QtWidgets.QLabel('

Right-click for more options.

', self) + self.right_click_label.setAlignment(Qt.AlignmentFlag.AlignVCenter + | Qt.AlignmentFlag.AlignCenter) + + # Browse button + self.browse_button = QtWidgets.QPushButton('Browse', self) + self.browse_button.setStyleSheet(constants.get_standard_button_style()) + self.browse_button.clicked.connect(self.on_browse_clicked) + # Recent files + self.files_view = RecentFilesView(self, parent) self.files_widget = QtWidgets.QWidget(self) files_layout = QtWidgets.QVBoxLayout() - files_layout.addStretch(50) - files_layout.addWidget( - QtWidgets.QLabel('

Recent Files

', self)) - self.files_view = RecentFilesView(self, parent) + files_layout.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.recent_files_label = QtWidgets.QLabel('

Recent Files

', self) + self.recent_files_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + files_layout.addWidget(self.recent_files_label) files_layout.addWidget(self.files_view) - files_layout.addStretch(50) self.files_widget.setLayout(files_layout) self.files_widget.hide() - # Help text - self.label = QtWidgets.QLabel(self.txt, self) - self.label.setAlignment(Qt.AlignmentFlag.AlignVCenter - | Qt.AlignmentFlag.AlignCenter) + # Center content widget + center_widget = QtWidgets.QWidget(self) + center_layout = QtWidgets.QVBoxLayout() + center_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + center_layout.addStretch() + center_layout.addWidget(self.icon_label) + center_layout.addWidget(self.label) + center_layout.addWidget(self.right_click_label) + center_layout.addWidget(self.browse_button) + center_layout.setAlignment(self.browse_button, Qt.AlignmentFlag.AlignHCenter) + center_layout.addWidget(self.files_widget) + center_layout.addStretch() + center_widget.setLayout(center_layout) + self.layout = QtWidgets.QHBoxLayout() - self.layout.addStretch(50) - self.layout.addWidget(self.label) - self.layout.addStretch(50) + self.layout.addStretch() + self.layout.addWidget(center_widget) + self.layout.addStretch() self.setLayout(self.layout) def show(self): files = BeeSettings().get_recent_files(existing_only=True) self.files_view.update_files(files) - if files and self.layout.indexOf(self.files_widget) < 0: - self.layout.insertWidget(0, self.files_widget) - self.files_widget.show() + self.files_widget.setVisible(bool(files)) super().show() def disable_mouse_events(self): - self.files_view.setAttribute( + self.icon_label.setAttribute( Qt.WidgetAttribute.WA_TransparentForMouseEvents) self.label.setAttribute( Qt.WidgetAttribute.WA_TransparentForMouseEvents) + self.right_click_label.setAttribute( + Qt.WidgetAttribute.WA_TransparentForMouseEvents) + self.browse_button.setAttribute( + Qt.WidgetAttribute.WA_TransparentForMouseEvents) + self.files_view.setAttribute( + Qt.WidgetAttribute.WA_TransparentForMouseEvents) + self.files_widget.setAttribute( + Qt.WidgetAttribute.WA_TransparentForMouseEvents) def enable_mouse_events(self): - self.files_view.setAttribute( + self.icon_label.setAttribute( Qt.WidgetAttribute.WA_TransparentForMouseEvents, on=False) self.label.setAttribute( Qt.WidgetAttribute.WA_TransparentForMouseEvents, on=False) + self.right_click_label.setAttribute( + Qt.WidgetAttribute.WA_TransparentForMouseEvents, + on=False) + self.browse_button.setAttribute( + Qt.WidgetAttribute.WA_TransparentForMouseEvents, + on=False) + self.files_view.setAttribute( + Qt.WidgetAttribute.WA_TransparentForMouseEvents, + on=False) + self.files_widget.setAttribute( + Qt.WidgetAttribute.WA_TransparentForMouseEvents, + on=False) def mousePressEvent(self, event): if self.mousePressEventMainControls(event): @@ -159,3 +213,56 @@ class WelcomeOverlay(MainControlsMixin, QtWidgets.QWidget): if self.keyPressEventMainControls(event): return super().keyPressEvent(event) + + def on_browse_clicked(self): + """Open file dialog to select .bee files or images.""" + if not hasattr(self.control_target, 'get_supported_image_formats'): + return + + # Get supported image formats + formats = self.control_target.get_supported_image_formats(QtGui.QImageReader) + + # Create filter string for file dialog + # First option: All supported files (bee + images) + all_formats = f'*.bee {formats}' + filter_str = ';;'.join(( + f'All Supported Files ({all_formats})', + f'{constants.APPNAME} File (*.bee)', + f'Images ({formats})', + 'All Files (*)' + )) + + # Open file dialog allowing multiple selection + filenames, selected_filter = QtWidgets.QFileDialog.getOpenFileNames( + parent=self, + caption='Open file or images', + filter=filter_str) + + if not filenames: + return + + # Check if any selected file is a .bee file + bee_files = [f for f in filenames if fileio.is_bee_file(f)] + image_files = [f for f in filenames if not fileio.is_bee_file(f)] + + # If we have .bee files, open the first one (clear scene first) + if bee_files: + if hasattr(self.control_target, 'get_confirmation_unsaved_changes'): + confirm = self.control_target.get_confirmation_unsaved_changes( + 'There are unsaved changes. ' + 'Are you sure you want to open a new scene?') + if not confirm: + return + + if hasattr(self.control_target, 'open_from_file'): + filename = os.path.normpath(bee_files[0]) + self.control_target.open_from_file(filename) + self.control_target.filename = filename + # If there are also image files, add them after opening .bee + if image_files and hasattr(self.control_target, 'do_insert_images'): + self.control_target.do_insert_images(image_files) + return + + # If only images, insert them + if image_files and hasattr(self.control_target, 'do_insert_images'): + self.control_target.do_insert_images(image_files) From cf49e8659e61f287c51a326fd1eb32839106c6db Mon Sep 17 00:00:00 2001 From: DS_Litvinov Date: Thu, 20 Nov 2025 16:25:49 +0300 Subject: [PATCH 2/5] Add confirmation dialog for unsaved changes on close Implemented a check for unsaved changes in the close event of the main window. Users are prompted to confirm if they wish to quit without saving, preventing accidental data loss. --- beeref/__main__.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/beeref/__main__.py b/beeref/__main__.py index bc9dd58..84f29d9 100755 --- a/beeref/__main__.py +++ b/beeref/__main__.py @@ -64,6 +64,15 @@ class BeeRefMainWindow(QtWidgets.QMainWindow): self.show() def closeEvent(self, event): + # Проверяем наличие несохраненных изменений + confirm = self.view.get_confirmation_unsaved_changes( + 'There are unsaved changes. Are you sure you want to quit?') + if not confirm: + # Пользователь отменил закрытие + event.ignore() + return + + # Сохраняем геометрию окна geom = self.saveGeometry() self.view.settings.setValue('MainWindow/geometry', geom) event.accept() From 8756529c13850cba23cdb3c43fab6f8ed20bae01 Mon Sep 17 00:00:00 2001 From: Dmitry Litvinov Date: Thu, 20 Nov 2025 18:53:48 +0300 Subject: [PATCH 3/5] Add floating menu styles and icon sizes Introduced new constants for floating menu dimensions and colors in `constants.py`. Updated styles for buttons, separators, and combo boxes to utilize these constants for consistency. Adjusted icon sizes for various combo boxes in the floating menu widgets to enhance UI appearance. --- beeref/assets/icons/grayscale.svg | 8 +++- beeref/assets/icons/opacity.svg | 2 +- beeref/constants.py | 66 +++++++++++++++++----------- beeref/widgets/draw_floating_menu.py | 1 + beeref/widgets/floating_menu.py | 1 + beeref/widgets/gif_floating_menu.py | 1 + beeref/widgets/text_floating_menu.py | 3 +- 7 files changed, 53 insertions(+), 29 deletions(-) diff --git a/beeref/assets/icons/grayscale.svg b/beeref/assets/icons/grayscale.svg index 7675bb8..2fbc692 100644 --- a/beeref/assets/icons/grayscale.svg +++ b/beeref/assets/icons/grayscale.svg @@ -1,4 +1,8 @@ - - + + + + + + diff --git a/beeref/assets/icons/opacity.svg b/beeref/assets/icons/opacity.svg index 64709e0..a910fc4 100644 --- a/beeref/assets/icons/opacity.svg +++ b/beeref/assets/icons/opacity.svg @@ -1,3 +1,3 @@ - + diff --git a/beeref/constants.py b/beeref/constants.py index 269d075..dde1f06 100644 --- a/beeref/constants.py +++ b/beeref/constants.py @@ -21,6 +21,14 @@ COPYRIGHT = 'Copyright © 2021-2024 Rebecca Breu' CHANGED_SYMBOL = '✎' +# Floating Menu Sizes +FLOATING_MENU_BUTTON_SIZE = 32 +FLOATING_MENU_ICON_SIZE = 32 +FLOATING_MENU_BUTTON_PADDING = 4 +FLOATING_MENU_CORNER_RADIUS = 4 +FLOATING_MENU_BASE_CORNER_RADIUS = 8 +FLOATING_MENU_BOTTOM_MARGIN = 8 + COLORS = { # Qt: 'Active:Base': (60, 60, 60), @@ -44,6 +52,11 @@ COLORS = { 'Scene:Selection': (116, 234, 231), 'Scene:Canvas': (60, 60, 60), 'Scene:Text': (200, 200, 200), + + # Floating Menu specific: + 'FloatingMenu:ButtonBackground': (255, 255, 255, 30), + 'FloatingMenu:Border': (255, 255, 255, 10), + 'FloatingMenu:SeparatorBackground': (255, 255, 255, 8), } @@ -92,7 +105,7 @@ def get_floating_menu_base_style(): return f""" QWidget#FloatingMenu {{ background-color: {_css_color(bg)}; - border-radius: 8px; + border-radius: {FLOATING_MENU_BASE_CORNER_RADIUS}px; border: 1px solid {_css_color(border)}; }} """ @@ -100,23 +113,26 @@ def get_floating_menu_base_style(): def get_floating_menu_button_style(): """Push button styling for floating menus.""" - background_color = (255, 255, 255, 30) - border_color = (255, 255, 255, 10) + background_color = COLORS['FloatingMenu:ButtonBackground'] + border_color = COLORS['FloatingMenu:Border'] inactive = COLORS['Disabled:Text'] active = COLORS['Active:Text'] accent = COLORS['Active:Highlight'] + size = FLOATING_MENU_BUTTON_SIZE + padding = FLOATING_MENU_BUTTON_PADDING + radius = FLOATING_MENU_CORNER_RADIUS return f""" QWidget#FloatingMenu QPushButton[floatingButton="true"] {{ background-color: {_css_color(background_color)}; color: {_css_color(inactive)}; border: 1px solid {_css_color(border_color)}; - border-radius: 4px; - padding: 4px 10px; + border-radius: {radius}px; + padding: {padding}px; font-weight: 600; - min-width: 12px; - min-height: 28px; - max-height: 28px; + min-width: {size}px; + min-height: {size}px; + max-height: {size}px; }} QWidget#FloatingMenu QPushButton[floatingButton="true"]:hover, @@ -134,14 +150,14 @@ def get_floating_menu_button_style(): def get_floating_menu_separator_style(): """Separator styling for floating menus.""" - border_color = (255, 255, 255, 10) - r, g, b = border_color[:3] + separator_bg = COLORS['FloatingMenu:SeparatorBackground'] + size = FLOATING_MENU_BUTTON_SIZE return f""" QWidget#FloatingMenu QFrame#FloatingMenuSeparator {{ - background-color: rgba({r}, {g}, {b}, 30); - min-height: 28px; - max-height: 28px; + background-color: {_css_color(separator_bg)}; + min-height: {size}px; + max-height: {size}px; }} """ @@ -150,15 +166,16 @@ def get_floating_menu_combo_style(): """Combo-box styling for floating menus.""" from beeref.assets import BeeAssets - bg = COLORS['Active:Button'] + # Use Active:Button for consistency with other UI elements + bg = COLORS['FloatingMenu:ButtonBackground'] active_color = COLORS['Active:Text'] - border_color = (255, 255, 255, 10) - border_r, border_g, border_b = border_color[:3] + size = FLOATING_MENU_BUTTON_SIZE + radius = FLOATING_MENU_CORNER_RADIUS - # Получаем путь к иконке стрелки + # Get arrow icon path assets = BeeAssets() arrow_icon_path = assets.PATH.joinpath('icons', 'small-down.svg') - # Используем прямой путь, экранируя обратные слеши для Windows + # Escape backslashes for Windows compatibility arrow_icon_path_str = str(arrow_icon_path).replace('\\', '/') return f""" @@ -166,18 +183,17 @@ def get_floating_menu_combo_style(): QWidget#FloatingMenu QFontComboBox {{ background-color: {_css_color(bg)}; color: {_css_color(active_color)}; - border: 1px solid rgba({border_r}, {border_g}, {border_b}, 160); - border-radius: 4px; - padding: 4px 12px; + border-radius: {radius}px; + padding: 4px 16px; min-width: 40px; - min-height: 28px; - max-height: 28px; + min-height: {size}px; + max-height: {size}px; }} QWidget#FloatingMenu QComboBox::drop-down, QWidget#FloatingMenu QFontComboBox::drop-down {{ border: none; - width: 16px; + width: 20px; }} QWidget#FloatingMenu QComboBox::down-arrow, @@ -185,7 +201,7 @@ def get_floating_menu_combo_style(): image: url({arrow_icon_path_str}); width: 12px; height: 12px; - margin-right: 4px; + margin-right: 8px; }} QWidget#FloatingMenu QComboBox QAbstractItemView, diff --git a/beeref/widgets/draw_floating_menu.py b/beeref/widgets/draw_floating_menu.py index 9d9ce04..e6ef86b 100644 --- a/beeref/widgets/draw_floating_menu.py +++ b/beeref/widgets/draw_floating_menu.py @@ -101,6 +101,7 @@ class DrawFloatingMenu(FloatingMenu): combo = UpwardComboBox(self) combo.setObjectName("FloatingMenuLineStyle") combo.setMinimumWidth(40) + combo.setIconSize(QtCore.QSize(32, 32)) # Добавляем элементы с иконками combo.addItem(solid_icon, "", 'solid') diff --git a/beeref/widgets/floating_menu.py b/beeref/widgets/floating_menu.py index de0cd1d..f0cedc5 100644 --- a/beeref/widgets/floating_menu.py +++ b/beeref/widgets/floating_menu.py @@ -142,6 +142,7 @@ class FloatingMenu(QtWidgets.QWidget): button.setCheckable(checkable) if icon: button.setIcon(icon) + button.setIconSize(QtCore.QSize(32, 32)) button.setProperty("floatingButton", True) if callback: # Connect directly - button events are handled independently diff --git a/beeref/widgets/gif_floating_menu.py b/beeref/widgets/gif_floating_menu.py index 85c9978..b92eefb 100644 --- a/beeref/widgets/gif_floating_menu.py +++ b/beeref/widgets/gif_floating_menu.py @@ -64,6 +64,7 @@ class GifFloatingMenu(FloatingMenu): self.speed_combo = QtWidgets.QComboBox(self) self.speed_combo.setObjectName("FloatingMenuGifSpeed") self.speed_combo.setMinimumWidth(40) + self.speed_combo.setIconSize(QtCore.QSize(32, 32)) for speed in self.SPEED_VALUES: label = self._format_speed_label(speed) diff --git a/beeref/widgets/text_floating_menu.py b/beeref/widgets/text_floating_menu.py index 952b874..d6c9c47 100644 --- a/beeref/widgets/text_floating_menu.py +++ b/beeref/widgets/text_floating_menu.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -from PyQt6 import QtGui, QtWidgets +from PyQt6 import QtCore, QtGui, QtWidgets from beeref.assets import BeeAssets from beeref.widgets.floating_menu import FloatingMenu @@ -83,6 +83,7 @@ class TextFloatingMenu(FloatingMenu): self.font_combo = QtWidgets.QComboBox(self) self.font_combo.setObjectName("FloatingMenuFontFamily") self.font_combo.setMinimumWidth(40) + self.font_combo.setIconSize(QtCore.QSize(32, 32)) self.font_combo.setEditable(False) self.font_combo.setInsertPolicy( QtWidgets.QComboBox.InsertPolicy.NoInsert) From dc733cfc232bbdb541499fd31a0f89ce2e2f5072 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Fri, 21 Nov 2025 12:45:39 +0300 Subject: [PATCH 4/5] Update dependencies and icons; remove unused icons - Updated Python version requirement in `pyproject.toml` to allow any version from 3.9 onwards. - Modified dependencies for `lxml` and `pyQt6` to allow newer versions. - Replaced several SVG icons with larger versions and updated their designs for better visual consistency. - Removed unused icons: `trash-alt.svg` and `undo.svg`. - Updated the `reset_icon` in `text_floating_menu.py` to use `clear.svg` instead of `undo.svg`. --- beeref/assets/icons/clear.svg | 4 ++++ beeref/assets/icons/close.svg | 6 +++--- beeref/assets/icons/crop.svg | 4 ++-- beeref/assets/icons/draw-line.svg | 3 ++- beeref/assets/icons/flip_h.svg | 5 +++-- beeref/assets/icons/flip_v.svg | 5 +++-- beeref/assets/icons/frames.svg | 6 ++++-- beeref/assets/icons/gamut.svg | 13 +++++++++++-- beeref/assets/icons/grayscale.svg | 26 ++++++++++++++++++------- beeref/assets/icons/line-arrow-both.svg | 5 ++--- beeref/assets/icons/line-arrow-left.svg | 4 ++-- beeref/assets/icons/line-arrow.svg | 4 ++-- beeref/assets/icons/line-dashed.svg | 6 ++---- beeref/assets/icons/line-solid.svg | 4 ++-- beeref/assets/icons/next-frame.svg | 5 +++-- beeref/assets/icons/opacity.svg | 5 +++-- beeref/assets/icons/palette.svg | 17 ++++++++++++++-- beeref/assets/icons/pause.svg | 5 +++-- beeref/assets/icons/play.svg | 4 ++-- beeref/assets/icons/prev-frame.svg | 5 +++-- beeref/assets/icons/trash-alt.svg | 3 --- beeref/assets/icons/undo.svg | 3 --- beeref/widgets/text_floating_menu.py | 2 +- pyproject.toml | 8 ++++---- 24 files changed, 95 insertions(+), 57 deletions(-) create mode 100644 beeref/assets/icons/clear.svg delete mode 100644 beeref/assets/icons/trash-alt.svg delete mode 100644 beeref/assets/icons/undo.svg diff --git a/beeref/assets/icons/clear.svg b/beeref/assets/icons/clear.svg new file mode 100644 index 0000000..aba010f --- /dev/null +++ b/beeref/assets/icons/clear.svg @@ -0,0 +1,4 @@ + + + + diff --git a/beeref/assets/icons/close.svg b/beeref/assets/icons/close.svg index ad0c27f..86eef0c 100644 --- a/beeref/assets/icons/close.svg +++ b/beeref/assets/icons/close.svg @@ -1,4 +1,4 @@ - - + + + - diff --git a/beeref/assets/icons/crop.svg b/beeref/assets/icons/crop.svg index 2b00313..305202e 100644 --- a/beeref/assets/icons/crop.svg +++ b/beeref/assets/icons/crop.svg @@ -1,3 +1,3 @@ - - + + diff --git a/beeref/assets/icons/draw-line.svg b/beeref/assets/icons/draw-line.svg index a1f6d8d..c97fe4e 100644 --- a/beeref/assets/icons/draw-line.svg +++ b/beeref/assets/icons/draw-line.svg @@ -1,3 +1,4 @@ - + + diff --git a/beeref/assets/icons/flip_h.svg b/beeref/assets/icons/flip_h.svg index e5688f3..bab7acb 100644 --- a/beeref/assets/icons/flip_h.svg +++ b/beeref/assets/icons/flip_h.svg @@ -1,3 +1,4 @@ - - + + + diff --git a/beeref/assets/icons/flip_v.svg b/beeref/assets/icons/flip_v.svg index fd3e332..48c1963 100644 --- a/beeref/assets/icons/flip_v.svg +++ b/beeref/assets/icons/flip_v.svg @@ -1,3 +1,4 @@ - - + + + diff --git a/beeref/assets/icons/frames.svg b/beeref/assets/icons/frames.svg index 5db745e..b18001d 100644 --- a/beeref/assets/icons/frames.svg +++ b/beeref/assets/icons/frames.svg @@ -1,3 +1,5 @@ - - + + + + diff --git a/beeref/assets/icons/gamut.svg b/beeref/assets/icons/gamut.svg index 3383273..4877ecb 100644 --- a/beeref/assets/icons/gamut.svg +++ b/beeref/assets/icons/gamut.svg @@ -1,3 +1,12 @@ - - + + + + + + + + + + + diff --git a/beeref/assets/icons/grayscale.svg b/beeref/assets/icons/grayscale.svg index 2fbc692..eb393e2 100644 --- a/beeref/assets/icons/grayscale.svg +++ b/beeref/assets/icons/grayscale.svg @@ -1,8 +1,20 @@ - - - - - - - + + + + + + + + + + + + + + + + + + + diff --git a/beeref/assets/icons/line-arrow-both.svg b/beeref/assets/icons/line-arrow-both.svg index 3557e1d..9ac12c0 100644 --- a/beeref/assets/icons/line-arrow-both.svg +++ b/beeref/assets/icons/line-arrow-both.svg @@ -1,4 +1,3 @@ - - - + + diff --git a/beeref/assets/icons/line-arrow-left.svg b/beeref/assets/icons/line-arrow-left.svg index 278850f..56b40b6 100644 --- a/beeref/assets/icons/line-arrow-left.svg +++ b/beeref/assets/icons/line-arrow-left.svg @@ -1,3 +1,3 @@ - - + + diff --git a/beeref/assets/icons/line-arrow.svg b/beeref/assets/icons/line-arrow.svg index 59e5350..ef43b35 100644 --- a/beeref/assets/icons/line-arrow.svg +++ b/beeref/assets/icons/line-arrow.svg @@ -1,3 +1,3 @@ - - + + diff --git a/beeref/assets/icons/line-dashed.svg b/beeref/assets/icons/line-dashed.svg index cc0c2b3..e5ce7ad 100644 --- a/beeref/assets/icons/line-dashed.svg +++ b/beeref/assets/icons/line-dashed.svg @@ -1,5 +1,3 @@ - - - - + + diff --git a/beeref/assets/icons/line-solid.svg b/beeref/assets/icons/line-solid.svg index 853d485..4dec234 100644 --- a/beeref/assets/icons/line-solid.svg +++ b/beeref/assets/icons/line-solid.svg @@ -1,3 +1,3 @@ - - + + diff --git a/beeref/assets/icons/next-frame.svg b/beeref/assets/icons/next-frame.svg index f9ee17c..ed9ba6b 100644 --- a/beeref/assets/icons/next-frame.svg +++ b/beeref/assets/icons/next-frame.svg @@ -1,3 +1,4 @@ - - + + + diff --git a/beeref/assets/icons/opacity.svg b/beeref/assets/icons/opacity.svg index a910fc4..544905b 100644 --- a/beeref/assets/icons/opacity.svg +++ b/beeref/assets/icons/opacity.svg @@ -1,3 +1,4 @@ - - + + + diff --git a/beeref/assets/icons/palette.svg b/beeref/assets/icons/palette.svg index 9538b21..ac3a184 100644 --- a/beeref/assets/icons/palette.svg +++ b/beeref/assets/icons/palette.svg @@ -1,3 +1,16 @@ - - + + + + + + + + + + + + + + + diff --git a/beeref/assets/icons/pause.svg b/beeref/assets/icons/pause.svg index 786bf5b..cb24185 100644 --- a/beeref/assets/icons/pause.svg +++ b/beeref/assets/icons/pause.svg @@ -1,3 +1,4 @@ - - + + + diff --git a/beeref/assets/icons/play.svg b/beeref/assets/icons/play.svg index b87b2c6..4c58fed 100644 --- a/beeref/assets/icons/play.svg +++ b/beeref/assets/icons/play.svg @@ -1,3 +1,3 @@ - - + + diff --git a/beeref/assets/icons/prev-frame.svg b/beeref/assets/icons/prev-frame.svg index 0d45e98..d39a978 100644 --- a/beeref/assets/icons/prev-frame.svg +++ b/beeref/assets/icons/prev-frame.svg @@ -1,3 +1,4 @@ - - + + + diff --git a/beeref/assets/icons/trash-alt.svg b/beeref/assets/icons/trash-alt.svg deleted file mode 100644 index ed1423a..0000000 --- a/beeref/assets/icons/trash-alt.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/beeref/assets/icons/undo.svg b/beeref/assets/icons/undo.svg deleted file mode 100644 index b8da845..0000000 --- a/beeref/assets/icons/undo.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/beeref/widgets/text_floating_menu.py b/beeref/widgets/text_floating_menu.py index d6c9c47..9b7d536 100644 --- a/beeref/widgets/text_floating_menu.py +++ b/beeref/widgets/text_floating_menu.py @@ -31,7 +31,7 @@ class TextFloatingMenu(FloatingMenu): italic_icon = QtGui.QIcon(str(icons_path.joinpath('format-italic.svg'))) underline_icon = QtGui.QIcon(str(icons_path.joinpath('format-underline.svg'))) strikethrough_icon = QtGui.QIcon(str(icons_path.joinpath('format-strikethrough.svg'))) - reset_icon = QtGui.QIcon(str(icons_path.joinpath('undo.svg'))) + reset_icon = QtGui.QIcon(str(icons_path.joinpath('clear.svg'))) fonts_icon = QtGui.QIcon(str(icons_path.joinpath('fonts.svg'))) self.text_color_btn = self.add_button( diff --git a/pyproject.toml b/pyproject.toml index f60a2af..3140931 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,12 +17,12 @@ license = {file = "LICENSE"} authors = [ { name = "Rebecca Breu", email = "rebecca@rbreu.de" }, ] -requires-python = ">=3.9,<3.13" +requires-python = ">=3.9" dependencies = [ "exif>=1.3.5,<=1.6.0", - "lxml==5.1.0", - "pyQt6-Qt6>=6.7.0,<=6.7.0", - "pyQt6>=6.7.0,<=6.7.0", + "lxml>=5.1.0", + "pyQt6-Qt6>=6.10.0", + "pyQt6>=6.10.0", "rectangle-packer>=2.0.1,<=2.0.2", ] From c8b600a3c408d3946e8d17baff86fa5c80abcddd Mon Sep 17 00:00:00 2001 From: Dmitry Date: Fri, 21 Nov 2025 13:42:41 +0300 Subject: [PATCH 5/5] Refactor comments to English for consistency - Updated comments in `__main__.py`, `items.py`, `assets/__init__.py`, `image.py`, `draw_floating_menu.py`, and `floating_menu.py` to English for better readability and maintainability. - Ensured that all comments accurately describe the corresponding code functionality. --- beeref/__main__.py | 6 +- beeref/assets/__init__.py | 10 +-- beeref/fileio/image.py | 6 +- beeref/items.py | 120 +++++++++++++-------------- beeref/widgets/draw_floating_menu.py | 52 ++++++------ beeref/widgets/floating_menu.py | 64 +++++++------- 6 files changed, 129 insertions(+), 129 deletions(-) diff --git a/beeref/__main__.py b/beeref/__main__.py index 84f29d9..8cfb445 100755 --- a/beeref/__main__.py +++ b/beeref/__main__.py @@ -64,15 +64,15 @@ class BeeRefMainWindow(QtWidgets.QMainWindow): self.show() def closeEvent(self, event): - # Проверяем наличие несохраненных изменений + # Check for unsaved changes confirm = self.view.get_confirmation_unsaved_changes( 'There are unsaved changes. Are you sure you want to quit?') if not confirm: - # Пользователь отменил закрытие + # User cancelled closing event.ignore() return - # Сохраняем геометрию окна + # Save window geometry geom = self.saveGeometry() self.view.settings.setValue('MainWindow/geometry', geom) event.accept() diff --git a/beeref/assets/__init__.py b/beeref/assets/__init__.py index 237c089..1bbc471 100644 --- a/beeref/assets/__init__.py +++ b/beeref/assets/__init__.py @@ -59,20 +59,20 @@ class BeeAssets: pixmap, int(hotspot[0]/scaling), int(hotspot[1]/scaling)) def cursor_from_svg(self, filename, hotspot, size=24): - """Создает курсор из SVG файла.""" + """Creates cursor from SVG file.""" app = QtWidgets.QApplication.instance() scaling = app.primaryScreen().devicePixelRatio() - # Загружаем SVG + # Load SVG svg_path = str(self.PATH.joinpath(filename)) renderer = QtSvg.QSvgRenderer(svg_path) - # Создаем pixmap нужного размера + # Create pixmap of required size pixmap_size = int(size * scaling) pixmap = QtGui.QPixmap(pixmap_size, pixmap_size) - pixmap.fill(QtGui.QColor(0, 0, 0, 0)) # Прозрачный фон + pixmap.fill(QtGui.QColor(0, 0, 0, 0)) # Transparent background - # Рендерим SVG в pixmap + # Render SVG to pixmap painter = QtGui.QPainter(pixmap) renderer.render(painter) painter.end() diff --git a/beeref/fileio/image.py b/beeref/fileio/image.py index c18f0d6..c214169 100644 --- a/beeref/fileio/image.py +++ b/beeref/fileio/image.py @@ -30,7 +30,7 @@ logger = logging.getLogger(__name__) def is_gif_file(path): - """Проверяет, является ли файл GIF.""" + """Checks if file is a GIF.""" if isinstance(path, str): ext = os.path.splitext(path)[1].lower() return ext == '.gif' @@ -95,9 +95,9 @@ def exif_rotated_image(path=None): def load_image(path): if isinstance(path, str): path = os.path.normpath(path) - # Проверяем, является ли файл GIF + # Check if file is a GIF if is_gif_file(path): - return (None, path) # Возвращаем None для изображения, путь для GIF + return (None, path) # Return None for image, path for GIF return (exif_rotated_image(path), path) if path.isLocalFile(): path = os.path.normpath(path.toLocalFile()) diff --git a/beeref/items.py b/beeref/items.py index 8c56ead..2126562 100644 --- a/beeref/items.py +++ b/beeref/items.py @@ -745,7 +745,7 @@ class BeeDrawItem(BeeItemMixin, QtWidgets.QGraphicsPathItem): """Class for freehand drawing items.""" TYPE = 'draw' - CLICKABLE_PADDING = 8.0 # Отступ вокруг линии для увеличения кликабельной области + CLICKABLE_PADDING = 8.0 # Padding around line to increase clickable area def __init__(self, path=None, **kwargs): super().__init__() @@ -753,9 +753,9 @@ class BeeDrawItem(BeeItemMixin, QtWidgets.QGraphicsPathItem): logger.debug(f'Initialized {self}') self.is_image = False self.init_selectable() - self.is_editable = False # Рисование не редактируется через двойной клик + self.is_editable = False # Drawing is not editable via double-click - # Настройки пера по умолчанию + # Default pen settings self.pen_color = QtGui.QColor(*COLORS['Scene:Text']) self.pen_width = 8 self.pen_style = 'solid' # 'solid', 'dashed', 'arrow', '<-', '<->' @@ -766,7 +766,7 @@ class BeeDrawItem(BeeItemMixin, QtWidgets.QGraphicsPathItem): self.setPath(path) def setPath(self, path): - """Устанавливает путь и обновляет геометрию.""" + """Sets path and updates geometry.""" self.prepareGeometryChange() super().setPath(path) self.update() @@ -776,7 +776,7 @@ class BeeDrawItem(BeeItemMixin, QtWidgets.QGraphicsPathItem): data = kwargs.get('data', {}) item = cls() - # Восстановление пути из данных + # Restore path from data if 'path' in data and data['path']: path = QtGui.QPainterPath() path_data = data['path'] @@ -800,7 +800,7 @@ class BeeDrawItem(BeeItemMixin, QtWidgets.QGraphicsPathItem): return f'Drawing ({self.path().elementCount()} points)' def get_extra_save_data(self): - """Сохраняет данные рисунка для сериализации.""" + """Saves drawing data for serialization.""" path = self.path() path_data = [] for i in range(path.elementCount()): @@ -815,7 +815,7 @@ class BeeDrawItem(BeeItemMixin, QtWidgets.QGraphicsPathItem): } def _update_pen(self): - """Обновляет перо с текущими настройками.""" + """Updates pen with current settings.""" if self.pen_style == 'dashed': pen_style = QtCore.Qt.PenStyle.DashLine else: @@ -828,19 +828,19 @@ class BeeDrawItem(BeeItemMixin, QtWidgets.QGraphicsPathItem): self.setPen(pen) def set_pen_color(self, color: QtGui.QColor): - """Устанавливает цвет пера.""" + """Sets pen color.""" self.pen_color = color self._update_pen() self.update() def set_pen_width(self, width: int): - """Устанавливает толщину пера.""" - self.pen_width = max(1, min(width, 50)) # Ограничение 1-50 + """Sets pen width.""" + self.pen_width = max(1, min(width, 50)) # Limit 1-50 self._update_pen() self.update() def set_pen_style(self, style: str): - """Устанавливает стиль линии: 'solid', 'dashed', 'arrow', '<-', '<->'.""" + """Sets line style: 'solid', 'dashed', 'arrow', '<-', '<->'.""" if style in ('solid', 'dashed', 'arrow', '<-', '<->'): self.pen_style = style self._update_pen() @@ -861,31 +861,31 @@ class BeeDrawItem(BeeItemMixin, QtWidgets.QGraphicsPathItem): return item def bounding_rect_unselected(self): - """Возвращает границы элемента без учета выделения.""" + """Returns item bounds without selection.""" path = self.path() if path.isEmpty(): return QtCore.QRectF() - # Получаем boundingRect напрямую из пути + # Get boundingRect directly from path base_rect = path.boundingRect() - # Добавляем отступ для толщины пера и кликабельной области + # Add margin for pen width and clickable area margin = (self.pen_width / 2.0) + self.CLICKABLE_PADDING return base_rect.marginsAdded( QtCore.QMarginsF(margin, margin, margin, margin)) def shape(self): - """Возвращает прямоугольную кликабельную область, как в PureRef.""" + """Returns rectangular clickable area, like in PureRef.""" path = QtGui.QPainterPath() rect = self.bounding_rect_unselected() - # Если элемент выделен и есть ручки, добавляем области для ручек + # If item is selected and has handles, add handle areas if self.has_selection_handles(): margin = self.select_resize_size / 2 rect = rect.marginsAdded( QtCore.QMarginsF(margin, margin, margin, margin)) path.addRect(rect) - # Добавляем области для ручек поворота в углах + # Add rotation handle areas at corners for corner in self.corners: path.addPath(self.get_rotate_bounds(corner)) else: @@ -894,74 +894,74 @@ class BeeDrawItem(BeeItemMixin, QtWidgets.QGraphicsPathItem): return path def contains(self, point): - """Проверяет, попадает ли точка в прямоугольную область линии.""" - # Используем boundingRect для прямоугольной области клика + """Checks if point falls within rectangular line area.""" + # Use boundingRect for rectangular click area return self.bounding_rect_unselected().contains(point) def _get_path_end_points(self, path): - """Получает последние две точки пути для определения направления стрелки.""" + """Gets last two path points to determine arrow direction.""" if path.elementCount() < 2: return None, None - # Получаем последнюю точку пути + # Get last path point last_point = path.pointAtPercent(1.0) - # Получаем предпоследнюю точку (близко к концу) + # Get second-to-last point (close to end) if path.elementCount() >= 2: - prev_point = path.pointAtPercent(0.95) # 95% от пути + prev_point = path.pointAtPercent(0.95) # 95% of path else: prev_point = path.pointAtPercent(0.0) return prev_point, last_point def _get_path_start_points(self, path): - """Получает первые две точки пути для определения направления стрелки.""" + """Gets first two path points to determine arrow direction.""" if path.elementCount() < 2: return None, None - # Получаем первую точку пути + # Get first path point first_point = path.pointAtPercent(0.0) - # Получаем вторую точку (близко к началу) + # Get second point (close to start) if path.elementCount() >= 2: - second_point = path.pointAtPercent(0.05) # 5% от пути + second_point = path.pointAtPercent(0.05) # 5% of path else: second_point = path.pointAtPercent(1.0) return first_point, second_point def _draw_arrow_right(self, painter, path): - """Рисует стрелку вправо в конце линии.""" + """Draws arrow to the right at the end of line.""" if path.elementCount() < 2: return - # Получаем последние две точки для определения направления + # Get last two points to determine direction prev_point, last_point = self._get_path_end_points(path) if prev_point is None or last_point is None: return - # Вычисляем направление стрелки + # Calculate arrow direction dx = last_point.x() - prev_point.x() dy = last_point.y() - prev_point.y() length = (dx * dx + dy * dy) ** 0.5 if length == 0: return - # Нормализуем вектор направления + # Normalize direction vector dx /= length dy /= length - # Размер стрелки зависит от толщины линии + # Arrow size depends on line width arrow_size = max(self.pen_width * 3, 8) - # Угол стрелки - angle = 0.5 # примерно 30 градусов + # Arrow angle + angle = 0.5 # approximately 30 degrees - # Координаты конца стрелки + # Arrow end coordinates end_x = last_point.x() end_y = last_point.y() - # Координаты боковых точек стрелки + # Arrow side point coordinates perp_x = -dy perp_y = dx arrow_x1 = end_x - arrow_size * dx + arrow_size * angle * perp_x @@ -969,7 +969,7 @@ class BeeDrawItem(BeeItemMixin, QtWidgets.QGraphicsPathItem): arrow_x2 = end_x - arrow_size * dx - arrow_size * angle * perp_x arrow_y2 = end_y - arrow_size * dy - arrow_size * angle * perp_y - # Рисуем стрелку + # Draw arrow arrow_path = QtGui.QPainterPath() arrow_path.moveTo(end_x, end_y) arrow_path.lineTo(arrow_x1, arrow_y1) @@ -983,36 +983,36 @@ class BeeDrawItem(BeeItemMixin, QtWidgets.QGraphicsPathItem): painter.drawPath(arrow_path) def _draw_arrow_left(self, painter, path): - """Рисует стрелку влево в начале линии.""" + """Draws arrow to the left at the start of line.""" if path.elementCount() < 2: return - # Получаем первые две точки для определения направления + # Get first two points to determine direction first_point, second_point = self._get_path_start_points(path) if first_point is None or second_point is None: return - # Вычисляем направление стрелки (от второй точки к первой) + # Calculate arrow direction (from second point to first) dx = first_point.x() - second_point.x() dy = first_point.y() - second_point.y() length = (dx * dx + dy * dy) ** 0.5 if length == 0: return - # Нормализуем вектор направления + # Normalize direction vector dx /= length dy /= length - # Размер стрелки зависит от толщины линии + # Arrow size depends on line width arrow_size = max(self.pen_width * 3, 8) - # Угол стрелки - angle = 0.5 # примерно 30 градусов + # Arrow angle + angle = 0.5 # approximately 30 degrees - # Координаты начала стрелки + # Arrow start coordinates start_x = first_point.x() start_y = first_point.y() - # Координаты боковых точек стрелки + # Arrow side point coordinates perp_x = -dy perp_y = dx arrow_x1 = start_x - arrow_size * dx + arrow_size * angle * perp_x @@ -1020,7 +1020,7 @@ class BeeDrawItem(BeeItemMixin, QtWidgets.QGraphicsPathItem): arrow_x2 = start_x - arrow_size * dx - arrow_size * angle * perp_x arrow_y2 = start_y - arrow_size * dy - arrow_size * angle * perp_y - # Рисуем стрелку + # Draw arrow arrow_path = QtGui.QPainterPath() arrow_path.moveTo(start_x, start_y) arrow_path.lineTo(arrow_x1, arrow_y1) @@ -1034,11 +1034,11 @@ class BeeDrawItem(BeeItemMixin, QtWidgets.QGraphicsPathItem): painter.drawPath(arrow_path) def _draw_arrow_both(self, painter, path): - """Рисует стрелки в обе стороны линии.""" + """Draws arrows on both sides of line.""" if path.elementCount() < 2: return - # Рисуем стрелку вправо (в конце) + # Draw arrow to the right (at end) prev_point, last_point = self._get_path_end_points(path) if prev_point is not None and last_point is not None: dx = last_point.x() - prev_point.x() @@ -1070,7 +1070,7 @@ class BeeDrawItem(BeeItemMixin, QtWidgets.QGraphicsPathItem): QtCore.Qt.PenJoinStyle.RoundJoin)) painter.drawPath(arrow_path) - # Рисуем стрелку влево (в начале) + # Draw arrow to the left (at start) first_point, second_point = self._get_path_start_points(path) if first_point is not None and second_point is not None: dx = first_point.x() - second_point.x() @@ -1103,15 +1103,15 @@ class BeeDrawItem(BeeItemMixin, QtWidgets.QGraphicsPathItem): painter.drawPath(arrow_path) def paint(self, painter, option, widget): - """Отрисовка пути с рамкой выделения.""" - # Отключаем стандартную отрисовку Qt для выделенных элементов + """Renders path with selection outline.""" + # Disable standard Qt rendering for selected items option.state &= ~QtWidgets.QStyle.StateFlag.State_Selected option.state &= ~QtWidgets.QStyle.StateFlag.State_HasFocus - # Рисуем основную линию + # Draw main line super().paint(painter, option, widget) path = self.path() - # Рисуем стрелку в зависимости от стиля + # Draw arrow depending on style if self.pen_style == 'arrow': self._draw_arrow_right(painter, path) elif self.pen_style == '<-': @@ -1122,13 +1122,13 @@ class BeeDrawItem(BeeItemMixin, QtWidgets.QGraphicsPathItem): self.paint_selectable(painter, option, widget) def paint_debug(self, painter, option, widget): - """Переопределяем для полного отключения отладочной информации.""" - # Полностью отключаем отладочную информацию для линий + """Override to completely disable debug information.""" + # Completely disable debug information for lines pass def paint_selectable(self, painter, option, widget): - """Переопределяем для убирания пунктирной обводки (отладочной информации).""" - # Не вызываем paint_debug, чтобы убрать пунктирную обводку + """Override to remove dashed outline (debug information).""" + # Don't call paint_debug to remove dashed outline # self.paint_debug(painter, option, widget) if not self.has_selection_outline(): @@ -1152,7 +1152,7 @@ class BeeDrawItem(BeeItemMixin, QtWidgets.QGraphicsPathItem): painter.drawPoint(corner) def copy_to_clipboard(self, clipboard): - """Для рисования копирование не поддерживается.""" + """Copying is not supported for drawings.""" pass @@ -1231,6 +1231,6 @@ class BeeErrorItem(BeeItemMixin, QtWidgets.QGraphicsTextItem): clipboard.setText(self.toPlainText()) -# Импортируем GIF item для регистрации в item_registry +# Import GIF item for registration in item_registry from beeref.gif_item import BeeGifItem # noqa: E402, F401 diff --git a/beeref/widgets/draw_floating_menu.py b/beeref/widgets/draw_floating_menu.py index e6ef86b..9428385 100644 --- a/beeref/widgets/draw_floating_menu.py +++ b/beeref/widgets/draw_floating_menu.py @@ -25,15 +25,15 @@ class UpwardComboBox(QtWidgets.QComboBox): def showPopup(self): """Override to show popup above the combobox.""" super().showPopup() - # Используем QTimer для получения popup после его создания + # Use QTimer to get popup after it's created QtCore.QTimer.singleShot(0, self._reposition_popup) def _reposition_popup(self): - """Перемещает popup выше combobox.""" - # Ищем активный popup виджет + """Moves popup above the combobox.""" + # Find active popup widget popup = QtWidgets.QApplication.activePopupWidget() if not popup: - # Альтернативный способ - найти через view + # Alternative method - find through view view = self.view() if view: popup = view.parent() @@ -41,9 +41,9 @@ class UpwardComboBox(QtWidgets.QComboBox): popup = popup.parent() if popup: - # Получаем глобальную позицию combobox + # Get global position of combobox global_pos = self.mapToGlobal(QtCore.QPoint(0, 0)) - # Вычисляем новую позицию выше combobox + # Calculate new position above combobox popup_height = popup.height() new_y = global_pos.y() - popup_height popup.move(global_pos.x(), new_y) @@ -56,7 +56,7 @@ class DrawFloatingMenu(FloatingMenu): super().__init__(parent, view) self.current_item: Optional["BeeDrawItem"] = None - # Кнопка выбора цвета через colorpicker + # Color selection button via colorpicker assets = BeeAssets() icons_path = assets.PATH.joinpath('icons') palette_icon = QtGui.QIcon(str(icons_path.joinpath('palette.svg'))) @@ -68,15 +68,15 @@ class DrawFloatingMenu(FloatingMenu): ) self.color_btn.setToolTip("Color") - # Combobox для выбора стиля линии + # Combobox for line style selection self.add_separator() self.style_combo = self.add_style_combobox() - # Селектор толщины пера + # Pen width selector self.add_separator() self.width_slider = self.add_width_slider() - # Кнопка закрытия меню (отмена режима рисования) + # Close menu button (cancel drawing mode) self.add_separator() close_icon = QtGui.QIcon(str(icons_path.joinpath('close.svg'))) self.close_button = self.add_button( @@ -87,11 +87,11 @@ class DrawFloatingMenu(FloatingMenu): self.close_button.setToolTip("Close") def add_style_combobox(self): - """Добавляет combobox для выбора стиля линии.""" + """Adds combobox for line style selection.""" assets = BeeAssets() icons_path = assets.PATH.joinpath('icons') - # Загружаем иконки для стилей + # Load icons for styles solid_icon = QtGui.QIcon(str(icons_path.joinpath('line-solid.svg'))) dashed_icon = QtGui.QIcon(str(icons_path.joinpath('line-dashed.svg'))) arrow_icon = QtGui.QIcon(str(icons_path.joinpath('line-arrow.svg'))) @@ -103,20 +103,20 @@ class DrawFloatingMenu(FloatingMenu): combo.setMinimumWidth(40) combo.setIconSize(QtCore.QSize(32, 32)) - # Добавляем элементы с иконками + # Add items with icons combo.addItem(solid_icon, "", 'solid') combo.addItem(dashed_icon, "", 'dashed') combo.addItem(arrow_icon, "", 'arrow') combo.addItem(arrow_left_icon, "", '<-') combo.addItem(arrow_both_icon, "", '<->') - # Устанавливаем иконки для элементов + # Set icons for items combo.setItemIcon(0, solid_icon) combo.setItemIcon(1, dashed_icon) combo.setItemIcon(2, arrow_icon) combo.setItemIcon(3, arrow_left_icon) combo.setItemIcon(4, arrow_both_icon) - combo.setCurrentIndex(0) # По умолчанию solid + combo.setCurrentIndex(0) # Default to solid combo.currentIndexChanged.connect(self._on_style_changed) combo.setToolTip("Line style") @@ -124,7 +124,7 @@ class DrawFloatingMenu(FloatingMenu): return combo def _on_color_clicked(self) -> None: - """Открывает диалог выбора цвета.""" + """Opens color selection dialog.""" if not self.current_item: return @@ -136,7 +136,7 @@ class DrawFloatingMenu(FloatingMenu): self.set_pen_color(color) def _on_style_changed(self, index: int) -> None: - """Обработчик изменения стиля линии.""" + """Handler for line style change.""" if not self.current_item: return style = self.style_combo.itemData(index) @@ -144,7 +144,7 @@ class DrawFloatingMenu(FloatingMenu): self.set_pen_style(style) def add_width_slider(self): - """Добавляет слайдер для выбора толщины пера.""" + """Adds slider for pen width selection.""" container = QtWidgets.QWidget() layout = QtWidgets.QHBoxLayout(container) layout.setContentsMargins(0, 0, 0, 0) @@ -162,11 +162,11 @@ class DrawFloatingMenu(FloatingMenu): return slider def show_for_item(self, item: "BeeDrawItem") -> None: - """Показывает меню для выбранного элемента рисования.""" + """Shows menu for selected drawing item.""" self.current_item = item if item: self.width_slider.setValue(item.pen_width) - # Устанавливаем текущий стиль в combobox + # Set current style in combobox style_index = self.style_combo.findData(item.pen_style) if style_index >= 0: self.style_combo.blockSignals(True) @@ -175,29 +175,29 @@ class DrawFloatingMenu(FloatingMenu): super().show_for_item(item) def set_pen_color(self, color: QtGui.QColor): - """Устанавливает цвет пера для выбранного элемента.""" + """Sets pen color for selected item.""" if self.current_item: self.current_item.set_pen_color(color) def set_pen_width(self, width: int): - """Устанавливает толщину пера для выбранного элемента.""" + """Sets pen width for selected item.""" if self.current_item: self.current_item.set_pen_width(width) def set_pen_style(self, style: str): - """Устанавливает стиль линии для выбранного элемента.""" + """Sets line style for selected item.""" if self.current_item: self.current_item.set_pen_style(style) def _on_close_clicked(self) -> None: - """Закрывает меню и отменяет режим рисования.""" + """Closes menu and cancels drawing mode.""" self.view.cancel_drawing_mode() self.hide_menu() def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: - """Обрабатывает нажатия клавиш.""" + """Handles key press events.""" if event.key() == QtCore.Qt.Key.Key_Escape: - # ESC закрывает меню и отменяет режим рисования + # ESC closes menu and cancels drawing mode self.view.cancel_drawing_mode() self.hide_menu() event.accept() diff --git a/beeref/widgets/floating_menu.py b/beeref/widgets/floating_menu.py index f0cedc5..462396f 100644 --- a/beeref/widgets/floating_menu.py +++ b/beeref/widgets/floating_menu.py @@ -39,10 +39,10 @@ class FloatingMenu(QtWidgets.QWidget): self._cached_viewport_size: Optional[QtCore.QSize] = None self._cached_window_pos: Optional[QtCore.QPoint] = None - # Таймер для отслеживания перемещения главного окна + # Timer for tracking main window movement self._position_timer = QtCore.QTimer(self) self._position_timer.timeout.connect(self._check_window_position) - self._position_timer.setInterval(50) # Проверка каждые 50мс + self._position_timer.setInterval(50) # Check every 50ms # Main layout with uniform spacing self._layout = QtWidgets.QHBoxLayout(self) @@ -56,7 +56,7 @@ class FloatingMenu(QtWidgets.QWidget): self.hide() def _check_window_position(self) -> None: - """Проверяет позицию viewport и обновляет позицию меню при необходимости.""" + """Checks viewport position and updates menu position if needed.""" if not self.isVisible(): return @@ -68,11 +68,11 @@ class FloatingMenu(QtWidgets.QWidget): if viewport is None: return - # Получаем позицию viewport в глобальных координатах + # Get viewport position in global coordinates view_rect = viewport.rect() current_viewport_pos = viewport.mapToGlobal(view_rect.topLeft()) - # Если позиция viewport изменилась, обновляем позицию меню + # If viewport position changed, update menu position if self._cached_window_pos is not None and self._cached_window_pos != current_viewport_pos: self.update_position() @@ -80,50 +80,50 @@ class FloatingMenu(QtWidgets.QWidget): def _apply_rounded_mask(self) -> None: """ - Применяет закругленную маску к виджету с антиалиасингом. - Реализация на основе подхода из статьи VK Teams. + Applies rounded mask to widget with antialiasing. + Implementation based on approach from VK Teams article. """ size = self.size() if size.width() == 0 or size.height() == 0: return - # Для более плавного сглаживания используем QBitmap с антиалиасингом - # Создаем изображение с увеличенным разрешением (как в статье) + # Use QBitmap with antialiasing for smoother rendering + # Create image with increased resolution (as in article) scale_factor = 2 scaled_size = QtCore.QSize( int(size.width() * scale_factor), int(size.height() * scale_factor) ) - # Создаем QPixmap для рисования с антиалиасингом + # Create QPixmap for drawing with antialiasing pixmap = QtGui.QPixmap(scaled_size) pixmap.fill(QtCore.Qt.GlobalColor.transparent) painter = QtGui.QPainter(pixmap) painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, True) painter.setPen(QtCore.Qt.PenStyle.NoPen) - painter.setBrush(QtCore.Qt.GlobalColor.black) # Черный для маски + painter.setBrush(QtCore.Qt.GlobalColor.black) # Black for mask - # Рисуем закругленный прямоугольник на увеличенном разрешении + # Draw rounded rectangle at increased resolution scaled_rect = QtCore.QRectF(0, 0, scaled_size.width(), scaled_size.height()) scaled_radius = self.CORNER_RADIUS * scale_factor painter.drawRoundedRect(scaled_rect, scaled_radius, scaled_radius) painter.end() - # Масштабируем обратно с сглаживанием + # Scale back with smoothing pixmap = pixmap.scaled( size, QtCore.Qt.AspectRatioMode.IgnoreAspectRatio, QtCore.Qt.TransformationMode.SmoothTransformation ) - # Преобразуем в QBitmap для маски - # В маске: непрозрачные пиксели = видимые области, прозрачные = невидимые + # Convert to QBitmap for mask + # In mask: opaque pixels = visible areas, transparent = invisible image = pixmap.toImage() - # Создаем маску из непрозрачных пикселей + # Create mask from opaque pixels bitmap = QtGui.QBitmap.fromImage(image.createAlphaMask()) - # Применяем маску + # Apply mask self.setMask(bitmap) def add_widget(self, widget: QtWidgets.QWidget) -> QtWidgets.QWidget: @@ -172,18 +172,18 @@ class FloatingMenu(QtWidgets.QWidget): self._cached_viewport_size = None self._cached_window_pos = None self.update_position() - # Запускаем таймер для отслеживания перемещения окна + # Start timer for tracking window movement self._position_timer.start() # Ensure menu is on top after positioning self.raise_() - # Возвращаем фокус view, чтобы события клавиатуры обрабатывались правильно - # FloatingMenu не должен перехватывать фокус, так как он имеет NoFocus + # Return focus to view so keyboard events are handled correctly + # FloatingMenu should not intercept focus as it has NoFocus if self.view: self.view.setFocus() def hide_menu(self) -> None: self.current_item = None - # Останавливаем таймер + # Stop timer self._position_timer.stop() # Clear cache when hiding menu self._cached_position = None @@ -207,7 +207,7 @@ class FloatingMenu(QtWidgets.QWidget): view_rect = viewport.rect() viewport_size = view_rect.size() - # Получаем текущую позицию viewport для проверки изменений + # Get current viewport position for change detection current_viewport_pos = viewport.mapToGlobal(view_rect.topLeft()) # Check if viewport size and position changed @@ -232,28 +232,28 @@ class FloatingMenu(QtWidgets.QWidget): bottom_right_global = viewport.mapToGlobal(view_rect.bottomRight()) top_right_global = viewport.mapToGlobal(view_rect.topRight()) - # Получаем границы viewport в глобальных координатах + # Get viewport bounds in global coordinates viewport_left = top_left_global.x() viewport_right = top_right_global.x() viewport_top = top_left_global.y() viewport_bottom = bottom_left_global.y() viewport_width = viewport_right - viewport_left - # Вычисляем позицию по X (центрируем, но не выходим за границы) + # Calculate X position (center, but don't go beyond boundaries) x = viewport_left + max(0, (viewport_width - width) // 2) - # Ограничиваем, чтобы меню не выходило за левую и правую границы + # Limit to prevent menu from going beyond left and right boundaries x = max(viewport_left, min(x, viewport_right - width)) - # Вычисляем позицию по Y (снизу с отступом) + # Calculate Y position (bottom with margin) y = viewport_bottom - height - self.BOTTOM_MARGIN - # Ограничиваем, чтобы меню не выходило за верхнюю границу - # Если меню не помещается снизу, размещаем его сверху + # Limit to prevent menu from going beyond top boundary + # If menu doesn't fit at bottom, place it at top if y < viewport_top: y = viewport_top + self.BOTTOM_MARGIN - # Также проверяем, что меню не выходит за нижнюю границу + # Also check that menu doesn't go beyond bottom boundary if y + height > viewport_bottom: y = viewport_bottom - height - self.BOTTOM_MARGIN - # Если и так не помещается, размещаем сверху + # If still doesn't fit, place at top if y < viewport_top: y = viewport_top + self.BOTTOM_MARGIN @@ -265,7 +265,7 @@ class FloatingMenu(QtWidgets.QWidget): self._cached_position = new_position self._cached_viewport_size = viewport_size self._cached_window_pos = current_viewport_pos - # Обновляем маску после изменения размера/позиции + # Update mask after size/position change self._apply_rounded_mask() def parentWidget(self): @@ -289,6 +289,6 @@ class FloatingMenu(QtWidgets.QWidget): super().mouseReleaseEvent(event) def resizeEvent(self, event: QtGui.QResizeEvent) -> None: - """Обновляет маску при изменении размера виджета.""" + """Updates mask when widget size changes.""" super().resizeEvent(event) self._apply_rounded_mask()