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..8cfb445 100755
--- a/beeref/__main__.py
+++ b/beeref/__main__.py
@@ -64,6 +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()
@@ -109,9 +118,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..1bbc471 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):
+ """Creates cursor from SVG file."""
+ app = QtWidgets.QApplication.instance()
+ scaling = app.primaryScreen().devicePixelRatio()
+
+ # Load SVG
+ svg_path = str(self.PATH.joinpath(filename))
+ renderer = QtSvg.QSvgRenderer(svg_path)
+
+ # 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)) # Transparent background
+
+ # Render SVG to 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/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
new file mode 100644
index 0000000..86eef0c
--- /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..305202e
--- /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..c97fe4e
--- /dev/null
+++ b/beeref/assets/icons/draw-line.svg
@@ -0,0 +1,4 @@
+
diff --git a/beeref/assets/icons/flip_h.svg b/beeref/assets/icons/flip_h.svg
new file mode 100644
index 0000000..bab7acb
--- /dev/null
+++ b/beeref/assets/icons/flip_h.svg
@@ -0,0 +1,4 @@
+
diff --git a/beeref/assets/icons/flip_v.svg b/beeref/assets/icons/flip_v.svg
new file mode 100644
index 0000000..48c1963
--- /dev/null
+++ b/beeref/assets/icons/flip_v.svg
@@ -0,0 +1,4 @@
+
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..b18001d
--- /dev/null
+++ b/beeref/assets/icons/frames.svg
@@ -0,0 +1,5 @@
+
diff --git a/beeref/assets/icons/gamut.svg b/beeref/assets/icons/gamut.svg
new file mode 100644
index 0000000..4877ecb
--- /dev/null
+++ b/beeref/assets/icons/gamut.svg
@@ -0,0 +1,12 @@
+
diff --git a/beeref/assets/icons/grayscale.svg b/beeref/assets/icons/grayscale.svg
new file mode 100644
index 0000000..eb393e2
--- /dev/null
+++ b/beeref/assets/icons/grayscale.svg
@@ -0,0 +1,20 @@
+
diff --git a/beeref/assets/icons/line-arrow-both.svg b/beeref/assets/icons/line-arrow-both.svg
new file mode 100644
index 0000000..9ac12c0
--- /dev/null
+++ b/beeref/assets/icons/line-arrow-both.svg
@@ -0,0 +1,3 @@
+
diff --git a/beeref/assets/icons/line-arrow-left.svg b/beeref/assets/icons/line-arrow-left.svg
new file mode 100644
index 0000000..56b40b6
--- /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..ef43b35
--- /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..e5ce7ad
--- /dev/null
+++ b/beeref/assets/icons/line-dashed.svg
@@ -0,0 +1,3 @@
+
diff --git a/beeref/assets/icons/line-solid.svg b/beeref/assets/icons/line-solid.svg
new file mode 100644
index 0000000..4dec234
--- /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..ed9ba6b
--- /dev/null
+++ b/beeref/assets/icons/next-frame.svg
@@ -0,0 +1,4 @@
+
diff --git a/beeref/assets/icons/opacity.svg b/beeref/assets/icons/opacity.svg
new file mode 100644
index 0000000..544905b
--- /dev/null
+++ b/beeref/assets/icons/opacity.svg
@@ -0,0 +1,4 @@
+
diff --git a/beeref/assets/icons/palette.svg b/beeref/assets/icons/palette.svg
new file mode 100644
index 0000000..ac3a184
--- /dev/null
+++ b/beeref/assets/icons/palette.svg
@@ -0,0 +1,16 @@
+
diff --git a/beeref/assets/icons/pause.svg b/beeref/assets/icons/pause.svg
new file mode 100644
index 0000000..cb24185
--- /dev/null
+++ b/beeref/assets/icons/pause.svg
@@ -0,0 +1,4 @@
+
diff --git a/beeref/assets/icons/play.svg b/beeref/assets/icons/play.svg
new file mode 100644
index 0000000..4c58fed
--- /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..d39a978
--- /dev/null
+++ b/beeref/assets/icons/prev-frame.svg
@@ -0,0 +1,4 @@
+
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/constants.py b/beeref/constants.py
index ae8988a..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,4 +52,178 @@ 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),
}
+
+
+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: {FLOATING_MENU_BASE_CORNER_RADIUS}px;
+ border: 1px solid {_css_color(border)};
+ }}
+ """
+
+
+def get_floating_menu_button_style():
+ """Push button styling for floating menus."""
+ 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: {radius}px;
+ padding: {padding}px;
+ font-weight: 600;
+ min-width: {size}px;
+ min-height: {size}px;
+ max-height: {size}px;
+ }}
+
+ 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."""
+ separator_bg = COLORS['FloatingMenu:SeparatorBackground']
+ size = FLOATING_MENU_BUTTON_SIZE
+
+ return f"""
+ QWidget#FloatingMenu QFrame#FloatingMenuSeparator {{
+ background-color: {_css_color(separator_bg)};
+ min-height: {size}px;
+ max-height: {size}px;
+ }}
+ """
+
+
+def get_floating_menu_combo_style():
+ """Combo-box styling for floating menus."""
+ from beeref.assets import BeeAssets
+
+ # Use Active:Button for consistency with other UI elements
+ bg = COLORS['FloatingMenu:ButtonBackground']
+ active_color = COLORS['Active:Text']
+ 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')
+ # Escape backslashes for Windows compatibility
+ 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-radius: {radius}px;
+ padding: 4px 16px;
+ min-width: 40px;
+ min-height: {size}px;
+ max-height: {size}px;
+ }}
+
+ QWidget#FloatingMenu QComboBox::drop-down,
+ QWidget#FloatingMenu QFontComboBox::drop-down {{
+ border: none;
+ width: 20px;
+ }}
+
+ QWidget#FloatingMenu QComboBox::down-arrow,
+ QWidget#FloatingMenu QFontComboBox::down-arrow {{
+ image: url({arrow_icon_path_str});
+ width: 12px;
+ height: 12px;
+ margin-right: 8px;
+ }}
+
+ 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..c214169 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):
+ """Checks if file is a 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)
+ # Check if file is a GIF
+ if is_gif_file(path):
+ 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())
+ 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..2126562 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 # Padding around line to increase clickable area
+
+ 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 # 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', '<-', '<->'
+ self._update_pen()
+ self.setBrush(QtGui.QBrush(QtCore.Qt.BrushStyle.NoBrush))
+
+ if path:
+ self.setPath(path)
+
+ def setPath(self, path):
+ """Sets path and updates geometry."""
+ self.prepareGeometryChange()
+ super().setPath(path)
+ self.update()
+
+ @classmethod
+ def create_from_data(cls, **kwargs):
+ data = kwargs.get('data', {})
+ item = cls()
+
+ # Restore path from data
+ 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):
+ """Saves drawing data for serialization."""
+ 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):
+ """Updates pen with current settings."""
+ 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):
+ """Sets pen color."""
+ self.pen_color = color
+ self._update_pen()
+ self.update()
+
+ def set_pen_width(self, width: int):
+ """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):
+ """Sets line style: '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):
+ """Returns item bounds without selection."""
+ path = self.path()
+ if path.isEmpty():
+ return QtCore.QRectF()
+
+ # 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):
+ """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:
+ path.addRect(rect)
+
+ return path
+
+ def contains(self, point):
+ """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% 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% 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)
+ # 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
+ 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
+
+ # Draw arrow
+ 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):
+ """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)
+ # 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
+ 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
+
+ # Draw arrow
+ 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):
+ """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()
+ 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)
+
+ # 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()
+ 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):
+ """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 == '<-':
+ 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):
+ """Override to completely disable debug information."""
+ # Completely disable debug information for lines
+ pass
+
+ def paint_selectable(self, painter, option, widget):
+ """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():
+ 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):
+ """Copying is not supported for drawings."""
+ 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())
+
+
+# Import GIF item for registration in 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..9428385
--- /dev/null
+++ b/beeref/widgets/draw_floating_menu.py
@@ -0,0 +1,206 @@
+"""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()
+ # Use QTimer to get popup after it's created
+ QtCore.QTimer.singleShot(0, self._reposition_popup)
+
+ def _reposition_popup(self):
+ """Moves popup above the combobox."""
+ # Find active popup widget
+ popup = QtWidgets.QApplication.activePopupWidget()
+ if not popup:
+ # Alternative method - find through view
+ view = self.view()
+ if view:
+ popup = view.parent()
+ while popup and not isinstance(popup, QtWidgets.QFrame):
+ popup = popup.parent()
+
+ if popup:
+ # Get global position of combobox
+ global_pos = self.mapToGlobal(QtCore.QPoint(0, 0))
+ # Calculate new position above 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
+
+ # Color selection button via 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 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(
+ "",
+ icon=close_icon,
+ callback=self._on_close_clicked
+ )
+ self.close_button.setToolTip("Close")
+
+ def add_style_combobox(self):
+ """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')))
+ 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.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) # Default to solid
+ combo.currentIndexChanged.connect(self._on_style_changed)
+ combo.setToolTip("Line style")
+
+ self.add_widget(combo)
+ return combo
+
+ def _on_color_clicked(self) -> None:
+ """Opens color selection dialog."""
+ 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:
+ """Handler for line style change."""
+ if not self.current_item:
+ return
+ style = self.style_combo.itemData(index)
+ if style:
+ 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)
+
+ 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:
+ """Shows menu for selected drawing item."""
+ self.current_item = item
+ if item:
+ self.width_slider.setValue(item.pen_width)
+ # Set current style in 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):
+ """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 closes menu and cancels drawing mode
+ 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..462396f
--- /dev/null
+++ b/beeref/widgets/floating_menu.py
@@ -0,0 +1,294 @@
+"""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
+
+ # 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) # Check every 50ms
+
+ # 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:
+ """Checks viewport position and updates menu position if needed."""
+ if not self.isVisible():
+ return
+
+ view = self.view
+ if view is None:
+ return
+
+ viewport = view.viewport()
+ if viewport is None:
+ return
+
+ # Get viewport position in global coordinates
+ view_rect = viewport.rect()
+ current_viewport_pos = viewport.mapToGlobal(view_rect.topLeft())
+
+ # 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()
+
+ self._cached_window_pos = current_viewport_pos
+
+ def _apply_rounded_mask(self) -> None:
+ """
+ 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
+
+ # 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)
+ )
+
+ # 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) # 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
+ )
+
+ # 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:
+ 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.setIconSize(QtCore.QSize(32, 32))
+ 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()
+ # Start timer for tracking window movement
+ self._position_timer.start()
+ # Ensure menu is on top after positioning
+ self.raise_()
+ # 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
+ 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()
+
+ # Get current viewport position for change detection
+ 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())
+
+ # 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
+
+ # 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))
+
+ # 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
+
+ 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
+ # Update mask after size/position change
+ 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:
+ """Updates mask when widget size changes."""
+ 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..b92eefb
--- /dev/null
+++ b/beeref/widgets/gif_floating_menu.py
@@ -0,0 +1,232 @@
+"""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)
+ self.speed_combo.setIconSize(QtCore.QSize(32, 32))
+
+ 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..9b7d536
--- /dev/null
+++ b/beeref/widgets/text_floating_menu.py
@@ -0,0 +1,186 @@
+"""Floating menu shown when a single text item is selected."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from PyQt6 import QtCore, 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('clear.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.setIconSize(QtCore.QSize(32, 32))
+ 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)
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",
]