new features
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
|
||||
```
|
||||
|
||||
|
|
@ -109,9 +109,11 @@ def main():
|
|||
logger.info(f'Logging to: {logfile_name()}')
|
||||
settings.on_startup()
|
||||
args = CommandlineArgs(with_check=True) # Force checking
|
||||
assert not args.debug_raise_error, args.debug_raise_error
|
||||
if args.debug_raise_error:
|
||||
raise RuntimeError(args.debug_raise_error)
|
||||
|
||||
os.environ["QT_DEBUG_PLUGINS"] = "1"
|
||||
if args.loglevel == 'DEBUG':
|
||||
os.environ["QT_DEBUG_PLUGINS"] = "1"
|
||||
app = BeeRefApplication(sys.argv)
|
||||
palette = create_palette_from_dict(constants.COLORS)
|
||||
app.setPalette(palette)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""Создает курсор из SVG файла."""
|
||||
app = QtWidgets.QApplication.instance()
|
||||
scaling = app.primaryScreen().devicePixelRatio()
|
||||
|
||||
# Загружаем SVG
|
||||
svg_path = str(self.PATH.joinpath(filename))
|
||||
renderer = QtSvg.QSvgRenderer(svg_path)
|
||||
|
||||
# Создаем pixmap нужного размера
|
||||
pixmap_size = int(size * scaling)
|
||||
pixmap = QtGui.QPixmap(pixmap_size, pixmap_size)
|
||||
pixmap.fill(QtGui.QColor(0, 0, 0, 0)) # Прозрачный фон
|
||||
|
||||
# Рендерим SVG в pixmap
|
||||
painter = QtGui.QPainter(pixmap)
|
||||
renderer.render(painter)
|
||||
painter.end()
|
||||
|
||||
pixmap.setDevicePixelRatio(scaling)
|
||||
return QtGui.QCursor(
|
||||
pixmap, int(hotspot[0]/scaling), int(hotspot[1]/scaling))
|
||||
|
|
|
|||
4
beeref/assets/icons/close.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="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12L19 6.41Z" fill="white"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 279 B |
3
beeref/assets/icons/crop.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 fill-rule="evenodd" clip-rule="evenodd" d="M8.556 16C8.25 16 8 15.751 8 15.444V8H15.445C15.751 8 16 8.249 16 8.556V16H8.556ZM21 16H18V8.556C18 7.146 16.854 6 15.445 6H8V3C8 2.447 7.553 2 7 2C6.448 2 6 2.447 6 3V6H3C2.448 6 2 6.447 2 7C2 7.553 2.448 8 3 8H6V15.444C6 16.854 7.147 18 8.556 18H16V21C16 21.553 16.448 22 17 22C17.553 22 18 21.553 18 21V18H21C21.553 18 22 17.553 22 17C22 16.447 21.553 16 21 16Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 533 B |
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 |
3
beeref/assets/icons/draw-line.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 fill-rule="evenodd" clip-rule="evenodd" d="M11.9831 7.32291L6.36614 12.9399L6.10214 15.8959L9.07914 15.6249L14.6791 10.0189L11.9831 7.32291ZM17.9661 6.72891L15.2711 4.03491L13.3231 5.98291L16.0181 8.67891L17.9661 6.72891ZM5.09114 17.9959C5.06014 17.9989 5.03014 17.9999 5.00014 17.9999C4.73614 17.9999 4.48114 17.8959 4.29314 17.7069C4.08314 17.4969 3.97714 17.2049 4.00414 16.9099L4.38314 12.7389C4.42514 12.2819 4.62714 11.8509 4.95214 11.5259L13.9481 2.52891C14.6501 1.82491 15.9241 1.85991 16.6641 2.59891L19.4021 5.33691C20.1681 6.10391 20.1991 7.32091 19.4711 8.05091L10.4741 17.0479C10.1491 17.3739 9.71914 17.5749 9.26114 17.6169L5.09114 17.9959ZM5 20H19C19.55 20 20 20.45 20 21C20 21.55 19.55 22 19 22H5C4.45 22 4 21.55 4 21C4 20.45 4.45 20 5 20Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 881 B |
3
beeref/assets/icons/flip_h.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="M17 7.99982H4C3.4 7.99982 3 7.59982 3 6.99982C3 6.39982 3.4 5.99982 4 5.99982H17.1L15.5 4.79982C15.1 4.49982 15 3.79982 15.3 3.39982C15.6 2.99982 16.3 2.89982 16.7 3.19982L20.6 6.19982C20.9 6.39982 21 6.69982 21 6.99982C21 7.29982 20.9 7.59982 20.6 7.79982L16.6 10.7998C16.4 10.8998 16.2 10.9998 16 10.9998C15.7 10.9998 15.4 10.8998 15.2 10.5998C14.9 10.1998 15 9.49982 15.4 9.19982L17 7.99982ZM7 14.9998H20C20.6 14.9998 21 15.3998 21 15.9998C21 16.5998 20.6 16.9998 20 16.9998H6.9L8.5 18.1998C8.9 18.4998 9 19.1998 8.7 19.5998C8.5 19.8998 8.2 19.9998 7.9 19.9998C7.7 19.9998 7.5 19.8998 7.3 19.7998L3.4 16.7998C3.1 16.5998 3 16.2998 3 15.9998C3 15.6998 3.1 15.3998 3.4 15.1998L7.4 12.1998C7.8 11.8998 8.5 11.9998 8.8 12.3998C9.1 12.7998 9 13.4998 8.6 13.7998L7 14.9998Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 899 B |
3
beeref/assets/icons/flip_v.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.99982 7L7.99982 20C7.99982 20.6 7.59982 21 6.99982 21C6.39982 21 5.99982 20.6 5.99982 20L5.99982 6.9L4.79982 8.5C4.49982 8.9 3.79982 9 3.39982 8.7C2.99982 8.4 2.89982 7.7 3.19982 7.3L6.19982 3.4C6.39982 3.1 6.69982 3 6.99982 3C7.29982 3 7.59982 3.1 7.79982 3.4L10.7998 7.4C10.8998 7.6 10.9998 7.8 10.9998 8C10.9998 8.3 10.8998 8.6 10.5998 8.8C10.1998 9.1 9.49982 9 9.19982 8.6L7.99982 7ZM14.9998 17L14.9998 4C14.9998 3.4 15.3998 3 15.9998 3C16.5998 3 16.9998 3.4 16.9998 4V17.1L18.1998 15.5C18.4998 15.1 19.1998 15 19.5998 15.3C19.8998 15.5 19.9998 15.8 19.9998 16.1C19.9998 16.3 19.8998 16.5 19.7998 16.7L16.7998 20.6C16.5998 20.9 16.2998 21 15.9998 21C15.6998 21 15.3998 20.9 15.1998 20.6L12.1998 16.6C11.8998 16.2 11.9998 15.5 12.3998 15.2C12.7998 14.9 13.4998 15 13.7998 15.4L14.9998 17Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 923 B |
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 |
3
beeref/assets/icons/frames.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 fill-rule="evenodd" clip-rule="evenodd" d="M19 18.256C19 18.666 18.666 19 18.256 19H17V17H19V18.256ZM5 18.256V17H7V19H5.744C5.334 19 5 18.666 5 18.256ZM5.744 5H7V7H5V5.744C5 5.334 5.334 5 5.744 5ZM19 5.744V7H17V5H18.256C18.666 5 19 5.334 19 5.744ZM17 15H19V13H17V15ZM17 11H19V9H17V11ZM9 19H15V5H9V19ZM5 15H7V13H5V15ZM5 11H7V9H5V11ZM18.256 3H5.744C4.231 3 3 4.232 3 5.744V18.256C3 19.769 4.231 21 5.744 21H18.256C19.769 21 21 19.769 21 18.256V5.744C21 4.232 19.769 3 18.256 3Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 601 B |
3
beeref/assets/icons/gamut.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 fill-rule="evenodd" clip-rule="evenodd" d="M5 8H8.818V5H5V8ZM5 13H8.818V10H5V13ZM8.818 17.091C8.818 18.144 7.962 19 6.909 19C5.856 19 5 18.144 5 17.091V15H8.818V17.091ZM10.818 4C10.818 3.447 10.371 3 9.818 3H4C3.447 3 3 3.447 3 4V17.091C3 19.246 4.754 21 6.909 21C9.064 21 10.818 19.246 10.818 17.091V4ZM20 13.1816H17.305L15.44 15.1816L11.881 18.9996L10.466 20.5176L10.017 20.9996H20C20.553 20.9996 21 20.5526 21 19.9996V14.1816C21 13.6286 20.553 13.1816 20 13.1816ZM14.0535 5.4438L18.1895 9.2998C18.5935 9.6768 18.6165 10.3098 18.2395 10.7128L11.7605 17.6618C11.7663 17.6138 11.7729 17.566 11.7796 17.5182C11.799 17.3774 11.8185 17.2363 11.8185 17.0908V14.6678V9.3078V6.3748L12.6405 5.4928C12.8215 5.2988 13.0725 5.1848 13.3365 5.1758C13.6105 5.1538 13.8605 5.2628 14.0535 5.4438Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 907 B |
4
beeref/assets/icons/grayscale.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="M18 3C19.6569 3 21 4.34315 21 6V18C21 19.6569 19.6569 21 18 21H6C4.34315 21 3 19.6569 3 18V6C3 4.34315 4.34315 3 6 3H18ZM8 5C6.34315 5 5 6.34315 5 8V16C5 17.6569 6.34315 19 8 19H16C17.6569 19 19 17.6569 19 16V8C19 6.34315 17.6569 5 16 5H8Z" fill="white"/>
|
||||
<path d="M12 6H9C7.34315 6 6 7.34315 6 9V15C6 16.6569 7.34315 18 9 18H12V6Z" fill="#FFFEFE"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 462 B |
4
beeref/assets/icons/line-arrow-both.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="M7 12.9744H19C19.6 12.9744 20 12.5744 20 11.9744C20 11.3744 19.6 10.9744 19 10.9744H6.9L8.5 9.77443C8.9 9.47443 9 8.77443 8.7 8.37443C8.4 7.97443 7.7 7.87443 7.3 8.17443L3.4 11.1744C3.1 11.3744 3 11.6744 3 11.9744C3 12.2744 3.1 12.5744 3.4 12.7744L7.4 15.7744C7.6 15.8744 7.8 15.9744 8 15.9744C8.3 15.9744 8.6 15.8744 8.8 15.5744C9.1 15.1744 9 14.4744 8.6 14.1744L7 12.9744Z" fill="white"/>
|
||||
<path d="M17 12.9744H5C4.4 12.9744 4 12.5744 4 11.9744C4 11.3744 4.4 10.9744 5 10.9744H17.1L15.5 9.77443C15.1 9.47443 15 8.77443 15.3 8.37443C15.6 7.97443 16.3 7.87443 16.7 8.17443L20.6 11.1744C20.9 11.3744 21 11.6744 21 11.9744C21 12.2744 20.9 12.5744 20.6 12.7744L16.6 15.7744C16.4 15.8744 16.2 15.9744 16 15.9744C15.7 15.9744 15.4 15.8744 15.2 15.5744C14.9 15.1744 15 14.4744 15.4 14.1744L17 12.9744Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 923 B |
3
beeref/assets/icons/line-arrow-left.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 12.9744H20C20.6 12.9744 21 12.5744 21 11.9744C21 11.3744 20.6 10.9744 20 10.9744H6.9L8.5 9.77443C8.9 9.47443 9 8.77443 8.7 8.37443C8.4 7.97443 7.7 7.87443 7.3 8.17443L3.4 11.1744C3.1 11.3744 3 11.6744 3 11.9744C3 12.2744 3.1 12.5744 3.4 12.7744L7.4 15.7744C7.6 15.8744 7.8 15.9744 8 15.9744C8.3 15.9744 8.6 15.8744 8.8 15.5744C9.1 15.1744 9 14.4744 8.6 14.1744L7 12.9744Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 503 B |
3
beeref/assets/icons/line-arrow.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="M17 12.9744H4C3.4 12.9744 3 12.5744 3 11.9744C3 11.3744 3.4 10.9744 4 10.9744H17.1L15.5 9.77443C15.1 9.47443 15 8.77443 15.3 8.37443C15.6 7.97443 16.3 7.87443 16.7 8.17443L20.6 11.1744C20.9 11.3744 21 11.6744 21 11.9744C21 12.2744 20.9 12.5744 20.6 12.7744L16.6 15.7744C16.4 15.8744 16.2 15.9744 16 15.9744C15.7 15.9744 15.4 15.8744 15.2 15.5744C14.9 15.1744 15 14.4744 15.4 14.1744L17 12.9744Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 523 B |
5
beeref/assets/icons/line-dashed.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 12H6" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M10 12L14 12" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M18 12H21" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 335 B |
3
beeref/assets/icons/line-solid.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="M2 12H22" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 179 B |
3
beeref/assets/icons/next-frame.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 fill-rule="evenodd" clip-rule="evenodd" d="M20.858 10.6736L15.759 6.45959C15.15 5.95759 14.282 5.85659 13.549 6.20059C12.902 6.50659 12.5 7.11359 12.5 7.78659V10.3776L7.759 6.45959C7.15 5.95759 6.281 5.85659 5.549 6.20059C4.902 6.50659 4.5 7.11359 4.5 7.78659V16.2126C4.5 16.8856 4.902 17.4926 5.549 17.7986C5.838 17.9346 6.149 18.0006 6.455 18.0006C6.926 18.0006 7.39 17.8436 7.759 17.5386L12.5 13.6226V16.2126C12.5 16.8856 12.902 17.4926 13.549 17.7986C13.838 17.9346 14.149 18.0006 14.455 18.0006C14.926 18.0006 15.39 17.8436 15.759 17.5386L20.858 13.3256C21.266 12.9896 21.5 12.5056 21.5 11.9996C21.5 11.4936 21.266 11.0096 20.858 10.6736Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 769 B |
3
beeref/assets/icons/opacity.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="M18 3C19.6569 3 21 4.34315 21 6V18C21 19.6569 19.6569 21 18 21H6C4.34315 21 3 19.6569 3 18V6C3 4.34315 4.34315 3 6 3H18ZM12 12V19H19V12H12ZM5 5V12H12V5H5Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 283 B |
3
beeref/assets/icons/palette.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 fill-rule="evenodd" clip-rule="evenodd" d="M6.36614 14.9395L11.9451 9.35953L14.6411 12.0555L11.8511 14.8515M19.4021 7.33653L16.6631 4.59753C15.8951 3.82953 14.6771 3.80153 13.9481 4.52953L11.9451 6.53153L10.7071 5.29353C10.3161 4.90253 9.68314 4.90253 9.29314 5.29353C8.90214 5.68353 8.90214 6.31653 9.29314 6.70753L10.5311 7.94553L4.95214 13.5255C4.62714 13.8505 4.42514 14.2825 4.38314 14.7395L4.00414 18.9095C3.97714 19.2055 4.08314 19.4975 4.29314 19.7075C4.48114 19.8955 4.73614 20.0005 5.00014 20.0005C5.03014 20.0005 5.06014 19.9995 5.09114 19.9965L9.26114 19.6175C9.71814 19.5755 10.1491 19.3735 10.4741 19.0475L16.0541 13.4685L17.2931 14.7075C17.4881 14.9025 17.7441 15.0005 18.0001 15.0005C18.2561 15.0005 18.5121 14.9025 18.7071 14.7075C19.0971 14.3165 19.0971 13.6835 18.7071 13.2935L17.4691 12.0545L19.4701 10.0515C20.2001 9.32253 20.1701 8.10453 19.4021 7.33653Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1,002 B |
3
beeref/assets/icons/pause.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 fill-rule="evenodd" clip-rule="evenodd" d="M8 5C6.89543 5 6 5.89543 6 7V17C6 18.1046 6.89543 19 8 19C9.10457 19 10 18.1046 10 17V7C10 5.89543 9.10457 5 8 5ZM16 5C14.8954 5 14 5.89543 14 7V17C14 18.1046 14.8954 19 16 19C17.1046 19 18 18.1046 18 17V7C18 5.89543 17.1046 5 16 5Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 401 B |
3
beeref/assets/icons/play.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 fill-rule="evenodd" clip-rule="evenodd" d="M7 17.5353C7 18.7089 8.28685 19.4279 9.28626 18.8128L18.2815 13.2762C19.2332 12.6904 19.2331 11.3069 18.2812 10.7212L9.28604 5.18673C8.28663 4.57182 7 5.29086 7 6.46428V17.5353Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 346 B |
3
beeref/assets/icons/prev-frame.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 fill-rule="evenodd" clip-rule="evenodd" d="M18.45 6.20097C17.716 5.85597 16.849 5.95797 16.241 6.46097L11.5 10.377V7.78697C11.5 7.11397 11.097 6.50697 10.45 6.20097C9.716 5.85597 8.849 5.95797 8.241 6.46097L3.141 10.674C2.734 11.01 2.5 11.494 2.5 12C2.5 12.506 2.734 12.99 3.141 13.326L8.241 17.54C8.609 17.844 9.073 18.001 9.544 18.001C9.851 18.001 10.161 17.934 10.45 17.799C11.097 17.493 11.5 16.886 11.5 16.213V13.622L16.241 17.54C16.609 17.844 17.073 18.001 17.544 18.001C17.851 18.001 18.161 17.934 18.45 17.799C19.097 17.493 19.5 16.886 19.5 16.213V7.78697C19.5 7.11397 19.097 6.50697 18.45 6.20097Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 732 B |
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 |
3
beeref/assets/icons/trash-alt.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 fill-rule="evenodd" clip-rule="evenodd" d="M10 16C10 16.55 9.55 17 9 17C8.45 17 8 16.55 8 16V12C8 11.45 8.45 11 9 11C9.55 11 10 11.45 10 12V16ZM16 16C16 16.55 15.55 17 15 17C14.45 17 14 16.55 14 16V12C14 11.45 14.45 11 15 11C15.55 11 16 11.45 16 12V16ZM18 19C18 19.551 17.552 20 17 20H7C6.448 20 6 19.551 6 19V8H18V19ZM10 4.328C10 4.173 10.214 4 10.5 4H13.5C13.786 4 14 4.173 14 4.328V6H10V4.328ZM21 6H20H16V4.328C16 3.044 14.879 2 13.5 2H10.5C9.121 2 8 3.044 8 4.328V6H4H3C2.45 6 2 6.45 2 7C2 7.55 2.45 8 3 8H4V19C4 20.654 5.346 22 7 22H17C18.654 22 20 20.654 20 19V8H21C21.55 8 22 7.55 22 7C22 6.45 21.55 6 21 6V6Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 742 B |
3
beeref/assets/icons/undo.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.1523 2.00211C9.25997 1.95883 6.64866 3.15654 4.79688 5.09L2.85352 3.14664C2.65852 2.95164 2.34148 2.95164 2.14648 3.14664C2.04848 3.24364 2 3.37216 2 3.50016V8.00016C2 8.26537 2.10536 8.51973 2.29289 8.70726C2.48043 8.8948 2.73478 9.00016 3 9.00016H7.5C7.628 9.00016 7.75552 8.95067 7.85352 8.85367C8.04852 8.65867 8.04852 8.34164 7.85352 8.14664L6.2168 6.50992C8.02806 4.60134 10.7519 3.56922 13.6914 4.17594C16.7664 4.81094 19.2318 7.30002 19.8398 10.381C20.8498 15.499 16.941 20.0002 12 20.0002C7.881 20.0002 4.47987 16.8704 4.04688 12.8654C3.99287 12.3684 3.5625 12.0002 3.0625 12.0002C2.4625 12.0002 1.9955 12.5253 2.0625 13.1213C2.6215 18.1093 6.866 22.0002 12 22.0002C18.136 22.0002 22.9993 16.4438 21.8223 10.0978C21.0773 6.07481 17.8171 2.86041 13.7871 2.15641C13.2347 2.06003 12.6888 2.01014 12.1523 2.00211Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 951 B |
|
|
@ -45,3 +45,169 @@ COLORS = {
|
|||
'Scene:Canvas': (60, 60, 60),
|
||||
'Scene:Text': (200, 200, 200),
|
||||
}
|
||||
|
||||
|
||||
def get_welcome_overlay_icon_style():
|
||||
return 'padding: 12px; margin-bottom: 12px;'
|
||||
|
||||
|
||||
def get_standard_button_style():
|
||||
highlight = COLORS['Active:Highlight']
|
||||
text = COLORS['Active:ButtonText']
|
||||
return (
|
||||
'QPushButton {'
|
||||
f'color: rgb({text[0]}, {text[1]}, {text[2]});'
|
||||
f'background-color: rgba({highlight[0]}, {highlight[1]}, {highlight[2]}, 0.25);'
|
||||
'padding: 0.6em 2em;'
|
||||
'border: none;'
|
||||
'border-radius: 6px;'
|
||||
'}'
|
||||
'QPushButton:hover {'
|
||||
f'background-color: rgba({highlight[0]}, {highlight[1]}, {highlight[2]}, 0.4);'
|
||||
'}'
|
||||
'QPushButton:pressed {'
|
||||
f'background-color: rgba({highlight[0]}, {highlight[1]}, {highlight[2]}, 0.6);'
|
||||
'}'
|
||||
)
|
||||
|
||||
|
||||
# Floating Menu Styles
|
||||
|
||||
def _css_color(color):
|
||||
"""Return a CSS-compatible rgb/rgba string for the given color tuple."""
|
||||
length = len(color)
|
||||
if length == 3:
|
||||
r, g, b = color
|
||||
return f"rgb({r}, {g}, {b})"
|
||||
if length == 4:
|
||||
r, g, b, a = color
|
||||
return f"rgba({r}, {g}, {b}, {a})"
|
||||
raise ValueError('Color tuples must have 3 (RGB) or 4 (RGBA) components.')
|
||||
|
||||
|
||||
def get_floating_menu_base_style():
|
||||
"""Base container style for floating menus."""
|
||||
bg = COLORS['Active:Window']
|
||||
border = COLORS['Active:Base']
|
||||
return f"""
|
||||
QWidget#FloatingMenu {{
|
||||
background-color: {_css_color(bg)};
|
||||
border-radius: 8px;
|
||||
border: 1px solid {_css_color(border)};
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def get_floating_menu_button_style():
|
||||
"""Push button styling for floating menus."""
|
||||
background_color = (255, 255, 255, 30)
|
||||
border_color = (255, 255, 255, 10)
|
||||
inactive = COLORS['Disabled:Text']
|
||||
active = COLORS['Active:Text']
|
||||
accent = COLORS['Active:Highlight']
|
||||
|
||||
return f"""
|
||||
QWidget#FloatingMenu QPushButton[floatingButton="true"] {{
|
||||
background-color: {_css_color(background_color)};
|
||||
color: {_css_color(inactive)};
|
||||
border: 1px solid {_css_color(border_color)};
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
font-weight: 600;
|
||||
min-width: 12px;
|
||||
min-height: 28px;
|
||||
max-height: 28px;
|
||||
}}
|
||||
|
||||
QWidget#FloatingMenu QPushButton[floatingButton="true"]:hover,
|
||||
QWidget#FloatingMenu QPushButton[floatingButton="true"]:checked,
|
||||
QWidget#FloatingMenu QPushButton[floatingButton="true"][active="true"] {{
|
||||
color: {_css_color(active)};
|
||||
}}
|
||||
|
||||
QWidget#FloatingMenu QPushButton[floatingButton="true"]:checked,
|
||||
QWidget#FloatingMenu QPushButton[floatingButton="true"][active="true"] {{
|
||||
border-bottom: 2px solid {_css_color(accent)};
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def get_floating_menu_separator_style():
|
||||
"""Separator styling for floating menus."""
|
||||
border_color = (255, 255, 255, 10)
|
||||
r, g, b = border_color[:3]
|
||||
|
||||
return f"""
|
||||
QWidget#FloatingMenu QFrame#FloatingMenuSeparator {{
|
||||
background-color: rgba({r}, {g}, {b}, 30);
|
||||
min-height: 28px;
|
||||
max-height: 28px;
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def get_floating_menu_combo_style():
|
||||
"""Combo-box styling for floating menus."""
|
||||
from beeref.assets import BeeAssets
|
||||
|
||||
bg = COLORS['Active:Button']
|
||||
active_color = COLORS['Active:Text']
|
||||
border_color = (255, 255, 255, 10)
|
||||
border_r, border_g, border_b = border_color[:3]
|
||||
|
||||
# Получаем путь к иконке стрелки
|
||||
assets = BeeAssets()
|
||||
arrow_icon_path = assets.PATH.joinpath('icons', 'small-down.svg')
|
||||
# Используем прямой путь, экранируя обратные слеши для Windows
|
||||
arrow_icon_path_str = str(arrow_icon_path).replace('\\', '/')
|
||||
|
||||
return f"""
|
||||
QWidget#FloatingMenu QComboBox,
|
||||
QWidget#FloatingMenu QFontComboBox {{
|
||||
background-color: {_css_color(bg)};
|
||||
color: {_css_color(active_color)};
|
||||
border: 1px solid rgba({border_r}, {border_g}, {border_b}, 160);
|
||||
border-radius: 4px;
|
||||
padding: 4px 12px;
|
||||
min-width: 40px;
|
||||
min-height: 28px;
|
||||
max-height: 28px;
|
||||
}}
|
||||
|
||||
QWidget#FloatingMenu QComboBox::drop-down,
|
||||
QWidget#FloatingMenu QFontComboBox::drop-down {{
|
||||
border: none;
|
||||
width: 16px;
|
||||
}}
|
||||
|
||||
QWidget#FloatingMenu QComboBox::down-arrow,
|
||||
QWidget#FloatingMenu QFontComboBox::down-arrow {{
|
||||
image: url({arrow_icon_path_str});
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-right: 4px;
|
||||
}}
|
||||
|
||||
QWidget#FloatingMenu QComboBox QAbstractItemView,
|
||||
QWidget#FloatingMenu QFontComboBox QAbstractItemView {{
|
||||
min-width: 152px;
|
||||
}}
|
||||
|
||||
QWidget#FloatingMenu QComboBox#FloatingMenuFontSize QAbstractItemView {{
|
||||
min-width: 80px;
|
||||
}}
|
||||
|
||||
QWidget#FloatingMenu QComboBox#FloatingMenuGifSpeed QAbstractItemView {{
|
||||
min-width: 80px;
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def get_floating_menu_style():
|
||||
"""Aggregate stylesheet for floating menus."""
|
||||
return ''.join([
|
||||
get_floating_menu_base_style(),
|
||||
get_floating_menu_button_style(),
|
||||
get_floating_menu_separator_style(),
|
||||
get_floating_menu_combo_style(),
|
||||
])
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""Проверяет, является ли файл GIF."""
|
||||
if isinstance(path, str):
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
return ext == '.gif'
|
||||
elif hasattr(path, 'isLocalFile') and path.isLocalFile():
|
||||
ext = os.path.splitext(path.toLocalFile())[1].lower()
|
||||
return ext == '.gif'
|
||||
return False
|
||||
|
||||
|
||||
def exif_rotated_image(path=None):
|
||||
"""Returns a QImage that is transformed according to the source's
|
||||
orientation EXIF data.
|
||||
|
|
@ -84,9 +95,14 @@ def exif_rotated_image(path=None):
|
|||
def load_image(path):
|
||||
if isinstance(path, str):
|
||||
path = os.path.normpath(path)
|
||||
# Проверяем, является ли файл GIF
|
||||
if is_gif_file(path):
|
||||
return (None, path) # Возвращаем None для изображения, путь для GIF
|
||||
return (exif_rotated_image(path), path)
|
||||
if path.isLocalFile():
|
||||
path = os.path.normpath(path.toLocalFile())
|
||||
if is_gif_file(path):
|
||||
return (None, path)
|
||||
return (exif_rotated_image(path), path)
|
||||
|
||||
url = bytes(path.toEncoded()).decode()
|
||||
|
|
|
|||
|
|
@ -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 # Отступ вокруг линии для увеличения кликабельной области
|
||||
|
||||
def __init__(self, path=None, **kwargs):
|
||||
super().__init__()
|
||||
self.save_id = None
|
||||
logger.debug(f'Initialized {self}')
|
||||
self.is_image = False
|
||||
self.init_selectable()
|
||||
self.is_editable = False # Рисование не редактируется через двойной клик
|
||||
|
||||
# Настройки пера по умолчанию
|
||||
self.pen_color = QtGui.QColor(*COLORS['Scene:Text'])
|
||||
self.pen_width = 8
|
||||
self.pen_style = 'solid' # 'solid', 'dashed', 'arrow', '<-', '<->'
|
||||
self._update_pen()
|
||||
self.setBrush(QtGui.QBrush(QtCore.Qt.BrushStyle.NoBrush))
|
||||
|
||||
if path:
|
||||
self.setPath(path)
|
||||
|
||||
def setPath(self, path):
|
||||
"""Устанавливает путь и обновляет геометрию."""
|
||||
self.prepareGeometryChange()
|
||||
super().setPath(path)
|
||||
self.update()
|
||||
|
||||
@classmethod
|
||||
def create_from_data(cls, **kwargs):
|
||||
data = kwargs.get('data', {})
|
||||
item = cls()
|
||||
|
||||
# Восстановление пути из данных
|
||||
if 'path' in data and data['path']:
|
||||
path = QtGui.QPainterPath()
|
||||
path_data = data['path']
|
||||
for i, point_data in enumerate(path_data):
|
||||
if i == 0:
|
||||
path.moveTo(point_data['x'], point_data['y'])
|
||||
else:
|
||||
path.lineTo(point_data['x'], point_data['y'])
|
||||
item.setPath(path)
|
||||
|
||||
if 'pen_color' in data:
|
||||
item.pen_color = QtGui.QColor(data['pen_color'])
|
||||
if 'pen_width' in data:
|
||||
item.pen_width = data['pen_width']
|
||||
if 'pen_style' in data:
|
||||
item.pen_style = data['pen_style']
|
||||
item._update_pen()
|
||||
return item
|
||||
|
||||
def __str__(self):
|
||||
return f'Drawing ({self.path().elementCount()} points)'
|
||||
|
||||
def get_extra_save_data(self):
|
||||
"""Сохраняет данные рисунка для сериализации."""
|
||||
path = self.path()
|
||||
path_data = []
|
||||
for i in range(path.elementCount()):
|
||||
elem = path.elementAt(i)
|
||||
path_data.append({'x': elem.x, 'y': elem.y})
|
||||
|
||||
return {
|
||||
'path': path_data,
|
||||
'pen_color': self.pen_color.name(),
|
||||
'pen_width': self.pen_width,
|
||||
'pen_style': self.pen_style,
|
||||
}
|
||||
|
||||
def _update_pen(self):
|
||||
"""Обновляет перо с текущими настройками."""
|
||||
if self.pen_style == 'dashed':
|
||||
pen_style = QtCore.Qt.PenStyle.DashLine
|
||||
else:
|
||||
pen_style = QtCore.Qt.PenStyle.SolidLine
|
||||
|
||||
pen = QtGui.QPen(self.pen_color, self.pen_width,
|
||||
pen_style,
|
||||
QtCore.Qt.PenCapStyle.RoundCap,
|
||||
QtCore.Qt.PenJoinStyle.RoundJoin)
|
||||
self.setPen(pen)
|
||||
|
||||
def set_pen_color(self, color: QtGui.QColor):
|
||||
"""Устанавливает цвет пера."""
|
||||
self.pen_color = color
|
||||
self._update_pen()
|
||||
self.update()
|
||||
|
||||
def set_pen_width(self, width: int):
|
||||
"""Устанавливает толщину пера."""
|
||||
self.pen_width = max(1, min(width, 50)) # Ограничение 1-50
|
||||
self._update_pen()
|
||||
self.update()
|
||||
|
||||
def set_pen_style(self, style: str):
|
||||
"""Устанавливает стиль линии: 'solid', 'dashed', 'arrow', '<-', '<->'."""
|
||||
if style in ('solid', 'dashed', 'arrow', '<-', '<->'):
|
||||
self.pen_style = style
|
||||
self._update_pen()
|
||||
self.update()
|
||||
|
||||
def create_copy(self):
|
||||
item = BeeDrawItem()
|
||||
item.setPath(self.path())
|
||||
item.setPos(self.pos())
|
||||
item.setZValue(self.zValue())
|
||||
item.setScale(self.scale())
|
||||
item.setRotation(self.rotation())
|
||||
item.set_pen_color(self.pen_color)
|
||||
item.set_pen_width(self.pen_width)
|
||||
item.set_pen_style(self.pen_style)
|
||||
if self.flip() == -1:
|
||||
item.do_flip()
|
||||
return item
|
||||
|
||||
def bounding_rect_unselected(self):
|
||||
"""Возвращает границы элемента без учета выделения."""
|
||||
path = self.path()
|
||||
if path.isEmpty():
|
||||
return QtCore.QRectF()
|
||||
|
||||
# Получаем boundingRect напрямую из пути
|
||||
base_rect = path.boundingRect()
|
||||
|
||||
# Добавляем отступ для толщины пера и кликабельной области
|
||||
margin = (self.pen_width / 2.0) + self.CLICKABLE_PADDING
|
||||
return base_rect.marginsAdded(
|
||||
QtCore.QMarginsF(margin, margin, margin, margin))
|
||||
|
||||
def shape(self):
|
||||
"""Возвращает прямоугольную кликабельную область, как в PureRef."""
|
||||
path = QtGui.QPainterPath()
|
||||
rect = self.bounding_rect_unselected()
|
||||
|
||||
# Если элемент выделен и есть ручки, добавляем области для ручек
|
||||
if self.has_selection_handles():
|
||||
margin = self.select_resize_size / 2
|
||||
rect = rect.marginsAdded(
|
||||
QtCore.QMarginsF(margin, margin, margin, margin))
|
||||
path.addRect(rect)
|
||||
# Добавляем области для ручек поворота в углах
|
||||
for corner in self.corners:
|
||||
path.addPath(self.get_rotate_bounds(corner))
|
||||
else:
|
||||
path.addRect(rect)
|
||||
|
||||
return path
|
||||
|
||||
def contains(self, point):
|
||||
"""Проверяет, попадает ли точка в прямоугольную область линии."""
|
||||
# Используем boundingRect для прямоугольной области клика
|
||||
return self.bounding_rect_unselected().contains(point)
|
||||
|
||||
|
||||
def _get_path_end_points(self, path):
|
||||
"""Получает последние две точки пути для определения направления стрелки."""
|
||||
if path.elementCount() < 2:
|
||||
return None, None
|
||||
|
||||
# Получаем последнюю точку пути
|
||||
last_point = path.pointAtPercent(1.0)
|
||||
|
||||
# Получаем предпоследнюю точку (близко к концу)
|
||||
if path.elementCount() >= 2:
|
||||
prev_point = path.pointAtPercent(0.95) # 95% от пути
|
||||
else:
|
||||
prev_point = path.pointAtPercent(0.0)
|
||||
|
||||
return prev_point, last_point
|
||||
|
||||
def _get_path_start_points(self, path):
|
||||
"""Получает первые две точки пути для определения направления стрелки."""
|
||||
if path.elementCount() < 2:
|
||||
return None, None
|
||||
|
||||
# Получаем первую точку пути
|
||||
first_point = path.pointAtPercent(0.0)
|
||||
|
||||
# Получаем вторую точку (близко к началу)
|
||||
if path.elementCount() >= 2:
|
||||
second_point = path.pointAtPercent(0.05) # 5% от пути
|
||||
else:
|
||||
second_point = path.pointAtPercent(1.0)
|
||||
|
||||
return first_point, second_point
|
||||
|
||||
def _draw_arrow_right(self, painter, path):
|
||||
"""Рисует стрелку вправо в конце линии."""
|
||||
if path.elementCount() < 2:
|
||||
return
|
||||
|
||||
# Получаем последние две точки для определения направления
|
||||
prev_point, last_point = self._get_path_end_points(path)
|
||||
if prev_point is None or last_point is None:
|
||||
return
|
||||
|
||||
# Вычисляем направление стрелки
|
||||
dx = last_point.x() - prev_point.x()
|
||||
dy = last_point.y() - prev_point.y()
|
||||
length = (dx * dx + dy * dy) ** 0.5
|
||||
if length == 0:
|
||||
return
|
||||
|
||||
# Нормализуем вектор направления
|
||||
dx /= length
|
||||
dy /= length
|
||||
|
||||
# Размер стрелки зависит от толщины линии
|
||||
arrow_size = max(self.pen_width * 3, 8)
|
||||
# Угол стрелки
|
||||
angle = 0.5 # примерно 30 градусов
|
||||
|
||||
# Координаты конца стрелки
|
||||
end_x = last_point.x()
|
||||
end_y = last_point.y()
|
||||
|
||||
# Координаты боковых точек стрелки
|
||||
perp_x = -dy
|
||||
perp_y = dx
|
||||
arrow_x1 = end_x - arrow_size * dx + arrow_size * angle * perp_x
|
||||
arrow_y1 = end_y - arrow_size * dy + arrow_size * angle * perp_y
|
||||
arrow_x2 = end_x - arrow_size * dx - arrow_size * angle * perp_x
|
||||
arrow_y2 = end_y - arrow_size * dy - arrow_size * angle * perp_y
|
||||
|
||||
# Рисуем стрелку
|
||||
arrow_path = QtGui.QPainterPath()
|
||||
arrow_path.moveTo(end_x, end_y)
|
||||
arrow_path.lineTo(arrow_x1, arrow_y1)
|
||||
arrow_path.moveTo(end_x, end_y)
|
||||
arrow_path.lineTo(arrow_x2, arrow_y2)
|
||||
|
||||
painter.setPen(QtGui.QPen(self.pen_color, self.pen_width,
|
||||
QtCore.Qt.PenStyle.SolidLine,
|
||||
QtCore.Qt.PenCapStyle.RoundCap,
|
||||
QtCore.Qt.PenJoinStyle.RoundJoin))
|
||||
painter.drawPath(arrow_path)
|
||||
|
||||
def _draw_arrow_left(self, painter, path):
|
||||
"""Рисует стрелку влево в начале линии."""
|
||||
if path.elementCount() < 2:
|
||||
return
|
||||
|
||||
# Получаем первые две точки для определения направления
|
||||
first_point, second_point = self._get_path_start_points(path)
|
||||
if first_point is None or second_point is None:
|
||||
return
|
||||
|
||||
# Вычисляем направление стрелки (от второй точки к первой)
|
||||
dx = first_point.x() - second_point.x()
|
||||
dy = first_point.y() - second_point.y()
|
||||
length = (dx * dx + dy * dy) ** 0.5
|
||||
if length == 0:
|
||||
return
|
||||
|
||||
# Нормализуем вектор направления
|
||||
dx /= length
|
||||
dy /= length
|
||||
|
||||
# Размер стрелки зависит от толщины линии
|
||||
arrow_size = max(self.pen_width * 3, 8)
|
||||
# Угол стрелки
|
||||
angle = 0.5 # примерно 30 градусов
|
||||
|
||||
# Координаты начала стрелки
|
||||
start_x = first_point.x()
|
||||
start_y = first_point.y()
|
||||
|
||||
# Координаты боковых точек стрелки
|
||||
perp_x = -dy
|
||||
perp_y = dx
|
||||
arrow_x1 = start_x - arrow_size * dx + arrow_size * angle * perp_x
|
||||
arrow_y1 = start_y - arrow_size * dy + arrow_size * angle * perp_y
|
||||
arrow_x2 = start_x - arrow_size * dx - arrow_size * angle * perp_x
|
||||
arrow_y2 = start_y - arrow_size * dy - arrow_size * angle * perp_y
|
||||
|
||||
# Рисуем стрелку
|
||||
arrow_path = QtGui.QPainterPath()
|
||||
arrow_path.moveTo(start_x, start_y)
|
||||
arrow_path.lineTo(arrow_x1, arrow_y1)
|
||||
arrow_path.moveTo(start_x, start_y)
|
||||
arrow_path.lineTo(arrow_x2, arrow_y2)
|
||||
|
||||
painter.setPen(QtGui.QPen(self.pen_color, self.pen_width,
|
||||
QtCore.Qt.PenStyle.SolidLine,
|
||||
QtCore.Qt.PenCapStyle.RoundCap,
|
||||
QtCore.Qt.PenJoinStyle.RoundJoin))
|
||||
painter.drawPath(arrow_path)
|
||||
|
||||
def _draw_arrow_both(self, painter, path):
|
||||
"""Рисует стрелки в обе стороны линии."""
|
||||
if path.elementCount() < 2:
|
||||
return
|
||||
|
||||
# Рисуем стрелку вправо (в конце)
|
||||
prev_point, last_point = self._get_path_end_points(path)
|
||||
if prev_point is not None and last_point is not None:
|
||||
dx = last_point.x() - prev_point.x()
|
||||
dy = last_point.y() - prev_point.y()
|
||||
length = (dx * dx + dy * dy) ** 0.5
|
||||
if length > 0:
|
||||
dx /= length
|
||||
dy /= length
|
||||
arrow_size = max(self.pen_width * 3, 8)
|
||||
angle = 0.5
|
||||
end_x = last_point.x()
|
||||
end_y = last_point.y()
|
||||
perp_x = -dy
|
||||
perp_y = dx
|
||||
arrow_x1 = end_x - arrow_size * dx + arrow_size * angle * perp_x
|
||||
arrow_y1 = end_y - arrow_size * dy + arrow_size * angle * perp_y
|
||||
arrow_x2 = end_x - arrow_size * dx - arrow_size * angle * perp_x
|
||||
arrow_y2 = end_y - arrow_size * dy - arrow_size * angle * perp_y
|
||||
|
||||
arrow_path = QtGui.QPainterPath()
|
||||
arrow_path.moveTo(end_x, end_y)
|
||||
arrow_path.lineTo(arrow_x1, arrow_y1)
|
||||
arrow_path.moveTo(end_x, end_y)
|
||||
arrow_path.lineTo(arrow_x2, arrow_y2)
|
||||
|
||||
painter.setPen(QtGui.QPen(self.pen_color, self.pen_width,
|
||||
QtCore.Qt.PenStyle.SolidLine,
|
||||
QtCore.Qt.PenCapStyle.RoundCap,
|
||||
QtCore.Qt.PenJoinStyle.RoundJoin))
|
||||
painter.drawPath(arrow_path)
|
||||
|
||||
# Рисуем стрелку влево (в начале)
|
||||
first_point, second_point = self._get_path_start_points(path)
|
||||
if first_point is not None and second_point is not None:
|
||||
dx = first_point.x() - second_point.x()
|
||||
dy = first_point.y() - second_point.y()
|
||||
length = (dx * dx + dy * dy) ** 0.5
|
||||
if length > 0:
|
||||
dx /= length
|
||||
dy /= length
|
||||
arrow_size = max(self.pen_width * 3, 8)
|
||||
angle = 0.5
|
||||
start_x = first_point.x()
|
||||
start_y = first_point.y()
|
||||
perp_x = -dy
|
||||
perp_y = dx
|
||||
arrow_x1 = start_x - arrow_size * dx + arrow_size * angle * perp_x
|
||||
arrow_y1 = start_y - arrow_size * dy + arrow_size * angle * perp_y
|
||||
arrow_x2 = start_x - arrow_size * dx - arrow_size * angle * perp_x
|
||||
arrow_y2 = start_y - arrow_size * dy - arrow_size * angle * perp_y
|
||||
|
||||
arrow_path = QtGui.QPainterPath()
|
||||
arrow_path.moveTo(start_x, start_y)
|
||||
arrow_path.lineTo(arrow_x1, arrow_y1)
|
||||
arrow_path.moveTo(start_x, start_y)
|
||||
arrow_path.lineTo(arrow_x2, arrow_y2)
|
||||
|
||||
painter.setPen(QtGui.QPen(self.pen_color, self.pen_width,
|
||||
QtCore.Qt.PenStyle.SolidLine,
|
||||
QtCore.Qt.PenCapStyle.RoundCap,
|
||||
QtCore.Qt.PenJoinStyle.RoundJoin))
|
||||
painter.drawPath(arrow_path)
|
||||
|
||||
def paint(self, painter, option, widget):
|
||||
"""Отрисовка пути с рамкой выделения."""
|
||||
# Отключаем стандартную отрисовку Qt для выделенных элементов
|
||||
option.state &= ~QtWidgets.QStyle.StateFlag.State_Selected
|
||||
option.state &= ~QtWidgets.QStyle.StateFlag.State_HasFocus
|
||||
# Рисуем основную линию
|
||||
super().paint(painter, option, widget)
|
||||
|
||||
path = self.path()
|
||||
# Рисуем стрелку в зависимости от стиля
|
||||
if self.pen_style == 'arrow':
|
||||
self._draw_arrow_right(painter, path)
|
||||
elif self.pen_style == '<-':
|
||||
self._draw_arrow_left(painter, path)
|
||||
elif self.pen_style == '<->':
|
||||
self._draw_arrow_both(painter, path)
|
||||
|
||||
self.paint_selectable(painter, option, widget)
|
||||
|
||||
def paint_debug(self, painter, option, widget):
|
||||
"""Переопределяем для полного отключения отладочной информации."""
|
||||
# Полностью отключаем отладочную информацию для линий
|
||||
pass
|
||||
|
||||
def paint_selectable(self, painter, option, widget):
|
||||
"""Переопределяем для убирания пунктирной обводки (отладочной информации)."""
|
||||
# Не вызываем paint_debug, чтобы убрать пунктирную обводку
|
||||
# self.paint_debug(painter, option, widget)
|
||||
|
||||
if not self.has_selection_outline():
|
||||
return
|
||||
|
||||
pen = QtGui.QPen(SELECT_COLOR)
|
||||
pen.setWidth(self.SELECT_LINE_WIDTH)
|
||||
pen.setCosmetic(True)
|
||||
painter.setPen(pen)
|
||||
painter.setBrush(QtGui.QBrush())
|
||||
|
||||
# Draw the main selection rectangle
|
||||
painter.drawRect(self.bounding_rect_unselected())
|
||||
|
||||
# If it's a single selection, draw the handles:
|
||||
if self.has_selection_handles():
|
||||
pen.setWidth(self.SELECT_HANDLE_SIZE)
|
||||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
painter.setPen(pen)
|
||||
for corner in self.corners:
|
||||
painter.drawPoint(corner)
|
||||
|
||||
def copy_to_clipboard(self, clipboard):
|
||||
"""Для рисования копирование не поддерживается."""
|
||||
pass
|
||||
|
||||
|
||||
@register_item
|
||||
class BeeErrorItem(BeeItemMixin, QtWidgets.QGraphicsTextItem):
|
||||
"""Class for displaying error messages when an item can't be loaded
|
||||
|
|
@ -804,3 +1229,8 @@ class BeeErrorItem(BeeItemMixin, QtWidgets.QGraphicsTextItem):
|
|||
|
||||
def copy_to_clipboard(self, clipboard):
|
||||
clipboard.setText(self.toPlainText())
|
||||
|
||||
|
||||
# Импортируем GIF item для регистрации в item_registry
|
||||
from beeref.gif_item import BeeGifItem # noqa: E402, F401
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
205
beeref/widgets/draw_floating_menu.py
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
"""Floating menu for drawing items."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
from PyQt6 import QtCore, QtGui, QtWidgets
|
||||
|
||||
from beeref import constants
|
||||
from beeref import widgets
|
||||
from beeref.assets import BeeAssets
|
||||
from beeref.widgets.floating_menu import FloatingMenu
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - type checking only
|
||||
from beeref.view import BeeGraphicsView
|
||||
from beeref.items import BeeDrawItem
|
||||
|
||||
|
||||
class UpwardComboBox(QtWidgets.QComboBox):
|
||||
"""QComboBox that opens its dropdown menu upward."""
|
||||
|
||||
def showPopup(self):
|
||||
"""Override to show popup above the combobox."""
|
||||
super().showPopup()
|
||||
# Используем QTimer для получения popup после его создания
|
||||
QtCore.QTimer.singleShot(0, self._reposition_popup)
|
||||
|
||||
def _reposition_popup(self):
|
||||
"""Перемещает popup выше combobox."""
|
||||
# Ищем активный popup виджет
|
||||
popup = QtWidgets.QApplication.activePopupWidget()
|
||||
if not popup:
|
||||
# Альтернативный способ - найти через view
|
||||
view = self.view()
|
||||
if view:
|
||||
popup = view.parent()
|
||||
while popup and not isinstance(popup, QtWidgets.QFrame):
|
||||
popup = popup.parent()
|
||||
|
||||
if popup:
|
||||
# Получаем глобальную позицию combobox
|
||||
global_pos = self.mapToGlobal(QtCore.QPoint(0, 0))
|
||||
# Вычисляем новую позицию выше combobox
|
||||
popup_height = popup.height()
|
||||
new_y = global_pos.y() - popup_height
|
||||
popup.move(global_pos.x(), new_y)
|
||||
|
||||
|
||||
class DrawFloatingMenu(FloatingMenu):
|
||||
"""Floating menu for drawing tools."""
|
||||
|
||||
def __init__(self, parent: QtWidgets.QWidget, view: "BeeGraphicsView"):
|
||||
super().__init__(parent, view)
|
||||
self.current_item: Optional["BeeDrawItem"] = None
|
||||
|
||||
# Кнопка выбора цвета через colorpicker
|
||||
assets = BeeAssets()
|
||||
icons_path = assets.PATH.joinpath('icons')
|
||||
palette_icon = QtGui.QIcon(str(icons_path.joinpath('palette.svg')))
|
||||
|
||||
self.color_btn = self.add_button(
|
||||
"",
|
||||
icon=palette_icon,
|
||||
callback=self._on_color_clicked,
|
||||
)
|
||||
self.color_btn.setToolTip("Color")
|
||||
|
||||
# Combobox для выбора стиля линии
|
||||
self.add_separator()
|
||||
self.style_combo = self.add_style_combobox()
|
||||
|
||||
# Селектор толщины пера
|
||||
self.add_separator()
|
||||
self.width_slider = self.add_width_slider()
|
||||
|
||||
# Кнопка закрытия меню (отмена режима рисования)
|
||||
self.add_separator()
|
||||
close_icon = QtGui.QIcon(str(icons_path.joinpath('close.svg')))
|
||||
self.close_button = self.add_button(
|
||||
"",
|
||||
icon=close_icon,
|
||||
callback=self._on_close_clicked
|
||||
)
|
||||
self.close_button.setToolTip("Close")
|
||||
|
||||
def add_style_combobox(self):
|
||||
"""Добавляет combobox для выбора стиля линии."""
|
||||
assets = BeeAssets()
|
||||
icons_path = assets.PATH.joinpath('icons')
|
||||
|
||||
# Загружаем иконки для стилей
|
||||
solid_icon = QtGui.QIcon(str(icons_path.joinpath('line-solid.svg')))
|
||||
dashed_icon = QtGui.QIcon(str(icons_path.joinpath('line-dashed.svg')))
|
||||
arrow_icon = QtGui.QIcon(str(icons_path.joinpath('line-arrow.svg')))
|
||||
arrow_left_icon = QtGui.QIcon(str(icons_path.joinpath('line-arrow-left.svg')))
|
||||
arrow_both_icon = QtGui.QIcon(str(icons_path.joinpath('line-arrow-both.svg')))
|
||||
|
||||
combo = UpwardComboBox(self)
|
||||
combo.setObjectName("FloatingMenuLineStyle")
|
||||
combo.setMinimumWidth(40)
|
||||
|
||||
# Добавляем элементы с иконками
|
||||
combo.addItem(solid_icon, "", 'solid')
|
||||
combo.addItem(dashed_icon, "", 'dashed')
|
||||
combo.addItem(arrow_icon, "", 'arrow')
|
||||
combo.addItem(arrow_left_icon, "", '<-')
|
||||
combo.addItem(arrow_both_icon, "", '<->')
|
||||
# Устанавливаем иконки для элементов
|
||||
combo.setItemIcon(0, solid_icon)
|
||||
combo.setItemIcon(1, dashed_icon)
|
||||
combo.setItemIcon(2, arrow_icon)
|
||||
combo.setItemIcon(3, arrow_left_icon)
|
||||
combo.setItemIcon(4, arrow_both_icon)
|
||||
|
||||
combo.setCurrentIndex(0) # По умолчанию solid
|
||||
combo.currentIndexChanged.connect(self._on_style_changed)
|
||||
combo.setToolTip("Line style")
|
||||
|
||||
self.add_widget(combo)
|
||||
return combo
|
||||
|
||||
def _on_color_clicked(self) -> None:
|
||||
"""Открывает диалог выбора цвета."""
|
||||
if not self.current_item:
|
||||
return
|
||||
|
||||
initial = self.current_item.pen_color
|
||||
dialog = widgets.color_picker.ColorPickerDialog(self, initial)
|
||||
if dialog.exec() != QtWidgets.QDialog.DialogCode.Accepted:
|
||||
return
|
||||
color = dialog.selectedColor()
|
||||
self.set_pen_color(color)
|
||||
|
||||
def _on_style_changed(self, index: int) -> None:
|
||||
"""Обработчик изменения стиля линии."""
|
||||
if not self.current_item:
|
||||
return
|
||||
style = self.style_combo.itemData(index)
|
||||
if style:
|
||||
self.set_pen_style(style)
|
||||
|
||||
def add_width_slider(self):
|
||||
"""Добавляет слайдер для выбора толщины пера."""
|
||||
container = QtWidgets.QWidget()
|
||||
layout = QtWidgets.QHBoxLayout(container)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
label = QtWidgets.QLabel("Width:")
|
||||
layout.addWidget(label)
|
||||
|
||||
slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal)
|
||||
slider.setRange(1, 20)
|
||||
slider.setValue(8)
|
||||
slider.valueChanged.connect(self.set_pen_width)
|
||||
layout.addWidget(slider)
|
||||
|
||||
self.add_widget(container)
|
||||
return slider
|
||||
|
||||
def show_for_item(self, item: "BeeDrawItem") -> None:
|
||||
"""Показывает меню для выбранного элемента рисования."""
|
||||
self.current_item = item
|
||||
if item:
|
||||
self.width_slider.setValue(item.pen_width)
|
||||
# Устанавливаем текущий стиль в combobox
|
||||
style_index = self.style_combo.findData(item.pen_style)
|
||||
if style_index >= 0:
|
||||
self.style_combo.blockSignals(True)
|
||||
self.style_combo.setCurrentIndex(style_index)
|
||||
self.style_combo.blockSignals(False)
|
||||
super().show_for_item(item)
|
||||
|
||||
def set_pen_color(self, color: QtGui.QColor):
|
||||
"""Устанавливает цвет пера для выбранного элемента."""
|
||||
if self.current_item:
|
||||
self.current_item.set_pen_color(color)
|
||||
|
||||
def set_pen_width(self, width: int):
|
||||
"""Устанавливает толщину пера для выбранного элемента."""
|
||||
if self.current_item:
|
||||
self.current_item.set_pen_width(width)
|
||||
|
||||
def set_pen_style(self, style: str):
|
||||
"""Устанавливает стиль линии для выбранного элемента."""
|
||||
if self.current_item:
|
||||
self.current_item.set_pen_style(style)
|
||||
|
||||
def _on_close_clicked(self) -> None:
|
||||
"""Закрывает меню и отменяет режим рисования."""
|
||||
self.view.cancel_drawing_mode()
|
||||
self.hide_menu()
|
||||
|
||||
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:
|
||||
"""Обрабатывает нажатия клавиш."""
|
||||
if event.key() == QtCore.Qt.Key.Key_Escape:
|
||||
# ESC закрывает меню и отменяет режим рисования
|
||||
self.view.cancel_drawing_mode()
|
||||
self.hide_menu()
|
||||
event.accept()
|
||||
return
|
||||
super().keyPressEvent(event)
|
||||
|
||||
293
beeref/widgets/floating_menu.py
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
"""Floating menu widgets shown for single item selections."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, TYPE_CHECKING, Callable
|
||||
|
||||
from PyQt6 import QtCore, QtGui, QtWidgets
|
||||
|
||||
from beeref import constants
|
||||
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - type checking only
|
||||
from beeref.view import BeeGraphicsView
|
||||
|
||||
|
||||
class FloatingMenu(QtWidgets.QWidget):
|
||||
"""Base widget for floating menus pinned to the bottom centre."""
|
||||
|
||||
BOTTOM_MARGIN = 8
|
||||
CORNER_RADIUS = 8
|
||||
|
||||
def __init__(self, parent: QtWidgets.QWidget, view: "BeeGraphicsView"):
|
||||
# Create as independent window to prevent event blocking
|
||||
super().__init__(None)
|
||||
self.setObjectName("FloatingMenu")
|
||||
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground, True)
|
||||
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_ShowWithoutActivating, True)
|
||||
# Make it a tool window that stays on top
|
||||
self.setWindowFlag(QtCore.Qt.WindowType.FramelessWindowHint, True)
|
||||
self.setWindowFlag(QtCore.Qt.WindowType.Tool, True)
|
||||
self.setWindowFlag(QtCore.Qt.WindowType.WindowStaysOnTopHint, True)
|
||||
# Store parent for positioning
|
||||
self._parent_widget = parent
|
||||
self.view = view
|
||||
self.current_item: Optional[QtWidgets.QGraphicsItem] = None
|
||||
|
||||
# Cache for update_position optimization
|
||||
self._cached_position: Optional[QtCore.QPoint] = None
|
||||
self._cached_viewport_size: Optional[QtCore.QSize] = None
|
||||
self._cached_window_pos: Optional[QtCore.QPoint] = None
|
||||
|
||||
# Таймер для отслеживания перемещения главного окна
|
||||
self._position_timer = QtCore.QTimer(self)
|
||||
self._position_timer.timeout.connect(self._check_window_position)
|
||||
self._position_timer.setInterval(50) # Проверка каждые 50мс
|
||||
|
||||
# Main layout with uniform spacing
|
||||
self._layout = QtWidgets.QHBoxLayout(self)
|
||||
self._layout.setContentsMargins(8, 6, 8, 6)
|
||||
self._layout.setSpacing(6)
|
||||
self._layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.setFocusPolicy(QtCore.Qt.FocusPolicy.NoFocus)
|
||||
self.setStyleSheet(constants.get_floating_menu_style())
|
||||
|
||||
self.hide()
|
||||
|
||||
def _check_window_position(self) -> None:
|
||||
"""Проверяет позицию viewport и обновляет позицию меню при необходимости."""
|
||||
if not self.isVisible():
|
||||
return
|
||||
|
||||
view = self.view
|
||||
if view is None:
|
||||
return
|
||||
|
||||
viewport = view.viewport()
|
||||
if viewport is None:
|
||||
return
|
||||
|
||||
# Получаем позицию viewport в глобальных координатах
|
||||
view_rect = viewport.rect()
|
||||
current_viewport_pos = viewport.mapToGlobal(view_rect.topLeft())
|
||||
|
||||
# Если позиция viewport изменилась, обновляем позицию меню
|
||||
if self._cached_window_pos is not None and self._cached_window_pos != current_viewport_pos:
|
||||
self.update_position()
|
||||
|
||||
self._cached_window_pos = current_viewport_pos
|
||||
|
||||
def _apply_rounded_mask(self) -> None:
|
||||
"""
|
||||
Применяет закругленную маску к виджету с антиалиасингом.
|
||||
Реализация на основе подхода из статьи VK Teams.
|
||||
"""
|
||||
size = self.size()
|
||||
if size.width() == 0 or size.height() == 0:
|
||||
return
|
||||
|
||||
# Для более плавного сглаживания используем QBitmap с антиалиасингом
|
||||
# Создаем изображение с увеличенным разрешением (как в статье)
|
||||
scale_factor = 2
|
||||
scaled_size = QtCore.QSize(
|
||||
int(size.width() * scale_factor),
|
||||
int(size.height() * scale_factor)
|
||||
)
|
||||
|
||||
# Создаем QPixmap для рисования с антиалиасингом
|
||||
pixmap = QtGui.QPixmap(scaled_size)
|
||||
pixmap.fill(QtCore.Qt.GlobalColor.transparent)
|
||||
|
||||
painter = QtGui.QPainter(pixmap)
|
||||
painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, True)
|
||||
painter.setPen(QtCore.Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QtCore.Qt.GlobalColor.black) # Черный для маски
|
||||
|
||||
# Рисуем закругленный прямоугольник на увеличенном разрешении
|
||||
scaled_rect = QtCore.QRectF(0, 0, scaled_size.width(), scaled_size.height())
|
||||
scaled_radius = self.CORNER_RADIUS * scale_factor
|
||||
painter.drawRoundedRect(scaled_rect, scaled_radius, scaled_radius)
|
||||
painter.end()
|
||||
|
||||
# Масштабируем обратно с сглаживанием
|
||||
pixmap = pixmap.scaled(
|
||||
size,
|
||||
QtCore.Qt.AspectRatioMode.IgnoreAspectRatio,
|
||||
QtCore.Qt.TransformationMode.SmoothTransformation
|
||||
)
|
||||
|
||||
# Преобразуем в QBitmap для маски
|
||||
# В маске: непрозрачные пиксели = видимые области, прозрачные = невидимые
|
||||
image = pixmap.toImage()
|
||||
# Создаем маску из непрозрачных пикселей
|
||||
bitmap = QtGui.QBitmap.fromImage(image.createAlphaMask())
|
||||
|
||||
# Применяем маску
|
||||
self.setMask(bitmap)
|
||||
|
||||
def add_widget(self, widget: QtWidgets.QWidget) -> QtWidgets.QWidget:
|
||||
widget.setParent(self)
|
||||
self._layout.addWidget(widget)
|
||||
return widget
|
||||
|
||||
def add_button(
|
||||
self,
|
||||
text: str,
|
||||
icon: Optional[QtGui.QIcon] = None,
|
||||
callback: Optional[Callable[[], None]] = None,
|
||||
checkable: bool = False,
|
||||
) -> QtWidgets.QPushButton:
|
||||
button = QtWidgets.QPushButton(text, self)
|
||||
button.setCheckable(checkable)
|
||||
if icon:
|
||||
button.setIcon(icon)
|
||||
button.setProperty("floatingButton", True)
|
||||
if callback:
|
||||
# Connect directly - button events are handled independently
|
||||
button.clicked.connect(callback)
|
||||
self._layout.addWidget(button)
|
||||
return button
|
||||
|
||||
def add_separator(self) -> None:
|
||||
separator = QtWidgets.QFrame(self)
|
||||
separator.setObjectName("FloatingMenuSeparator")
|
||||
separator.setFrameShape(QtWidgets.QFrame.Shape.VLine)
|
||||
separator.setFrameShadow(QtWidgets.QFrame.Shadow.Plain)
|
||||
separator.setFixedWidth(1)
|
||||
self._layout.addWidget(separator)
|
||||
|
||||
def show_for_item(self, item: QtWidgets.QGraphicsItem) -> None:
|
||||
self.current_item = item
|
||||
self.show_menu()
|
||||
|
||||
def show_menu(self) -> None:
|
||||
self.adjustSize()
|
||||
self._apply_rounded_mask()
|
||||
self.show()
|
||||
self.raise_()
|
||||
# Reset cache when showing menu to ensure position update
|
||||
self._cached_position = None
|
||||
self._cached_viewport_size = None
|
||||
self._cached_window_pos = None
|
||||
self.update_position()
|
||||
# Запускаем таймер для отслеживания перемещения окна
|
||||
self._position_timer.start()
|
||||
# Ensure menu is on top after positioning
|
||||
self.raise_()
|
||||
# Возвращаем фокус view, чтобы события клавиатуры обрабатывались правильно
|
||||
# FloatingMenu не должен перехватывать фокус, так как он имеет NoFocus
|
||||
if self.view:
|
||||
self.view.setFocus()
|
||||
|
||||
def hide_menu(self) -> None:
|
||||
self.current_item = None
|
||||
# Останавливаем таймер
|
||||
self._position_timer.stop()
|
||||
# Clear cache when hiding menu
|
||||
self._cached_position = None
|
||||
self._cached_viewport_size = None
|
||||
self._cached_window_pos = None
|
||||
self.hide()
|
||||
|
||||
def update_position(self) -> None:
|
||||
if not self.isVisible():
|
||||
return
|
||||
|
||||
parent = self._parent_widget
|
||||
view = self.view
|
||||
if parent is None or view is None:
|
||||
return
|
||||
|
||||
viewport = view.viewport()
|
||||
if viewport is None:
|
||||
return
|
||||
|
||||
view_rect = viewport.rect()
|
||||
viewport_size = view_rect.size()
|
||||
|
||||
# Получаем текущую позицию viewport для проверки изменений
|
||||
current_viewport_pos = viewport.mapToGlobal(view_rect.topLeft())
|
||||
|
||||
# Check if viewport size and position changed
|
||||
if (self._cached_viewport_size is not None and
|
||||
self._cached_viewport_size == viewport_size and
|
||||
self._cached_window_pos is not None and
|
||||
self._cached_window_pos == current_viewport_pos and
|
||||
self._cached_position is not None):
|
||||
# If viewport size and position haven't changed, check menu position
|
||||
current_pos = self.pos()
|
||||
if current_pos == self._cached_position:
|
||||
# Position hasn't changed, skip update
|
||||
return
|
||||
|
||||
size = self.sizeHint()
|
||||
width = self.width() or size.width()
|
||||
height = self.height() or size.height()
|
||||
|
||||
# Calculate position in global coordinates (independent window)
|
||||
top_left_global = viewport.mapToGlobal(view_rect.topLeft())
|
||||
bottom_left_global = viewport.mapToGlobal(view_rect.bottomLeft())
|
||||
bottom_right_global = viewport.mapToGlobal(view_rect.bottomRight())
|
||||
top_right_global = viewport.mapToGlobal(view_rect.topRight())
|
||||
|
||||
# Получаем границы viewport в глобальных координатах
|
||||
viewport_left = top_left_global.x()
|
||||
viewport_right = top_right_global.x()
|
||||
viewport_top = top_left_global.y()
|
||||
viewport_bottom = bottom_left_global.y()
|
||||
viewport_width = viewport_right - viewport_left
|
||||
|
||||
# Вычисляем позицию по X (центрируем, но не выходим за границы)
|
||||
x = viewport_left + max(0, (viewport_width - width) // 2)
|
||||
# Ограничиваем, чтобы меню не выходило за левую и правую границы
|
||||
x = max(viewport_left, min(x, viewport_right - width))
|
||||
|
||||
# Вычисляем позицию по Y (снизу с отступом)
|
||||
y = viewport_bottom - height - self.BOTTOM_MARGIN
|
||||
# Ограничиваем, чтобы меню не выходило за верхнюю границу
|
||||
# Если меню не помещается снизу, размещаем его сверху
|
||||
if y < viewport_top:
|
||||
y = viewport_top + self.BOTTOM_MARGIN
|
||||
# Также проверяем, что меню не выходит за нижнюю границу
|
||||
if y + height > viewport_bottom:
|
||||
y = viewport_bottom - height - self.BOTTOM_MARGIN
|
||||
# Если и так не помещается, размещаем сверху
|
||||
if y < viewport_top:
|
||||
y = viewport_top + self.BOTTOM_MARGIN
|
||||
|
||||
new_position = QtCore.QPoint(x, y)
|
||||
|
||||
# Update position only if it actually changed
|
||||
if self._cached_position != new_position:
|
||||
self.move(new_position)
|
||||
self._cached_position = new_position
|
||||
self._cached_viewport_size = viewport_size
|
||||
self._cached_window_pos = current_viewport_pos
|
||||
# Обновляем маску после изменения размера/позиции
|
||||
self._apply_rounded_mask()
|
||||
|
||||
def parentWidget(self):
|
||||
"""Override to return stored parent widget for compatibility."""
|
||||
return self._parent_widget
|
||||
|
||||
def mousePressEvent(self, event: QtGui.QMouseEvent) -> None:
|
||||
"""Intercept mouse events to prevent them from reaching view."""
|
||||
# Accept event to prevent propagation
|
||||
event.accept()
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None:
|
||||
"""Intercept mouse events to prevent them from reaching view."""
|
||||
event.accept()
|
||||
super().mouseMoveEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event: QtGui.QMouseEvent) -> None:
|
||||
"""Intercept mouse events to prevent them from reaching view."""
|
||||
event.accept()
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
def resizeEvent(self, event: QtGui.QResizeEvent) -> None:
|
||||
"""Обновляет маску при изменении размера виджета."""
|
||||
super().resizeEvent(event)
|
||||
self._apply_rounded_mask()
|
||||
231
beeref/widgets/gif_floating_menu.py
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
"""Floating menu shown when a single GIF item is selected."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from PyQt6 import QtCore, QtGui, QtWidgets
|
||||
|
||||
from beeref.assets import BeeAssets
|
||||
from beeref.widgets.floating_menu import FloatingMenu
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from beeref.view import BeeGraphicsView
|
||||
from beeref.gif_item import BeeGifItem
|
||||
from beeref.widgets.gif_frames_menu import GifFramesMenu
|
||||
|
||||
|
||||
class GifFloatingMenu(FloatingMenu):
|
||||
"""Contextual floating toolbar for GIF items."""
|
||||
|
||||
SPEED_VALUES = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
|
||||
|
||||
def __init__(self, parent: QtWidgets.QWidget, view: "BeeGraphicsView"):
|
||||
super().__init__(parent, view)
|
||||
|
||||
self._init_frames_menu(parent, view)
|
||||
self._init_icons()
|
||||
self._init_speed_combobox()
|
||||
self._init_control_buttons()
|
||||
|
||||
def _init_frames_menu(
|
||||
self,
|
||||
parent: QtWidgets.QWidget,
|
||||
view: "BeeGraphicsView"
|
||||
) -> None:
|
||||
"""Initializes frames menu."""
|
||||
self.frames_menu: Optional["GifFramesMenu"] = None
|
||||
try:
|
||||
from beeref.widgets.gif_frames_menu import GifFramesMenu
|
||||
self.frames_menu = GifFramesMenu(parent, view, self)
|
||||
except ImportError as e:
|
||||
logger.warning(f'Failed to import frames menu: {e}')
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to initialize frames menu: {e}')
|
||||
|
||||
def _init_icons(self) -> None:
|
||||
"""Loads icons for control buttons."""
|
||||
assets = BeeAssets()
|
||||
icons_path = assets.PATH.joinpath('icons')
|
||||
|
||||
self.prev_frame_icon = QtGui.QIcon(
|
||||
str(icons_path.joinpath('prev-frame.svg')))
|
||||
self.play_icon = QtGui.QIcon(str(icons_path.joinpath('play.svg')))
|
||||
self.pause_icon = QtGui.QIcon(str(icons_path.joinpath('pause.svg')))
|
||||
self.next_frame_icon = QtGui.QIcon(
|
||||
str(icons_path.joinpath('next-frame.svg')))
|
||||
self.frames_icon = QtGui.QIcon(str(icons_path.joinpath('frames.svg')))
|
||||
|
||||
def _init_speed_combobox(self) -> None:
|
||||
"""Initializes combobox for playback speed selection."""
|
||||
self.speed_combo = QtWidgets.QComboBox(self)
|
||||
self.speed_combo.setObjectName("FloatingMenuGifSpeed")
|
||||
self.speed_combo.setMinimumWidth(40)
|
||||
|
||||
for speed in self.SPEED_VALUES:
|
||||
label = self._format_speed_label(speed)
|
||||
self.speed_combo.addItem(label, speed)
|
||||
|
||||
# Set default value (1.0x)
|
||||
default_index = self.SPEED_VALUES.index(1.0)
|
||||
self.speed_combo.setCurrentIndex(default_index)
|
||||
self.speed_combo.currentIndexChanged.connect(self._on_speed_changed)
|
||||
self.speed_combo.setToolTip("Playback speed")
|
||||
self.add_widget(self.speed_combo)
|
||||
|
||||
def _format_speed_label(self, speed: float) -> str:
|
||||
"""Formats speed value for display in combobox."""
|
||||
if speed < 1.0:
|
||||
return f"{speed:.2f}x"
|
||||
else:
|
||||
speed_str = f"{speed:.2f}".rstrip('0').rstrip('.')
|
||||
return f"{speed_str}x"
|
||||
|
||||
def _init_control_buttons(self) -> None:
|
||||
"""Initializes playback control buttons."""
|
||||
self.prev_frame_btn = self.add_button(
|
||||
"",
|
||||
icon=self.prev_frame_icon,
|
||||
callback=self.on_previous_frame,
|
||||
)
|
||||
self.prev_frame_btn.setToolTip("Previous frame")
|
||||
|
||||
self.play_pause_btn = self.add_button(
|
||||
"",
|
||||
icon=self.play_icon,
|
||||
callback=self.on_toggle_play_pause,
|
||||
)
|
||||
self.play_pause_btn.setToolTip("Play/Pause")
|
||||
|
||||
self.next_frame_btn = self.add_button(
|
||||
"",
|
||||
icon=self.next_frame_icon,
|
||||
callback=self.on_next_frame,
|
||||
)
|
||||
self.next_frame_btn.setToolTip("Next frame")
|
||||
|
||||
self.frames_btn = self.add_button(
|
||||
"",
|
||||
icon=self.frames_icon,
|
||||
callback=self.on_toggle_frames_menu,
|
||||
)
|
||||
self.frames_btn.setToolTip("Show frames timeline")
|
||||
|
||||
def show_for_item(self, item: "BeeGifItem") -> None:
|
||||
"""Shows menu for specified GIF item."""
|
||||
super().show_for_item(item)
|
||||
self.update_play_pause_button()
|
||||
self.update_speed_combo()
|
||||
self._hide_frames_menu()
|
||||
|
||||
def _hide_frames_menu(self) -> None:
|
||||
"""Hides frames menu if it's open."""
|
||||
if self.frames_menu:
|
||||
self.frames_menu.hide_menu()
|
||||
|
||||
def update_speed_combo(self) -> None:
|
||||
"""Updates speed value in combobox."""
|
||||
if not self._has_gif_item():
|
||||
return
|
||||
|
||||
current_speed = self.current_item.get_speed()
|
||||
closest_speed = min(
|
||||
self.SPEED_VALUES,
|
||||
key=lambda x: abs(x - current_speed)
|
||||
)
|
||||
index = self.SPEED_VALUES.index(closest_speed)
|
||||
|
||||
self.speed_combo.blockSignals(True)
|
||||
self.speed_combo.setCurrentIndex(index)
|
||||
self.speed_combo.blockSignals(False)
|
||||
|
||||
def _on_speed_changed(self, index: int) -> None:
|
||||
"""Handler for playback speed change."""
|
||||
if not self._has_gif_item():
|
||||
return
|
||||
|
||||
speed = self.speed_combo.currentData()
|
||||
if speed is not None:
|
||||
self.current_item.set_speed(speed)
|
||||
|
||||
def update_play_pause_button(self) -> None:
|
||||
"""Updates Play/Pause button icon."""
|
||||
if not self._has_gif_item():
|
||||
return
|
||||
|
||||
if self.current_item.is_playing:
|
||||
self.play_pause_btn.setIcon(self.pause_icon)
|
||||
self.play_pause_btn.setToolTip("Pause")
|
||||
else:
|
||||
self.play_pause_btn.setIcon(self.play_icon)
|
||||
self.play_pause_btn.setToolTip("Play")
|
||||
|
||||
def on_toggle_play_pause(self) -> None:
|
||||
"""Toggles GIF play/pause."""
|
||||
if not self._has_gif_item():
|
||||
return
|
||||
|
||||
# Defer execution so button can process event
|
||||
def do_toggle():
|
||||
self.current_item.toggle_animation()
|
||||
self.update_play_pause_button()
|
||||
|
||||
QtCore.QTimer.singleShot(0, do_toggle)
|
||||
|
||||
def on_previous_frame(self) -> None:
|
||||
"""Goes to previous frame."""
|
||||
if not self._has_gif_item():
|
||||
return
|
||||
|
||||
# Defer execution so button can process event
|
||||
def do_previous():
|
||||
self.current_item.previous_frame()
|
||||
self.update_play_pause_button()
|
||||
self._update_frames_menu_if_visible()
|
||||
|
||||
QtCore.QTimer.singleShot(0, do_previous)
|
||||
|
||||
def on_next_frame(self) -> None:
|
||||
"""Goes to next frame."""
|
||||
if not self._has_gif_item():
|
||||
return
|
||||
|
||||
# Defer execution so button can process event
|
||||
def do_next():
|
||||
self.current_item.next_frame()
|
||||
self.update_play_pause_button()
|
||||
self._update_frames_menu_if_visible()
|
||||
|
||||
QtCore.QTimer.singleShot(0, do_next)
|
||||
|
||||
def on_toggle_frames_menu(self) -> None:
|
||||
"""Toggles frames menu visibility."""
|
||||
if not self._has_gif_item() or not self.frames_menu:
|
||||
return
|
||||
|
||||
self.frames_menu.toggle_menu(self.current_item)
|
||||
|
||||
def hide_menu(self) -> None:
|
||||
"""Hides menu and frames menu."""
|
||||
self._hide_frames_menu()
|
||||
super().hide_menu()
|
||||
|
||||
def update_position(self) -> None:
|
||||
"""Updates position of menu and frames menu."""
|
||||
super().update_position()
|
||||
|
||||
if self.frames_menu and self.frames_menu.isVisible():
|
||||
self.frames_menu.update_position()
|
||||
|
||||
def _has_gif_item(self) -> bool:
|
||||
"""Checks if current item is a GIF item."""
|
||||
return (self.current_item is not None and
|
||||
hasattr(self.current_item, 'is_playing'))
|
||||
|
||||
def _update_frames_menu_if_visible(self) -> None:
|
||||
"""Updates frames menu if it's visible."""
|
||||
if self.frames_menu and self.frames_menu.isVisible():
|
||||
self.frames_menu.load_frames(self.current_item)
|
||||
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)
|
||||
185
beeref/widgets/text_floating_menu.py
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
"""Floating menu shown when a single text item is selected."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from PyQt6 import QtGui, QtWidgets
|
||||
|
||||
from beeref.assets import BeeAssets
|
||||
from beeref.widgets.floating_menu import FloatingMenu
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from beeref.view import BeeGraphicsView
|
||||
from beeref.items import BeeTextItem
|
||||
|
||||
|
||||
class TextFloatingMenu(FloatingMenu):
|
||||
"""Contextual floating toolbar for text items."""
|
||||
|
||||
FONT_SIZES = [8, 10, 12, 14, 16, 18, 24, 32, 48]
|
||||
|
||||
def __init__(self, parent: QtWidgets.QWidget, view: "BeeGraphicsView"):
|
||||
super().__init__(parent, view)
|
||||
|
||||
# Load icons
|
||||
assets = BeeAssets()
|
||||
icons_path = assets.PATH.joinpath('icons')
|
||||
text_color_icon = QtGui.QIcon(str(icons_path.joinpath('format-color-text.svg')))
|
||||
palette_icon = QtGui.QIcon(str(icons_path.joinpath('palette.svg')))
|
||||
bold_icon = QtGui.QIcon(str(icons_path.joinpath('format-bold.svg')))
|
||||
italic_icon = QtGui.QIcon(str(icons_path.joinpath('format-italic.svg')))
|
||||
underline_icon = QtGui.QIcon(str(icons_path.joinpath('format-underline.svg')))
|
||||
strikethrough_icon = QtGui.QIcon(str(icons_path.joinpath('format-strikethrough.svg')))
|
||||
reset_icon = QtGui.QIcon(str(icons_path.joinpath('undo.svg')))
|
||||
fonts_icon = QtGui.QIcon(str(icons_path.joinpath('fonts.svg')))
|
||||
|
||||
self.text_color_btn = self.add_button(
|
||||
"",
|
||||
icon=text_color_icon,
|
||||
callback=self._on_text_color_clicked,
|
||||
)
|
||||
self.background_btn = self.add_button(
|
||||
"",
|
||||
icon=palette_icon,
|
||||
callback=self._on_background_clicked,
|
||||
)
|
||||
self.add_separator()
|
||||
|
||||
self.bold_btn = self.add_button(
|
||||
"",
|
||||
icon=bold_icon,
|
||||
callback=self._on_bold_clicked,
|
||||
checkable=True,
|
||||
)
|
||||
self.italic_btn = self.add_button(
|
||||
"",
|
||||
icon=italic_icon,
|
||||
callback=self._on_italic_clicked,
|
||||
checkable=True,
|
||||
)
|
||||
self.underline_btn = self.add_button(
|
||||
"",
|
||||
icon=underline_icon,
|
||||
callback=self._on_underline_clicked,
|
||||
checkable=True,
|
||||
)
|
||||
self.strikethrough_btn = self.add_button(
|
||||
"",
|
||||
icon=strikethrough_icon,
|
||||
callback=self._on_strikethrough_clicked,
|
||||
checkable=True,
|
||||
)
|
||||
self.add_separator()
|
||||
|
||||
self.size_combo = QtWidgets.QComboBox(self)
|
||||
self.size_combo.setObjectName("FloatingMenuFontSize")
|
||||
self.size_combo.setMinimumWidth(40)
|
||||
for size in self.FONT_SIZES:
|
||||
self.size_combo.addItem(str(size), size)
|
||||
self.size_combo.currentIndexChanged.connect(self._on_size_changed)
|
||||
self.add_widget(self.size_combo)
|
||||
|
||||
self.font_combo = QtWidgets.QComboBox(self)
|
||||
self.font_combo.setObjectName("FloatingMenuFontFamily")
|
||||
self.font_combo.setMinimumWidth(40)
|
||||
self.font_combo.setEditable(False)
|
||||
self.font_combo.setInsertPolicy(
|
||||
QtWidgets.QComboBox.InsertPolicy.NoInsert)
|
||||
families = QtGui.QFontDatabase.families()
|
||||
self.font_combo.addItems(families)
|
||||
# Add icon to all font items
|
||||
for i in range(self.font_combo.count()):
|
||||
self.font_combo.setItemIcon(i, fonts_icon)
|
||||
self.font_combo.currentTextChanged.connect(self._on_font_changed)
|
||||
self.add_widget(self.font_combo)
|
||||
|
||||
self.add_separator()
|
||||
|
||||
self.add_button(
|
||||
"",
|
||||
icon=reset_icon,
|
||||
callback=self.view.reset_selected_text_format,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def show_for_item(self, item: "BeeTextItem") -> None:
|
||||
font = item.font()
|
||||
self._update_font_controls(font)
|
||||
self._update_colors(item)
|
||||
super().show_for_item(item)
|
||||
|
||||
# UI updates -------------------------------------------------------
|
||||
def _update_font_controls(self, font: QtGui.QFont) -> None:
|
||||
self.bold_btn.setChecked(font.weight() >= QtGui.QFont.Weight.Bold)
|
||||
self.italic_btn.setChecked(font.italic())
|
||||
self.underline_btn.setChecked(font.underline())
|
||||
self.strikethrough_btn.setChecked(font.strikeOut())
|
||||
|
||||
size = font.pointSize()
|
||||
if size == -1:
|
||||
size = int(font.pointSizeF())
|
||||
|
||||
try:
|
||||
index = self.FONT_SIZES.index(size)
|
||||
except ValueError:
|
||||
index = -1
|
||||
self.size_combo.blockSignals(True)
|
||||
if index >= 0:
|
||||
self.size_combo.setCurrentIndex(index)
|
||||
else:
|
||||
self.size_combo.setCurrentText(str(size))
|
||||
self.size_combo.blockSignals(False)
|
||||
|
||||
self.font_combo.blockSignals(True)
|
||||
family = font.family()
|
||||
index = self.font_combo.findText(family)
|
||||
if index >= 0:
|
||||
self.font_combo.setCurrentIndex(index)
|
||||
self.font_combo.blockSignals(False)
|
||||
|
||||
def _update_colors(self, item: "BeeTextItem") -> None:
|
||||
text_color = item.defaultTextColor()
|
||||
self.text_color_btn.setProperty(
|
||||
"active", "true" if text_color else "false")
|
||||
self.text_color_btn.style().unpolish(self.text_color_btn)
|
||||
self.text_color_btn.style().polish(self.text_color_btn)
|
||||
|
||||
bg_color = getattr(item, "background_color", None)
|
||||
self.background_btn.setProperty(
|
||||
"active", "true" if bg_color and bg_color.alpha() > 0 else "false")
|
||||
self.background_btn.style().unpolish(self.background_btn)
|
||||
self.background_btn.style().polish(self.background_btn)
|
||||
|
||||
# Slots ------------------------------------------------------------
|
||||
def _on_text_color_clicked(self) -> None:
|
||||
self.view.change_selected_text_color()
|
||||
|
||||
def _on_background_clicked(self) -> None:
|
||||
self.view.change_selected_text_background()
|
||||
|
||||
def _on_bold_clicked(self) -> None:
|
||||
self.view.toggle_selected_text_bold()
|
||||
|
||||
def _on_italic_clicked(self) -> None:
|
||||
self.view.toggle_selected_text_italic()
|
||||
|
||||
def _on_underline_clicked(self) -> None:
|
||||
self.view.toggle_selected_text_underline()
|
||||
|
||||
def _on_strikethrough_clicked(self) -> None:
|
||||
self.view.toggle_selected_text_strikethrough()
|
||||
|
||||
def _on_size_changed(self, index: int) -> None:
|
||||
size = self.size_combo.currentData()
|
||||
if size is None:
|
||||
try:
|
||||
size = int(self.size_combo.currentText())
|
||||
except ValueError:
|
||||
return
|
||||
self.view.change_selected_text_size(size)
|
||||
|
||||
def _on_font_changed(self, family: str) -> None:
|
||||
if not family:
|
||||
return
|
||||
self.view.change_selected_text_font(family)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||