new features

This commit is contained in:
DS_Litvinov 2025-11-20 15:59:15 +03:00
parent caed5c3801
commit c2ecc42834
54 changed files with 3957 additions and 75 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

@ -109,9 +109,11 @@ def main():
logger.info(f'Logging to: {logfile_name()}')
settings.on_startup()
args = CommandlineArgs(with_check=True) # Force checking
assert not args.debug_raise_error, args.debug_raise_error
if args.debug_raise_error:
raise RuntimeError(args.debug_raise_error)
os.environ["QT_DEBUG_PLUGINS"] = "1"
if args.loglevel == 'DEBUG':
os.environ["QT_DEBUG_PLUGINS"] = "1"
app = BeeRefApplication(sys.argv)
palette = create_palette_from_dict(constants.COLORS)
app.setPalette(palette)

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):
"""Создает курсор из SVG файла."""
app = QtWidgets.QApplication.instance()
scaling = app.primaryScreen().devicePixelRatio()
# Загружаем SVG
svg_path = str(self.PATH.joinpath(filename))
renderer = QtSvg.QSvgRenderer(svg_path)
# Создаем pixmap нужного размера
pixmap_size = int(size * scaling)
pixmap = QtGui.QPixmap(pixmap_size, pixmap_size)
pixmap.fill(QtGui.QColor(0, 0, 0, 0)) # Прозрачный фон
# Рендерим SVG в pixmap
painter = QtGui.QPainter(pixmap)
renderer.render(painter)
painter.end()
pixmap.setDevicePixelRatio(scaling)
return QtGui.QCursor(
pixmap, int(hotspot[0]/scaling), int(hotspot[1]/scaling))

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="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12L19 6.41Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 279 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 fill-rule="evenodd" clip-rule="evenodd" d="M8.556 16C8.25 16 8 15.751 8 15.444V8H15.445C15.751 8 16 8.249 16 8.556V16H8.556ZM21 16H18V8.556C18 7.146 16.854 6 15.445 6H8V3C8 2.447 7.553 2 7 2C6.448 2 6 2.447 6 3V6H3C2.448 6 2 6.447 2 7C2 7.553 2.448 8 3 8H6V15.444C6 16.854 7.147 18 8.556 18H16V21C16 21.553 16.448 22 17 22C17.553 22 18 21.553 18 21V18H21C21.553 18 22 17.553 22 17C22 16.447 21.553 16 21 16Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 533 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,3 @@
<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="M11.9831 7.32291L6.36614 12.9399L6.10214 15.8959L9.07914 15.6249L14.6791 10.0189L11.9831 7.32291ZM17.9661 6.72891L15.2711 4.03491L13.3231 5.98291L16.0181 8.67891L17.9661 6.72891ZM5.09114 17.9959C5.06014 17.9989 5.03014 17.9999 5.00014 17.9999C4.73614 17.9999 4.48114 17.8959 4.29314 17.7069C4.08314 17.4969 3.97714 17.2049 4.00414 16.9099L4.38314 12.7389C4.42514 12.2819 4.62714 11.8509 4.95214 11.5259L13.9481 2.52891C14.6501 1.82491 15.9241 1.85991 16.6641 2.59891L19.4021 5.33691C20.1681 6.10391 20.1991 7.32091 19.4711 8.05091L10.4741 17.0479C10.1491 17.3739 9.71914 17.5749 9.26114 17.6169L5.09114 17.9959ZM5 20H19C19.55 20 20 20.45 20 21C20 21.55 19.55 22 19 22H5C4.45 22 4 21.55 4 21C4 20.45 4.45 20 5 20Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 881 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="M17 7.99982H4C3.4 7.99982 3 7.59982 3 6.99982C3 6.39982 3.4 5.99982 4 5.99982H17.1L15.5 4.79982C15.1 4.49982 15 3.79982 15.3 3.39982C15.6 2.99982 16.3 2.89982 16.7 3.19982L20.6 6.19982C20.9 6.39982 21 6.69982 21 6.99982C21 7.29982 20.9 7.59982 20.6 7.79982L16.6 10.7998C16.4 10.8998 16.2 10.9998 16 10.9998C15.7 10.9998 15.4 10.8998 15.2 10.5998C14.9 10.1998 15 9.49982 15.4 9.19982L17 7.99982ZM7 14.9998H20C20.6 14.9998 21 15.3998 21 15.9998C21 16.5998 20.6 16.9998 20 16.9998H6.9L8.5 18.1998C8.9 18.4998 9 19.1998 8.7 19.5998C8.5 19.8998 8.2 19.9998 7.9 19.9998C7.7 19.9998 7.5 19.8998 7.3 19.7998L3.4 16.7998C3.1 16.5998 3 16.2998 3 15.9998C3 15.6998 3.1 15.3998 3.4 15.1998L7.4 12.1998C7.8 11.8998 8.5 11.9998 8.8 12.3998C9.1 12.7998 9 13.4998 8.6 13.7998L7 14.9998Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 899 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="M7.99982 7L7.99982 20C7.99982 20.6 7.59982 21 6.99982 21C6.39982 21 5.99982 20.6 5.99982 20L5.99982 6.9L4.79982 8.5C4.49982 8.9 3.79982 9 3.39982 8.7C2.99982 8.4 2.89982 7.7 3.19982 7.3L6.19982 3.4C6.39982 3.1 6.69982 3 6.99982 3C7.29982 3 7.59982 3.1 7.79982 3.4L10.7998 7.4C10.8998 7.6 10.9998 7.8 10.9998 8C10.9998 8.3 10.8998 8.6 10.5998 8.8C10.1998 9.1 9.49982 9 9.19982 8.6L7.99982 7ZM14.9998 17L14.9998 4C14.9998 3.4 15.3998 3 15.9998 3C16.5998 3 16.9998 3.4 16.9998 4V17.1L18.1998 15.5C18.4998 15.1 19.1998 15 19.5998 15.3C19.8998 15.5 19.9998 15.8 19.9998 16.1C19.9998 16.3 19.8998 16.5 19.7998 16.7L16.7998 20.6C16.5998 20.9 16.2998 21 15.9998 21C15.6998 21 15.3998 20.9 15.1998 20.6L12.1998 16.6C11.8998 16.2 11.9998 15.5 12.3998 15.2C12.7998 14.9 13.4998 15 13.7998 15.4L14.9998 17Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 923 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,3 @@
<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="M19 18.256C19 18.666 18.666 19 18.256 19H17V17H19V18.256ZM5 18.256V17H7V19H5.744C5.334 19 5 18.666 5 18.256ZM5.744 5H7V7H5V5.744C5 5.334 5.334 5 5.744 5ZM19 5.744V7H17V5H18.256C18.666 5 19 5.334 19 5.744ZM17 15H19V13H17V15ZM17 11H19V9H17V11ZM9 19H15V5H9V19ZM5 15H7V13H5V15ZM5 11H7V9H5V11ZM18.256 3H5.744C4.231 3 3 4.232 3 5.744V18.256C3 19.769 4.231 21 5.744 21H18.256C19.769 21 21 19.769 21 18.256V5.744C21 4.232 19.769 3 18.256 3Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 601 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 fill-rule="evenodd" clip-rule="evenodd" d="M5 8H8.818V5H5V8ZM5 13H8.818V10H5V13ZM8.818 17.091C8.818 18.144 7.962 19 6.909 19C5.856 19 5 18.144 5 17.091V15H8.818V17.091ZM10.818 4C10.818 3.447 10.371 3 9.818 3H4C3.447 3 3 3.447 3 4V17.091C3 19.246 4.754 21 6.909 21C9.064 21 10.818 19.246 10.818 17.091V4ZM20 13.1816H17.305L15.44 15.1816L11.881 18.9996L10.466 20.5176L10.017 20.9996H20C20.553 20.9996 21 20.5526 21 19.9996V14.1816C21 13.6286 20.553 13.1816 20 13.1816ZM14.0535 5.4438L18.1895 9.2998C18.5935 9.6768 18.6165 10.3098 18.2395 10.7128L11.7605 17.6618C11.7663 17.6138 11.7729 17.566 11.7796 17.5182C11.799 17.3774 11.8185 17.2363 11.8185 17.0908V14.6678V9.3078V6.3748L12.6405 5.4928C12.8215 5.2988 13.0725 5.1848 13.3365 5.1758C13.6105 5.1538 13.8605 5.2628 14.0535 5.4438Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 907 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="M18 3C19.6569 3 21 4.34315 21 6V18C21 19.6569 19.6569 21 18 21H6C4.34315 21 3 19.6569 3 18V6C3 4.34315 4.34315 3 6 3H18ZM8 5C6.34315 5 5 6.34315 5 8V16C5 17.6569 6.34315 19 8 19H16C17.6569 19 19 17.6569 19 16V8C19 6.34315 17.6569 5 16 5H8Z" fill="white"/>
<path d="M12 6H9C7.34315 6 6 7.34315 6 9V15C6 16.6569 7.34315 18 9 18H12V6Z" fill="#FFFEFE"/>
</svg>

After

Width:  |  Height:  |  Size: 462 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="M7 12.9744H19C19.6 12.9744 20 12.5744 20 11.9744C20 11.3744 19.6 10.9744 19 10.9744H6.9L8.5 9.77443C8.9 9.47443 9 8.77443 8.7 8.37443C8.4 7.97443 7.7 7.87443 7.3 8.17443L3.4 11.1744C3.1 11.3744 3 11.6744 3 11.9744C3 12.2744 3.1 12.5744 3.4 12.7744L7.4 15.7744C7.6 15.8744 7.8 15.9744 8 15.9744C8.3 15.9744 8.6 15.8744 8.8 15.5744C9.1 15.1744 9 14.4744 8.6 14.1744L7 12.9744Z" fill="white"/>
<path d="M17 12.9744H5C4.4 12.9744 4 12.5744 4 11.9744C4 11.3744 4.4 10.9744 5 10.9744H17.1L15.5 9.77443C15.1 9.47443 15 8.77443 15.3 8.37443C15.6 7.97443 16.3 7.87443 16.7 8.17443L20.6 11.1744C20.9 11.3744 21 11.6744 21 11.9744C21 12.2744 20.9 12.5744 20.6 12.7744L16.6 15.7744C16.4 15.8744 16.2 15.9744 16 15.9744C15.7 15.9744 15.4 15.8744 15.2 15.5744C14.9 15.1744 15 14.4744 15.4 14.1744L17 12.9744Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 923 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="M7 12.9744H20C20.6 12.9744 21 12.5744 21 11.9744C21 11.3744 20.6 10.9744 20 10.9744H6.9L8.5 9.77443C8.9 9.47443 9 8.77443 8.7 8.37443C8.4 7.97443 7.7 7.87443 7.3 8.17443L3.4 11.1744C3.1 11.3744 3 11.6744 3 11.9744C3 12.2744 3.1 12.5744 3.4 12.7744L7.4 15.7744C7.6 15.8744 7.8 15.9744 8 15.9744C8.3 15.9744 8.6 15.8744 8.8 15.5744C9.1 15.1744 9 14.4744 8.6 14.1744L7 12.9744Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 503 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="M17 12.9744H4C3.4 12.9744 3 12.5744 3 11.9744C3 11.3744 3.4 10.9744 4 10.9744H17.1L15.5 9.77443C15.1 9.47443 15 8.77443 15.3 8.37443C15.6 7.97443 16.3 7.87443 16.7 8.17443L20.6 11.1744C20.9 11.3744 21 11.6744 21 11.9744C21 12.2744 20.9 12.5744 20.6 12.7744L16.6 15.7744C16.4 15.8744 16.2 15.9744 16 15.9744C15.7 15.9744 15.4 15.8744 15.2 15.5744C14.9 15.1744 15 14.4744 15.4 14.1744L17 12.9744Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 523 B

View file

@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 12H6" stroke="white" stroke-width="2" stroke-linecap="round"/>
<path d="M10 12L14 12" stroke="white" stroke-width="2" stroke-linecap="round"/>
<path d="M18 12H21" stroke="white" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 335 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="M2 12H22" stroke="white" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 179 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 fill-rule="evenodd" clip-rule="evenodd" d="M20.858 10.6736L15.759 6.45959C15.15 5.95759 14.282 5.85659 13.549 6.20059C12.902 6.50659 12.5 7.11359 12.5 7.78659V10.3776L7.759 6.45959C7.15 5.95759 6.281 5.85659 5.549 6.20059C4.902 6.50659 4.5 7.11359 4.5 7.78659V16.2126C4.5 16.8856 4.902 17.4926 5.549 17.7986C5.838 17.9346 6.149 18.0006 6.455 18.0006C6.926 18.0006 7.39 17.8436 7.759 17.5386L12.5 13.6226V16.2126C12.5 16.8856 12.902 17.4926 13.549 17.7986C13.838 17.9346 14.149 18.0006 14.455 18.0006C14.926 18.0006 15.39 17.8436 15.759 17.5386L20.858 13.3256C21.266 12.9896 21.5 12.5056 21.5 11.9996C21.5 11.4936 21.266 11.0096 20.858 10.6736Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 769 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="M18 3C19.6569 3 21 4.34315 21 6V18C21 19.6569 19.6569 21 18 21H6C4.34315 21 3 19.6569 3 18V6C3 4.34315 4.34315 3 6 3H18ZM12 12V19H19V12H12ZM5 5V12H12V5H5Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 283 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 fill-rule="evenodd" clip-rule="evenodd" d="M6.36614 14.9395L11.9451 9.35953L14.6411 12.0555L11.8511 14.8515M19.4021 7.33653L16.6631 4.59753C15.8951 3.82953 14.6771 3.80153 13.9481 4.52953L11.9451 6.53153L10.7071 5.29353C10.3161 4.90253 9.68314 4.90253 9.29314 5.29353C8.90214 5.68353 8.90214 6.31653 9.29314 6.70753L10.5311 7.94553L4.95214 13.5255C4.62714 13.8505 4.42514 14.2825 4.38314 14.7395L4.00414 18.9095C3.97714 19.2055 4.08314 19.4975 4.29314 19.7075C4.48114 19.8955 4.73614 20.0005 5.00014 20.0005C5.03014 20.0005 5.06014 19.9995 5.09114 19.9965L9.26114 19.6175C9.71814 19.5755 10.1491 19.3735 10.4741 19.0475L16.0541 13.4685L17.2931 14.7075C17.4881 14.9025 17.7441 15.0005 18.0001 15.0005C18.2561 15.0005 18.5121 14.9025 18.7071 14.7075C19.0971 14.3165 19.0971 13.6835 18.7071 13.2935L17.4691 12.0545L19.4701 10.0515C20.2001 9.32253 20.1701 8.10453 19.4021 7.33653Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1,002 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 fill-rule="evenodd" clip-rule="evenodd" d="M8 5C6.89543 5 6 5.89543 6 7V17C6 18.1046 6.89543 19 8 19C9.10457 19 10 18.1046 10 17V7C10 5.89543 9.10457 5 8 5ZM16 5C14.8954 5 14 5.89543 14 7V17C14 18.1046 14.8954 19 16 19C17.1046 19 18 18.1046 18 17V7C18 5.89543 17.1046 5 16 5Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 401 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 fill-rule="evenodd" clip-rule="evenodd" d="M7 17.5353C7 18.7089 8.28685 19.4279 9.28626 18.8128L18.2815 13.2762C19.2332 12.6904 19.2331 11.3069 18.2812 10.7212L9.28604 5.18673C8.28663 4.57182 7 5.29086 7 6.46428V17.5353Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 346 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 fill-rule="evenodd" clip-rule="evenodd" d="M18.45 6.20097C17.716 5.85597 16.849 5.95797 16.241 6.46097L11.5 10.377V7.78697C11.5 7.11397 11.097 6.50697 10.45 6.20097C9.716 5.85597 8.849 5.95797 8.241 6.46097L3.141 10.674C2.734 11.01 2.5 11.494 2.5 12C2.5 12.506 2.734 12.99 3.141 13.326L8.241 17.54C8.609 17.844 9.073 18.001 9.544 18.001C9.851 18.001 10.161 17.934 10.45 17.799C11.097 17.493 11.5 16.886 11.5 16.213V13.622L16.241 17.54C16.609 17.844 17.073 18.001 17.544 18.001C17.851 18.001 18.161 17.934 18.45 17.799C19.097 17.493 19.5 16.886 19.5 16.213V7.78697C19.5 7.11397 19.097 6.50697 18.45 6.20097Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 732 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

@ -0,0 +1,3 @@
<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="M10 16C10 16.55 9.55 17 9 17C8.45 17 8 16.55 8 16V12C8 11.45 8.45 11 9 11C9.55 11 10 11.45 10 12V16ZM16 16C16 16.55 15.55 17 15 17C14.45 17 14 16.55 14 16V12C14 11.45 14.45 11 15 11C15.55 11 16 11.45 16 12V16ZM18 19C18 19.551 17.552 20 17 20H7C6.448 20 6 19.551 6 19V8H18V19ZM10 4.328C10 4.173 10.214 4 10.5 4H13.5C13.786 4 14 4.173 14 4.328V6H10V4.328ZM21 6H20H16V4.328C16 3.044 14.879 2 13.5 2H10.5C9.121 2 8 3.044 8 4.328V6H4H3C2.45 6 2 6.45 2 7C2 7.55 2.45 8 3 8H4V19C4 20.654 5.346 22 7 22H17C18.654 22 20 20.654 20 19V8H21C21.55 8 22 7.55 22 7C22 6.45 21.55 6 21 6V6Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 742 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.1523 2.00211C9.25997 1.95883 6.64866 3.15654 4.79688 5.09L2.85352 3.14664C2.65852 2.95164 2.34148 2.95164 2.14648 3.14664C2.04848 3.24364 2 3.37216 2 3.50016V8.00016C2 8.26537 2.10536 8.51973 2.29289 8.70726C2.48043 8.8948 2.73478 9.00016 3 9.00016H7.5C7.628 9.00016 7.75552 8.95067 7.85352 8.85367C8.04852 8.65867 8.04852 8.34164 7.85352 8.14664L6.2168 6.50992C8.02806 4.60134 10.7519 3.56922 13.6914 4.17594C16.7664 4.81094 19.2318 7.30002 19.8398 10.381C20.8498 15.499 16.941 20.0002 12 20.0002C7.881 20.0002 4.47987 16.8704 4.04688 12.8654C3.99287 12.3684 3.5625 12.0002 3.0625 12.0002C2.4625 12.0002 1.9955 12.5253 2.0625 13.1213C2.6215 18.1093 6.866 22.0002 12 22.0002C18.136 22.0002 22.9993 16.4438 21.8223 10.0978C21.0773 6.07481 17.8171 2.86041 13.7871 2.15641C13.2347 2.06003 12.6888 2.01014 12.1523 2.00211Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 951 B

View file

@ -45,3 +45,169 @@ COLORS = {
'Scene:Canvas': (60, 60, 60),
'Scene:Text': (200, 200, 200),
}
def get_welcome_overlay_icon_style():
return 'padding: 12px; margin-bottom: 12px;'
def get_standard_button_style():
highlight = COLORS['Active:Highlight']
text = COLORS['Active:ButtonText']
return (
'QPushButton {'
f'color: rgb({text[0]}, {text[1]}, {text[2]});'
f'background-color: rgba({highlight[0]}, {highlight[1]}, {highlight[2]}, 0.25);'
'padding: 0.6em 2em;'
'border: none;'
'border-radius: 6px;'
'}'
'QPushButton:hover {'
f'background-color: rgba({highlight[0]}, {highlight[1]}, {highlight[2]}, 0.4);'
'}'
'QPushButton:pressed {'
f'background-color: rgba({highlight[0]}, {highlight[1]}, {highlight[2]}, 0.6);'
'}'
)
# Floating Menu Styles
def _css_color(color):
"""Return a CSS-compatible rgb/rgba string for the given color tuple."""
length = len(color)
if length == 3:
r, g, b = color
return f"rgb({r}, {g}, {b})"
if length == 4:
r, g, b, a = color
return f"rgba({r}, {g}, {b}, {a})"
raise ValueError('Color tuples must have 3 (RGB) or 4 (RGBA) components.')
def get_floating_menu_base_style():
"""Base container style for floating menus."""
bg = COLORS['Active:Window']
border = COLORS['Active:Base']
return f"""
QWidget#FloatingMenu {{
background-color: {_css_color(bg)};
border-radius: 8px;
border: 1px solid {_css_color(border)};
}}
"""
def get_floating_menu_button_style():
"""Push button styling for floating menus."""
background_color = (255, 255, 255, 30)
border_color = (255, 255, 255, 10)
inactive = COLORS['Disabled:Text']
active = COLORS['Active:Text']
accent = COLORS['Active:Highlight']
return f"""
QWidget#FloatingMenu QPushButton[floatingButton="true"] {{
background-color: {_css_color(background_color)};
color: {_css_color(inactive)};
border: 1px solid {_css_color(border_color)};
border-radius: 4px;
padding: 4px 10px;
font-weight: 600;
min-width: 12px;
min-height: 28px;
max-height: 28px;
}}
QWidget#FloatingMenu QPushButton[floatingButton="true"]:hover,
QWidget#FloatingMenu QPushButton[floatingButton="true"]:checked,
QWidget#FloatingMenu QPushButton[floatingButton="true"][active="true"] {{
color: {_css_color(active)};
}}
QWidget#FloatingMenu QPushButton[floatingButton="true"]:checked,
QWidget#FloatingMenu QPushButton[floatingButton="true"][active="true"] {{
border-bottom: 2px solid {_css_color(accent)};
}}
"""
def get_floating_menu_separator_style():
"""Separator styling for floating menus."""
border_color = (255, 255, 255, 10)
r, g, b = border_color[:3]
return f"""
QWidget#FloatingMenu QFrame#FloatingMenuSeparator {{
background-color: rgba({r}, {g}, {b}, 30);
min-height: 28px;
max-height: 28px;
}}
"""
def get_floating_menu_combo_style():
"""Combo-box styling for floating menus."""
from beeref.assets import BeeAssets
bg = COLORS['Active:Button']
active_color = COLORS['Active:Text']
border_color = (255, 255, 255, 10)
border_r, border_g, border_b = border_color[:3]
# Получаем путь к иконке стрелки
assets = BeeAssets()
arrow_icon_path = assets.PATH.joinpath('icons', 'small-down.svg')
# Используем прямой путь, экранируя обратные слеши для Windows
arrow_icon_path_str = str(arrow_icon_path).replace('\\', '/')
return f"""
QWidget#FloatingMenu QComboBox,
QWidget#FloatingMenu QFontComboBox {{
background-color: {_css_color(bg)};
color: {_css_color(active_color)};
border: 1px solid rgba({border_r}, {border_g}, {border_b}, 160);
border-radius: 4px;
padding: 4px 12px;
min-width: 40px;
min-height: 28px;
max-height: 28px;
}}
QWidget#FloatingMenu QComboBox::drop-down,
QWidget#FloatingMenu QFontComboBox::drop-down {{
border: none;
width: 16px;
}}
QWidget#FloatingMenu QComboBox::down-arrow,
QWidget#FloatingMenu QFontComboBox::down-arrow {{
image: url({arrow_icon_path_str});
width: 12px;
height: 12px;
margin-right: 4px;
}}
QWidget#FloatingMenu QComboBox QAbstractItemView,
QWidget#FloatingMenu QFontComboBox QAbstractItemView {{
min-width: 152px;
}}
QWidget#FloatingMenu QComboBox#FloatingMenuFontSize QAbstractItemView {{
min-width: 80px;
}}
QWidget#FloatingMenu QComboBox#FloatingMenuGifSpeed QAbstractItemView {{
min-width: 80px;
}}
"""
def get_floating_menu_style():
"""Aggregate stylesheet for floating menus."""
return ''.join([
get_floating_menu_base_style(),
get_floating_menu_button_style(),
get_floating_menu_separator_style(),
get_floating_menu_combo_style(),
])

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):
"""Проверяет, является ли файл GIF."""
if isinstance(path, str):
ext = os.path.splitext(path)[1].lower()
return ext == '.gif'
elif hasattr(path, 'isLocalFile') and path.isLocalFile():
ext = os.path.splitext(path.toLocalFile())[1].lower()
return ext == '.gif'
return False
def exif_rotated_image(path=None):
"""Returns a QImage that is transformed according to the source's
orientation EXIF data.
@ -84,9 +95,14 @@ def exif_rotated_image(path=None):
def load_image(path):
if isinstance(path, str):
path = os.path.normpath(path)
# Проверяем, является ли файл GIF
if is_gif_file(path):
return (None, path) # Возвращаем None для изображения, путь для GIF
return (exif_rotated_image(path), path)
if path.isLocalFile():
path = os.path.normpath(path.toLocalFile())
if is_gif_file(path):
return (None, path)
return (exif_rotated_image(path), path)
url = bytes(path.toEncoded()).decode()

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 # Отступ вокруг линии для увеличения кликабельной области
def __init__(self, path=None, **kwargs):
super().__init__()
self.save_id = None
logger.debug(f'Initialized {self}')
self.is_image = False
self.init_selectable()
self.is_editable = False # Рисование не редактируется через двойной клик
# Настройки пера по умолчанию
self.pen_color = QtGui.QColor(*COLORS['Scene:Text'])
self.pen_width = 8
self.pen_style = 'solid' # 'solid', 'dashed', 'arrow', '<-', '<->'
self._update_pen()
self.setBrush(QtGui.QBrush(QtCore.Qt.BrushStyle.NoBrush))
if path:
self.setPath(path)
def setPath(self, path):
"""Устанавливает путь и обновляет геометрию."""
self.prepareGeometryChange()
super().setPath(path)
self.update()
@classmethod
def create_from_data(cls, **kwargs):
data = kwargs.get('data', {})
item = cls()
# Восстановление пути из данных
if 'path' in data and data['path']:
path = QtGui.QPainterPath()
path_data = data['path']
for i, point_data in enumerate(path_data):
if i == 0:
path.moveTo(point_data['x'], point_data['y'])
else:
path.lineTo(point_data['x'], point_data['y'])
item.setPath(path)
if 'pen_color' in data:
item.pen_color = QtGui.QColor(data['pen_color'])
if 'pen_width' in data:
item.pen_width = data['pen_width']
if 'pen_style' in data:
item.pen_style = data['pen_style']
item._update_pen()
return item
def __str__(self):
return f'Drawing ({self.path().elementCount()} points)'
def get_extra_save_data(self):
"""Сохраняет данные рисунка для сериализации."""
path = self.path()
path_data = []
for i in range(path.elementCount()):
elem = path.elementAt(i)
path_data.append({'x': elem.x, 'y': elem.y})
return {
'path': path_data,
'pen_color': self.pen_color.name(),
'pen_width': self.pen_width,
'pen_style': self.pen_style,
}
def _update_pen(self):
"""Обновляет перо с текущими настройками."""
if self.pen_style == 'dashed':
pen_style = QtCore.Qt.PenStyle.DashLine
else:
pen_style = QtCore.Qt.PenStyle.SolidLine
pen = QtGui.QPen(self.pen_color, self.pen_width,
pen_style,
QtCore.Qt.PenCapStyle.RoundCap,
QtCore.Qt.PenJoinStyle.RoundJoin)
self.setPen(pen)
def set_pen_color(self, color: QtGui.QColor):
"""Устанавливает цвет пера."""
self.pen_color = color
self._update_pen()
self.update()
def set_pen_width(self, width: int):
"""Устанавливает толщину пера."""
self.pen_width = max(1, min(width, 50)) # Ограничение 1-50
self._update_pen()
self.update()
def set_pen_style(self, style: str):
"""Устанавливает стиль линии: 'solid', 'dashed', 'arrow', '<-', '<->'."""
if style in ('solid', 'dashed', 'arrow', '<-', '<->'):
self.pen_style = style
self._update_pen()
self.update()
def create_copy(self):
item = BeeDrawItem()
item.setPath(self.path())
item.setPos(self.pos())
item.setZValue(self.zValue())
item.setScale(self.scale())
item.setRotation(self.rotation())
item.set_pen_color(self.pen_color)
item.set_pen_width(self.pen_width)
item.set_pen_style(self.pen_style)
if self.flip() == -1:
item.do_flip()
return item
def bounding_rect_unselected(self):
"""Возвращает границы элемента без учета выделения."""
path = self.path()
if path.isEmpty():
return QtCore.QRectF()
# Получаем boundingRect напрямую из пути
base_rect = path.boundingRect()
# Добавляем отступ для толщины пера и кликабельной области
margin = (self.pen_width / 2.0) + self.CLICKABLE_PADDING
return base_rect.marginsAdded(
QtCore.QMarginsF(margin, margin, margin, margin))
def shape(self):
"""Возвращает прямоугольную кликабельную область, как в PureRef."""
path = QtGui.QPainterPath()
rect = self.bounding_rect_unselected()
# Если элемент выделен и есть ручки, добавляем области для ручек
if self.has_selection_handles():
margin = self.select_resize_size / 2
rect = rect.marginsAdded(
QtCore.QMarginsF(margin, margin, margin, margin))
path.addRect(rect)
# Добавляем области для ручек поворота в углах
for corner in self.corners:
path.addPath(self.get_rotate_bounds(corner))
else:
path.addRect(rect)
return path
def contains(self, point):
"""Проверяет, попадает ли точка в прямоугольную область линии."""
# Используем boundingRect для прямоугольной области клика
return self.bounding_rect_unselected().contains(point)
def _get_path_end_points(self, path):
"""Получает последние две точки пути для определения направления стрелки."""
if path.elementCount() < 2:
return None, None
# Получаем последнюю точку пути
last_point = path.pointAtPercent(1.0)
# Получаем предпоследнюю точку (близко к концу)
if path.elementCount() >= 2:
prev_point = path.pointAtPercent(0.95) # 95% от пути
else:
prev_point = path.pointAtPercent(0.0)
return prev_point, last_point
def _get_path_start_points(self, path):
"""Получает первые две точки пути для определения направления стрелки."""
if path.elementCount() < 2:
return None, None
# Получаем первую точку пути
first_point = path.pointAtPercent(0.0)
# Получаем вторую точку (близко к началу)
if path.elementCount() >= 2:
second_point = path.pointAtPercent(0.05) # 5% от пути
else:
second_point = path.pointAtPercent(1.0)
return first_point, second_point
def _draw_arrow_right(self, painter, path):
"""Рисует стрелку вправо в конце линии."""
if path.elementCount() < 2:
return
# Получаем последние две точки для определения направления
prev_point, last_point = self._get_path_end_points(path)
if prev_point is None or last_point is None:
return
# Вычисляем направление стрелки
dx = last_point.x() - prev_point.x()
dy = last_point.y() - prev_point.y()
length = (dx * dx + dy * dy) ** 0.5
if length == 0:
return
# Нормализуем вектор направления
dx /= length
dy /= length
# Размер стрелки зависит от толщины линии
arrow_size = max(self.pen_width * 3, 8)
# Угол стрелки
angle = 0.5 # примерно 30 градусов
# Координаты конца стрелки
end_x = last_point.x()
end_y = last_point.y()
# Координаты боковых точек стрелки
perp_x = -dy
perp_y = dx
arrow_x1 = end_x - arrow_size * dx + arrow_size * angle * perp_x
arrow_y1 = end_y - arrow_size * dy + arrow_size * angle * perp_y
arrow_x2 = end_x - arrow_size * dx - arrow_size * angle * perp_x
arrow_y2 = end_y - arrow_size * dy - arrow_size * angle * perp_y
# Рисуем стрелку
arrow_path = QtGui.QPainterPath()
arrow_path.moveTo(end_x, end_y)
arrow_path.lineTo(arrow_x1, arrow_y1)
arrow_path.moveTo(end_x, end_y)
arrow_path.lineTo(arrow_x2, arrow_y2)
painter.setPen(QtGui.QPen(self.pen_color, self.pen_width,
QtCore.Qt.PenStyle.SolidLine,
QtCore.Qt.PenCapStyle.RoundCap,
QtCore.Qt.PenJoinStyle.RoundJoin))
painter.drawPath(arrow_path)
def _draw_arrow_left(self, painter, path):
"""Рисует стрелку влево в начале линии."""
if path.elementCount() < 2:
return
# Получаем первые две точки для определения направления
first_point, second_point = self._get_path_start_points(path)
if first_point is None or second_point is None:
return
# Вычисляем направление стрелки (от второй точки к первой)
dx = first_point.x() - second_point.x()
dy = first_point.y() - second_point.y()
length = (dx * dx + dy * dy) ** 0.5
if length == 0:
return
# Нормализуем вектор направления
dx /= length
dy /= length
# Размер стрелки зависит от толщины линии
arrow_size = max(self.pen_width * 3, 8)
# Угол стрелки
angle = 0.5 # примерно 30 градусов
# Координаты начала стрелки
start_x = first_point.x()
start_y = first_point.y()
# Координаты боковых точек стрелки
perp_x = -dy
perp_y = dx
arrow_x1 = start_x - arrow_size * dx + arrow_size * angle * perp_x
arrow_y1 = start_y - arrow_size * dy + arrow_size * angle * perp_y
arrow_x2 = start_x - arrow_size * dx - arrow_size * angle * perp_x
arrow_y2 = start_y - arrow_size * dy - arrow_size * angle * perp_y
# Рисуем стрелку
arrow_path = QtGui.QPainterPath()
arrow_path.moveTo(start_x, start_y)
arrow_path.lineTo(arrow_x1, arrow_y1)
arrow_path.moveTo(start_x, start_y)
arrow_path.lineTo(arrow_x2, arrow_y2)
painter.setPen(QtGui.QPen(self.pen_color, self.pen_width,
QtCore.Qt.PenStyle.SolidLine,
QtCore.Qt.PenCapStyle.RoundCap,
QtCore.Qt.PenJoinStyle.RoundJoin))
painter.drawPath(arrow_path)
def _draw_arrow_both(self, painter, path):
"""Рисует стрелки в обе стороны линии."""
if path.elementCount() < 2:
return
# Рисуем стрелку вправо (в конце)
prev_point, last_point = self._get_path_end_points(path)
if prev_point is not None and last_point is not None:
dx = last_point.x() - prev_point.x()
dy = last_point.y() - prev_point.y()
length = (dx * dx + dy * dy) ** 0.5
if length > 0:
dx /= length
dy /= length
arrow_size = max(self.pen_width * 3, 8)
angle = 0.5
end_x = last_point.x()
end_y = last_point.y()
perp_x = -dy
perp_y = dx
arrow_x1 = end_x - arrow_size * dx + arrow_size * angle * perp_x
arrow_y1 = end_y - arrow_size * dy + arrow_size * angle * perp_y
arrow_x2 = end_x - arrow_size * dx - arrow_size * angle * perp_x
arrow_y2 = end_y - arrow_size * dy - arrow_size * angle * perp_y
arrow_path = QtGui.QPainterPath()
arrow_path.moveTo(end_x, end_y)
arrow_path.lineTo(arrow_x1, arrow_y1)
arrow_path.moveTo(end_x, end_y)
arrow_path.lineTo(arrow_x2, arrow_y2)
painter.setPen(QtGui.QPen(self.pen_color, self.pen_width,
QtCore.Qt.PenStyle.SolidLine,
QtCore.Qt.PenCapStyle.RoundCap,
QtCore.Qt.PenJoinStyle.RoundJoin))
painter.drawPath(arrow_path)
# Рисуем стрелку влево (в начале)
first_point, second_point = self._get_path_start_points(path)
if first_point is not None and second_point is not None:
dx = first_point.x() - second_point.x()
dy = first_point.y() - second_point.y()
length = (dx * dx + dy * dy) ** 0.5
if length > 0:
dx /= length
dy /= length
arrow_size = max(self.pen_width * 3, 8)
angle = 0.5
start_x = first_point.x()
start_y = first_point.y()
perp_x = -dy
perp_y = dx
arrow_x1 = start_x - arrow_size * dx + arrow_size * angle * perp_x
arrow_y1 = start_y - arrow_size * dy + arrow_size * angle * perp_y
arrow_x2 = start_x - arrow_size * dx - arrow_size * angle * perp_x
arrow_y2 = start_y - arrow_size * dy - arrow_size * angle * perp_y
arrow_path = QtGui.QPainterPath()
arrow_path.moveTo(start_x, start_y)
arrow_path.lineTo(arrow_x1, arrow_y1)
arrow_path.moveTo(start_x, start_y)
arrow_path.lineTo(arrow_x2, arrow_y2)
painter.setPen(QtGui.QPen(self.pen_color, self.pen_width,
QtCore.Qt.PenStyle.SolidLine,
QtCore.Qt.PenCapStyle.RoundCap,
QtCore.Qt.PenJoinStyle.RoundJoin))
painter.drawPath(arrow_path)
def paint(self, painter, option, widget):
"""Отрисовка пути с рамкой выделения."""
# Отключаем стандартную отрисовку Qt для выделенных элементов
option.state &= ~QtWidgets.QStyle.StateFlag.State_Selected
option.state &= ~QtWidgets.QStyle.StateFlag.State_HasFocus
# Рисуем основную линию
super().paint(painter, option, widget)
path = self.path()
# Рисуем стрелку в зависимости от стиля
if self.pen_style == 'arrow':
self._draw_arrow_right(painter, path)
elif self.pen_style == '<-':
self._draw_arrow_left(painter, path)
elif self.pen_style == '<->':
self._draw_arrow_both(painter, path)
self.paint_selectable(painter, option, widget)
def paint_debug(self, painter, option, widget):
"""Переопределяем для полного отключения отладочной информации."""
# Полностью отключаем отладочную информацию для линий
pass
def paint_selectable(self, painter, option, widget):
"""Переопределяем для убирания пунктирной обводки (отладочной информации)."""
# Не вызываем paint_debug, чтобы убрать пунктирную обводку
# self.paint_debug(painter, option, widget)
if not self.has_selection_outline():
return
pen = QtGui.QPen(SELECT_COLOR)
pen.setWidth(self.SELECT_LINE_WIDTH)
pen.setCosmetic(True)
painter.setPen(pen)
painter.setBrush(QtGui.QBrush())
# Draw the main selection rectangle
painter.drawRect(self.bounding_rect_unselected())
# If it's a single selection, draw the handles:
if self.has_selection_handles():
pen.setWidth(self.SELECT_HANDLE_SIZE)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
for corner in self.corners:
painter.drawPoint(corner)
def copy_to_clipboard(self, clipboard):
"""Для рисования копирование не поддерживается."""
pass
@register_item
class BeeErrorItem(BeeItemMixin, QtWidgets.QGraphicsTextItem):
"""Class for displaying error messages when an item can't be loaded
@ -804,3 +1229,8 @@ class BeeErrorItem(BeeItemMixin, QtWidgets.QGraphicsTextItem):
def copy_to_clipboard(self, clipboard):
clipboard.setText(self.toPlainText())
# Импортируем GIF item для регистрации в item_registry
from beeref.gif_item import BeeGifItem # noqa: E402, F401

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,205 @@
"""Floating menu for drawing items."""
from __future__ import annotations
import logging
from typing import Optional, TYPE_CHECKING
from PyQt6 import QtCore, QtGui, QtWidgets
from beeref import constants
from beeref import widgets
from beeref.assets import BeeAssets
from beeref.widgets.floating_menu import FloatingMenu
logger = logging.getLogger(__name__)
if TYPE_CHECKING: # pragma: no cover - type checking only
from beeref.view import BeeGraphicsView
from beeref.items import BeeDrawItem
class UpwardComboBox(QtWidgets.QComboBox):
"""QComboBox that opens its dropdown menu upward."""
def showPopup(self):
"""Override to show popup above the combobox."""
super().showPopup()
# Используем QTimer для получения popup после его создания
QtCore.QTimer.singleShot(0, self._reposition_popup)
def _reposition_popup(self):
"""Перемещает popup выше combobox."""
# Ищем активный popup виджет
popup = QtWidgets.QApplication.activePopupWidget()
if not popup:
# Альтернативный способ - найти через view
view = self.view()
if view:
popup = view.parent()
while popup and not isinstance(popup, QtWidgets.QFrame):
popup = popup.parent()
if popup:
# Получаем глобальную позицию combobox
global_pos = self.mapToGlobal(QtCore.QPoint(0, 0))
# Вычисляем новую позицию выше combobox
popup_height = popup.height()
new_y = global_pos.y() - popup_height
popup.move(global_pos.x(), new_y)
class DrawFloatingMenu(FloatingMenu):
"""Floating menu for drawing tools."""
def __init__(self, parent: QtWidgets.QWidget, view: "BeeGraphicsView"):
super().__init__(parent, view)
self.current_item: Optional["BeeDrawItem"] = None
# Кнопка выбора цвета через colorpicker
assets = BeeAssets()
icons_path = assets.PATH.joinpath('icons')
palette_icon = QtGui.QIcon(str(icons_path.joinpath('palette.svg')))
self.color_btn = self.add_button(
"",
icon=palette_icon,
callback=self._on_color_clicked,
)
self.color_btn.setToolTip("Color")
# Combobox для выбора стиля линии
self.add_separator()
self.style_combo = self.add_style_combobox()
# Селектор толщины пера
self.add_separator()
self.width_slider = self.add_width_slider()
# Кнопка закрытия меню (отмена режима рисования)
self.add_separator()
close_icon = QtGui.QIcon(str(icons_path.joinpath('close.svg')))
self.close_button = self.add_button(
"",
icon=close_icon,
callback=self._on_close_clicked
)
self.close_button.setToolTip("Close")
def add_style_combobox(self):
"""Добавляет combobox для выбора стиля линии."""
assets = BeeAssets()
icons_path = assets.PATH.joinpath('icons')
# Загружаем иконки для стилей
solid_icon = QtGui.QIcon(str(icons_path.joinpath('line-solid.svg')))
dashed_icon = QtGui.QIcon(str(icons_path.joinpath('line-dashed.svg')))
arrow_icon = QtGui.QIcon(str(icons_path.joinpath('line-arrow.svg')))
arrow_left_icon = QtGui.QIcon(str(icons_path.joinpath('line-arrow-left.svg')))
arrow_both_icon = QtGui.QIcon(str(icons_path.joinpath('line-arrow-both.svg')))
combo = UpwardComboBox(self)
combo.setObjectName("FloatingMenuLineStyle")
combo.setMinimumWidth(40)
# Добавляем элементы с иконками
combo.addItem(solid_icon, "", 'solid')
combo.addItem(dashed_icon, "", 'dashed')
combo.addItem(arrow_icon, "", 'arrow')
combo.addItem(arrow_left_icon, "", '<-')
combo.addItem(arrow_both_icon, "", '<->')
# Устанавливаем иконки для элементов
combo.setItemIcon(0, solid_icon)
combo.setItemIcon(1, dashed_icon)
combo.setItemIcon(2, arrow_icon)
combo.setItemIcon(3, arrow_left_icon)
combo.setItemIcon(4, arrow_both_icon)
combo.setCurrentIndex(0) # По умолчанию solid
combo.currentIndexChanged.connect(self._on_style_changed)
combo.setToolTip("Line style")
self.add_widget(combo)
return combo
def _on_color_clicked(self) -> None:
"""Открывает диалог выбора цвета."""
if not self.current_item:
return
initial = self.current_item.pen_color
dialog = widgets.color_picker.ColorPickerDialog(self, initial)
if dialog.exec() != QtWidgets.QDialog.DialogCode.Accepted:
return
color = dialog.selectedColor()
self.set_pen_color(color)
def _on_style_changed(self, index: int) -> None:
"""Обработчик изменения стиля линии."""
if not self.current_item:
return
style = self.style_combo.itemData(index)
if style:
self.set_pen_style(style)
def add_width_slider(self):
"""Добавляет слайдер для выбора толщины пера."""
container = QtWidgets.QWidget()
layout = QtWidgets.QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
label = QtWidgets.QLabel("Width:")
layout.addWidget(label)
slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal)
slider.setRange(1, 20)
slider.setValue(8)
slider.valueChanged.connect(self.set_pen_width)
layout.addWidget(slider)
self.add_widget(container)
return slider
def show_for_item(self, item: "BeeDrawItem") -> None:
"""Показывает меню для выбранного элемента рисования."""
self.current_item = item
if item:
self.width_slider.setValue(item.pen_width)
# Устанавливаем текущий стиль в combobox
style_index = self.style_combo.findData(item.pen_style)
if style_index >= 0:
self.style_combo.blockSignals(True)
self.style_combo.setCurrentIndex(style_index)
self.style_combo.blockSignals(False)
super().show_for_item(item)
def set_pen_color(self, color: QtGui.QColor):
"""Устанавливает цвет пера для выбранного элемента."""
if self.current_item:
self.current_item.set_pen_color(color)
def set_pen_width(self, width: int):
"""Устанавливает толщину пера для выбранного элемента."""
if self.current_item:
self.current_item.set_pen_width(width)
def set_pen_style(self, style: str):
"""Устанавливает стиль линии для выбранного элемента."""
if self.current_item:
self.current_item.set_pen_style(style)
def _on_close_clicked(self) -> None:
"""Закрывает меню и отменяет режим рисования."""
self.view.cancel_drawing_mode()
self.hide_menu()
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:
"""Обрабатывает нажатия клавиш."""
if event.key() == QtCore.Qt.Key.Key_Escape:
# ESC закрывает меню и отменяет режим рисования
self.view.cancel_drawing_mode()
self.hide_menu()
event.accept()
return
super().keyPressEvent(event)

View file

@ -0,0 +1,293 @@
"""Floating menu widgets shown for single item selections."""
from __future__ import annotations
from typing import Optional, TYPE_CHECKING, Callable
from PyQt6 import QtCore, QtGui, QtWidgets
from beeref import constants
if TYPE_CHECKING: # pragma: no cover - type checking only
from beeref.view import BeeGraphicsView
class FloatingMenu(QtWidgets.QWidget):
"""Base widget for floating menus pinned to the bottom centre."""
BOTTOM_MARGIN = 8
CORNER_RADIUS = 8
def __init__(self, parent: QtWidgets.QWidget, view: "BeeGraphicsView"):
# Create as independent window to prevent event blocking
super().__init__(None)
self.setObjectName("FloatingMenu")
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground, True)
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_ShowWithoutActivating, True)
# Make it a tool window that stays on top
self.setWindowFlag(QtCore.Qt.WindowType.FramelessWindowHint, True)
self.setWindowFlag(QtCore.Qt.WindowType.Tool, True)
self.setWindowFlag(QtCore.Qt.WindowType.WindowStaysOnTopHint, True)
# Store parent for positioning
self._parent_widget = parent
self.view = view
self.current_item: Optional[QtWidgets.QGraphicsItem] = None
# Cache for update_position optimization
self._cached_position: Optional[QtCore.QPoint] = None
self._cached_viewport_size: Optional[QtCore.QSize] = None
self._cached_window_pos: Optional[QtCore.QPoint] = None
# Таймер для отслеживания перемещения главного окна
self._position_timer = QtCore.QTimer(self)
self._position_timer.timeout.connect(self._check_window_position)
self._position_timer.setInterval(50) # Проверка каждые 50мс
# Main layout with uniform spacing
self._layout = QtWidgets.QHBoxLayout(self)
self._layout.setContentsMargins(8, 6, 8, 6)
self._layout.setSpacing(6)
self._layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.setFocusPolicy(QtCore.Qt.FocusPolicy.NoFocus)
self.setStyleSheet(constants.get_floating_menu_style())
self.hide()
def _check_window_position(self) -> None:
"""Проверяет позицию viewport и обновляет позицию меню при необходимости."""
if not self.isVisible():
return
view = self.view
if view is None:
return
viewport = view.viewport()
if viewport is None:
return
# Получаем позицию viewport в глобальных координатах
view_rect = viewport.rect()
current_viewport_pos = viewport.mapToGlobal(view_rect.topLeft())
# Если позиция viewport изменилась, обновляем позицию меню
if self._cached_window_pos is not None and self._cached_window_pos != current_viewport_pos:
self.update_position()
self._cached_window_pos = current_viewport_pos
def _apply_rounded_mask(self) -> None:
"""
Применяет закругленную маску к виджету с антиалиасингом.
Реализация на основе подхода из статьи VK Teams.
"""
size = self.size()
if size.width() == 0 or size.height() == 0:
return
# Для более плавного сглаживания используем QBitmap с антиалиасингом
# Создаем изображение с увеличенным разрешением (как в статье)
scale_factor = 2
scaled_size = QtCore.QSize(
int(size.width() * scale_factor),
int(size.height() * scale_factor)
)
# Создаем QPixmap для рисования с антиалиасингом
pixmap = QtGui.QPixmap(scaled_size)
pixmap.fill(QtCore.Qt.GlobalColor.transparent)
painter = QtGui.QPainter(pixmap)
painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, True)
painter.setPen(QtCore.Qt.PenStyle.NoPen)
painter.setBrush(QtCore.Qt.GlobalColor.black) # Черный для маски
# Рисуем закругленный прямоугольник на увеличенном разрешении
scaled_rect = QtCore.QRectF(0, 0, scaled_size.width(), scaled_size.height())
scaled_radius = self.CORNER_RADIUS * scale_factor
painter.drawRoundedRect(scaled_rect, scaled_radius, scaled_radius)
painter.end()
# Масштабируем обратно с сглаживанием
pixmap = pixmap.scaled(
size,
QtCore.Qt.AspectRatioMode.IgnoreAspectRatio,
QtCore.Qt.TransformationMode.SmoothTransformation
)
# Преобразуем в QBitmap для маски
# В маске: непрозрачные пиксели = видимые области, прозрачные = невидимые
image = pixmap.toImage()
# Создаем маску из непрозрачных пикселей
bitmap = QtGui.QBitmap.fromImage(image.createAlphaMask())
# Применяем маску
self.setMask(bitmap)
def add_widget(self, widget: QtWidgets.QWidget) -> QtWidgets.QWidget:
widget.setParent(self)
self._layout.addWidget(widget)
return widget
def add_button(
self,
text: str,
icon: Optional[QtGui.QIcon] = None,
callback: Optional[Callable[[], None]] = None,
checkable: bool = False,
) -> QtWidgets.QPushButton:
button = QtWidgets.QPushButton(text, self)
button.setCheckable(checkable)
if icon:
button.setIcon(icon)
button.setProperty("floatingButton", True)
if callback:
# Connect directly - button events are handled independently
button.clicked.connect(callback)
self._layout.addWidget(button)
return button
def add_separator(self) -> None:
separator = QtWidgets.QFrame(self)
separator.setObjectName("FloatingMenuSeparator")
separator.setFrameShape(QtWidgets.QFrame.Shape.VLine)
separator.setFrameShadow(QtWidgets.QFrame.Shadow.Plain)
separator.setFixedWidth(1)
self._layout.addWidget(separator)
def show_for_item(self, item: QtWidgets.QGraphicsItem) -> None:
self.current_item = item
self.show_menu()
def show_menu(self) -> None:
self.adjustSize()
self._apply_rounded_mask()
self.show()
self.raise_()
# Reset cache when showing menu to ensure position update
self._cached_position = None
self._cached_viewport_size = None
self._cached_window_pos = None
self.update_position()
# Запускаем таймер для отслеживания перемещения окна
self._position_timer.start()
# Ensure menu is on top after positioning
self.raise_()
# Возвращаем фокус view, чтобы события клавиатуры обрабатывались правильно
# FloatingMenu не должен перехватывать фокус, так как он имеет NoFocus
if self.view:
self.view.setFocus()
def hide_menu(self) -> None:
self.current_item = None
# Останавливаем таймер
self._position_timer.stop()
# Clear cache when hiding menu
self._cached_position = None
self._cached_viewport_size = None
self._cached_window_pos = None
self.hide()
def update_position(self) -> None:
if not self.isVisible():
return
parent = self._parent_widget
view = self.view
if parent is None or view is None:
return
viewport = view.viewport()
if viewport is None:
return
view_rect = viewport.rect()
viewport_size = view_rect.size()
# Получаем текущую позицию viewport для проверки изменений
current_viewport_pos = viewport.mapToGlobal(view_rect.topLeft())
# Check if viewport size and position changed
if (self._cached_viewport_size is not None and
self._cached_viewport_size == viewport_size and
self._cached_window_pos is not None and
self._cached_window_pos == current_viewport_pos and
self._cached_position is not None):
# If viewport size and position haven't changed, check menu position
current_pos = self.pos()
if current_pos == self._cached_position:
# Position hasn't changed, skip update
return
size = self.sizeHint()
width = self.width() or size.width()
height = self.height() or size.height()
# Calculate position in global coordinates (independent window)
top_left_global = viewport.mapToGlobal(view_rect.topLeft())
bottom_left_global = viewport.mapToGlobal(view_rect.bottomLeft())
bottom_right_global = viewport.mapToGlobal(view_rect.bottomRight())
top_right_global = viewport.mapToGlobal(view_rect.topRight())
# Получаем границы viewport в глобальных координатах
viewport_left = top_left_global.x()
viewport_right = top_right_global.x()
viewport_top = top_left_global.y()
viewport_bottom = bottom_left_global.y()
viewport_width = viewport_right - viewport_left
# Вычисляем позицию по X (центрируем, но не выходим за границы)
x = viewport_left + max(0, (viewport_width - width) // 2)
# Ограничиваем, чтобы меню не выходило за левую и правую границы
x = max(viewport_left, min(x, viewport_right - width))
# Вычисляем позицию по Y (снизу с отступом)
y = viewport_bottom - height - self.BOTTOM_MARGIN
# Ограничиваем, чтобы меню не выходило за верхнюю границу
# Если меню не помещается снизу, размещаем его сверху
if y < viewport_top:
y = viewport_top + self.BOTTOM_MARGIN
# Также проверяем, что меню не выходит за нижнюю границу
if y + height > viewport_bottom:
y = viewport_bottom - height - self.BOTTOM_MARGIN
# Если и так не помещается, размещаем сверху
if y < viewport_top:
y = viewport_top + self.BOTTOM_MARGIN
new_position = QtCore.QPoint(x, y)
# Update position only if it actually changed
if self._cached_position != new_position:
self.move(new_position)
self._cached_position = new_position
self._cached_viewport_size = viewport_size
self._cached_window_pos = current_viewport_pos
# Обновляем маску после изменения размера/позиции
self._apply_rounded_mask()
def parentWidget(self):
"""Override to return stored parent widget for compatibility."""
return self._parent_widget
def mousePressEvent(self, event: QtGui.QMouseEvent) -> None:
"""Intercept mouse events to prevent them from reaching view."""
# Accept event to prevent propagation
event.accept()
super().mousePressEvent(event)
def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None:
"""Intercept mouse events to prevent them from reaching view."""
event.accept()
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event: QtGui.QMouseEvent) -> None:
"""Intercept mouse events to prevent them from reaching view."""
event.accept()
super().mouseReleaseEvent(event)
def resizeEvent(self, event: QtGui.QResizeEvent) -> None:
"""Обновляет маску при изменении размера виджета."""
super().resizeEvent(event)
self._apply_rounded_mask()

View file

@ -0,0 +1,231 @@
"""Floating menu shown when a single GIF item is selected."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Optional
from PyQt6 import QtCore, QtGui, QtWidgets
from beeref.assets import BeeAssets
from beeref.widgets.floating_menu import FloatingMenu
logger = logging.getLogger(__name__)
if TYPE_CHECKING: # pragma: no cover
from beeref.view import BeeGraphicsView
from beeref.gif_item import BeeGifItem
from beeref.widgets.gif_frames_menu import GifFramesMenu
class GifFloatingMenu(FloatingMenu):
"""Contextual floating toolbar for GIF items."""
SPEED_VALUES = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
def __init__(self, parent: QtWidgets.QWidget, view: "BeeGraphicsView"):
super().__init__(parent, view)
self._init_frames_menu(parent, view)
self._init_icons()
self._init_speed_combobox()
self._init_control_buttons()
def _init_frames_menu(
self,
parent: QtWidgets.QWidget,
view: "BeeGraphicsView"
) -> None:
"""Initializes frames menu."""
self.frames_menu: Optional["GifFramesMenu"] = None
try:
from beeref.widgets.gif_frames_menu import GifFramesMenu
self.frames_menu = GifFramesMenu(parent, view, self)
except ImportError as e:
logger.warning(f'Failed to import frames menu: {e}')
except Exception as e:
logger.warning(f'Failed to initialize frames menu: {e}')
def _init_icons(self) -> None:
"""Loads icons for control buttons."""
assets = BeeAssets()
icons_path = assets.PATH.joinpath('icons')
self.prev_frame_icon = QtGui.QIcon(
str(icons_path.joinpath('prev-frame.svg')))
self.play_icon = QtGui.QIcon(str(icons_path.joinpath('play.svg')))
self.pause_icon = QtGui.QIcon(str(icons_path.joinpath('pause.svg')))
self.next_frame_icon = QtGui.QIcon(
str(icons_path.joinpath('next-frame.svg')))
self.frames_icon = QtGui.QIcon(str(icons_path.joinpath('frames.svg')))
def _init_speed_combobox(self) -> None:
"""Initializes combobox for playback speed selection."""
self.speed_combo = QtWidgets.QComboBox(self)
self.speed_combo.setObjectName("FloatingMenuGifSpeed")
self.speed_combo.setMinimumWidth(40)
for speed in self.SPEED_VALUES:
label = self._format_speed_label(speed)
self.speed_combo.addItem(label, speed)
# Set default value (1.0x)
default_index = self.SPEED_VALUES.index(1.0)
self.speed_combo.setCurrentIndex(default_index)
self.speed_combo.currentIndexChanged.connect(self._on_speed_changed)
self.speed_combo.setToolTip("Playback speed")
self.add_widget(self.speed_combo)
def _format_speed_label(self, speed: float) -> str:
"""Formats speed value for display in combobox."""
if speed < 1.0:
return f"{speed:.2f}x"
else:
speed_str = f"{speed:.2f}".rstrip('0').rstrip('.')
return f"{speed_str}x"
def _init_control_buttons(self) -> None:
"""Initializes playback control buttons."""
self.prev_frame_btn = self.add_button(
"",
icon=self.prev_frame_icon,
callback=self.on_previous_frame,
)
self.prev_frame_btn.setToolTip("Previous frame")
self.play_pause_btn = self.add_button(
"",
icon=self.play_icon,
callback=self.on_toggle_play_pause,
)
self.play_pause_btn.setToolTip("Play/Pause")
self.next_frame_btn = self.add_button(
"",
icon=self.next_frame_icon,
callback=self.on_next_frame,
)
self.next_frame_btn.setToolTip("Next frame")
self.frames_btn = self.add_button(
"",
icon=self.frames_icon,
callback=self.on_toggle_frames_menu,
)
self.frames_btn.setToolTip("Show frames timeline")
def show_for_item(self, item: "BeeGifItem") -> None:
"""Shows menu for specified GIF item."""
super().show_for_item(item)
self.update_play_pause_button()
self.update_speed_combo()
self._hide_frames_menu()
def _hide_frames_menu(self) -> None:
"""Hides frames menu if it's open."""
if self.frames_menu:
self.frames_menu.hide_menu()
def update_speed_combo(self) -> None:
"""Updates speed value in combobox."""
if not self._has_gif_item():
return
current_speed = self.current_item.get_speed()
closest_speed = min(
self.SPEED_VALUES,
key=lambda x: abs(x - current_speed)
)
index = self.SPEED_VALUES.index(closest_speed)
self.speed_combo.blockSignals(True)
self.speed_combo.setCurrentIndex(index)
self.speed_combo.blockSignals(False)
def _on_speed_changed(self, index: int) -> None:
"""Handler for playback speed change."""
if not self._has_gif_item():
return
speed = self.speed_combo.currentData()
if speed is not None:
self.current_item.set_speed(speed)
def update_play_pause_button(self) -> None:
"""Updates Play/Pause button icon."""
if not self._has_gif_item():
return
if self.current_item.is_playing:
self.play_pause_btn.setIcon(self.pause_icon)
self.play_pause_btn.setToolTip("Pause")
else:
self.play_pause_btn.setIcon(self.play_icon)
self.play_pause_btn.setToolTip("Play")
def on_toggle_play_pause(self) -> None:
"""Toggles GIF play/pause."""
if not self._has_gif_item():
return
# Defer execution so button can process event
def do_toggle():
self.current_item.toggle_animation()
self.update_play_pause_button()
QtCore.QTimer.singleShot(0, do_toggle)
def on_previous_frame(self) -> None:
"""Goes to previous frame."""
if not self._has_gif_item():
return
# Defer execution so button can process event
def do_previous():
self.current_item.previous_frame()
self.update_play_pause_button()
self._update_frames_menu_if_visible()
QtCore.QTimer.singleShot(0, do_previous)
def on_next_frame(self) -> None:
"""Goes to next frame."""
if not self._has_gif_item():
return
# Defer execution so button can process event
def do_next():
self.current_item.next_frame()
self.update_play_pause_button()
self._update_frames_menu_if_visible()
QtCore.QTimer.singleShot(0, do_next)
def on_toggle_frames_menu(self) -> None:
"""Toggles frames menu visibility."""
if not self._has_gif_item() or not self.frames_menu:
return
self.frames_menu.toggle_menu(self.current_item)
def hide_menu(self) -> None:
"""Hides menu and frames menu."""
self._hide_frames_menu()
super().hide_menu()
def update_position(self) -> None:
"""Updates position of menu and frames menu."""
super().update_position()
if self.frames_menu and self.frames_menu.isVisible():
self.frames_menu.update_position()
def _has_gif_item(self) -> bool:
"""Checks if current item is a GIF item."""
return (self.current_item is not None and
hasattr(self.current_item, 'is_playing'))
def _update_frames_menu_if_visible(self) -> None:
"""Updates frames menu if it's visible."""
if self.frames_menu and self.frames_menu.isVisible():
self.frames_menu.load_frames(self.current_item)

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,185 @@
"""Floating menu shown when a single text item is selected."""
from __future__ import annotations
from typing import TYPE_CHECKING
from PyQt6 import QtGui, QtWidgets
from beeref.assets import BeeAssets
from beeref.widgets.floating_menu import FloatingMenu
if TYPE_CHECKING:
from beeref.view import BeeGraphicsView
from beeref.items import BeeTextItem
class TextFloatingMenu(FloatingMenu):
"""Contextual floating toolbar for text items."""
FONT_SIZES = [8, 10, 12, 14, 16, 18, 24, 32, 48]
def __init__(self, parent: QtWidgets.QWidget, view: "BeeGraphicsView"):
super().__init__(parent, view)
# Load icons
assets = BeeAssets()
icons_path = assets.PATH.joinpath('icons')
text_color_icon = QtGui.QIcon(str(icons_path.joinpath('format-color-text.svg')))
palette_icon = QtGui.QIcon(str(icons_path.joinpath('palette.svg')))
bold_icon = QtGui.QIcon(str(icons_path.joinpath('format-bold.svg')))
italic_icon = QtGui.QIcon(str(icons_path.joinpath('format-italic.svg')))
underline_icon = QtGui.QIcon(str(icons_path.joinpath('format-underline.svg')))
strikethrough_icon = QtGui.QIcon(str(icons_path.joinpath('format-strikethrough.svg')))
reset_icon = QtGui.QIcon(str(icons_path.joinpath('undo.svg')))
fonts_icon = QtGui.QIcon(str(icons_path.joinpath('fonts.svg')))
self.text_color_btn = self.add_button(
"",
icon=text_color_icon,
callback=self._on_text_color_clicked,
)
self.background_btn = self.add_button(
"",
icon=palette_icon,
callback=self._on_background_clicked,
)
self.add_separator()
self.bold_btn = self.add_button(
"",
icon=bold_icon,
callback=self._on_bold_clicked,
checkable=True,
)
self.italic_btn = self.add_button(
"",
icon=italic_icon,
callback=self._on_italic_clicked,
checkable=True,
)
self.underline_btn = self.add_button(
"",
icon=underline_icon,
callback=self._on_underline_clicked,
checkable=True,
)
self.strikethrough_btn = self.add_button(
"",
icon=strikethrough_icon,
callback=self._on_strikethrough_clicked,
checkable=True,
)
self.add_separator()
self.size_combo = QtWidgets.QComboBox(self)
self.size_combo.setObjectName("FloatingMenuFontSize")
self.size_combo.setMinimumWidth(40)
for size in self.FONT_SIZES:
self.size_combo.addItem(str(size), size)
self.size_combo.currentIndexChanged.connect(self._on_size_changed)
self.add_widget(self.size_combo)
self.font_combo = QtWidgets.QComboBox(self)
self.font_combo.setObjectName("FloatingMenuFontFamily")
self.font_combo.setMinimumWidth(40)
self.font_combo.setEditable(False)
self.font_combo.setInsertPolicy(
QtWidgets.QComboBox.InsertPolicy.NoInsert)
families = QtGui.QFontDatabase.families()
self.font_combo.addItems(families)
# Add icon to all font items
for i in range(self.font_combo.count()):
self.font_combo.setItemIcon(i, fonts_icon)
self.font_combo.currentTextChanged.connect(self._on_font_changed)
self.add_widget(self.font_combo)
self.add_separator()
self.add_button(
"",
icon=reset_icon,
callback=self.view.reset_selected_text_format,
)
# ------------------------------------------------------------------
def show_for_item(self, item: "BeeTextItem") -> None:
font = item.font()
self._update_font_controls(font)
self._update_colors(item)
super().show_for_item(item)
# UI updates -------------------------------------------------------
def _update_font_controls(self, font: QtGui.QFont) -> None:
self.bold_btn.setChecked(font.weight() >= QtGui.QFont.Weight.Bold)
self.italic_btn.setChecked(font.italic())
self.underline_btn.setChecked(font.underline())
self.strikethrough_btn.setChecked(font.strikeOut())
size = font.pointSize()
if size == -1:
size = int(font.pointSizeF())
try:
index = self.FONT_SIZES.index(size)
except ValueError:
index = -1
self.size_combo.blockSignals(True)
if index >= 0:
self.size_combo.setCurrentIndex(index)
else:
self.size_combo.setCurrentText(str(size))
self.size_combo.blockSignals(False)
self.font_combo.blockSignals(True)
family = font.family()
index = self.font_combo.findText(family)
if index >= 0:
self.font_combo.setCurrentIndex(index)
self.font_combo.blockSignals(False)
def _update_colors(self, item: "BeeTextItem") -> None:
text_color = item.defaultTextColor()
self.text_color_btn.setProperty(
"active", "true" if text_color else "false")
self.text_color_btn.style().unpolish(self.text_color_btn)
self.text_color_btn.style().polish(self.text_color_btn)
bg_color = getattr(item, "background_color", None)
self.background_btn.setProperty(
"active", "true" if bg_color and bg_color.alpha() > 0 else "false")
self.background_btn.style().unpolish(self.background_btn)
self.background_btn.style().polish(self.background_btn)
# Slots ------------------------------------------------------------
def _on_text_color_clicked(self) -> None:
self.view.change_selected_text_color()
def _on_background_clicked(self) -> None:
self.view.change_selected_text_background()
def _on_bold_clicked(self) -> None:
self.view.toggle_selected_text_bold()
def _on_italic_clicked(self) -> None:
self.view.toggle_selected_text_italic()
def _on_underline_clicked(self) -> None:
self.view.toggle_selected_text_underline()
def _on_strikethrough_clicked(self) -> None:
self.view.toggle_selected_text_strikethrough()
def _on_size_changed(self, index: int) -> None:
size = self.size_combo.currentData()
if size is None:
try:
size = int(self.size_combo.currentText())
except ValueError:
return
self.view.change_selected_text_size(size)
def _on_font_changed(self, family: str) -> None:
if not family:
return
self.view.change_selected_text_font(family)

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)