This commit is contained in:
Nopomuk 2025-11-21 10:44:04 +00:00 committed by GitHub
commit 4e5ff11702
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 4032 additions and 79 deletions

6
.gitignore vendored
View file

@ -134,4 +134,8 @@ dmypy.json
# github pages
Gemfile.lock
_site
.bundle
.bundle
# macOS
.DS_Store
**/.DS_Store

73
GIT_SYNC_FIX.md Normal file
View file

@ -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
```

View file

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

View file

@ -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',
),

View file

@ -72,6 +72,7 @@ menu_structure = [
'items': [
'insert_images',
'insert_text',
'insert_draw',
],
},
{

View file

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

View file

@ -0,0 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 4C21.5228 4 25.5 5.5 25.5 7C25.5 9 22 25 22 25C21.5 26.5 19.3137 27.5 16 27.5C12.6863 27.5 10.5 26.5 9.99999 25C9.99999 25 6.5 9 6.5 7C6.5 5.34315 10.4771 4 16 4Z" fill="white" fill-opacity="0.8"/>
<path d="M25 7C25 8.10457 20.9706 9 16 9C11.0294 9 7 8.10457 7 7C7 5.89543 11.0294 4.5 16 4.5C20.9706 4.5 25 5.89543 25 7Z" fill="black" fill-opacity="0.48"/>
</svg>

After

Width:  |  Height:  |  Size: 473 B

View file

@ -0,0 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.80762 25.1924L25.1924 6.80761" stroke="white" stroke-width="2" stroke-linecap="round"/>
<path d="M25.1924 25.1924L6.80761 6.80761" stroke="white" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 303 B

View file

@ -0,0 +1,3 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 2C9.55228 2 10 2.44772 10 3V8H22C23.1046 8 24 8.89543 24 10V22H29C29.5523 22 30 22.4477 30 23C30 23.5523 29.5523 24 29 24H24V29C24 29.5523 23.5523 30 23 30C22.4477 30 22 29.5523 22 29V24H10C8.89543 24 8 23.1046 8 22V10H3C2.44772 10 2 9.55228 2 9C2 8.44772 2.44772 8 3 8H8V3C8 2.44772 8.44772 2 9 2ZM10 22H22V10H10V22Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 449 B

View file

@ -0,0 +1,5 @@
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M240 2H16C8.26801 2 2 8.26801 2 16V240C2 247.732 8.26801 254 16 254H240C247.732 254 254 247.732 254 240V16C254 8.26801 247.732 2 240 2Z" fill="white" fill-opacity="0.24"/>
<path d="M240 250V254H228V250H240ZM254 240C254 247.732 247.732 254 240 254V250C245.523 250 250 245.523 250 240V228H254V240ZM220 250V254H196V250H220ZM188 250V254H164V250H188ZM156 250V254H132V250H156ZM124 250V254H100V250H124ZM92 250V254H68V250H92ZM60 250V254H36V250H60ZM28 250V254H16V250H28ZM2 240V228H6V240C6 245.523 10.4772 250 16 250V254C8.26801 254 2 247.732 2 240ZM6 196V220H2V196H6ZM254 196V220H250V196H254ZM6 164V188H2V164H6ZM254 164V188H250V164H254ZM6 132V156H2V132H6ZM254 132V156H250V132H254ZM6 100V124H2V100H6ZM254 100V124H250V100H254ZM6 68V92H2V68H6ZM254 68V92H250V68H254ZM6 36V60H2V36H6ZM254 36V60H250V36H254ZM2 16C2 8.26801 8.26801 2 16 2H28V6H16C10.4772 6 6 10.4772 6 16V28H2V16ZM254 28H250V16C250 10.4772 245.523 6 240 6H228V2H240C247.732 2 254 8.26801 254 16V28ZM60 2V6H36V2H60ZM92 2V6H68V2H92ZM124 2V6H100V2H124ZM156 2V6H132V2H156ZM188 2V6H164V2H188ZM220 2V6H196V2H220Z" fill="white" fill-opacity="0.32"/>
<path d="M126.147 126.08C127.198 125.078 128.859 125.086 129.886 126.115L137.886 134.115C138.928 135.158 138.928 136.842 137.886 137.885C137.366 138.405 136.683 138.666 136.001 138.666C135.318 138.666 134.634 138.405 134.114 137.885L130.667 134.438V149.334C130.667 150.808 129.472 152 128.001 152C126.529 152 125.333 150.808 125.333 149.334V134.283L121.854 137.643C120.796 138.669 119.107 138.635 118.083 137.576C117.059 136.515 117.089 134.829 118.147 133.805L126.147 126.08ZM127.999 104C134.882 104 140.947 108.43 143.134 114.787C149.637 115.67 154.667 121.259 154.667 128C154.667 131.256 153.482 134.39 151.333 136.824C150.805 137.419 150.072 137.725 149.333 137.725C148.706 137.725 148.077 137.507 147.567 137.059C146.466 136.08 146.36 134.398 147.335 133.291C148.623 131.835 149.333 129.952 149.333 128C149.333 123.589 145.744 120 141.333 120H141.065C139.797 120 138.703 119.104 138.452 117.859C137.455 112.921 133.06 109.334 127.999 109.334C122.941 109.334 118.544 112.921 117.55 117.859C117.299 119.104 116.202 120 114.933 120H114.667C110.256 120 106.667 123.589 106.667 128C106.667 129.952 107.376 131.835 108.667 133.291C109.64 134.398 109.536 136.08 108.433 137.059C107.329 138.035 105.642 137.925 104.669 136.824C102.517 134.39 101.333 131.256 101.333 128C101.333 121.259 106.362 115.67 112.866 114.787C115.055 108.43 121.119 104 127.999 104Z" fill="white" fill-opacity="0.8"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.09114 4.004C5.06014 4.001 5.03014 4 5.00014 4C4.73614 4 4.48114 4.104 4.29314 4.293C4.08314 4.503 3.97714 4.795 4.00414 5.09L4.38314 9.261C4.42514 9.718 4.62714 10.149 4.95214 10.474L13.9481 19.471C14.6501 20.175 15.9241 20.14 16.6641 19.401L19.4021 16.663C20.1681 15.896 20.1991 14.679 19.4711 13.949L10.4741 4.952C10.1491 4.626 9.71914 4.425 9.26114 4.383L5.09114 4.004Z" fill="white" stroke="black" stroke-width="2"/>
<path d="M12 17.5L17.5 12" stroke="black" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 637 B

View file

@ -0,0 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 8.5C2.44772 8.5 2 8.94772 2 9.5C2 10.0523 2.44772 10.5 3 10.5V9.5V8.5ZM29.7071 10.2071C30.0976 9.81658 30.0976 9.18342 29.7071 8.79289L23.3431 2.42893C22.9526 2.03841 22.3195 2.03841 21.9289 2.42893C21.5384 2.81946 21.5384 3.45262 21.9289 3.84315L27.5858 9.5L21.9289 15.1569C21.5384 15.5474 21.5384 16.1805 21.9289 16.5711C22.3195 16.9616 22.9526 16.9616 23.3431 16.5711L29.7071 10.2071ZM3 9.5V10.5H29V9.5V8.5H3V9.5Z" fill="white"/>
<path d="M29 21.5C29.5523 21.5 30 21.9477 30 22.5C30 23.0523 29.5523 23.5 29 23.5V22.5V21.5ZM2.29289 23.2071C1.90237 22.8166 1.90237 22.1834 2.29289 21.7929L8.65685 15.4289C9.04738 15.0384 9.68054 15.0384 10.0711 15.4289C10.4616 15.8195 10.4616 16.4526 10.0711 16.8431L4.41421 22.5L10.0711 28.1569C10.4616 28.5474 10.4616 29.1805 10.0711 29.5711C9.68054 29.9616 9.04738 29.9616 8.65685 29.5711L2.29289 23.2071ZM29 22.5V23.5H3V22.5V21.5H29V22.5Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1,009 B

View file

@ -0,0 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.5 29C8.5 29.5523 8.94772 30 9.5 30C10.0523 30 10.5 29.5523 10.5 29H9.5H8.5ZM10.2071 2.29289C9.81658 1.90237 9.18342 1.90237 8.79289 2.29289L2.42893 8.65685C2.03841 9.04738 2.03841 9.68054 2.42893 10.0711C2.81946 10.4616 3.45262 10.4616 3.84315 10.0711L9.5 4.41421L15.1569 10.0711C15.5474 10.4616 16.1805 10.4616 16.5711 10.0711C16.9616 9.68054 16.9616 9.04738 16.5711 8.65685L10.2071 2.29289ZM9.5 29H10.5L10.5 3H9.5H8.5L8.5 29H9.5Z" fill="white"/>
<path d="M21.5 3C21.5 2.44772 21.9477 2 22.5 2C23.0523 2 23.5 2.44772 23.5 3H22.5H21.5ZM23.2071 29.7071C22.8166 30.0976 22.1834 30.0976 21.7929 29.7071L15.4289 23.3431C15.0384 22.9526 15.0384 22.3195 15.4289 21.9289C15.8195 21.5384 16.4526 21.5384 16.8431 21.9289L22.5 27.5858L28.1569 21.9289C28.5474 21.5384 29.1805 21.5384 29.5711 21.9289C29.9616 22.3195 29.9616 22.9526 29.5711 23.3431L23.2071 29.7071ZM22.5 3H23.5V29H22.5H21.5V3H22.5Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1,018 B

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 18L8 5H7L3 18M4.23077 14H10.7692M14.5 10C16 9 20 8 20 11.5C20 15 20 18 20 18M20 12.5C18.5 13 14 13 14 16C14 19 18.5 18 20 15.5" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 325 B

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 6.5V18H13C14.6569 18 16 16.6569 16 15C16 13.3431 14.6569 12 13 12H8H12.25C13.7688 12 15 10.7688 15 9.25C15 7.73122 13.7688 6.5 12.25 6.5H8Z" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 337 B

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.5 15L12.5 3H11.5L7.5 15M8.5 12H15.5M2 20H22" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 242 B

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 6L10 18M6 18H14M10 6H18" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 222 B

View file

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.2 12H14.8M4 12H18.5" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14.4844 8.20312C14.4727 7.22717 14.0573 6.89062 13.4532 6.48437C12.849 6.07292 12.0886 5.86719 11.1719 5.86719C10.5157 5.86719 9.94797 5.97135 9.4688 6.17969C8.98963 6.38281 8.61724 6.66406 8.35161 7.02344C8.0912 7.3776 7.96099 7.78125 7.96099 8.23438C7.96099 8.61458 8.04953 8.94271 8.22661 9.21875C8.40891 9.49479 8.64588 9.72656 8.93755 9.91406C9.23443 10.0964 9.55213 10.25 9.89068 10.375C10.2292 10.4948 10.5547 10.5938 10.8672 10.6719L12.4297 11.0781C12.9402 11.2031 13.4636 11.3724 14.0001 11.5859C14.5365 11.7995 15.0339 12.0807 15.4922 12.4297C15.9506 12.7786 16.3204 13.2109 16.6016 13.7266C16.8881 14.2422 17.0313 14.8594 17.0313 15.5781C17.0313 16.4844 16.7969 17.2891 16.3282 17.9922C15.8646 18.6953 15.1902 19.25 14.3047 19.6562C13.4245 20.0625 12.3594 20.2656 11.1094 20.2656C9.91151 20.2656 8.87505 20.0755 8.00005 19.6953C7.12505 19.3151 6.44015 18.776 5.94536 18.0781C5.45057 17.375 5.20127 16.5401 5.12505 15.5781C5 14 7.54693 14.2858 7.54693 15.5781C7.54693 16.1581 7.7813 16.638 8.10943 17.0234C8.44276 17.4036 8.86724 17.6875 9.38286 17.875C9.9037 18.0573 10.474 18.1484 11.0938 18.1484C11.7761 18.1484 12.3829 18.0417 12.9141 17.8281C13.4506 17.6094 13.8724 17.3073 14.1797 16.9219C14.487 16.5313 14.6407 16.0755 14.6407 15.5547C14.6407 15.0807 14.5053 14.6927 14.2344 14.3906C13.9688 14.0885 13.6068 13.8385 13.1485 13.6406C12.6954 13.4427 12.1823 13.2682 11.6094 13.1172L9.7188 12.6016C8.43755 12.2526 7.42193 11.7396 6.67193 11.0625C5.92713 10.3854 5.55474 9.48958 5.55474 8.375C5.55474 7.45312 5.80474 6.64844 6.30474 5.96094C6.80474 5.27344 7.48182 4.73958 8.33599 4.35938C9.19016 3.97396 10.1537 3.78125 11.2266 3.78125C12.3099 3.78125 13.2657 3.97135 14.0938 4.35156C14.9271 4.73177 15.5834 5.25521 16.0626 5.92187C16.5417 6.58333 16.7846 7.05104 16.8126 8.20312C16.8405 9.3552 14.4962 9.17908 14.4844 8.20312Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 4V11C7 13.7614 9.23858 16 12 16C14.7614 16 17 13.7614 17 11V4M6 20H18" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 267 B

View file

@ -0,0 +1,5 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.0471 13.3823C18.0188 12.5505 18.5085 11.7881 19.2767 11.468L28.6154 7.57692C29.2741 7.30247 30 7.78642 30 8.5V20.908C30 21.6584 29.5799 22.3457 28.9121 22.6879L19.9472 27.2817C19.295 27.6159 18.5168 27.1582 18.4918 26.4258L18.0471 13.3823Z" fill="white"/>
<path d="M10.0471 11.3823C10.0188 10.5505 10.5085 9.78813 11.2767 9.46803L20.6154 5.57692C21.2741 5.30247 22 5.78642 22 6.5V18.908C22 19.6584 21.5799 20.3457 20.9121 20.6879L11.9472 25.2817C11.295 25.6159 10.5168 25.1582 10.4918 24.4258L10.0471 11.3823Z" fill="white" fill-opacity="0.8"/>
<path d="M2.04712 9.38232C2.01877 8.55054 2.50849 7.78813 3.27673 7.46803L12.6154 3.57692C13.2741 3.30247 14 3.78642 14 4.5V16.908C14 17.6584 13.5799 18.3457 12.9121 18.6879L3.94724 23.2817C3.29504 23.6159 2.51676 23.1582 2.49179 22.4258L2.04712 9.38232Z" fill="white" fill-opacity="0.32"/>
</svg>

After

Width:  |  Height:  |  Size: 952 B

View file

@ -0,0 +1,12 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_34_16)">
<path d="M2 4C2 2.89543 2.89543 2 4 2H12C13.1046 2 14 2.89543 14 4V24C14 27.3137 11.3137 30 8 30C4.68629 30 2 27.3137 2 24V4Z" fill="white"/>
<path d="M18.3848 5.41421C19.1658 4.63317 20.4322 4.63317 21.2132 5.41421L26.8701 11.0711C27.6511 11.8521 27.6511 13.1184 26.8701 13.8995L12.7279 28.0416C10.3848 30.3848 6.58579 30.3848 4.24265 28.0416C1.8995 25.6985 1.8995 21.8995 4.24265 19.5563L18.3848 5.41421Z" fill="white" fill-opacity="0.64"/>
<path d="M28 18C29.1046 18 30 18.8954 30 20V28C30 29.1046 29.1046 30 28 30L12.5 30L25 18H28Z" fill="white" fill-opacity="0.32"/>
</g>
<defs>
<clipPath id="clip0_34_16">
<rect width="32" height="32" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 813 B

View file

@ -0,0 +1,20 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 2C23.732 2 30 8.26801 30 16C30 23.732 23.732 30 16 30C8.26801 30 2 23.732 2 16C2 8.26801 8.26801 2 16 2ZM16 29C23.1797 29 29 23.1797 29 16C29 8.8203 23.1797 3 16 3V29Z" fill="white"/>
<path d="M16 16.9286C16 9.74887 16 3 16 3C23.1797 3 29 8.8203 29 16C29 23.1797 23.1797 29 16 29C16 29 16 24.1083 16 16.9286Z" fill="white" fill-opacity="0.24"/>
<circle cx="18" cy="6" r="1" fill="white" fill-opacity="0.8"/>
<circle cx="18" cy="10" r="1" fill="white" fill-opacity="0.8"/>
<circle cx="22" cy="10" r="1" fill="white" fill-opacity="0.8"/>
<circle cx="22" cy="6" r="1" fill="white" fill-opacity="0.8"/>
<circle cx="18" cy="14" r="1" fill="white" fill-opacity="0.8"/>
<circle cx="22" cy="14" r="1" fill="white" fill-opacity="0.8"/>
<circle cx="26" cy="14" r="1" fill="white" fill-opacity="0.8"/>
<circle cx="26" cy="10" r="1" fill="white" fill-opacity="0.8"/>
<circle cx="18" cy="18" r="1" fill="white" fill-opacity="0.8"/>
<circle cx="22" cy="18" r="1" fill="white" fill-opacity="0.8"/>
<circle cx="26" cy="18" r="1" fill="white" fill-opacity="0.8"/>
<circle cx="26" cy="22" r="1" fill="white" fill-opacity="0.8"/>
<circle cx="18" cy="22" r="1" fill="white" fill-opacity="0.8"/>
<circle cx="22" cy="22" r="1" fill="white" fill-opacity="0.8"/>
<circle cx="22" cy="26" r="1" fill="white" fill-opacity="0.8"/>
<circle cx="18" cy="26" r="1" fill="white" fill-opacity="0.8"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,3 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M29.7071 15.2929C30.0976 15.6834 30.0976 16.3166 29.7071 16.7071L23.3431 23.0711C22.9526 23.4616 22.3195 23.4616 21.9289 23.0711C21.5384 22.6805 21.5384 22.0474 21.9289 21.6569L27.5858 16L21.9289 10.3431C21.5384 9.95262 21.5384 9.31946 21.9289 8.92893C22.3195 8.53841 22.9526 8.53841 23.3431 8.92893L29.7071 15.2929ZM2.29289 16.7071C1.90237 16.3166 1.90237 15.6834 2.29289 15.2929L8.65685 8.92893C9.04738 8.53841 9.68054 8.53841 10.0711 8.92893C10.4616 9.31946 10.4616 9.95262 10.0711 10.3431L4.41421 16L10.0711 21.6569C10.4616 22.0474 10.4616 22.6805 10.0711 23.0711C9.68054 23.4616 9.04738 23.4616 8.65685 23.0711L2.29289 16.7071ZM29 16V17H3V16V15H29V16Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 785 B

View file

@ -0,0 +1,3 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M29 15C29.5523 15 30 15.4477 30 16C30 16.5523 29.5523 17 29 17V16V15ZM2.29289 16.7071C1.90237 16.3166 1.90237 15.6834 2.29289 15.2929L8.65685 8.92893C9.04738 8.53841 9.68054 8.53841 10.0711 8.92893C10.4616 9.31946 10.4616 9.95262 10.0711 10.3431L4.41421 16L10.0711 21.6569C10.4616 22.0474 10.4616 22.6805 10.0711 23.0711C9.68054 23.4616 9.04738 23.4616 8.65685 23.0711L2.29289 16.7071ZM29 16V17H3V16V15H29V16Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 538 B

View file

@ -0,0 +1,3 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M29.7071 15.2929C30.0976 15.6834 30.0976 16.3166 29.7071 16.7071L23.3431 23.0711C22.9526 23.4616 22.3195 23.4616 21.9289 23.0711C21.5384 22.6805 21.5384 22.0474 21.9289 21.6569L27.5858 16L21.9289 10.3431C21.5384 9.95262 21.5384 9.31946 21.9289 8.92893C22.3195 8.53841 22.9526 8.53841 23.3431 8.92893L29.7071 15.2929ZM3 17C2.44772 17 2 16.5523 2 16C2 15.4477 2.44772 15 3 15V16V17ZM29 16V17H3V16V15H29V16Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 533 B

View file

@ -0,0 +1,3 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M29 16H3" stroke="white" stroke-width="2" stroke-linecap="round" stroke-dasharray="8 8"/>
</svg>

After

Width:  |  Height:  |  Size: 202 B

View file

@ -0,0 +1,3 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M29 16H3" stroke="white" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 179 B

View file

@ -0,0 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 5.78197C6 5.3501 6.3501 5 6.78197 5C6.98046 5 7.17153 5.07549 7.31643 5.21115L17.4265 14.6773C17.7924 15.0199 18 15.4987 18 16C18 16.5013 17.7924 16.9801 17.4265 17.3227L7.31643 26.7888C7.17153 26.9245 6.98046 27 6.78197 27C6.3501 27 6 26.6499 6 26.218V5.78197Z" fill="white"/>
<path d="M17 5.78197C17 5.3501 17.3501 5 17.782 5C17.9805 5 18.1715 5.07549 18.3164 5.21115L28.4265 14.6773C28.7924 15.0199 29 15.4987 29 16C29 16.5013 28.7924 16.9801 28.4265 17.3227L18.3164 26.7888C18.1715 26.9245 17.9805 27 17.782 27C17.3501 27 17 26.6499 17 26.218V5.78197Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 687 B

View file

@ -0,0 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 30H6C3.79086 30 2 28.2091 2 26V25H7V30ZM19 30H13V25H19V30ZM30 26C30 28.2091 28.2091 30 26 30H25V25H30V26ZM13 25H7V19H13V25ZM25 25H19V19H25V25ZM19 19H13V13H19V19ZM30 19H25V13H30V19ZM7 19H2V13H7V19ZM13 13H7V7H13V13ZM25 13H19V7H25V13ZM19 7H13V2H19V7ZM26 2C28.2091 2 30 3.79086 30 6V7H25V2H26ZM7 7H2V6C2 3.79086 3.79086 2 6 2H7V7Z" fill="white" fill-opacity="0.64"/>
<rect x="2.5" y="2.5" width="27" height="27" rx="3.5" fill="white" fill-opacity="0.48" stroke="white"/>
</svg>

After

Width:  |  Height:  |  Size: 582 B

View file

@ -0,0 +1,16 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_34_33)">
<mask id="path-1-inside-1_34_33" fill="white">
<path d="M15.8046 26.6807C15.4738 27.0113 15.0371 27.2156 14.5713 27.258L8.44763 27.8146C7.85658 27.8683 7.2722 27.6569 6.8525 27.2373L6.76273 27.1475C6.34312 26.7278 6.13171 26.1434 6.18544 25.5524L6.74201 19.4287C6.78436 18.9629 6.98866 18.5262 7.3193 18.1954L16 9.51472L24.4853 18L15.8046 26.6807Z"/>
</mask>
<path d="M15.8046 26.6807L16.5115 27.388L16.5117 27.3878L15.8046 26.6807ZM14.5713 27.258L14.6618 28.2539L14.6618 28.2539L14.5713 27.258ZM8.44763 27.8146L8.35711 26.8187L8.35709 26.8187L8.44763 27.8146ZM6.8525 27.2373L6.14539 27.9444L6.14547 27.9445L6.8525 27.2373ZM6.76273 27.1475L6.05554 27.8545L6.05562 27.8546L6.76273 27.1475ZM6.18544 25.5524L7.18133 25.6429L7.18134 25.6429L6.18544 25.5524ZM6.74201 19.4287L5.74612 19.3382L5.74612 19.3382L6.74201 19.4287ZM7.3193 18.1954L6.61219 17.4883L6.61203 17.4885L7.3193 18.1954ZM16 9.51472L16.7071 8.80761L16 8.1005L15.2929 8.80761L16 9.51472ZM24.4853 18L25.1924 18.7071L25.8995 18L25.1924 17.2929L24.4853 18ZM15.8046 26.6807L15.0976 25.9734C14.9324 26.1386 14.7139 26.2409 14.4807 26.2621L14.5713 27.258L14.6618 28.2539C15.3603 28.1904 16.0151 27.8841 16.5115 27.388L15.8046 26.6807ZM14.5713 27.258L14.4808 26.2621L8.35711 26.8187L8.44763 27.8146L8.53815 28.8105L14.6618 28.2539L14.5713 27.258ZM8.44763 27.8146L8.35709 26.8187C8.06142 26.8455 7.76926 26.7398 7.55952 26.5301L6.8525 27.2373L6.14547 27.9445C6.77513 28.574 7.65175 28.891 8.53817 28.8105L8.44763 27.8146ZM6.8525 27.2373L7.5596 26.5302L7.46983 26.4404L6.76273 27.1475L6.05562 27.8546L6.14539 27.9444L6.8525 27.2373ZM6.76273 27.1475L7.46991 26.4405C7.26022 26.2307 7.15445 25.9386 7.18133 25.6429L6.18544 25.5524L5.18955 25.4618C5.10896 26.3482 5.42603 27.2249 6.05554 27.8545L6.76273 27.1475ZM6.18544 25.5524L7.18134 25.6429L7.73791 19.5192L6.74201 19.4287L5.74612 19.3382L5.18955 25.4619L6.18544 25.5524ZM6.74201 19.4287L7.7379 19.5192C7.7591 19.2861 7.86143 19.0676 8.02657 18.9024L7.3193 18.1954L6.61203 17.4885C6.11588 17.9849 5.80961 18.6397 5.74612 19.3382L6.74201 19.4287ZM7.3193 18.1954L8.0264 18.9025L16.7071 10.2218L16 9.51472L15.2929 8.80761L6.61219 17.4883L7.3193 18.1954ZM16 9.51472L15.2929 10.2218L23.7782 18.7071L24.4853 18L25.1924 17.2929L16.7071 8.80761L16 9.51472ZM24.4853 18L23.7782 17.2929L15.0975 25.9736L15.8046 26.6807L16.5117 27.3878L25.1924 18.7071L24.4853 18Z" fill="white" mask="url(#path-1-inside-1_34_33)"/>
<path d="M15.8053 26.6814C15.4746 27.0116 15.0381 27.2157 14.5727 27.258L8.44763 27.8146C7.85659 27.8683 7.2722 27.6569 6.8525 27.2373L6.76273 27.1475C6.34313 26.7278 6.13171 26.1434 6.18544 25.5524L6.74201 19.4287C6.78436 18.9629 6.98866 18.5262 7.3193 18.1954L7.51472 18H24.5005L15.8053 26.6814Z" fill="white" fill-opacity="0.32"/>
<rect x="14.5858" y="6.68629" width="18" height="2" rx="1" transform="rotate(45 14.5858 6.68629)" fill="white"/>
<path d="M20.2426 6.68629C21.0237 5.90524 22.29 5.90524 23.0711 6.68629L27.3137 10.9289C28.0948 11.71 28.0948 12.9763 27.3137 13.7574L23.7782 17.2929L16.7071 10.2218L20.2426 6.68629Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_34_33">
<rect width="32" height="32" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -0,0 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="6" y="4" width="7" height="24" rx="2" fill="white"/>
<rect x="19" y="4" width="7" height="24" rx="2" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 228 B

View file

@ -0,0 +1,3 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 4.73409C5 3.77638 5.77638 3 6.73409 3C7.02776 3 7.31662 3.07458 7.57359 3.21675L28.1875 14.6219C28.6888 14.8993 29 15.427 29 16C29 16.573 28.6888 17.1007 28.1875 17.3781L7.57359 28.7832C7.31662 28.9254 7.02776 29 6.73409 29C5.77638 29 5 28.2236 5 27.2659V4.73409Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 395 B

View file

@ -0,0 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M26 5.78197C26 5.3501 25.6499 5 25.218 5C25.0195 5 24.8285 5.07549 24.6836 5.21115L14.5735 14.6773C14.2076 15.0199 14 15.4987 14 16C14 16.5013 14.2076 16.9801 14.5735 17.3227L24.6836 26.7888C24.8285 26.9245 25.0195 27 25.218 27C25.6499 27 26 26.6499 26 26.218V5.78197Z" fill="white"/>
<path d="M15 5.78197C15 5.3501 14.6499 5 14.218 5C14.0195 5 13.8285 5.07549 13.6836 5.21115L3.57355 14.6773C3.20764 15.0199 3 15.4987 3 16C3 16.5013 3.20764 16.9801 3.57355 17.3227L13.6836 26.7888C13.8285 26.9245 14.0195 27 14.218 27C14.6499 27 15 26.6499 15 26.218V5.78197Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 688 B

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.9772 10.2319L12 14.5L7.02287 10.2319C6.62915 9.87791 6.04322 9.93591 5.71543 10.3599C5.57057 10.5469 5.5 10.7739 5.5 10.9999C5.5 11.2859 5.61422 11.5699 5.83429 11.7679L11.4058 16.768C11.4494 16.807 11.5005 16.827 11.5488 16.856C11.5878 16.88 11.6212 16.909 11.6639 16.927C11.7707 16.972 11.883 16.999 11.9963 16.999L12 17L12.0038 16.999C12.117 16.999 12.2294 16.972 12.3362 16.927C12.3789 16.909 12.4123 16.88 12.4513 16.856C12.4996 16.827 12.5507 16.807 12.5943 16.768L18.1658 11.7679C18.5595 11.4149 18.6134 10.7849 18.2847 10.3599C17.9569 9.93591 17.3709 9.87791 16.9772 10.2319Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 716 B

View file

@ -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(),
])

View file

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

View file

@ -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()

View file

@ -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):

303
beeref/gif_item.py Normal file
View file

@ -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 <https://www.gnu.org/licenses/>.
"""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)

View file

@ -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

View file

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

View file

@ -14,6 +14,7 @@
# along with BeeRef. If not, see <https://www.gnu.org/licenses/>.
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:

View file

@ -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,
)

View file

@ -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()

View file

@ -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",
]

View file

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

View file

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

View file

@ -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()

View file

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

View file

@ -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()

View file

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

View file

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

View file

@ -14,11 +14,15 @@
# along with BeeRef. If not, see <https://www.gnu.org/licenses/>.
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 = """<p>Paste or drop images here.</p>
<p>Right-click for more options.</p>"""
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('<p>Paste or drop images here.</p>', self)
self.label.setAlignment(Qt.AlignmentFlag.AlignVCenter
| Qt.AlignmentFlag.AlignCenter)
# Right-click text
self.right_click_label = QtWidgets.QLabel('<p>Right-click for more options.</p>', 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('<h3>Recent Files</h3>', self))
self.files_view = RecentFilesView(self, parent)
files_layout.setAlignment(Qt.AlignmentFlag.AlignHCenter)
self.recent_files_label = QtWidgets.QLabel('<h3>Recent Files</h3>', 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)

View file

@ -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",
]