Add basic figure drawing support

This commit is contained in:
Aleksey Alexandrovich Okoneshnikov 2026-02-21 16:17:32 -08:00
parent caed5c3801
commit 6fc6dd8dce
No known key found for this signature in database
GPG key ID: 25BA731585792EB7
5 changed files with 308 additions and 2 deletions

View file

@ -447,4 +447,20 @@ actions = ActionList([
text='&Open Settings Folder',
callback='on_action_open_settings_dir',
),
Action(
id='draw_mode',
text='&Draw',
shortcuts=['D'],
callback='on_action_draw_mode',
),
Action(
id='set_brush_color',
text='Brush &Color...',
callback='on_action_set_brush_color',
),
Action(
id='set_brush_size',
text='Brush &Size...',
callback='on_action_set_brush_size',
),
])

View file

@ -105,6 +105,14 @@ menu_structure = [
'arrange_square',
],
},
{
'menu': '&Drawing',
'items': [
'draw_mode',
'set_brush_color',
'set_brush_size',
],
},
{
'menu': '&Images',
'items': [

View file

@ -199,7 +199,7 @@ class SQLiteIO:
'SELECT items.id, type, x, y, z, scale, rotation, flip, '
' items.data, null as data '
'FROM items '
'WHERE items.type = "text"'))
'WHERE items.type IN ("text", "path")'))
if self.worker:
self.worker.begin_processing.emit(len(rows))

View file

@ -17,9 +17,11 @@
text).
"""
import copy
from collections import defaultdict
from functools import cached_property
import logging
import math
import os.path
from PyQt6 import QtCore, QtGui, QtWidgets
@ -731,6 +733,166 @@ class BeeTextItem(BeeItemMixin, QtWidgets.QGraphicsTextItem):
clipboard.setText(self.toPlainText())
@register_item
class BeePathItem(BeeItemMixin, QtWidgets.QGraphicsItem):
"""Class for freehand drawing/sketch strokes added by the user."""
TYPE = 'path'
def __init__(self, strokes=None, **kwargs):
super().__init__()
self.save_id = None
self.is_image = False
self.strokes = strokes or []
self.temp_stroke = None
self._cached_rect = QtCore.QRectF(0, 0, 1, 1)
self._cache_pixmap = None
self.init_selectable()
logger.debug(f'Initialized {self}')
@classmethod
def create_from_data(cls, **kwargs):
data = kwargs.get('data', {})
item = cls(strokes=data.get('strokes', []))
item._update_bounding_rect()
item._invalidate_cache()
return item
def __str__(self):
n = len(self.strokes)
return f'Path ({n} stroke{"s" if n != 1 else ""})'
def get_extra_save_data(self):
return {'strokes': self.strokes}
def create_copy(self):
item = BeePathItem(strokes=copy.deepcopy(self.strokes))
item.setPos(self.pos())
item.setZValue(self.zValue())
item.setScale(self.scale())
item.setRotation(self.rotation())
if self.flip() == -1:
item.do_flip()
item._update_bounding_rect()
item._invalidate_cache()
return item
def contains(self, point):
return self.boundingRect().contains(point)
def bounding_rect_unselected(self):
return QtCore.QRectF(self._cached_rect)
def add_stroke(self, stroke):
self.prepareGeometryChange()
self.strokes.append(stroke)
self._update_bounding_rect()
self._invalidate_cache()
self.update()
def _invalidate_cache(self):
self._cache_pixmap = None
def _update_bounding_rect(self):
if not self.strokes:
self._cached_rect = QtCore.QRectF(0, 0, 1, 1)
return
min_x = float('inf')
min_y = float('inf')
max_x = float('-inf')
max_y = float('-inf')
max_r = 0
for stroke in self.strokes:
base_size = stroke.get('base_size', 10)
for pt in stroke.get('points', []):
r = base_size * pt.get('pressure', 1.0) / 2
if r > max_r:
max_r = r
x = pt['x']
y = pt['y']
if x < min_x:
min_x = x
if x > max_x:
max_x = x
if y < min_y:
min_y = y
if y > max_y:
max_y = y
pad = max_r + 1
self._cached_rect = QtCore.QRectF(
min_x - pad, min_y - pad,
(max_x - min_x) + 2 * pad,
(max_y - min_y) + 2 * pad)
def _paint_stroke(self, painter, stroke):
color_data = stroke.get('color', [0, 0, 0, 255])
color = QtGui.QColor(*color_data)
base_size = stroke.get('base_size', 10)
points = stroke.get('points', [])
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QtGui.QBrush(color))
for i, pt in enumerate(points):
pressure = pt.get('pressure', 1.0)
radius = base_size * pressure / 2
painter.drawEllipse(
QtCore.QPointF(pt['x'], pt['y']),
radius, radius)
if i > 0:
prev = points[i - 1]
dx = pt['x'] - prev['x']
dy = pt['y'] - prev['y']
dist = math.hypot(dx, dy)
if dist > 1:
steps = int(dist)
for s in range(1, steps):
t = s / dist
ix = prev['x'] + dx * t
iy = prev['y'] + dy * t
prev_p = prev.get('pressure', 1.0)
ip = prev_p + (pressure - prev_p) * t
ir = base_size * ip / 2
painter.drawEllipse(
QtCore.QPointF(ix, iy), ir, ir)
def _ensure_cache(self):
if self._cache_pixmap is not None:
return
if not self.strokes:
return
rect = self._cached_rect
w = max(1, int(math.ceil(rect.width())))
h = max(1, int(math.ceil(rect.height())))
pixmap = QtGui.QPixmap(w, h)
pixmap.fill(QtGui.QColor(0, 0, 0, 0))
painter = QtGui.QPainter(pixmap)
painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
painter.translate(-rect.x(), -rect.y())
for stroke in self.strokes:
self._paint_stroke(painter, stroke)
painter.end()
self._cache_pixmap = pixmap
def paint(self, painter, option, widget):
if self.strokes:
self._ensure_cache()
if self._cache_pixmap is not None:
painter.drawPixmap(
QtCore.QPointF(
self._cached_rect.x(),
self._cached_rect.y()),
self._cache_pixmap)
if self.temp_stroke:
painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
self._paint_stroke(painter, self.temp_stroke)
self.paint_selectable(painter, option, widget)
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

View file

@ -29,7 +29,7 @@ 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.items import BeePixmapItem, BeeTextItem, BeePathItem
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 +46,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)
@ -69,6 +70,11 @@ class BeeGraphicsView(MainControlsMixin,
self.filename = None
self.previous_transform = None
self.active_mode = None
self.draw_item = None
self.draw_current_stroke = None
self.draw_brush_size = 10.0
self.draw_brush_color = [0, 0, 0, 255]
self._tablet_pressure = 1.0
self.scene = BeeGraphicsScene(self.undo_stack)
self.scene.changed.connect(self.on_scene_changed)
@ -107,6 +113,8 @@ class BeeGraphicsView(MainControlsMixin,
def cancel_active_modes(self):
self.scene.cancel_active_modes()
self.cancel_sample_color_mode()
if self.active_mode == self.DRAW_MODE:
self.exit_draw_mode(commit=True)
self.active_mode = None
def cancel_sample_color_mode(self):
@ -400,6 +408,54 @@ class BeeGraphicsView(MainControlsMixin,
pos,
self.scene.sample_color_at(self.mapToScene(pos)))
def on_action_draw_mode(self):
if self.active_mode == self.DRAW_MODE:
self.exit_draw_mode(commit=True)
else:
self.enter_draw_mode()
def enter_draw_mode(self):
self.cancel_active_modes()
self.scene.deselect_all_items()
self.active_mode = self.DRAW_MODE
self.viewport().setCursor(Qt.CursorShape.CrossCursor)
logger.debug('Entered draw mode')
def exit_draw_mode(self, commit=True):
logger.debug(f'Exiting draw mode, commit={commit}')
if self.draw_item:
if self.draw_current_stroke:
self.draw_item.add_stroke(self.draw_current_stroke)
self.draw_item.temp_stroke = None
self.draw_current_stroke = None
if commit and self.draw_item.strokes:
self.scene.removeItem(self.draw_item)
pos = self.draw_item.pos()
self.undo_stack.push(
commands.InsertItems(
self.scene, [self.draw_item], pos))
else:
self.scene.removeItem(self.draw_item)
self.draw_item = None
self.active_mode = None
self.viewport().unsetCursor()
def on_action_set_brush_color(self):
current = QtGui.QColor(*self.draw_brush_color)
color = QtWidgets.QColorDialog.getColor(
current, self, 'Select Brush Color',
QtWidgets.QColorDialog.ColorDialogOption.ShowAlphaChannel)
if color.isValid():
self.draw_brush_color = [
color.red(), color.green(), color.blue(), color.alpha()]
def on_action_set_brush_size(self):
size, ok = QtWidgets.QInputDialog.getInt(
self, 'Brush Size', 'Size (px):',
int(self.draw_brush_size), 1, 500)
if ok:
self.draw_brush_size = float(size)
def on_items_loaded(self, value):
logger.debug('On items loaded: add queued items')
self.scene.add_queued_items()
@ -854,7 +910,43 @@ class BeeGraphicsView(MainControlsMixin,
event.accept()
return
def tabletEvent(self, event):
if self.active_mode == self.DRAW_MODE:
self._tablet_pressure = event.pressure()
# Don't accept — let Qt synthesize mouse events for drawing
event.ignore()
else:
super().tabletEvent(event)
def mousePressEvent(self, event):
if self.active_mode == self.DRAW_MODE:
if event.button() == Qt.MouseButton.RightButton:
self.exit_draw_mode(commit=True)
event.accept()
return
if event.button() == Qt.MouseButton.LeftButton:
scene_pos = self.mapToScene(event.pos())
if not self.draw_item:
self.draw_item = BeePathItem()
self.draw_item.setPos(scene_pos)
self.scene.addItem(self.draw_item)
local_pos = self.draw_item.mapFromScene(scene_pos)
self.draw_current_stroke = {
'color': list(self.draw_brush_color),
'base_size': self.draw_brush_size,
'points': [{
'x': round(local_pos.x(), 1),
'y': round(local_pos.y(), 1),
'pressure': self._tablet_pressure,
}],
}
self.draw_item.temp_stroke = self.draw_current_stroke
self.draw_item.update()
event.accept()
return
event.accept()
return
if self.mousePressEventMainControls(event):
return
@ -900,6 +992,20 @@ class BeeGraphicsView(MainControlsMixin,
super().mousePressEvent(event)
def mouseMoveEvent(self, event):
if (self.active_mode == self.DRAW_MODE
and self.draw_current_stroke is not None):
scene_pos = self.mapToScene(event.pos())
local_pos = self.draw_item.mapFromScene(scene_pos)
self.draw_current_stroke['points'].append({
'x': round(local_pos.x(), 1),
'y': round(local_pos.y(), 1),
'pressure': self._tablet_pressure,
})
self.draw_item.temp_stroke = self.draw_current_stroke
self.draw_item.update()
event.accept()
return
if self.active_mode == self.PAN_MODE:
self.reset_previous_transform()
pos = event.position()
@ -931,6 +1037,15 @@ class BeeGraphicsView(MainControlsMixin,
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event):
if (self.active_mode == self.DRAW_MODE
and self.draw_current_stroke is not None):
self.draw_item.add_stroke(self.draw_current_stroke)
self.draw_item.temp_stroke = None
self.draw_current_stroke = None
self._tablet_pressure = 1.0
event.accept()
return
if self.active_mode == self.PAN_MODE:
logger.trace('End pan')
self.viewport().unsetCursor()
@ -953,6 +1068,11 @@ class BeeGraphicsView(MainControlsMixin,
def keyPressEvent(self, event):
if self.keyPressEventMainControls(event):
return
if self.active_mode == self.DRAW_MODE:
if event.key() == Qt.Key.Key_Escape:
self.exit_draw_mode(commit=True)
event.accept()
return
if self.active_mode == self.SAMPLE_COLOR_MODE:
self.cancel_sample_color_mode()
event.accept()