From 6fc6dd8dcecc86eec3c355059147e5dc481ed10b Mon Sep 17 00:00:00 2001 From: Aleksey Alexandrovich Okoneshnikov Date: Sat, 21 Feb 2026 16:17:32 -0800 Subject: [PATCH] Add basic figure drawing support --- beeref/actions/actions.py | 16 +++ beeref/actions/menu_structure.py | 8 ++ beeref/fileio/sql.py | 2 +- beeref/items.py | 162 +++++++++++++++++++++++++++++++ beeref/view.py | 122 ++++++++++++++++++++++- 5 files changed, 308 insertions(+), 2 deletions(-) diff --git a/beeref/actions/actions.py b/beeref/actions/actions.py index a9b7770..f990cb6 100644 --- a/beeref/actions/actions.py +++ b/beeref/actions/actions.py @@ -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', + ), ]) diff --git a/beeref/actions/menu_structure.py b/beeref/actions/menu_structure.py index 9c70966..4e87745 100644 --- a/beeref/actions/menu_structure.py +++ b/beeref/actions/menu_structure.py @@ -105,6 +105,14 @@ menu_structure = [ 'arrange_square', ], }, + { + 'menu': '&Drawing', + 'items': [ + 'draw_mode', + 'set_brush_color', + 'set_brush_size', + ], + }, { 'menu': '&Images', 'items': [ diff --git a/beeref/fileio/sql.py b/beeref/fileio/sql.py index 9e2a2b5..8da7e14 100644 --- a/beeref/fileio/sql.py +++ b/beeref/fileio/sql.py @@ -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)) diff --git a/beeref/items.py b/beeref/items.py index c42044a..c62a916 100644 --- a/beeref/items.py +++ b/beeref/items.py @@ -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 diff --git a/beeref/view.py b/beeref/view.py index 9e40532..ded2328 100644 --- a/beeref/view.py +++ b/beeref/view.py @@ -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()