Merge c8b600a3c4 into caed5c3801
6
.gitignore
vendored
|
|
@ -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
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
@ -64,6 +64,15 @@ class BeeRefMainWindow(QtWidgets.QMainWindow):
|
|||
self.show()
|
||||
|
||||
def closeEvent(self, event):
|
||||
# Check for unsaved changes
|
||||
confirm = self.view.get_confirmation_unsaved_changes(
|
||||
'There are unsaved changes. Are you sure you want to quit?')
|
||||
if not confirm:
|
||||
# User cancelled closing
|
||||
event.ignore()
|
||||
return
|
||||
|
||||
# Save window geometry
|
||||
geom = self.saveGeometry()
|
||||
self.view.settings.setValue('MainWindow/geometry', geom)
|
||||
event.accept()
|
||||
|
|
@ -109,9 +118,11 @@ def main():
|
|||
logger.info(f'Logging to: {logfile_name()}')
|
||||
settings.on_startup()
|
||||
args = CommandlineArgs(with_check=True) # Force checking
|
||||
assert not args.debug_raise_error, args.debug_raise_error
|
||||
if args.debug_raise_error:
|
||||
raise RuntimeError(args.debug_raise_error)
|
||||
|
||||
os.environ["QT_DEBUG_PLUGINS"] = "1"
|
||||
if args.loglevel == 'DEBUG':
|
||||
os.environ["QT_DEBUG_PLUGINS"] = "1"
|
||||
app = BeeRefApplication(sys.argv)
|
||||
palette = create_palette_from_dict(constants.COLORS)
|
||||
app.setPalette(palette)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
),
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ menu_structure = [
|
|||
'items': [
|
||||
'insert_images',
|
||||
'insert_text',
|
||||
'insert_draw',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
from importlib.resources import files as rsc_files
|
||||
import logging
|
||||
|
||||
from PyQt6 import QtGui, QtWidgets
|
||||
from PyQt6 import QtGui, QtWidgets, QtSvg
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -45,6 +45,8 @@ class BeeAssets:
|
|||
'cursor_flip_h.png', (20, 20))
|
||||
self.cursor_flip_v = self.cursor_from_image(
|
||||
'cursor_flip_v.png', (20, 20))
|
||||
self.cursor_draw_line = self.cursor_from_svg(
|
||||
'icons/draw-line.svg', (12, 12))
|
||||
|
||||
def cursor_from_image(self, filename, hotspot):
|
||||
app = QtWidgets.QApplication.instance()
|
||||
|
|
@ -55,3 +57,26 @@ class BeeAssets:
|
|||
pixmap.setDevicePixelRatio(scaling)
|
||||
return QtGui.QCursor(
|
||||
pixmap, int(hotspot[0]/scaling), int(hotspot[1]/scaling))
|
||||
|
||||
def cursor_from_svg(self, filename, hotspot, size=24):
|
||||
"""Creates cursor from SVG file."""
|
||||
app = QtWidgets.QApplication.instance()
|
||||
scaling = app.primaryScreen().devicePixelRatio()
|
||||
|
||||
# Load SVG
|
||||
svg_path = str(self.PATH.joinpath(filename))
|
||||
renderer = QtSvg.QSvgRenderer(svg_path)
|
||||
|
||||
# Create pixmap of required size
|
||||
pixmap_size = int(size * scaling)
|
||||
pixmap = QtGui.QPixmap(pixmap_size, pixmap_size)
|
||||
pixmap.fill(QtGui.QColor(0, 0, 0, 0)) # Transparent background
|
||||
|
||||
# Render SVG to pixmap
|
||||
painter = QtGui.QPainter(pixmap)
|
||||
renderer.render(painter)
|
||||
painter.end()
|
||||
|
||||
pixmap.setDevicePixelRatio(scaling)
|
||||
return QtGui.QCursor(
|
||||
pixmap, int(hotspot[0]/scaling), int(hotspot[1]/scaling))
|
||||
|
|
|
|||
4
beeref/assets/icons/clear.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16 4C21.5228 4 25.5 5.5 25.5 7C25.5 9 22 25 22 25C21.5 26.5 19.3137 27.5 16 27.5C12.6863 27.5 10.5 26.5 9.99999 25C9.99999 25 6.5 9 6.5 7C6.5 5.34315 10.4771 4 16 4Z" fill="white" fill-opacity="0.8"/>
|
||||
<path d="M25 7C25 8.10457 20.9706 9 16 9C11.0294 9 7 8.10457 7 7C7 5.89543 11.0294 4.5 16 4.5C20.9706 4.5 25 5.89543 25 7Z" fill="black" fill-opacity="0.48"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 473 B |
4
beeref/assets/icons/close.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.80762 25.1924L25.1924 6.80761" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M25.1924 25.1924L6.80761 6.80761" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 303 B |
3
beeref/assets/icons/crop.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 2C9.55228 2 10 2.44772 10 3V8H22C23.1046 8 24 8.89543 24 10V22H29C29.5523 22 30 22.4477 30 23C30 23.5523 29.5523 24 29 24H24V29C24 29.5523 23.5523 30 23 30C22.4477 30 22 29.5523 22 29V24H10C8.89543 24 8 23.1046 8 22V10H3C2.44772 10 2 9.55228 2 9C2 8.44772 2.44772 8 3 8H8V3C8 2.44772 8.44772 2 9 2ZM10 22H22V10H10V22Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 449 B |
5
beeref/assets/icons/drag-and-drop.svg
Normal 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 |
4
beeref/assets/icons/draw-line.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.09114 4.004C5.06014 4.001 5.03014 4 5.00014 4C4.73614 4 4.48114 4.104 4.29314 4.293C4.08314 4.503 3.97714 4.795 4.00414 5.09L4.38314 9.261C4.42514 9.718 4.62714 10.149 4.95214 10.474L13.9481 19.471C14.6501 20.175 15.9241 20.14 16.6641 19.401L19.4021 16.663C20.1681 15.896 20.1991 14.679 19.4711 13.949L10.4741 4.952C10.1491 4.626 9.71914 4.425 9.26114 4.383L5.09114 4.004Z" fill="white" stroke="black" stroke-width="2"/>
|
||||
<path d="M12 17.5L17.5 12" stroke="black" stroke-width="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 637 B |
4
beeref/assets/icons/flip_h.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 8.5C2.44772 8.5 2 8.94772 2 9.5C2 10.0523 2.44772 10.5 3 10.5V9.5V8.5ZM29.7071 10.2071C30.0976 9.81658 30.0976 9.18342 29.7071 8.79289L23.3431 2.42893C22.9526 2.03841 22.3195 2.03841 21.9289 2.42893C21.5384 2.81946 21.5384 3.45262 21.9289 3.84315L27.5858 9.5L21.9289 15.1569C21.5384 15.5474 21.5384 16.1805 21.9289 16.5711C22.3195 16.9616 22.9526 16.9616 23.3431 16.5711L29.7071 10.2071ZM3 9.5V10.5H29V9.5V8.5H3V9.5Z" fill="white"/>
|
||||
<path d="M29 21.5C29.5523 21.5 30 21.9477 30 22.5C30 23.0523 29.5523 23.5 29 23.5V22.5V21.5ZM2.29289 23.2071C1.90237 22.8166 1.90237 22.1834 2.29289 21.7929L8.65685 15.4289C9.04738 15.0384 9.68054 15.0384 10.0711 15.4289C10.4616 15.8195 10.4616 16.4526 10.0711 16.8431L4.41421 22.5L10.0711 28.1569C10.4616 28.5474 10.4616 29.1805 10.0711 29.5711C9.68054 29.9616 9.04738 29.9616 8.65685 29.5711L2.29289 23.2071ZM29 22.5V23.5H3V22.5V21.5H29V22.5Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1,009 B |
4
beeref/assets/icons/flip_v.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.5 29C8.5 29.5523 8.94772 30 9.5 30C10.0523 30 10.5 29.5523 10.5 29H9.5H8.5ZM10.2071 2.29289C9.81658 1.90237 9.18342 1.90237 8.79289 2.29289L2.42893 8.65685C2.03841 9.04738 2.03841 9.68054 2.42893 10.0711C2.81946 10.4616 3.45262 10.4616 3.84315 10.0711L9.5 4.41421L15.1569 10.0711C15.5474 10.4616 16.1805 10.4616 16.5711 10.0711C16.9616 9.68054 16.9616 9.04738 16.5711 8.65685L10.2071 2.29289ZM9.5 29H10.5L10.5 3H9.5H8.5L8.5 29H9.5Z" fill="white"/>
|
||||
<path d="M21.5 3C21.5 2.44772 21.9477 2 22.5 2C23.0523 2 23.5 2.44772 23.5 3H22.5H21.5ZM23.2071 29.7071C22.8166 30.0976 22.1834 30.0976 21.7929 29.7071L15.4289 23.3431C15.0384 22.9526 15.0384 22.3195 15.4289 21.9289C15.8195 21.5384 16.4526 21.5384 16.8431 21.9289L22.5 27.5858L28.1569 21.9289C28.5474 21.5384 29.1805 21.5384 29.5711 21.9289C29.9616 22.3195 29.9616 22.9526 29.5711 23.3431L23.2071 29.7071ZM22.5 3H23.5V29H22.5H21.5V3H22.5Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1,018 B |
3
beeref/assets/icons/fonts.svg
Normal 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 |
3
beeref/assets/icons/format-bold.svg
Normal 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 |
3
beeref/assets/icons/format-color-text.svg
Normal 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 |
3
beeref/assets/icons/format-italic.svg
Normal 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 |
4
beeref/assets/icons/format-strikethrough.svg
Normal 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 |
3
beeref/assets/icons/format-underline.svg
Normal 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 |
5
beeref/assets/icons/frames.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.0471 13.3823C18.0188 12.5505 18.5085 11.7881 19.2767 11.468L28.6154 7.57692C29.2741 7.30247 30 7.78642 30 8.5V20.908C30 21.6584 29.5799 22.3457 28.9121 22.6879L19.9472 27.2817C19.295 27.6159 18.5168 27.1582 18.4918 26.4258L18.0471 13.3823Z" fill="white"/>
|
||||
<path d="M10.0471 11.3823C10.0188 10.5505 10.5085 9.78813 11.2767 9.46803L20.6154 5.57692C21.2741 5.30247 22 5.78642 22 6.5V18.908C22 19.6584 21.5799 20.3457 20.9121 20.6879L11.9472 25.2817C11.295 25.6159 10.5168 25.1582 10.4918 24.4258L10.0471 11.3823Z" fill="white" fill-opacity="0.8"/>
|
||||
<path d="M2.04712 9.38232C2.01877 8.55054 2.50849 7.78813 3.27673 7.46803L12.6154 3.57692C13.2741 3.30247 14 3.78642 14 4.5V16.908C14 17.6584 13.5799 18.3457 12.9121 18.6879L3.94724 23.2817C3.29504 23.6159 2.51676 23.1582 2.49179 22.4258L2.04712 9.38232Z" fill="white" fill-opacity="0.32"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 952 B |
12
beeref/assets/icons/gamut.svg
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_34_16)">
|
||||
<path d="M2 4C2 2.89543 2.89543 2 4 2H12C13.1046 2 14 2.89543 14 4V24C14 27.3137 11.3137 30 8 30C4.68629 30 2 27.3137 2 24V4Z" fill="white"/>
|
||||
<path d="M18.3848 5.41421C19.1658 4.63317 20.4322 4.63317 21.2132 5.41421L26.8701 11.0711C27.6511 11.8521 27.6511 13.1184 26.8701 13.8995L12.7279 28.0416C10.3848 30.3848 6.58579 30.3848 4.24265 28.0416C1.8995 25.6985 1.8995 21.8995 4.24265 19.5563L18.3848 5.41421Z" fill="white" fill-opacity="0.64"/>
|
||||
<path d="M28 18C29.1046 18 30 18.8954 30 20V28C30 29.1046 29.1046 30 28 30L12.5 30L25 18H28Z" fill="white" fill-opacity="0.32"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_34_16">
|
||||
<rect width="32" height="32" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 813 B |
20
beeref/assets/icons/grayscale.svg
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16 2C23.732 2 30 8.26801 30 16C30 23.732 23.732 30 16 30C8.26801 30 2 23.732 2 16C2 8.26801 8.26801 2 16 2ZM16 29C23.1797 29 29 23.1797 29 16C29 8.8203 23.1797 3 16 3V29Z" fill="white"/>
|
||||
<path d="M16 16.9286C16 9.74887 16 3 16 3C23.1797 3 29 8.8203 29 16C29 23.1797 23.1797 29 16 29C16 29 16 24.1083 16 16.9286Z" fill="white" fill-opacity="0.24"/>
|
||||
<circle cx="18" cy="6" r="1" fill="white" fill-opacity="0.8"/>
|
||||
<circle cx="18" cy="10" r="1" fill="white" fill-opacity="0.8"/>
|
||||
<circle cx="22" cy="10" r="1" fill="white" fill-opacity="0.8"/>
|
||||
<circle cx="22" cy="6" r="1" fill="white" fill-opacity="0.8"/>
|
||||
<circle cx="18" cy="14" r="1" fill="white" fill-opacity="0.8"/>
|
||||
<circle cx="22" cy="14" r="1" fill="white" fill-opacity="0.8"/>
|
||||
<circle cx="26" cy="14" r="1" fill="white" fill-opacity="0.8"/>
|
||||
<circle cx="26" cy="10" r="1" fill="white" fill-opacity="0.8"/>
|
||||
<circle cx="18" cy="18" r="1" fill="white" fill-opacity="0.8"/>
|
||||
<circle cx="22" cy="18" r="1" fill="white" fill-opacity="0.8"/>
|
||||
<circle cx="26" cy="18" r="1" fill="white" fill-opacity="0.8"/>
|
||||
<circle cx="26" cy="22" r="1" fill="white" fill-opacity="0.8"/>
|
||||
<circle cx="18" cy="22" r="1" fill="white" fill-opacity="0.8"/>
|
||||
<circle cx="22" cy="22" r="1" fill="white" fill-opacity="0.8"/>
|
||||
<circle cx="22" cy="26" r="1" fill="white" fill-opacity="0.8"/>
|
||||
<circle cx="18" cy="26" r="1" fill="white" fill-opacity="0.8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
3
beeref/assets/icons/line-arrow-both.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M29.7071 15.2929C30.0976 15.6834 30.0976 16.3166 29.7071 16.7071L23.3431 23.0711C22.9526 23.4616 22.3195 23.4616 21.9289 23.0711C21.5384 22.6805 21.5384 22.0474 21.9289 21.6569L27.5858 16L21.9289 10.3431C21.5384 9.95262 21.5384 9.31946 21.9289 8.92893C22.3195 8.53841 22.9526 8.53841 23.3431 8.92893L29.7071 15.2929ZM2.29289 16.7071C1.90237 16.3166 1.90237 15.6834 2.29289 15.2929L8.65685 8.92893C9.04738 8.53841 9.68054 8.53841 10.0711 8.92893C10.4616 9.31946 10.4616 9.95262 10.0711 10.3431L4.41421 16L10.0711 21.6569C10.4616 22.0474 10.4616 22.6805 10.0711 23.0711C9.68054 23.4616 9.04738 23.4616 8.65685 23.0711L2.29289 16.7071ZM29 16V17H3V16V15H29V16Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 785 B |
3
beeref/assets/icons/line-arrow-left.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M29 15C29.5523 15 30 15.4477 30 16C30 16.5523 29.5523 17 29 17V16V15ZM2.29289 16.7071C1.90237 16.3166 1.90237 15.6834 2.29289 15.2929L8.65685 8.92893C9.04738 8.53841 9.68054 8.53841 10.0711 8.92893C10.4616 9.31946 10.4616 9.95262 10.0711 10.3431L4.41421 16L10.0711 21.6569C10.4616 22.0474 10.4616 22.6805 10.0711 23.0711C9.68054 23.4616 9.04738 23.4616 8.65685 23.0711L2.29289 16.7071ZM29 16V17H3V16V15H29V16Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 538 B |
3
beeref/assets/icons/line-arrow.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M29.7071 15.2929C30.0976 15.6834 30.0976 16.3166 29.7071 16.7071L23.3431 23.0711C22.9526 23.4616 22.3195 23.4616 21.9289 23.0711C21.5384 22.6805 21.5384 22.0474 21.9289 21.6569L27.5858 16L21.9289 10.3431C21.5384 9.95262 21.5384 9.31946 21.9289 8.92893C22.3195 8.53841 22.9526 8.53841 23.3431 8.92893L29.7071 15.2929ZM3 17C2.44772 17 2 16.5523 2 16C2 15.4477 2.44772 15 3 15V16V17ZM29 16V17H3V16V15H29V16Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 533 B |
3
beeref/assets/icons/line-dashed.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M29 16H3" stroke="white" stroke-width="2" stroke-linecap="round" stroke-dasharray="8 8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 202 B |
3
beeref/assets/icons/line-solid.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M29 16H3" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 179 B |
4
beeref/assets/icons/next-frame.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 5.78197C6 5.3501 6.3501 5 6.78197 5C6.98046 5 7.17153 5.07549 7.31643 5.21115L17.4265 14.6773C17.7924 15.0199 18 15.4987 18 16C18 16.5013 17.7924 16.9801 17.4265 17.3227L7.31643 26.7888C7.17153 26.9245 6.98046 27 6.78197 27C6.3501 27 6 26.6499 6 26.218V5.78197Z" fill="white"/>
|
||||
<path d="M17 5.78197C17 5.3501 17.3501 5 17.782 5C17.9805 5 18.1715 5.07549 18.3164 5.21115L28.4265 14.6773C28.7924 15.0199 29 15.4987 29 16C29 16.5013 28.7924 16.9801 28.4265 17.3227L18.3164 26.7888C18.1715 26.9245 17.9805 27 17.782 27C17.3501 27 17 26.6499 17 26.218V5.78197Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 687 B |
4
beeref/assets/icons/opacity.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7 30H6C3.79086 30 2 28.2091 2 26V25H7V30ZM19 30H13V25H19V30ZM30 26C30 28.2091 28.2091 30 26 30H25V25H30V26ZM13 25H7V19H13V25ZM25 25H19V19H25V25ZM19 19H13V13H19V19ZM30 19H25V13H30V19ZM7 19H2V13H7V19ZM13 13H7V7H13V13ZM25 13H19V7H25V13ZM19 7H13V2H19V7ZM26 2C28.2091 2 30 3.79086 30 6V7H25V2H26ZM7 7H2V6C2 3.79086 3.79086 2 6 2H7V7Z" fill="white" fill-opacity="0.64"/>
|
||||
<rect x="2.5" y="2.5" width="27" height="27" rx="3.5" fill="white" fill-opacity="0.48" stroke="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 582 B |
16
beeref/assets/icons/palette.svg
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_34_33)">
|
||||
<mask id="path-1-inside-1_34_33" fill="white">
|
||||
<path d="M15.8046 26.6807C15.4738 27.0113 15.0371 27.2156 14.5713 27.258L8.44763 27.8146C7.85658 27.8683 7.2722 27.6569 6.8525 27.2373L6.76273 27.1475C6.34312 26.7278 6.13171 26.1434 6.18544 25.5524L6.74201 19.4287C6.78436 18.9629 6.98866 18.5262 7.3193 18.1954L16 9.51472L24.4853 18L15.8046 26.6807Z"/>
|
||||
</mask>
|
||||
<path d="M15.8046 26.6807L16.5115 27.388L16.5117 27.3878L15.8046 26.6807ZM14.5713 27.258L14.6618 28.2539L14.6618 28.2539L14.5713 27.258ZM8.44763 27.8146L8.35711 26.8187L8.35709 26.8187L8.44763 27.8146ZM6.8525 27.2373L6.14539 27.9444L6.14547 27.9445L6.8525 27.2373ZM6.76273 27.1475L6.05554 27.8545L6.05562 27.8546L6.76273 27.1475ZM6.18544 25.5524L7.18133 25.6429L7.18134 25.6429L6.18544 25.5524ZM6.74201 19.4287L5.74612 19.3382L5.74612 19.3382L6.74201 19.4287ZM7.3193 18.1954L6.61219 17.4883L6.61203 17.4885L7.3193 18.1954ZM16 9.51472L16.7071 8.80761L16 8.1005L15.2929 8.80761L16 9.51472ZM24.4853 18L25.1924 18.7071L25.8995 18L25.1924 17.2929L24.4853 18ZM15.8046 26.6807L15.0976 25.9734C14.9324 26.1386 14.7139 26.2409 14.4807 26.2621L14.5713 27.258L14.6618 28.2539C15.3603 28.1904 16.0151 27.8841 16.5115 27.388L15.8046 26.6807ZM14.5713 27.258L14.4808 26.2621L8.35711 26.8187L8.44763 27.8146L8.53815 28.8105L14.6618 28.2539L14.5713 27.258ZM8.44763 27.8146L8.35709 26.8187C8.06142 26.8455 7.76926 26.7398 7.55952 26.5301L6.8525 27.2373L6.14547 27.9445C6.77513 28.574 7.65175 28.891 8.53817 28.8105L8.44763 27.8146ZM6.8525 27.2373L7.5596 26.5302L7.46983 26.4404L6.76273 27.1475L6.05562 27.8546L6.14539 27.9444L6.8525 27.2373ZM6.76273 27.1475L7.46991 26.4405C7.26022 26.2307 7.15445 25.9386 7.18133 25.6429L6.18544 25.5524L5.18955 25.4618C5.10896 26.3482 5.42603 27.2249 6.05554 27.8545L6.76273 27.1475ZM6.18544 25.5524L7.18134 25.6429L7.73791 19.5192L6.74201 19.4287L5.74612 19.3382L5.18955 25.4619L6.18544 25.5524ZM6.74201 19.4287L7.7379 19.5192C7.7591 19.2861 7.86143 19.0676 8.02657 18.9024L7.3193 18.1954L6.61203 17.4885C6.11588 17.9849 5.80961 18.6397 5.74612 19.3382L6.74201 19.4287ZM7.3193 18.1954L8.0264 18.9025L16.7071 10.2218L16 9.51472L15.2929 8.80761L6.61219 17.4883L7.3193 18.1954ZM16 9.51472L15.2929 10.2218L23.7782 18.7071L24.4853 18L25.1924 17.2929L16.7071 8.80761L16 9.51472ZM24.4853 18L23.7782 17.2929L15.0975 25.9736L15.8046 26.6807L16.5117 27.3878L25.1924 18.7071L24.4853 18Z" fill="white" mask="url(#path-1-inside-1_34_33)"/>
|
||||
<path d="M15.8053 26.6814C15.4746 27.0116 15.0381 27.2157 14.5727 27.258L8.44763 27.8146C7.85659 27.8683 7.2722 27.6569 6.8525 27.2373L6.76273 27.1475C6.34313 26.7278 6.13171 26.1434 6.18544 25.5524L6.74201 19.4287C6.78436 18.9629 6.98866 18.5262 7.3193 18.1954L7.51472 18H24.5005L15.8053 26.6814Z" fill="white" fill-opacity="0.32"/>
|
||||
<rect x="14.5858" y="6.68629" width="18" height="2" rx="1" transform="rotate(45 14.5858 6.68629)" fill="white"/>
|
||||
<path d="M20.2426 6.68629C21.0237 5.90524 22.29 5.90524 23.0711 6.68629L27.3137 10.9289C28.0948 11.71 28.0948 12.9763 27.3137 13.7574L23.7782 17.2929L16.7071 10.2218L20.2426 6.68629Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_34_33">
|
||||
<rect width="32" height="32" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
4
beeref/assets/icons/pause.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="6" y="4" width="7" height="24" rx="2" fill="white"/>
|
||||
<rect x="19" y="4" width="7" height="24" rx="2" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 228 B |
3
beeref/assets/icons/play.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 4.73409C5 3.77638 5.77638 3 6.73409 3C7.02776 3 7.31662 3.07458 7.57359 3.21675L28.1875 14.6219C28.6888 14.8993 29 15.427 29 16C29 16.573 28.6888 17.1007 28.1875 17.3781L7.57359 28.7832C7.31662 28.9254 7.02776 29 6.73409 29C5.77638 29 5 28.2236 5 27.2659V4.73409Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 395 B |
4
beeref/assets/icons/prev-frame.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M26 5.78197C26 5.3501 25.6499 5 25.218 5C25.0195 5 24.8285 5.07549 24.6836 5.21115L14.5735 14.6773C14.2076 15.0199 14 15.4987 14 16C14 16.5013 14.2076 16.9801 14.5735 17.3227L24.6836 26.7888C24.8285 26.9245 25.0195 27 25.218 27C25.6499 27 26 26.6499 26 26.218V5.78197Z" fill="white"/>
|
||||
<path d="M15 5.78197C15 5.3501 14.6499 5 14.218 5C14.0195 5 13.8285 5.07549 13.6836 5.21115L3.57355 14.6773C3.20764 15.0199 3 15.4987 3 16C3 16.5013 3.20764 16.9801 3.57355 17.3227L13.6836 26.7888C13.8285 26.9245 14.0195 27 14.218 27C14.6499 27 15 26.6499 15 26.218V5.78197Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 688 B |
3
beeref/assets/icons/small-down.svg
Normal 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 |
|
|
@ -21,6 +21,14 @@ COPYRIGHT = 'Copyright © 2021-2024 Rebecca Breu'
|
|||
|
||||
CHANGED_SYMBOL = '✎'
|
||||
|
||||
# Floating Menu Sizes
|
||||
FLOATING_MENU_BUTTON_SIZE = 32
|
||||
FLOATING_MENU_ICON_SIZE = 32
|
||||
FLOATING_MENU_BUTTON_PADDING = 4
|
||||
FLOATING_MENU_CORNER_RADIUS = 4
|
||||
FLOATING_MENU_BASE_CORNER_RADIUS = 8
|
||||
FLOATING_MENU_BOTTOM_MARGIN = 8
|
||||
|
||||
COLORS = {
|
||||
# Qt:
|
||||
'Active:Base': (60, 60, 60),
|
||||
|
|
@ -44,4 +52,178 @@ COLORS = {
|
|||
'Scene:Selection': (116, 234, 231),
|
||||
'Scene:Canvas': (60, 60, 60),
|
||||
'Scene:Text': (200, 200, 200),
|
||||
|
||||
# Floating Menu specific:
|
||||
'FloatingMenu:ButtonBackground': (255, 255, 255, 30),
|
||||
'FloatingMenu:Border': (255, 255, 255, 10),
|
||||
'FloatingMenu:SeparatorBackground': (255, 255, 255, 8),
|
||||
}
|
||||
|
||||
|
||||
def get_welcome_overlay_icon_style():
|
||||
return 'padding: 12px; margin-bottom: 12px;'
|
||||
|
||||
|
||||
def get_standard_button_style():
|
||||
highlight = COLORS['Active:Highlight']
|
||||
text = COLORS['Active:ButtonText']
|
||||
return (
|
||||
'QPushButton {'
|
||||
f'color: rgb({text[0]}, {text[1]}, {text[2]});'
|
||||
f'background-color: rgba({highlight[0]}, {highlight[1]}, {highlight[2]}, 0.25);'
|
||||
'padding: 0.6em 2em;'
|
||||
'border: none;'
|
||||
'border-radius: 6px;'
|
||||
'}'
|
||||
'QPushButton:hover {'
|
||||
f'background-color: rgba({highlight[0]}, {highlight[1]}, {highlight[2]}, 0.4);'
|
||||
'}'
|
||||
'QPushButton:pressed {'
|
||||
f'background-color: rgba({highlight[0]}, {highlight[1]}, {highlight[2]}, 0.6);'
|
||||
'}'
|
||||
)
|
||||
|
||||
|
||||
# Floating Menu Styles
|
||||
|
||||
def _css_color(color):
|
||||
"""Return a CSS-compatible rgb/rgba string for the given color tuple."""
|
||||
length = len(color)
|
||||
if length == 3:
|
||||
r, g, b = color
|
||||
return f"rgb({r}, {g}, {b})"
|
||||
if length == 4:
|
||||
r, g, b, a = color
|
||||
return f"rgba({r}, {g}, {b}, {a})"
|
||||
raise ValueError('Color tuples must have 3 (RGB) or 4 (RGBA) components.')
|
||||
|
||||
|
||||
def get_floating_menu_base_style():
|
||||
"""Base container style for floating menus."""
|
||||
bg = COLORS['Active:Window']
|
||||
border = COLORS['Active:Base']
|
||||
return f"""
|
||||
QWidget#FloatingMenu {{
|
||||
background-color: {_css_color(bg)};
|
||||
border-radius: {FLOATING_MENU_BASE_CORNER_RADIUS}px;
|
||||
border: 1px solid {_css_color(border)};
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def get_floating_menu_button_style():
|
||||
"""Push button styling for floating menus."""
|
||||
background_color = COLORS['FloatingMenu:ButtonBackground']
|
||||
border_color = COLORS['FloatingMenu:Border']
|
||||
inactive = COLORS['Disabled:Text']
|
||||
active = COLORS['Active:Text']
|
||||
accent = COLORS['Active:Highlight']
|
||||
size = FLOATING_MENU_BUTTON_SIZE
|
||||
padding = FLOATING_MENU_BUTTON_PADDING
|
||||
radius = FLOATING_MENU_CORNER_RADIUS
|
||||
|
||||
return f"""
|
||||
QWidget#FloatingMenu QPushButton[floatingButton="true"] {{
|
||||
background-color: {_css_color(background_color)};
|
||||
color: {_css_color(inactive)};
|
||||
border: 1px solid {_css_color(border_color)};
|
||||
border-radius: {radius}px;
|
||||
padding: {padding}px;
|
||||
font-weight: 600;
|
||||
min-width: {size}px;
|
||||
min-height: {size}px;
|
||||
max-height: {size}px;
|
||||
}}
|
||||
|
||||
QWidget#FloatingMenu QPushButton[floatingButton="true"]:hover,
|
||||
QWidget#FloatingMenu QPushButton[floatingButton="true"]:checked,
|
||||
QWidget#FloatingMenu QPushButton[floatingButton="true"][active="true"] {{
|
||||
color: {_css_color(active)};
|
||||
}}
|
||||
|
||||
QWidget#FloatingMenu QPushButton[floatingButton="true"]:checked,
|
||||
QWidget#FloatingMenu QPushButton[floatingButton="true"][active="true"] {{
|
||||
border-bottom: 2px solid {_css_color(accent)};
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def get_floating_menu_separator_style():
|
||||
"""Separator styling for floating menus."""
|
||||
separator_bg = COLORS['FloatingMenu:SeparatorBackground']
|
||||
size = FLOATING_MENU_BUTTON_SIZE
|
||||
|
||||
return f"""
|
||||
QWidget#FloatingMenu QFrame#FloatingMenuSeparator {{
|
||||
background-color: {_css_color(separator_bg)};
|
||||
min-height: {size}px;
|
||||
max-height: {size}px;
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def get_floating_menu_combo_style():
|
||||
"""Combo-box styling for floating menus."""
|
||||
from beeref.assets import BeeAssets
|
||||
|
||||
# Use Active:Button for consistency with other UI elements
|
||||
bg = COLORS['FloatingMenu:ButtonBackground']
|
||||
active_color = COLORS['Active:Text']
|
||||
size = FLOATING_MENU_BUTTON_SIZE
|
||||
radius = FLOATING_MENU_CORNER_RADIUS
|
||||
|
||||
# Get arrow icon path
|
||||
assets = BeeAssets()
|
||||
arrow_icon_path = assets.PATH.joinpath('icons', 'small-down.svg')
|
||||
# Escape backslashes for Windows compatibility
|
||||
arrow_icon_path_str = str(arrow_icon_path).replace('\\', '/')
|
||||
|
||||
return f"""
|
||||
QWidget#FloatingMenu QComboBox,
|
||||
QWidget#FloatingMenu QFontComboBox {{
|
||||
background-color: {_css_color(bg)};
|
||||
color: {_css_color(active_color)};
|
||||
border-radius: {radius}px;
|
||||
padding: 4px 16px;
|
||||
min-width: 40px;
|
||||
min-height: {size}px;
|
||||
max-height: {size}px;
|
||||
}}
|
||||
|
||||
QWidget#FloatingMenu QComboBox::drop-down,
|
||||
QWidget#FloatingMenu QFontComboBox::drop-down {{
|
||||
border: none;
|
||||
width: 20px;
|
||||
}}
|
||||
|
||||
QWidget#FloatingMenu QComboBox::down-arrow,
|
||||
QWidget#FloatingMenu QFontComboBox::down-arrow {{
|
||||
image: url({arrow_icon_path_str});
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-right: 8px;
|
||||
}}
|
||||
|
||||
QWidget#FloatingMenu QComboBox QAbstractItemView,
|
||||
QWidget#FloatingMenu QFontComboBox QAbstractItemView {{
|
||||
min-width: 152px;
|
||||
}}
|
||||
|
||||
QWidget#FloatingMenu QComboBox#FloatingMenuFontSize QAbstractItemView {{
|
||||
min-width: 80px;
|
||||
}}
|
||||
|
||||
QWidget#FloatingMenu QComboBox#FloatingMenuGifSpeed QAbstractItemView {{
|
||||
min-width: 80px;
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def get_floating_menu_style():
|
||||
"""Aggregate stylesheet for floating menus."""
|
||||
return ''.join([
|
||||
get_floating_menu_base_style(),
|
||||
get_floating_menu_button_style(),
|
||||
get_floating_menu_separator_style(),
|
||||
get_floating_menu_combo_style(),
|
||||
])
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -29,6 +29,17 @@ import plum
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_gif_file(path):
|
||||
"""Checks if file is a GIF."""
|
||||
if isinstance(path, str):
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
return ext == '.gif'
|
||||
elif hasattr(path, 'isLocalFile') and path.isLocalFile():
|
||||
ext = os.path.splitext(path.toLocalFile())[1].lower()
|
||||
return ext == '.gif'
|
||||
return False
|
||||
|
||||
|
||||
def exif_rotated_image(path=None):
|
||||
"""Returns a QImage that is transformed according to the source's
|
||||
orientation EXIF data.
|
||||
|
|
@ -84,9 +95,14 @@ def exif_rotated_image(path=None):
|
|||
def load_image(path):
|
||||
if isinstance(path, str):
|
||||
path = os.path.normpath(path)
|
||||
# Check if file is a GIF
|
||||
if is_gif_file(path):
|
||||
return (None, path) # Return None for image, path for GIF
|
||||
return (exif_rotated_image(path), path)
|
||||
if path.isLocalFile():
|
||||
path = os.path.normpath(path.toLocalFile())
|
||||
if is_gif_file(path):
|
||||
return (None, path)
|
||||
return (exif_rotated_image(path), path)
|
||||
|
||||
url = bytes(path.toEncoded()).decode()
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
470
beeref/items.py
|
|
@ -28,7 +28,7 @@ from PyQt6.QtCore import Qt
|
|||
from beeref import commands
|
||||
from beeref.config import BeeSettings
|
||||
from beeref.constants import COLORS
|
||||
from beeref.selection import SelectableMixin
|
||||
from beeref.selection import SelectableMixin, SELECT_COLOR
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -156,7 +156,7 @@ class BeePixmapItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem):
|
|||
|
||||
@grayscale.setter
|
||||
def grayscale(self, value):
|
||||
logger.debug('Setting grayscale for {self} to {value}')
|
||||
logger.debug(f'Setting grayscale for {self} to {value}')
|
||||
self._grayscale = value
|
||||
if value is True:
|
||||
# Using the grayscale image format to convert to grayscale
|
||||
|
|
@ -226,7 +226,8 @@ class BeePixmapItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem):
|
|||
|
||||
def get_filename_for_export(self, imgformat, save_id_default=None):
|
||||
save_id = self.save_id or save_id_default
|
||||
assert save_id is not None
|
||||
if save_id is None:
|
||||
raise ValueError("save_id must be provided for export")
|
||||
|
||||
if self.filename:
|
||||
basename = os.path.splitext(os.path.basename(self.filename))[0]
|
||||
|
|
@ -648,6 +649,7 @@ class BeeTextItem(BeeItemMixin, QtWidgets.QGraphicsTextItem):
|
|||
self.is_editable = True
|
||||
self.edit_mode = False
|
||||
self.setDefaultTextColor(QtGui.QColor(*COLORS['Scene:Text']))
|
||||
self.background_color = QtGui.QColor(0, 0, 0, 0) # Transparent by default
|
||||
|
||||
@classmethod
|
||||
def create_from_data(cls, **kwargs):
|
||||
|
|
@ -667,14 +669,27 @@ class BeeTextItem(BeeItemMixin, QtWidgets.QGraphicsTextItem):
|
|||
|
||||
def paint(self, painter, option, widget):
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
color = QtGui.QColor(0, 0, 0)
|
||||
color.setAlpha(40)
|
||||
brush = QtGui.QBrush(color)
|
||||
rect = QtWidgets.QGraphicsTextItem.boundingRect(self)
|
||||
|
||||
# Use background_color if set, otherwise use default semi-transparent black
|
||||
if hasattr(self, 'background_color') and self.background_color.alpha() > 0:
|
||||
brush = QtGui.QBrush(self.background_color)
|
||||
else:
|
||||
color = QtGui.QColor(0, 0, 0)
|
||||
color.setAlpha(40)
|
||||
brush = QtGui.QBrush(color)
|
||||
|
||||
painter.setBrush(brush)
|
||||
painter.drawRect(QtWidgets.QGraphicsTextItem.boundingRect(self))
|
||||
# Draw rounded rectangle with 2px radius
|
||||
painter.drawRoundedRect(rect, 2, 2)
|
||||
option.state = QtWidgets.QStyle.StateFlag.State_Enabled
|
||||
super().paint(painter, option, widget)
|
||||
self.paint_selectable(painter, option, widget)
|
||||
|
||||
def set_background_color(self, color: QtGui.QColor):
|
||||
"""Set the background color for the text item."""
|
||||
self.background_color = color
|
||||
self.update()
|
||||
|
||||
def create_copy(self):
|
||||
item = BeeTextItem(self.toPlainText())
|
||||
|
|
@ -684,6 +699,13 @@ class BeeTextItem(BeeItemMixin, QtWidgets.QGraphicsTextItem):
|
|||
item.setRotation(self.rotation())
|
||||
if self.flip() == -1:
|
||||
item.do_flip()
|
||||
# Copy text color
|
||||
item.setDefaultTextColor(self.defaultTextColor())
|
||||
# Copy font
|
||||
item.setFont(self.font())
|
||||
# Copy background color
|
||||
if hasattr(self, 'background_color'):
|
||||
item.set_background_color(self.background_color)
|
||||
return item
|
||||
|
||||
def enter_edit_mode(self):
|
||||
|
|
@ -714,23 +736,426 @@ class BeeTextItem(BeeItemMixin, QtWidgets.QGraphicsTextItem):
|
|||
def has_selection_handles(self):
|
||||
return super().has_selection_handles() and not self.edit_mode
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
if (event.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return)
|
||||
and event.modifiers() == Qt.KeyboardModifier.NoModifier):
|
||||
self.exit_edit_mode()
|
||||
event.accept()
|
||||
return
|
||||
if (event.key() == Qt.Key.Key_Escape
|
||||
and event.modifiers() == Qt.KeyboardModifier.NoModifier):
|
||||
self.exit_edit_mode(commit=False)
|
||||
event.accept()
|
||||
return
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def copy_to_clipboard(self, clipboard):
|
||||
clipboard.setText(self.toPlainText())
|
||||
|
||||
|
||||
@register_item
|
||||
class BeeDrawItem(BeeItemMixin, QtWidgets.QGraphicsPathItem):
|
||||
"""Class for freehand drawing items."""
|
||||
|
||||
TYPE = 'draw'
|
||||
CLICKABLE_PADDING = 8.0 # Padding around line to increase clickable area
|
||||
|
||||
def __init__(self, path=None, **kwargs):
|
||||
super().__init__()
|
||||
self.save_id = None
|
||||
logger.debug(f'Initialized {self}')
|
||||
self.is_image = False
|
||||
self.init_selectable()
|
||||
self.is_editable = False # Drawing is not editable via double-click
|
||||
|
||||
# Default pen settings
|
||||
self.pen_color = QtGui.QColor(*COLORS['Scene:Text'])
|
||||
self.pen_width = 8
|
||||
self.pen_style = 'solid' # 'solid', 'dashed', 'arrow', '<-', '<->'
|
||||
self._update_pen()
|
||||
self.setBrush(QtGui.QBrush(QtCore.Qt.BrushStyle.NoBrush))
|
||||
|
||||
if path:
|
||||
self.setPath(path)
|
||||
|
||||
def setPath(self, path):
|
||||
"""Sets path and updates geometry."""
|
||||
self.prepareGeometryChange()
|
||||
super().setPath(path)
|
||||
self.update()
|
||||
|
||||
@classmethod
|
||||
def create_from_data(cls, **kwargs):
|
||||
data = kwargs.get('data', {})
|
||||
item = cls()
|
||||
|
||||
# Restore path from data
|
||||
if 'path' in data and data['path']:
|
||||
path = QtGui.QPainterPath()
|
||||
path_data = data['path']
|
||||
for i, point_data in enumerate(path_data):
|
||||
if i == 0:
|
||||
path.moveTo(point_data['x'], point_data['y'])
|
||||
else:
|
||||
path.lineTo(point_data['x'], point_data['y'])
|
||||
item.setPath(path)
|
||||
|
||||
if 'pen_color' in data:
|
||||
item.pen_color = QtGui.QColor(data['pen_color'])
|
||||
if 'pen_width' in data:
|
||||
item.pen_width = data['pen_width']
|
||||
if 'pen_style' in data:
|
||||
item.pen_style = data['pen_style']
|
||||
item._update_pen()
|
||||
return item
|
||||
|
||||
def __str__(self):
|
||||
return f'Drawing ({self.path().elementCount()} points)'
|
||||
|
||||
def get_extra_save_data(self):
|
||||
"""Saves drawing data for serialization."""
|
||||
path = self.path()
|
||||
path_data = []
|
||||
for i in range(path.elementCount()):
|
||||
elem = path.elementAt(i)
|
||||
path_data.append({'x': elem.x, 'y': elem.y})
|
||||
|
||||
return {
|
||||
'path': path_data,
|
||||
'pen_color': self.pen_color.name(),
|
||||
'pen_width': self.pen_width,
|
||||
'pen_style': self.pen_style,
|
||||
}
|
||||
|
||||
def _update_pen(self):
|
||||
"""Updates pen with current settings."""
|
||||
if self.pen_style == 'dashed':
|
||||
pen_style = QtCore.Qt.PenStyle.DashLine
|
||||
else:
|
||||
pen_style = QtCore.Qt.PenStyle.SolidLine
|
||||
|
||||
pen = QtGui.QPen(self.pen_color, self.pen_width,
|
||||
pen_style,
|
||||
QtCore.Qt.PenCapStyle.RoundCap,
|
||||
QtCore.Qt.PenJoinStyle.RoundJoin)
|
||||
self.setPen(pen)
|
||||
|
||||
def set_pen_color(self, color: QtGui.QColor):
|
||||
"""Sets pen color."""
|
||||
self.pen_color = color
|
||||
self._update_pen()
|
||||
self.update()
|
||||
|
||||
def set_pen_width(self, width: int):
|
||||
"""Sets pen width."""
|
||||
self.pen_width = max(1, min(width, 50)) # Limit 1-50
|
||||
self._update_pen()
|
||||
self.update()
|
||||
|
||||
def set_pen_style(self, style: str):
|
||||
"""Sets line style: 'solid', 'dashed', 'arrow', '<-', '<->'."""
|
||||
if style in ('solid', 'dashed', 'arrow', '<-', '<->'):
|
||||
self.pen_style = style
|
||||
self._update_pen()
|
||||
self.update()
|
||||
|
||||
def create_copy(self):
|
||||
item = BeeDrawItem()
|
||||
item.setPath(self.path())
|
||||
item.setPos(self.pos())
|
||||
item.setZValue(self.zValue())
|
||||
item.setScale(self.scale())
|
||||
item.setRotation(self.rotation())
|
||||
item.set_pen_color(self.pen_color)
|
||||
item.set_pen_width(self.pen_width)
|
||||
item.set_pen_style(self.pen_style)
|
||||
if self.flip() == -1:
|
||||
item.do_flip()
|
||||
return item
|
||||
|
||||
def bounding_rect_unselected(self):
|
||||
"""Returns item bounds without selection."""
|
||||
path = self.path()
|
||||
if path.isEmpty():
|
||||
return QtCore.QRectF()
|
||||
|
||||
# Get boundingRect directly from path
|
||||
base_rect = path.boundingRect()
|
||||
|
||||
# Add margin for pen width and clickable area
|
||||
margin = (self.pen_width / 2.0) + self.CLICKABLE_PADDING
|
||||
return base_rect.marginsAdded(
|
||||
QtCore.QMarginsF(margin, margin, margin, margin))
|
||||
|
||||
def shape(self):
|
||||
"""Returns rectangular clickable area, like in PureRef."""
|
||||
path = QtGui.QPainterPath()
|
||||
rect = self.bounding_rect_unselected()
|
||||
|
||||
# If item is selected and has handles, add handle areas
|
||||
if self.has_selection_handles():
|
||||
margin = self.select_resize_size / 2
|
||||
rect = rect.marginsAdded(
|
||||
QtCore.QMarginsF(margin, margin, margin, margin))
|
||||
path.addRect(rect)
|
||||
# Add rotation handle areas at corners
|
||||
for corner in self.corners:
|
||||
path.addPath(self.get_rotate_bounds(corner))
|
||||
else:
|
||||
path.addRect(rect)
|
||||
|
||||
return path
|
||||
|
||||
def contains(self, point):
|
||||
"""Checks if point falls within rectangular line area."""
|
||||
# Use boundingRect for rectangular click area
|
||||
return self.bounding_rect_unselected().contains(point)
|
||||
|
||||
|
||||
def _get_path_end_points(self, path):
|
||||
"""Gets last two path points to determine arrow direction."""
|
||||
if path.elementCount() < 2:
|
||||
return None, None
|
||||
|
||||
# Get last path point
|
||||
last_point = path.pointAtPercent(1.0)
|
||||
|
||||
# Get second-to-last point (close to end)
|
||||
if path.elementCount() >= 2:
|
||||
prev_point = path.pointAtPercent(0.95) # 95% of path
|
||||
else:
|
||||
prev_point = path.pointAtPercent(0.0)
|
||||
|
||||
return prev_point, last_point
|
||||
|
||||
def _get_path_start_points(self, path):
|
||||
"""Gets first two path points to determine arrow direction."""
|
||||
if path.elementCount() < 2:
|
||||
return None, None
|
||||
|
||||
# Get first path point
|
||||
first_point = path.pointAtPercent(0.0)
|
||||
|
||||
# Get second point (close to start)
|
||||
if path.elementCount() >= 2:
|
||||
second_point = path.pointAtPercent(0.05) # 5% of path
|
||||
else:
|
||||
second_point = path.pointAtPercent(1.0)
|
||||
|
||||
return first_point, second_point
|
||||
|
||||
def _draw_arrow_right(self, painter, path):
|
||||
"""Draws arrow to the right at the end of line."""
|
||||
if path.elementCount() < 2:
|
||||
return
|
||||
|
||||
# Get last two points to determine direction
|
||||
prev_point, last_point = self._get_path_end_points(path)
|
||||
if prev_point is None or last_point is None:
|
||||
return
|
||||
|
||||
# Calculate arrow direction
|
||||
dx = last_point.x() - prev_point.x()
|
||||
dy = last_point.y() - prev_point.y()
|
||||
length = (dx * dx + dy * dy) ** 0.5
|
||||
if length == 0:
|
||||
return
|
||||
|
||||
# Normalize direction vector
|
||||
dx /= length
|
||||
dy /= length
|
||||
|
||||
# Arrow size depends on line width
|
||||
arrow_size = max(self.pen_width * 3, 8)
|
||||
# Arrow angle
|
||||
angle = 0.5 # approximately 30 degrees
|
||||
|
||||
# Arrow end coordinates
|
||||
end_x = last_point.x()
|
||||
end_y = last_point.y()
|
||||
|
||||
# Arrow side point coordinates
|
||||
perp_x = -dy
|
||||
perp_y = dx
|
||||
arrow_x1 = end_x - arrow_size * dx + arrow_size * angle * perp_x
|
||||
arrow_y1 = end_y - arrow_size * dy + arrow_size * angle * perp_y
|
||||
arrow_x2 = end_x - arrow_size * dx - arrow_size * angle * perp_x
|
||||
arrow_y2 = end_y - arrow_size * dy - arrow_size * angle * perp_y
|
||||
|
||||
# Draw arrow
|
||||
arrow_path = QtGui.QPainterPath()
|
||||
arrow_path.moveTo(end_x, end_y)
|
||||
arrow_path.lineTo(arrow_x1, arrow_y1)
|
||||
arrow_path.moveTo(end_x, end_y)
|
||||
arrow_path.lineTo(arrow_x2, arrow_y2)
|
||||
|
||||
painter.setPen(QtGui.QPen(self.pen_color, self.pen_width,
|
||||
QtCore.Qt.PenStyle.SolidLine,
|
||||
QtCore.Qt.PenCapStyle.RoundCap,
|
||||
QtCore.Qt.PenJoinStyle.RoundJoin))
|
||||
painter.drawPath(arrow_path)
|
||||
|
||||
def _draw_arrow_left(self, painter, path):
|
||||
"""Draws arrow to the left at the start of line."""
|
||||
if path.elementCount() < 2:
|
||||
return
|
||||
|
||||
# Get first two points to determine direction
|
||||
first_point, second_point = self._get_path_start_points(path)
|
||||
if first_point is None or second_point is None:
|
||||
return
|
||||
|
||||
# Calculate arrow direction (from second point to first)
|
||||
dx = first_point.x() - second_point.x()
|
||||
dy = first_point.y() - second_point.y()
|
||||
length = (dx * dx + dy * dy) ** 0.5
|
||||
if length == 0:
|
||||
return
|
||||
|
||||
# Normalize direction vector
|
||||
dx /= length
|
||||
dy /= length
|
||||
|
||||
# Arrow size depends on line width
|
||||
arrow_size = max(self.pen_width * 3, 8)
|
||||
# Arrow angle
|
||||
angle = 0.5 # approximately 30 degrees
|
||||
|
||||
# Arrow start coordinates
|
||||
start_x = first_point.x()
|
||||
start_y = first_point.y()
|
||||
|
||||
# Arrow side point coordinates
|
||||
perp_x = -dy
|
||||
perp_y = dx
|
||||
arrow_x1 = start_x - arrow_size * dx + arrow_size * angle * perp_x
|
||||
arrow_y1 = start_y - arrow_size * dy + arrow_size * angle * perp_y
|
||||
arrow_x2 = start_x - arrow_size * dx - arrow_size * angle * perp_x
|
||||
arrow_y2 = start_y - arrow_size * dy - arrow_size * angle * perp_y
|
||||
|
||||
# Draw arrow
|
||||
arrow_path = QtGui.QPainterPath()
|
||||
arrow_path.moveTo(start_x, start_y)
|
||||
arrow_path.lineTo(arrow_x1, arrow_y1)
|
||||
arrow_path.moveTo(start_x, start_y)
|
||||
arrow_path.lineTo(arrow_x2, arrow_y2)
|
||||
|
||||
painter.setPen(QtGui.QPen(self.pen_color, self.pen_width,
|
||||
QtCore.Qt.PenStyle.SolidLine,
|
||||
QtCore.Qt.PenCapStyle.RoundCap,
|
||||
QtCore.Qt.PenJoinStyle.RoundJoin))
|
||||
painter.drawPath(arrow_path)
|
||||
|
||||
def _draw_arrow_both(self, painter, path):
|
||||
"""Draws arrows on both sides of line."""
|
||||
if path.elementCount() < 2:
|
||||
return
|
||||
|
||||
# Draw arrow to the right (at end)
|
||||
prev_point, last_point = self._get_path_end_points(path)
|
||||
if prev_point is not None and last_point is not None:
|
||||
dx = last_point.x() - prev_point.x()
|
||||
dy = last_point.y() - prev_point.y()
|
||||
length = (dx * dx + dy * dy) ** 0.5
|
||||
if length > 0:
|
||||
dx /= length
|
||||
dy /= length
|
||||
arrow_size = max(self.pen_width * 3, 8)
|
||||
angle = 0.5
|
||||
end_x = last_point.x()
|
||||
end_y = last_point.y()
|
||||
perp_x = -dy
|
||||
perp_y = dx
|
||||
arrow_x1 = end_x - arrow_size * dx + arrow_size * angle * perp_x
|
||||
arrow_y1 = end_y - arrow_size * dy + arrow_size * angle * perp_y
|
||||
arrow_x2 = end_x - arrow_size * dx - arrow_size * angle * perp_x
|
||||
arrow_y2 = end_y - arrow_size * dy - arrow_size * angle * perp_y
|
||||
|
||||
arrow_path = QtGui.QPainterPath()
|
||||
arrow_path.moveTo(end_x, end_y)
|
||||
arrow_path.lineTo(arrow_x1, arrow_y1)
|
||||
arrow_path.moveTo(end_x, end_y)
|
||||
arrow_path.lineTo(arrow_x2, arrow_y2)
|
||||
|
||||
painter.setPen(QtGui.QPen(self.pen_color, self.pen_width,
|
||||
QtCore.Qt.PenStyle.SolidLine,
|
||||
QtCore.Qt.PenCapStyle.RoundCap,
|
||||
QtCore.Qt.PenJoinStyle.RoundJoin))
|
||||
painter.drawPath(arrow_path)
|
||||
|
||||
# Draw arrow to the left (at start)
|
||||
first_point, second_point = self._get_path_start_points(path)
|
||||
if first_point is not None and second_point is not None:
|
||||
dx = first_point.x() - second_point.x()
|
||||
dy = first_point.y() - second_point.y()
|
||||
length = (dx * dx + dy * dy) ** 0.5
|
||||
if length > 0:
|
||||
dx /= length
|
||||
dy /= length
|
||||
arrow_size = max(self.pen_width * 3, 8)
|
||||
angle = 0.5
|
||||
start_x = first_point.x()
|
||||
start_y = first_point.y()
|
||||
perp_x = -dy
|
||||
perp_y = dx
|
||||
arrow_x1 = start_x - arrow_size * dx + arrow_size * angle * perp_x
|
||||
arrow_y1 = start_y - arrow_size * dy + arrow_size * angle * perp_y
|
||||
arrow_x2 = start_x - arrow_size * dx - arrow_size * angle * perp_x
|
||||
arrow_y2 = start_y - arrow_size * dy - arrow_size * angle * perp_y
|
||||
|
||||
arrow_path = QtGui.QPainterPath()
|
||||
arrow_path.moveTo(start_x, start_y)
|
||||
arrow_path.lineTo(arrow_x1, arrow_y1)
|
||||
arrow_path.moveTo(start_x, start_y)
|
||||
arrow_path.lineTo(arrow_x2, arrow_y2)
|
||||
|
||||
painter.setPen(QtGui.QPen(self.pen_color, self.pen_width,
|
||||
QtCore.Qt.PenStyle.SolidLine,
|
||||
QtCore.Qt.PenCapStyle.RoundCap,
|
||||
QtCore.Qt.PenJoinStyle.RoundJoin))
|
||||
painter.drawPath(arrow_path)
|
||||
|
||||
def paint(self, painter, option, widget):
|
||||
"""Renders path with selection outline."""
|
||||
# Disable standard Qt rendering for selected items
|
||||
option.state &= ~QtWidgets.QStyle.StateFlag.State_Selected
|
||||
option.state &= ~QtWidgets.QStyle.StateFlag.State_HasFocus
|
||||
# Draw main line
|
||||
super().paint(painter, option, widget)
|
||||
|
||||
path = self.path()
|
||||
# Draw arrow depending on style
|
||||
if self.pen_style == 'arrow':
|
||||
self._draw_arrow_right(painter, path)
|
||||
elif self.pen_style == '<-':
|
||||
self._draw_arrow_left(painter, path)
|
||||
elif self.pen_style == '<->':
|
||||
self._draw_arrow_both(painter, path)
|
||||
|
||||
self.paint_selectable(painter, option, widget)
|
||||
|
||||
def paint_debug(self, painter, option, widget):
|
||||
"""Override to completely disable debug information."""
|
||||
# Completely disable debug information for lines
|
||||
pass
|
||||
|
||||
def paint_selectable(self, painter, option, widget):
|
||||
"""Override to remove dashed outline (debug information)."""
|
||||
# Don't call paint_debug to remove dashed outline
|
||||
# self.paint_debug(painter, option, widget)
|
||||
|
||||
if not self.has_selection_outline():
|
||||
return
|
||||
|
||||
pen = QtGui.QPen(SELECT_COLOR)
|
||||
pen.setWidth(self.SELECT_LINE_WIDTH)
|
||||
pen.setCosmetic(True)
|
||||
painter.setPen(pen)
|
||||
painter.setBrush(QtGui.QBrush())
|
||||
|
||||
# Draw the main selection rectangle
|
||||
painter.drawRect(self.bounding_rect_unselected())
|
||||
|
||||
# If it's a single selection, draw the handles:
|
||||
if self.has_selection_handles():
|
||||
pen.setWidth(self.SELECT_HANDLE_SIZE)
|
||||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
painter.setPen(pen)
|
||||
for corner in self.corners:
|
||||
painter.drawPoint(corner)
|
||||
|
||||
def copy_to_clipboard(self, clipboard):
|
||||
"""Copying is not supported for drawings."""
|
||||
pass
|
||||
|
||||
|
||||
@register_item
|
||||
class BeeErrorItem(BeeItemMixin, QtWidgets.QGraphicsTextItem):
|
||||
"""Class for displaying error messages when an item can't be loaded
|
||||
|
|
@ -804,3 +1229,8 @@ class BeeErrorItem(BeeItemMixin, QtWidgets.QGraphicsTextItem):
|
|||
|
||||
def copy_to_clipboard(self, clipboard):
|
||||
clipboard.setText(self.toPlainText())
|
||||
|
||||
|
||||
# Import GIF item for registration in item_registry
|
||||
from beeref.gif_item import BeeGifItem # noqa: E402, F401
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
805
beeref/view.py
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
351
beeref/widgets/color_picker.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
206
beeref/widgets/draw_floating_menu.py
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
"""Floating menu for drawing items."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
from PyQt6 import QtCore, QtGui, QtWidgets
|
||||
|
||||
from beeref import constants
|
||||
from beeref import widgets
|
||||
from beeref.assets import BeeAssets
|
||||
from beeref.widgets.floating_menu import FloatingMenu
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - type checking only
|
||||
from beeref.view import BeeGraphicsView
|
||||
from beeref.items import BeeDrawItem
|
||||
|
||||
|
||||
class UpwardComboBox(QtWidgets.QComboBox):
|
||||
"""QComboBox that opens its dropdown menu upward."""
|
||||
|
||||
def showPopup(self):
|
||||
"""Override to show popup above the combobox."""
|
||||
super().showPopup()
|
||||
# Use QTimer to get popup after it's created
|
||||
QtCore.QTimer.singleShot(0, self._reposition_popup)
|
||||
|
||||
def _reposition_popup(self):
|
||||
"""Moves popup above the combobox."""
|
||||
# Find active popup widget
|
||||
popup = QtWidgets.QApplication.activePopupWidget()
|
||||
if not popup:
|
||||
# Alternative method - find through view
|
||||
view = self.view()
|
||||
if view:
|
||||
popup = view.parent()
|
||||
while popup and not isinstance(popup, QtWidgets.QFrame):
|
||||
popup = popup.parent()
|
||||
|
||||
if popup:
|
||||
# Get global position of combobox
|
||||
global_pos = self.mapToGlobal(QtCore.QPoint(0, 0))
|
||||
# Calculate new position above combobox
|
||||
popup_height = popup.height()
|
||||
new_y = global_pos.y() - popup_height
|
||||
popup.move(global_pos.x(), new_y)
|
||||
|
||||
|
||||
class DrawFloatingMenu(FloatingMenu):
|
||||
"""Floating menu for drawing tools."""
|
||||
|
||||
def __init__(self, parent: QtWidgets.QWidget, view: "BeeGraphicsView"):
|
||||
super().__init__(parent, view)
|
||||
self.current_item: Optional["BeeDrawItem"] = None
|
||||
|
||||
# Color selection button via colorpicker
|
||||
assets = BeeAssets()
|
||||
icons_path = assets.PATH.joinpath('icons')
|
||||
palette_icon = QtGui.QIcon(str(icons_path.joinpath('palette.svg')))
|
||||
|
||||
self.color_btn = self.add_button(
|
||||
"",
|
||||
icon=palette_icon,
|
||||
callback=self._on_color_clicked,
|
||||
)
|
||||
self.color_btn.setToolTip("Color")
|
||||
|
||||
# Combobox for line style selection
|
||||
self.add_separator()
|
||||
self.style_combo = self.add_style_combobox()
|
||||
|
||||
# Pen width selector
|
||||
self.add_separator()
|
||||
self.width_slider = self.add_width_slider()
|
||||
|
||||
# Close menu button (cancel drawing mode)
|
||||
self.add_separator()
|
||||
close_icon = QtGui.QIcon(str(icons_path.joinpath('close.svg')))
|
||||
self.close_button = self.add_button(
|
||||
"",
|
||||
icon=close_icon,
|
||||
callback=self._on_close_clicked
|
||||
)
|
||||
self.close_button.setToolTip("Close")
|
||||
|
||||
def add_style_combobox(self):
|
||||
"""Adds combobox for line style selection."""
|
||||
assets = BeeAssets()
|
||||
icons_path = assets.PATH.joinpath('icons')
|
||||
|
||||
# Load icons for styles
|
||||
solid_icon = QtGui.QIcon(str(icons_path.joinpath('line-solid.svg')))
|
||||
dashed_icon = QtGui.QIcon(str(icons_path.joinpath('line-dashed.svg')))
|
||||
arrow_icon = QtGui.QIcon(str(icons_path.joinpath('line-arrow.svg')))
|
||||
arrow_left_icon = QtGui.QIcon(str(icons_path.joinpath('line-arrow-left.svg')))
|
||||
arrow_both_icon = QtGui.QIcon(str(icons_path.joinpath('line-arrow-both.svg')))
|
||||
|
||||
combo = UpwardComboBox(self)
|
||||
combo.setObjectName("FloatingMenuLineStyle")
|
||||
combo.setMinimumWidth(40)
|
||||
combo.setIconSize(QtCore.QSize(32, 32))
|
||||
|
||||
# Add items with icons
|
||||
combo.addItem(solid_icon, "", 'solid')
|
||||
combo.addItem(dashed_icon, "", 'dashed')
|
||||
combo.addItem(arrow_icon, "", 'arrow')
|
||||
combo.addItem(arrow_left_icon, "", '<-')
|
||||
combo.addItem(arrow_both_icon, "", '<->')
|
||||
# Set icons for items
|
||||
combo.setItemIcon(0, solid_icon)
|
||||
combo.setItemIcon(1, dashed_icon)
|
||||
combo.setItemIcon(2, arrow_icon)
|
||||
combo.setItemIcon(3, arrow_left_icon)
|
||||
combo.setItemIcon(4, arrow_both_icon)
|
||||
|
||||
combo.setCurrentIndex(0) # Default to solid
|
||||
combo.currentIndexChanged.connect(self._on_style_changed)
|
||||
combo.setToolTip("Line style")
|
||||
|
||||
self.add_widget(combo)
|
||||
return combo
|
||||
|
||||
def _on_color_clicked(self) -> None:
|
||||
"""Opens color selection dialog."""
|
||||
if not self.current_item:
|
||||
return
|
||||
|
||||
initial = self.current_item.pen_color
|
||||
dialog = widgets.color_picker.ColorPickerDialog(self, initial)
|
||||
if dialog.exec() != QtWidgets.QDialog.DialogCode.Accepted:
|
||||
return
|
||||
color = dialog.selectedColor()
|
||||
self.set_pen_color(color)
|
||||
|
||||
def _on_style_changed(self, index: int) -> None:
|
||||
"""Handler for line style change."""
|
||||
if not self.current_item:
|
||||
return
|
||||
style = self.style_combo.itemData(index)
|
||||
if style:
|
||||
self.set_pen_style(style)
|
||||
|
||||
def add_width_slider(self):
|
||||
"""Adds slider for pen width selection."""
|
||||
container = QtWidgets.QWidget()
|
||||
layout = QtWidgets.QHBoxLayout(container)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
label = QtWidgets.QLabel("Width:")
|
||||
layout.addWidget(label)
|
||||
|
||||
slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal)
|
||||
slider.setRange(1, 20)
|
||||
slider.setValue(8)
|
||||
slider.valueChanged.connect(self.set_pen_width)
|
||||
layout.addWidget(slider)
|
||||
|
||||
self.add_widget(container)
|
||||
return slider
|
||||
|
||||
def show_for_item(self, item: "BeeDrawItem") -> None:
|
||||
"""Shows menu for selected drawing item."""
|
||||
self.current_item = item
|
||||
if item:
|
||||
self.width_slider.setValue(item.pen_width)
|
||||
# Set current style in combobox
|
||||
style_index = self.style_combo.findData(item.pen_style)
|
||||
if style_index >= 0:
|
||||
self.style_combo.blockSignals(True)
|
||||
self.style_combo.setCurrentIndex(style_index)
|
||||
self.style_combo.blockSignals(False)
|
||||
super().show_for_item(item)
|
||||
|
||||
def set_pen_color(self, color: QtGui.QColor):
|
||||
"""Sets pen color for selected item."""
|
||||
if self.current_item:
|
||||
self.current_item.set_pen_color(color)
|
||||
|
||||
def set_pen_width(self, width: int):
|
||||
"""Sets pen width for selected item."""
|
||||
if self.current_item:
|
||||
self.current_item.set_pen_width(width)
|
||||
|
||||
def set_pen_style(self, style: str):
|
||||
"""Sets line style for selected item."""
|
||||
if self.current_item:
|
||||
self.current_item.set_pen_style(style)
|
||||
|
||||
def _on_close_clicked(self) -> None:
|
||||
"""Closes menu and cancels drawing mode."""
|
||||
self.view.cancel_drawing_mode()
|
||||
self.hide_menu()
|
||||
|
||||
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:
|
||||
"""Handles key press events."""
|
||||
if event.key() == QtCore.Qt.Key.Key_Escape:
|
||||
# ESC closes menu and cancels drawing mode
|
||||
self.view.cancel_drawing_mode()
|
||||
self.hide_menu()
|
||||
event.accept()
|
||||
return
|
||||
super().keyPressEvent(event)
|
||||
|
||||
294
beeref/widgets/floating_menu.py
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
"""Floating menu widgets shown for single item selections."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, TYPE_CHECKING, Callable
|
||||
|
||||
from PyQt6 import QtCore, QtGui, QtWidgets
|
||||
|
||||
from beeref import constants
|
||||
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - type checking only
|
||||
from beeref.view import BeeGraphicsView
|
||||
|
||||
|
||||
class FloatingMenu(QtWidgets.QWidget):
|
||||
"""Base widget for floating menus pinned to the bottom centre."""
|
||||
|
||||
BOTTOM_MARGIN = 8
|
||||
CORNER_RADIUS = 8
|
||||
|
||||
def __init__(self, parent: QtWidgets.QWidget, view: "BeeGraphicsView"):
|
||||
# Create as independent window to prevent event blocking
|
||||
super().__init__(None)
|
||||
self.setObjectName("FloatingMenu")
|
||||
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground, True)
|
||||
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_ShowWithoutActivating, True)
|
||||
# Make it a tool window that stays on top
|
||||
self.setWindowFlag(QtCore.Qt.WindowType.FramelessWindowHint, True)
|
||||
self.setWindowFlag(QtCore.Qt.WindowType.Tool, True)
|
||||
self.setWindowFlag(QtCore.Qt.WindowType.WindowStaysOnTopHint, True)
|
||||
# Store parent for positioning
|
||||
self._parent_widget = parent
|
||||
self.view = view
|
||||
self.current_item: Optional[QtWidgets.QGraphicsItem] = None
|
||||
|
||||
# Cache for update_position optimization
|
||||
self._cached_position: Optional[QtCore.QPoint] = None
|
||||
self._cached_viewport_size: Optional[QtCore.QSize] = None
|
||||
self._cached_window_pos: Optional[QtCore.QPoint] = None
|
||||
|
||||
# Timer for tracking main window movement
|
||||
self._position_timer = QtCore.QTimer(self)
|
||||
self._position_timer.timeout.connect(self._check_window_position)
|
||||
self._position_timer.setInterval(50) # Check every 50ms
|
||||
|
||||
# Main layout with uniform spacing
|
||||
self._layout = QtWidgets.QHBoxLayout(self)
|
||||
self._layout.setContentsMargins(8, 6, 8, 6)
|
||||
self._layout.setSpacing(6)
|
||||
self._layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.setFocusPolicy(QtCore.Qt.FocusPolicy.NoFocus)
|
||||
self.setStyleSheet(constants.get_floating_menu_style())
|
||||
|
||||
self.hide()
|
||||
|
||||
def _check_window_position(self) -> None:
|
||||
"""Checks viewport position and updates menu position if needed."""
|
||||
if not self.isVisible():
|
||||
return
|
||||
|
||||
view = self.view
|
||||
if view is None:
|
||||
return
|
||||
|
||||
viewport = view.viewport()
|
||||
if viewport is None:
|
||||
return
|
||||
|
||||
# Get viewport position in global coordinates
|
||||
view_rect = viewport.rect()
|
||||
current_viewport_pos = viewport.mapToGlobal(view_rect.topLeft())
|
||||
|
||||
# If viewport position changed, update menu position
|
||||
if self._cached_window_pos is not None and self._cached_window_pos != current_viewport_pos:
|
||||
self.update_position()
|
||||
|
||||
self._cached_window_pos = current_viewport_pos
|
||||
|
||||
def _apply_rounded_mask(self) -> None:
|
||||
"""
|
||||
Applies rounded mask to widget with antialiasing.
|
||||
Implementation based on approach from VK Teams article.
|
||||
"""
|
||||
size = self.size()
|
||||
if size.width() == 0 or size.height() == 0:
|
||||
return
|
||||
|
||||
# Use QBitmap with antialiasing for smoother rendering
|
||||
# Create image with increased resolution (as in article)
|
||||
scale_factor = 2
|
||||
scaled_size = QtCore.QSize(
|
||||
int(size.width() * scale_factor),
|
||||
int(size.height() * scale_factor)
|
||||
)
|
||||
|
||||
# Create QPixmap for drawing with antialiasing
|
||||
pixmap = QtGui.QPixmap(scaled_size)
|
||||
pixmap.fill(QtCore.Qt.GlobalColor.transparent)
|
||||
|
||||
painter = QtGui.QPainter(pixmap)
|
||||
painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, True)
|
||||
painter.setPen(QtCore.Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QtCore.Qt.GlobalColor.black) # Black for mask
|
||||
|
||||
# Draw rounded rectangle at increased resolution
|
||||
scaled_rect = QtCore.QRectF(0, 0, scaled_size.width(), scaled_size.height())
|
||||
scaled_radius = self.CORNER_RADIUS * scale_factor
|
||||
painter.drawRoundedRect(scaled_rect, scaled_radius, scaled_radius)
|
||||
painter.end()
|
||||
|
||||
# Scale back with smoothing
|
||||
pixmap = pixmap.scaled(
|
||||
size,
|
||||
QtCore.Qt.AspectRatioMode.IgnoreAspectRatio,
|
||||
QtCore.Qt.TransformationMode.SmoothTransformation
|
||||
)
|
||||
|
||||
# Convert to QBitmap for mask
|
||||
# In mask: opaque pixels = visible areas, transparent = invisible
|
||||
image = pixmap.toImage()
|
||||
# Create mask from opaque pixels
|
||||
bitmap = QtGui.QBitmap.fromImage(image.createAlphaMask())
|
||||
|
||||
# Apply mask
|
||||
self.setMask(bitmap)
|
||||
|
||||
def add_widget(self, widget: QtWidgets.QWidget) -> QtWidgets.QWidget:
|
||||
widget.setParent(self)
|
||||
self._layout.addWidget(widget)
|
||||
return widget
|
||||
|
||||
def add_button(
|
||||
self,
|
||||
text: str,
|
||||
icon: Optional[QtGui.QIcon] = None,
|
||||
callback: Optional[Callable[[], None]] = None,
|
||||
checkable: bool = False,
|
||||
) -> QtWidgets.QPushButton:
|
||||
button = QtWidgets.QPushButton(text, self)
|
||||
button.setCheckable(checkable)
|
||||
if icon:
|
||||
button.setIcon(icon)
|
||||
button.setIconSize(QtCore.QSize(32, 32))
|
||||
button.setProperty("floatingButton", True)
|
||||
if callback:
|
||||
# Connect directly - button events are handled independently
|
||||
button.clicked.connect(callback)
|
||||
self._layout.addWidget(button)
|
||||
return button
|
||||
|
||||
def add_separator(self) -> None:
|
||||
separator = QtWidgets.QFrame(self)
|
||||
separator.setObjectName("FloatingMenuSeparator")
|
||||
separator.setFrameShape(QtWidgets.QFrame.Shape.VLine)
|
||||
separator.setFrameShadow(QtWidgets.QFrame.Shadow.Plain)
|
||||
separator.setFixedWidth(1)
|
||||
self._layout.addWidget(separator)
|
||||
|
||||
def show_for_item(self, item: QtWidgets.QGraphicsItem) -> None:
|
||||
self.current_item = item
|
||||
self.show_menu()
|
||||
|
||||
def show_menu(self) -> None:
|
||||
self.adjustSize()
|
||||
self._apply_rounded_mask()
|
||||
self.show()
|
||||
self.raise_()
|
||||
# Reset cache when showing menu to ensure position update
|
||||
self._cached_position = None
|
||||
self._cached_viewport_size = None
|
||||
self._cached_window_pos = None
|
||||
self.update_position()
|
||||
# Start timer for tracking window movement
|
||||
self._position_timer.start()
|
||||
# Ensure menu is on top after positioning
|
||||
self.raise_()
|
||||
# Return focus to view so keyboard events are handled correctly
|
||||
# FloatingMenu should not intercept focus as it has NoFocus
|
||||
if self.view:
|
||||
self.view.setFocus()
|
||||
|
||||
def hide_menu(self) -> None:
|
||||
self.current_item = None
|
||||
# Stop timer
|
||||
self._position_timer.stop()
|
||||
# Clear cache when hiding menu
|
||||
self._cached_position = None
|
||||
self._cached_viewport_size = None
|
||||
self._cached_window_pos = None
|
||||
self.hide()
|
||||
|
||||
def update_position(self) -> None:
|
||||
if not self.isVisible():
|
||||
return
|
||||
|
||||
parent = self._parent_widget
|
||||
view = self.view
|
||||
if parent is None or view is None:
|
||||
return
|
||||
|
||||
viewport = view.viewport()
|
||||
if viewport is None:
|
||||
return
|
||||
|
||||
view_rect = viewport.rect()
|
||||
viewport_size = view_rect.size()
|
||||
|
||||
# Get current viewport position for change detection
|
||||
current_viewport_pos = viewport.mapToGlobal(view_rect.topLeft())
|
||||
|
||||
# Check if viewport size and position changed
|
||||
if (self._cached_viewport_size is not None and
|
||||
self._cached_viewport_size == viewport_size and
|
||||
self._cached_window_pos is not None and
|
||||
self._cached_window_pos == current_viewport_pos and
|
||||
self._cached_position is not None):
|
||||
# If viewport size and position haven't changed, check menu position
|
||||
current_pos = self.pos()
|
||||
if current_pos == self._cached_position:
|
||||
# Position hasn't changed, skip update
|
||||
return
|
||||
|
||||
size = self.sizeHint()
|
||||
width = self.width() or size.width()
|
||||
height = self.height() or size.height()
|
||||
|
||||
# Calculate position in global coordinates (independent window)
|
||||
top_left_global = viewport.mapToGlobal(view_rect.topLeft())
|
||||
bottom_left_global = viewport.mapToGlobal(view_rect.bottomLeft())
|
||||
bottom_right_global = viewport.mapToGlobal(view_rect.bottomRight())
|
||||
top_right_global = viewport.mapToGlobal(view_rect.topRight())
|
||||
|
||||
# Get viewport bounds in global coordinates
|
||||
viewport_left = top_left_global.x()
|
||||
viewport_right = top_right_global.x()
|
||||
viewport_top = top_left_global.y()
|
||||
viewport_bottom = bottom_left_global.y()
|
||||
viewport_width = viewport_right - viewport_left
|
||||
|
||||
# Calculate X position (center, but don't go beyond boundaries)
|
||||
x = viewport_left + max(0, (viewport_width - width) // 2)
|
||||
# Limit to prevent menu from going beyond left and right boundaries
|
||||
x = max(viewport_left, min(x, viewport_right - width))
|
||||
|
||||
# Calculate Y position (bottom with margin)
|
||||
y = viewport_bottom - height - self.BOTTOM_MARGIN
|
||||
# Limit to prevent menu from going beyond top boundary
|
||||
# If menu doesn't fit at bottom, place it at top
|
||||
if y < viewport_top:
|
||||
y = viewport_top + self.BOTTOM_MARGIN
|
||||
# Also check that menu doesn't go beyond bottom boundary
|
||||
if y + height > viewport_bottom:
|
||||
y = viewport_bottom - height - self.BOTTOM_MARGIN
|
||||
# If still doesn't fit, place at top
|
||||
if y < viewport_top:
|
||||
y = viewport_top + self.BOTTOM_MARGIN
|
||||
|
||||
new_position = QtCore.QPoint(x, y)
|
||||
|
||||
# Update position only if it actually changed
|
||||
if self._cached_position != new_position:
|
||||
self.move(new_position)
|
||||
self._cached_position = new_position
|
||||
self._cached_viewport_size = viewport_size
|
||||
self._cached_window_pos = current_viewport_pos
|
||||
# Update mask after size/position change
|
||||
self._apply_rounded_mask()
|
||||
|
||||
def parentWidget(self):
|
||||
"""Override to return stored parent widget for compatibility."""
|
||||
return self._parent_widget
|
||||
|
||||
def mousePressEvent(self, event: QtGui.QMouseEvent) -> None:
|
||||
"""Intercept mouse events to prevent them from reaching view."""
|
||||
# Accept event to prevent propagation
|
||||
event.accept()
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None:
|
||||
"""Intercept mouse events to prevent them from reaching view."""
|
||||
event.accept()
|
||||
super().mouseMoveEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event: QtGui.QMouseEvent) -> None:
|
||||
"""Intercept mouse events to prevent them from reaching view."""
|
||||
event.accept()
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
def resizeEvent(self, event: QtGui.QResizeEvent) -> None:
|
||||
"""Updates mask when widget size changes."""
|
||||
super().resizeEvent(event)
|
||||
self._apply_rounded_mask()
|
||||
232
beeref/widgets/gif_floating_menu.py
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
"""Floating menu shown when a single GIF item is selected."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from PyQt6 import QtCore, QtGui, QtWidgets
|
||||
|
||||
from beeref.assets import BeeAssets
|
||||
from beeref.widgets.floating_menu import FloatingMenu
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from beeref.view import BeeGraphicsView
|
||||
from beeref.gif_item import BeeGifItem
|
||||
from beeref.widgets.gif_frames_menu import GifFramesMenu
|
||||
|
||||
|
||||
class GifFloatingMenu(FloatingMenu):
|
||||
"""Contextual floating toolbar for GIF items."""
|
||||
|
||||
SPEED_VALUES = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
|
||||
|
||||
def __init__(self, parent: QtWidgets.QWidget, view: "BeeGraphicsView"):
|
||||
super().__init__(parent, view)
|
||||
|
||||
self._init_frames_menu(parent, view)
|
||||
self._init_icons()
|
||||
self._init_speed_combobox()
|
||||
self._init_control_buttons()
|
||||
|
||||
def _init_frames_menu(
|
||||
self,
|
||||
parent: QtWidgets.QWidget,
|
||||
view: "BeeGraphicsView"
|
||||
) -> None:
|
||||
"""Initializes frames menu."""
|
||||
self.frames_menu: Optional["GifFramesMenu"] = None
|
||||
try:
|
||||
from beeref.widgets.gif_frames_menu import GifFramesMenu
|
||||
self.frames_menu = GifFramesMenu(parent, view, self)
|
||||
except ImportError as e:
|
||||
logger.warning(f'Failed to import frames menu: {e}')
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to initialize frames menu: {e}')
|
||||
|
||||
def _init_icons(self) -> None:
|
||||
"""Loads icons for control buttons."""
|
||||
assets = BeeAssets()
|
||||
icons_path = assets.PATH.joinpath('icons')
|
||||
|
||||
self.prev_frame_icon = QtGui.QIcon(
|
||||
str(icons_path.joinpath('prev-frame.svg')))
|
||||
self.play_icon = QtGui.QIcon(str(icons_path.joinpath('play.svg')))
|
||||
self.pause_icon = QtGui.QIcon(str(icons_path.joinpath('pause.svg')))
|
||||
self.next_frame_icon = QtGui.QIcon(
|
||||
str(icons_path.joinpath('next-frame.svg')))
|
||||
self.frames_icon = QtGui.QIcon(str(icons_path.joinpath('frames.svg')))
|
||||
|
||||
def _init_speed_combobox(self) -> None:
|
||||
"""Initializes combobox for playback speed selection."""
|
||||
self.speed_combo = QtWidgets.QComboBox(self)
|
||||
self.speed_combo.setObjectName("FloatingMenuGifSpeed")
|
||||
self.speed_combo.setMinimumWidth(40)
|
||||
self.speed_combo.setIconSize(QtCore.QSize(32, 32))
|
||||
|
||||
for speed in self.SPEED_VALUES:
|
||||
label = self._format_speed_label(speed)
|
||||
self.speed_combo.addItem(label, speed)
|
||||
|
||||
# Set default value (1.0x)
|
||||
default_index = self.SPEED_VALUES.index(1.0)
|
||||
self.speed_combo.setCurrentIndex(default_index)
|
||||
self.speed_combo.currentIndexChanged.connect(self._on_speed_changed)
|
||||
self.speed_combo.setToolTip("Playback speed")
|
||||
self.add_widget(self.speed_combo)
|
||||
|
||||
def _format_speed_label(self, speed: float) -> str:
|
||||
"""Formats speed value for display in combobox."""
|
||||
if speed < 1.0:
|
||||
return f"{speed:.2f}x"
|
||||
else:
|
||||
speed_str = f"{speed:.2f}".rstrip('0').rstrip('.')
|
||||
return f"{speed_str}x"
|
||||
|
||||
def _init_control_buttons(self) -> None:
|
||||
"""Initializes playback control buttons."""
|
||||
self.prev_frame_btn = self.add_button(
|
||||
"",
|
||||
icon=self.prev_frame_icon,
|
||||
callback=self.on_previous_frame,
|
||||
)
|
||||
self.prev_frame_btn.setToolTip("Previous frame")
|
||||
|
||||
self.play_pause_btn = self.add_button(
|
||||
"",
|
||||
icon=self.play_icon,
|
||||
callback=self.on_toggle_play_pause,
|
||||
)
|
||||
self.play_pause_btn.setToolTip("Play/Pause")
|
||||
|
||||
self.next_frame_btn = self.add_button(
|
||||
"",
|
||||
icon=self.next_frame_icon,
|
||||
callback=self.on_next_frame,
|
||||
)
|
||||
self.next_frame_btn.setToolTip("Next frame")
|
||||
|
||||
self.frames_btn = self.add_button(
|
||||
"",
|
||||
icon=self.frames_icon,
|
||||
callback=self.on_toggle_frames_menu,
|
||||
)
|
||||
self.frames_btn.setToolTip("Show frames timeline")
|
||||
|
||||
def show_for_item(self, item: "BeeGifItem") -> None:
|
||||
"""Shows menu for specified GIF item."""
|
||||
super().show_for_item(item)
|
||||
self.update_play_pause_button()
|
||||
self.update_speed_combo()
|
||||
self._hide_frames_menu()
|
||||
|
||||
def _hide_frames_menu(self) -> None:
|
||||
"""Hides frames menu if it's open."""
|
||||
if self.frames_menu:
|
||||
self.frames_menu.hide_menu()
|
||||
|
||||
def update_speed_combo(self) -> None:
|
||||
"""Updates speed value in combobox."""
|
||||
if not self._has_gif_item():
|
||||
return
|
||||
|
||||
current_speed = self.current_item.get_speed()
|
||||
closest_speed = min(
|
||||
self.SPEED_VALUES,
|
||||
key=lambda x: abs(x - current_speed)
|
||||
)
|
||||
index = self.SPEED_VALUES.index(closest_speed)
|
||||
|
||||
self.speed_combo.blockSignals(True)
|
||||
self.speed_combo.setCurrentIndex(index)
|
||||
self.speed_combo.blockSignals(False)
|
||||
|
||||
def _on_speed_changed(self, index: int) -> None:
|
||||
"""Handler for playback speed change."""
|
||||
if not self._has_gif_item():
|
||||
return
|
||||
|
||||
speed = self.speed_combo.currentData()
|
||||
if speed is not None:
|
||||
self.current_item.set_speed(speed)
|
||||
|
||||
def update_play_pause_button(self) -> None:
|
||||
"""Updates Play/Pause button icon."""
|
||||
if not self._has_gif_item():
|
||||
return
|
||||
|
||||
if self.current_item.is_playing:
|
||||
self.play_pause_btn.setIcon(self.pause_icon)
|
||||
self.play_pause_btn.setToolTip("Pause")
|
||||
else:
|
||||
self.play_pause_btn.setIcon(self.play_icon)
|
||||
self.play_pause_btn.setToolTip("Play")
|
||||
|
||||
def on_toggle_play_pause(self) -> None:
|
||||
"""Toggles GIF play/pause."""
|
||||
if not self._has_gif_item():
|
||||
return
|
||||
|
||||
# Defer execution so button can process event
|
||||
def do_toggle():
|
||||
self.current_item.toggle_animation()
|
||||
self.update_play_pause_button()
|
||||
|
||||
QtCore.QTimer.singleShot(0, do_toggle)
|
||||
|
||||
def on_previous_frame(self) -> None:
|
||||
"""Goes to previous frame."""
|
||||
if not self._has_gif_item():
|
||||
return
|
||||
|
||||
# Defer execution so button can process event
|
||||
def do_previous():
|
||||
self.current_item.previous_frame()
|
||||
self.update_play_pause_button()
|
||||
self._update_frames_menu_if_visible()
|
||||
|
||||
QtCore.QTimer.singleShot(0, do_previous)
|
||||
|
||||
def on_next_frame(self) -> None:
|
||||
"""Goes to next frame."""
|
||||
if not self._has_gif_item():
|
||||
return
|
||||
|
||||
# Defer execution so button can process event
|
||||
def do_next():
|
||||
self.current_item.next_frame()
|
||||
self.update_play_pause_button()
|
||||
self._update_frames_menu_if_visible()
|
||||
|
||||
QtCore.QTimer.singleShot(0, do_next)
|
||||
|
||||
def on_toggle_frames_menu(self) -> None:
|
||||
"""Toggles frames menu visibility."""
|
||||
if not self._has_gif_item() or not self.frames_menu:
|
||||
return
|
||||
|
||||
self.frames_menu.toggle_menu(self.current_item)
|
||||
|
||||
def hide_menu(self) -> None:
|
||||
"""Hides menu and frames menu."""
|
||||
self._hide_frames_menu()
|
||||
super().hide_menu()
|
||||
|
||||
def update_position(self) -> None:
|
||||
"""Updates position of menu and frames menu."""
|
||||
super().update_position()
|
||||
|
||||
if self.frames_menu and self.frames_menu.isVisible():
|
||||
self.frames_menu.update_position()
|
||||
|
||||
def _has_gif_item(self) -> bool:
|
||||
"""Checks if current item is a GIF item."""
|
||||
return (self.current_item is not None and
|
||||
hasattr(self.current_item, 'is_playing'))
|
||||
|
||||
def _update_frames_menu_if_visible(self) -> None:
|
||||
"""Updates frames menu if it's visible."""
|
||||
if self.frames_menu and self.frames_menu.isVisible():
|
||||
self.frames_menu.load_frames(self.current_item)
|
||||
407
beeref/widgets/gif_frames_menu.py
Normal 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()
|
||||
|
||||
79
beeref/widgets/image_floating_menu.py
Normal 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)
|
||||
186
beeref/widgets/text_floating_menu.py
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
"""Floating menu shown when a single text item is selected."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from PyQt6 import QtCore, QtGui, QtWidgets
|
||||
|
||||
from beeref.assets import BeeAssets
|
||||
from beeref.widgets.floating_menu import FloatingMenu
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from beeref.view import BeeGraphicsView
|
||||
from beeref.items import BeeTextItem
|
||||
|
||||
|
||||
class TextFloatingMenu(FloatingMenu):
|
||||
"""Contextual floating toolbar for text items."""
|
||||
|
||||
FONT_SIZES = [8, 10, 12, 14, 16, 18, 24, 32, 48]
|
||||
|
||||
def __init__(self, parent: QtWidgets.QWidget, view: "BeeGraphicsView"):
|
||||
super().__init__(parent, view)
|
||||
|
||||
# Load icons
|
||||
assets = BeeAssets()
|
||||
icons_path = assets.PATH.joinpath('icons')
|
||||
text_color_icon = QtGui.QIcon(str(icons_path.joinpath('format-color-text.svg')))
|
||||
palette_icon = QtGui.QIcon(str(icons_path.joinpath('palette.svg')))
|
||||
bold_icon = QtGui.QIcon(str(icons_path.joinpath('format-bold.svg')))
|
||||
italic_icon = QtGui.QIcon(str(icons_path.joinpath('format-italic.svg')))
|
||||
underline_icon = QtGui.QIcon(str(icons_path.joinpath('format-underline.svg')))
|
||||
strikethrough_icon = QtGui.QIcon(str(icons_path.joinpath('format-strikethrough.svg')))
|
||||
reset_icon = QtGui.QIcon(str(icons_path.joinpath('clear.svg')))
|
||||
fonts_icon = QtGui.QIcon(str(icons_path.joinpath('fonts.svg')))
|
||||
|
||||
self.text_color_btn = self.add_button(
|
||||
"",
|
||||
icon=text_color_icon,
|
||||
callback=self._on_text_color_clicked,
|
||||
)
|
||||
self.background_btn = self.add_button(
|
||||
"",
|
||||
icon=palette_icon,
|
||||
callback=self._on_background_clicked,
|
||||
)
|
||||
self.add_separator()
|
||||
|
||||
self.bold_btn = self.add_button(
|
||||
"",
|
||||
icon=bold_icon,
|
||||
callback=self._on_bold_clicked,
|
||||
checkable=True,
|
||||
)
|
||||
self.italic_btn = self.add_button(
|
||||
"",
|
||||
icon=italic_icon,
|
||||
callback=self._on_italic_clicked,
|
||||
checkable=True,
|
||||
)
|
||||
self.underline_btn = self.add_button(
|
||||
"",
|
||||
icon=underline_icon,
|
||||
callback=self._on_underline_clicked,
|
||||
checkable=True,
|
||||
)
|
||||
self.strikethrough_btn = self.add_button(
|
||||
"",
|
||||
icon=strikethrough_icon,
|
||||
callback=self._on_strikethrough_clicked,
|
||||
checkable=True,
|
||||
)
|
||||
self.add_separator()
|
||||
|
||||
self.size_combo = QtWidgets.QComboBox(self)
|
||||
self.size_combo.setObjectName("FloatingMenuFontSize")
|
||||
self.size_combo.setMinimumWidth(40)
|
||||
for size in self.FONT_SIZES:
|
||||
self.size_combo.addItem(str(size), size)
|
||||
self.size_combo.currentIndexChanged.connect(self._on_size_changed)
|
||||
self.add_widget(self.size_combo)
|
||||
|
||||
self.font_combo = QtWidgets.QComboBox(self)
|
||||
self.font_combo.setObjectName("FloatingMenuFontFamily")
|
||||
self.font_combo.setMinimumWidth(40)
|
||||
self.font_combo.setIconSize(QtCore.QSize(32, 32))
|
||||
self.font_combo.setEditable(False)
|
||||
self.font_combo.setInsertPolicy(
|
||||
QtWidgets.QComboBox.InsertPolicy.NoInsert)
|
||||
families = QtGui.QFontDatabase.families()
|
||||
self.font_combo.addItems(families)
|
||||
# Add icon to all font items
|
||||
for i in range(self.font_combo.count()):
|
||||
self.font_combo.setItemIcon(i, fonts_icon)
|
||||
self.font_combo.currentTextChanged.connect(self._on_font_changed)
|
||||
self.add_widget(self.font_combo)
|
||||
|
||||
self.add_separator()
|
||||
|
||||
self.add_button(
|
||||
"",
|
||||
icon=reset_icon,
|
||||
callback=self.view.reset_selected_text_format,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def show_for_item(self, item: "BeeTextItem") -> None:
|
||||
font = item.font()
|
||||
self._update_font_controls(font)
|
||||
self._update_colors(item)
|
||||
super().show_for_item(item)
|
||||
|
||||
# UI updates -------------------------------------------------------
|
||||
def _update_font_controls(self, font: QtGui.QFont) -> None:
|
||||
self.bold_btn.setChecked(font.weight() >= QtGui.QFont.Weight.Bold)
|
||||
self.italic_btn.setChecked(font.italic())
|
||||
self.underline_btn.setChecked(font.underline())
|
||||
self.strikethrough_btn.setChecked(font.strikeOut())
|
||||
|
||||
size = font.pointSize()
|
||||
if size == -1:
|
||||
size = int(font.pointSizeF())
|
||||
|
||||
try:
|
||||
index = self.FONT_SIZES.index(size)
|
||||
except ValueError:
|
||||
index = -1
|
||||
self.size_combo.blockSignals(True)
|
||||
if index >= 0:
|
||||
self.size_combo.setCurrentIndex(index)
|
||||
else:
|
||||
self.size_combo.setCurrentText(str(size))
|
||||
self.size_combo.blockSignals(False)
|
||||
|
||||
self.font_combo.blockSignals(True)
|
||||
family = font.family()
|
||||
index = self.font_combo.findText(family)
|
||||
if index >= 0:
|
||||
self.font_combo.setCurrentIndex(index)
|
||||
self.font_combo.blockSignals(False)
|
||||
|
||||
def _update_colors(self, item: "BeeTextItem") -> None:
|
||||
text_color = item.defaultTextColor()
|
||||
self.text_color_btn.setProperty(
|
||||
"active", "true" if text_color else "false")
|
||||
self.text_color_btn.style().unpolish(self.text_color_btn)
|
||||
self.text_color_btn.style().polish(self.text_color_btn)
|
||||
|
||||
bg_color = getattr(item, "background_color", None)
|
||||
self.background_btn.setProperty(
|
||||
"active", "true" if bg_color and bg_color.alpha() > 0 else "false")
|
||||
self.background_btn.style().unpolish(self.background_btn)
|
||||
self.background_btn.style().polish(self.background_btn)
|
||||
|
||||
# Slots ------------------------------------------------------------
|
||||
def _on_text_color_clicked(self) -> None:
|
||||
self.view.change_selected_text_color()
|
||||
|
||||
def _on_background_clicked(self) -> None:
|
||||
self.view.change_selected_text_background()
|
||||
|
||||
def _on_bold_clicked(self) -> None:
|
||||
self.view.toggle_selected_text_bold()
|
||||
|
||||
def _on_italic_clicked(self) -> None:
|
||||
self.view.toggle_selected_text_italic()
|
||||
|
||||
def _on_underline_clicked(self) -> None:
|
||||
self.view.toggle_selected_text_underline()
|
||||
|
||||
def _on_strikethrough_clicked(self) -> None:
|
||||
self.view.toggle_selected_text_strikethrough()
|
||||
|
||||
def _on_size_changed(self, index: int) -> None:
|
||||
size = self.size_combo.currentData()
|
||||
if size is None:
|
||||
try:
|
||||
size = int(self.size_combo.currentText())
|
||||
except ValueError:
|
||||
return
|
||||
self.view.change_selected_text_size(size)
|
||||
|
||||
def _on_font_changed(self, family: str) -> None:
|
||||
if not family:
|
||||
return
|
||||
self.view.change_selected_text_font(family)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -17,12 +17,12 @@ license = {file = "LICENSE"}
|
|||
authors = [
|
||||
{ name = "Rebecca Breu", email = "rebecca@rbreu.de" },
|
||||
]
|
||||
requires-python = ">=3.9,<3.13"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"exif>=1.3.5,<=1.6.0",
|
||||
"lxml==5.1.0",
|
||||
"pyQt6-Qt6>=6.7.0,<=6.7.0",
|
||||
"pyQt6>=6.7.0,<=6.7.0",
|
||||
"lxml>=5.1.0",
|
||||
"pyQt6-Qt6>=6.10.0",
|
||||
"pyQt6>=6.10.0",
|
||||
"rectangle-packer>=2.0.1,<=2.0.2",
|
||||
]
|
||||
|
||||
|
|
|
|||