diff --git a/beeref/fileio/__init__.py b/beeref/fileio/__init__.py index 5834d97..a43ae9c 100644 --- a/beeref/fileio/__init__.py +++ b/beeref/fileio/__init__.py @@ -13,92 +13,130 @@ # You should have received a copy of the GNU General Public License # along with BeeRef. If not, see . -import logging +# 导入必要的模块 +import logging # 用于日志记录 -from PyQt6 import QtCore +from PyQt6 import QtCore # 导入PyQt6的QtCore模块,用于处理Qt的核心功能 -from beeref import commands -from beeref.fileio.errors import BeeFileIOError -from beeref.fileio.image import load_image -from beeref.fileio.sql import SQLiteIO, is_bee_file -from beeref.items import BeePixmapItem +from beeref import commands # 导入commands命令模块,用于实现撤销和重做功能 +from beeref.fileio.errors import BeeFileIOError # 导入自定义的文件IO错误类 +from beeref.fileio.image import load_image # 导入加载图片的函数 +from beeref.fileio.sql import SQLiteIO, is_bee_file # 导入SQLite数据库相关的类和函数 +from beeref.items import BeePixmapItem # 导入自定义的图片项类 +# 定义公共API,指定从该模块导入时可访问的名称 __all__ = [ - 'is_bee_file', - 'load_bee', - 'save_bee', - 'load_images', - 'ThreadedLoader', - 'BeeFileIOError', + 'is_bee_file', # 判断文件是否为BeeRef格式的函数 + 'load_bee', # 加载BeeRef原生文件的函数 + 'save_bee', # 保存BeeRef原生文件的函数 + 'load_images', # 加载图像函数 + 'ThreadedLoader', # 线程加载器类 + 'BeeFileIOError', # 自定义的文件IO错误类 ] +# 创建日志记录器 logger = logging.getLogger(__name__) +# 加载BeeRef原生文件函数 def load_bee(filename, scene, worker=None): """Load BeeRef native file.""" - logger.info(f'Loading from file {filename}...') - io = SQLiteIO(filename, scene, readonly=True, worker=worker) - return io.read() + """ + 参数: + filename: 要加载的文件路径 + scene: 目标场景对象 + worker: 可选的工作线程对象,用于报告进度 + """ + logger.info(f'Loading from file {filename}...') # 记录加载文件的信息日志 + io = SQLiteIO(filename, scene, readonly=True, worker=worker) # 创建SQLiteIO对象,用于读取文件,设置为只读模式 + return io.read() # 调用read方法读取文件内容并返回 +# 保存BeeRef原生文件函数 def save_bee(filename, scene, create_new=False, worker=None): """Save BeeRef native file.""" - logger.info(f'Saving to file {filename}...') - logger.debug(f'Create new: {create_new}') - io = SQLiteIO(filename, scene, create_new, worker=worker) - io.write() - logger.info('End save') + """ + 参数: + filename: 要保存的文件路径 + scene: 要保存的场景对象 + create_new: 是否创建新文件 + worker: 可选的工作线程对象,用于报告进度 + """ + logger.info(f'Saving to file {filename}...') # 记录保存文件的信息日志 + logger.debug(f'Create new: {create_new}') # 记录创建新文件的调试日志 + io = SQLiteIO(filename, scene, create_new, worker=worker) # 创建SQLiteIO对象,用于写入文件,根据create_new参数确定是否创建新文件 + io.write() # 调用write方法将场景数据写入文件 + logger.info('End save') # 记录保存结束的信息日志 + +# 加载图像到现有场景函数 def load_images(filenames, pos, scene, worker): """Add images to existing scene.""" - - errors = [] - items = [] - worker.begin_processing.emit(len(filenames)) + """ + 参数: + filenames: 图像文件路径列表 + pos: 图像在场景中的位置 + scene: 目标场景对象 + worker: 工作线程对象,用于报告进度和处理取消操作 + """ + errors = [] # 存储加载失败的图像文件路径 + items = [] # 存储成功加载的图片项对象 + worker.begin_processing.emit(len(filenames)) # 发送开始处理信号,参数为图像文件数量 + # 遍历图像文件路径列表 for i, filename in enumerate(filenames): - logger.info(f'Loading image from file {filename}') - img, filename = load_image(filename) - worker.progress.emit(i) - if img.isNull(): - logger.info(f'Could not load file {filename}') - errors.append(filename) - continue + logger.info(f'Loading image from file {filename}') # 记录加载图像文件的信息日志 + img, filename = load_image(filename) # 调用load_image函数加载图像文件,返回图像对象和文件名 + worker.progress.emit(i) # 发送进度更新信号 + if img.isNull(): # 检查图像是否加载失败 + logger.info(f'Could not load file {filename}') # 记录加载失败的图像文件路径的信息日志 + errors.append(filename) # 将加载失败的图像文件路径添加到errors列表中 + continue # 继续处理下一个图像文件 - item = BeePixmapItem(img, filename) - item.set_pos_center(pos) - scene.add_item_later({'item': item, 'type': 'pixmap'}, selected=True) - items.append(item) + item = BeePixmapItem(img, filename) # 创建BeePixmapItem对象,参数为加载的图像对象和文件名 + item.set_pos_center(pos) # 设置图片项的位置为指定的中心位置 + scene.add_item_later({'item': item, 'type': 'pixmap'}, selected=True) # 将图片项添加到场景中,参数为字典,包含项对象和类型 + items.append(item) # 将图片项对象添加到items列表中 if worker.canceled: - break + break # 如果工作线程被取消,跳出循环 # Give main thread time to process items: - worker.msleep(10) + worker.msleep(10) # 线程休眠10毫秒,允许主线程处理其他任务 + # 将添加项的操作添加到撤销栈 scene.undo_stack.push( commands.InsertItems(scene, items, ignore_first_redo=True)) - worker.finished.emit('', errors) + worker.finished.emit('', errors) # 发送完成信号,传递错误列表 +# 用于加载和保存的专用线程类 class ThreadedIO(QtCore.QThread): """Dedicated thread for loading and saving.""" - progress = QtCore.pyqtSignal(int) - finished = QtCore.pyqtSignal(str, list) - begin_processing = QtCore.pyqtSignal(int) - user_input_required = QtCore.pyqtSignal(str) + # 定义信号 + progress = QtCore.pyqtSignal(int) # 进度更新信号 + finished = QtCore.pyqtSignal(str, list) # 完成信号,传递文件名和和错误列表 + begin_processing = QtCore.pyqtSignal(int) # 开始处理信号,传递总处理数量 + user_input_required = QtCore.pyqtSignal(str) # 用户输入required信号,参数为提示信息 + # 初始化线程,设置要执行的函数和参数 def __init__(self, func, *args, **kwargs): - super().__init__() - self.func = func - self.args = args - self.kwargs = kwargs - self.kwargs['worker'] = self - self.canceled = False - + """ + 参数: + func: 要在线程中执行的函数 + *args: 传递给func的位置参数 + **kwargs: 传递给func的关键字参数 + """ + super().__init__() # 调用父类QThread构造函数 + self.func = func # 存储要执行的函数 + self.args = args # 存储要传递给函数的位置参数 + self.kwargs = kwargs # 存储要传递给函数的关键字参数 + self.kwargs['worker'] = self # 将自身作为worker参数传递给函数 + self.canceled = False # 取消标志,初始化为False + # 线程执行函数 def run(self): - self.func(*self.args, **self.kwargs) + self.func(*self.args, **self.kwargs) # 调用指定的函数执行IO操作 + # 处理取消操作的方法 def on_canceled(self): - self.canceled = True + self.canceled = True # 设置取消标志为True diff --git a/beeref/fileio/errors.py b/beeref/fileio/errors.py index 55e7dff..2437d23 100644 --- a/beeref/fileio/errors.py +++ b/beeref/fileio/errors.py @@ -1,25 +1,30 @@ -# This file is part of BeeRef. +# This file is part of BeeRef. # 此文件是 BeeRef 项目的一部分 +# +# BeeRef is free software: you can redistribute it and/or modify # BeeRef 是自由软件:您可以重新分发和/或修改它 +# it under the terms of the GNU General Public License as published by # 根据 GNU 通用公共许可证的条款,由 Free Software Foundation 发布, +# the Free Software Foundation, either version 3 of the License, or # 无论是版本 3 的许可证,还是(在您选择的情况下)任何更高版本。 # -# 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 +# BeeRef is distributed in the hope that it will be useful, # BeeRef 是分发在希望它会有用的基础上 +# but WITHOUT ANY WARRANTY; without even the implied warranty of # 但没有任何保修;甚至没有对适销性或特定用途适用性的暗示保证。 +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # 有关更多详细信息,请参阅 GNU 通用公共许可证 # 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 . +# You should have received a copy of the GNU General Public License # 您应该已经收到了 GNU 通用公共许可证的副本 +# along with BeeRef. If not, see . # 如果没有,请参阅 +# 图片加载错误的提示消息,当图片格式未知或过大时显示 IMG_LOADING_ERROR_MSG = ( - 'Unknown format or too big?\n' - 'Check Settings -> Images & Items -> Maximum Image Size') + 'Unknown format or too big?\n' # 未知格式或文件太大? + 'Check Settings -> Images & Items -> Maximum Image Size') # 请检查设置 -> 图像和项目 -> 最大图像大小 +# 自定义的文件IO错误类,继承自Python内置的Exception类 class BeeFileIOError(Exception): + # 构造函数,初始化错误消息和文件名 def __init__(self, msg, filename): - self.msg = msg - self.filename = filename + self.msg = msg # 保存错误消息 + self.filename = filename # 保存发生错误的文件名 + diff --git a/beeref/fileio/export.py b/beeref/fileio/export.py index 69267b3..23b8857 100644 --- a/beeref/fileio/export.py +++ b/beeref/fileio/export.py @@ -13,269 +13,307 @@ # You should have received a copy of the GNU General Public License # along with BeeRef. If not, see . -import base64 -import logging -import pathlib -from xml.etree import ElementTree as ET +# 导入必要的模块和类 +import base64 # 用于编码图片数据 +import logging # 用于记录日志 +import pathlib # 用于处理文件路径 +from xml.etree import ElementTree as ET # 用于创建XML文档 -from PyQt6 import QtCore, QtGui +from PyQt6 import QtCore, QtGui # 用于处理Qt的核心功能和图形用户界面 -from .errors import BeeFileIOError -from beeref import constants, widgets -from beeref.items import BeePixmapItem +from .errors import BeeFileIOError # 自定义异常类,用于导出时的错误处理 +from beeref import constants, widgets # 导入 BeeRef 常量和小部件 +from beeref.items import BeePixmapItem # 导入 BeeRef 图片项类 -logger = logging.getLogger(__name__) +# 创建日志记录器 +logger = logging.getLogger(__name__) -class ExporterRegistry(dict): +# 导出器注册类,继承自字典类 +class ExporterRegistry(dict): - DEFAULT_TYPE = 0 + DEFAULT_TYPE = 0 # 默认导出器类型 - def __getitem__(self, key): - key = key.removeprefix('.') - exp = self.get(key, super().__getitem__(self.DEFAULT_TYPE)) - logger.debug(f'Exporter for type {key}: {exp}') - return exp + def __getitem__(self, key): # 获取指定类型的导出器 + key = key.removeprefix('.') # 移除类型前缀的点号 + exp = self.get(key, super().__getitem__(self.DEFAULT_TYPE)) # 获取指定类型的导出器,若不存在则返回默认导出器 + logger.debug(f'Exporter for type {key}: {exp}') # 记录获取的导出器 + return exp # 返回获取的导出器 -exporter_registry = ExporterRegistry() +# 创建导出器注册表实例 +exporter_registry = ExporterRegistry() -def register_exporter(cls): - exporter_registry[cls.TYPE] = cls - return cls +# 导出器注册装饰器,用于注册导出器类 +def register_exporter(cls): + exporter_registry[cls.TYPE] = cls # 将导出器类注册到导出器注册实例中 + return cls # 返回导出器类注册装饰器,用于注册导出器类 +# 导出器基类,用于定义导出器的通用方法 class ExporterBase: + # 发送开始处理信号 def emit_begin_processing(self, worker, start): - if worker: - worker.begin_processing.emit(start) - + if worker: # 如果有工作线程 + worker.begin_processing.emit(start) # 触发工作线程的开始处理信号 + else: # 若没有工作线程 + logger.debug(f'No worker, emit begin processing signal: {start}') # 若没有工作线程,则记录日志 + # 发送进度更新信号 def emit_progress(self, worker, progress): - if worker: - worker.progress.emit(progress) + if worker: # 若有工作线程 + worker.progress.emit(progress) # 若有工作线程,则触发工作线程的进度更新信号 + # 发送完成信号 def emit_finished(self, worker, filename, errors): - filename = str(filename) - if worker: - worker.finished.emit(filename, errors) + filename = str(filename) # 确保文件名是字符串类型 + if worker: # 若有工作线程 + worker.finished.emit(filename, errors) # 若有工作线程,则触发工作线程的完成信号 + # 发送需要用户输入信号 def emit_user_input_required(self, worker, msg): - if worker: - worker.user_input_required.emit(msg) + if worker: # 若有工作线程 + worker.user_input_required.emit(msg) # 若有工作线程,则触发工作线程的用户输入要求信号 + # 处理导出错误 def handle_export_error(self, filename, error, worker): - filename = str(filename) - logger.debug(f'Export failed: {error}') - if worker: - worker.finished.emit(filename, [str(error)]) - return - else: - e = error if isinstance(error, Exception) else None - raise BeeFileIOError(msg=str(error), filename=filename) from e + filename = str(filename) # 确保文件名是字符串类型 + logger.debug(f'Export failed: {error}') # 记录导出失败的日志 + if worker: # 若有工作线程 + worker.finished.emit(filename, [str(error)]) # 若有工作线程,则触发工作线程的完成信号,包含错误信息 + return # 若有工作线程,则返回错误信息 + else: # 若没有工作线程 + e = error if isinstance(error, Exception) else None # 若错误不是异常类型,则设为None + raise BeeFileIOError(msg=str(error), filename=filename) from e # 抛出自定义异常,包含错误信息和文件名 +# 场景导出器基类,用于定义场景导出器为单个图像 class SceneExporterBase(ExporterBase): - """For exporting the scene to a single image.""" + """For exporting the scene to a single image.""" # 场景导出器基类,用于定义场景导出器的通用方法 + # 场景导出器基类的初始化方法 def __init__(self, scene): - self.scene = scene - self.scene.cancel_active_modes() - self.scene.deselect_all_items() + self.scene = scene # 场景导出器基类的初始化方法,接收场景作为参数 + self.scene.cancel_active_modes() # 取消场景中所有活动模式 + self.scene.deselect_all_items() # 取消场景中所有项的选择 # Selection outlines/handles will be rendered to the exported # image, so deselect first. (Alternatively, pass an attribute # to paint functions to not paint them?) - rect = self.scene.itemsBoundingRect() - logger.trace(f'Items bounding rect: {rect}') - size = QtCore.QSize(int(rect.width()), int(rect.height())) - logger.trace(f'Export size without margins: {size}') - self.margin = max(size.width(), size.height()) * 0.03 - self.default_size = size.grownBy( - QtCore.QMargins(*([int(self.margin)] * 4))) - logger.debug(f'Default export margin: {self.margin}') - logger.debug(f'Default export size with margins: {self.default_size}') + rect = self.scene.itemsBoundingRect() # 获取场景中所有项的边界矩形 + logger.trace(f'Items bounding rect: {rect}') # 记录场景中所有项的边界矩形 + size = QtCore.QSize(int(rect.width()), int(rect.height())) # 计算导出图像的大小,取项边界矩形的宽度和高度 + logger.trace(f'Export size without margins: {size}') # 记录导出图像的大小,不包含边距 + self.margin = max(size.width(), size.height()) * 0.03 # 计算导出图像的边距,取宽度和高度的较大值乘以0.03 + self.default_size = size.grownBy( # 计算导出图像的大小,包含边距 + QtCore.QMargins(*([int(self.margin)] * 4))) # 计算导出图像的大小,包含边距 + logger.debug(f'Default export margin: {self.margin}') # 记录导出图像的边距 + logger.debug(f'Default export size with margins: {self.default_size}')# 记录导出图像的大小,包含边距 +# 注册场景到像素图导出器 @register_exporter -class SceneToPixmapExporter(SceneExporterBase): +class SceneToPixmapExporter(SceneExporterBase): # 场景导出器基类,用于定义场景导出器的通用方法 - TYPE = ExporterRegistry.DEFAULT_TYPE + TYPE = ExporterRegistry.DEFAULT_TYPE # 设置为默认导出类型 + # 场景导出器基类的用户输入方法,用于获取用户输入的导出大小 def get_user_input(self, parent): - """Ask user for final export size.""" + """Ask user for final export size.""" # 场景导出器基类的用户输入方法,用于获取用户输入的导出大小 - dialog = widgets.SceneToPixmapExporterDialog( - parent=parent, - default_size=self.default_size, + # 创建导出对话框,让用户输入最终导出尺寸 + dialog = widgets.SceneToPixmapExporterDialog( # 创建场景导出器对话框实例 + parent=parent, # 场景导出器对话框的父窗口 + default_size=self.default_size, # 场景导出器对话框的默认导出大小 ) - if dialog.exec(): - size = dialog.value() - logger.debug(f'Got export size {size}') - self.size = size - return True - else: - return False + if dialog.exec(): # 若场景导出器对话框执行成功 + size = dialog.value() # 获取场景导出器对话框中用户输入的导出大小 + logger.debug(f'Got export size {size}') # 记录用户输入的导出大小 + self.size = size # 将用户输入的导出大小赋值给场景导出器基类的属性 + return True # 返回True表示用户输入成功 + else: # 若场景导出器对话框执行失败 + return False # 返回False表示用户输入失败 + # 场景导出器基类的渲染方法,用于将场景渲染为图像 def render_to_image(self): - logger.debug(f'Final export size: {self.size}') - margin = self.margin * self.size.width() / self.default_size.width() - logger.debug(f'Final export margin: {margin}') + logger.debug(f'Final export size: {self.size}') # 记录最终导出图像的大小 + # 按比例调整边距 + margin = self.margin * self.size.width() / self.default_size.width() # 计算最终导出图像的边距,取宽度和高度的较大值乘以0.03 + logger.debug(f'Final export margin: {margin}') # 记录最终导出图像的边距 - image = QtGui.QImage(self.size, QtGui.QImage.Format.Format_RGB32) - image.fill(QtGui.QColor(*constants.COLORS['Scene:Canvas'])) - painter = QtGui.QPainter(image) + image = QtGui.QImage(self.size, QtGui.QImage.Format.Format_RGB32) # 创建导出图像,大小为最终导出大小,格式为RGB32 + image.fill(QtGui.QColor(*constants.COLORS['Scene:Canvas'])) # 用画布颜色作为导出图像的背景颜色 + painter = QtGui.QPainter(image) # 创建画家对象 + # 定义目标矩形 target_rect = QtCore.QRectF( - margin, - margin, - self.size.width() - 2 * margin, - self.size.height() - 2 * margin) - logger.trace(f'Final export target_rect: {target_rect}') - self.scene.render(painter, - source=self.scene.itemsBoundingRect(), - target=target_rect) - painter.end() - return image + margin, # 取导出图像的边距作为偏移量 + margin, # 取导出图像的边距作为偏移量 + self.size.width() - 2 * margin, # 取导出图像的宽度减去2倍边距作为矩形宽度 + self.size.height() - 2 * margin) # 取导出图像的高度减去2倍边距作为矩形高度 + logger.trace(f'Final export target_rect: {target_rect}') # 记录最终导出图像的目标矩形 + self.scene.render(painter, # 渲染场景到导出图像的绘图器 + source=self.scene.itemsBoundingRect(), # 获取场景中所有项的边界矩形 + target=target_rect) # 将场景渲染到最终导出图像的目标矩形 + painter.end() # 结束导出图像的绘图器 + return image # 返回导出图像 + + # 场景导出器基类的导出方法,用于将场景导出为图像文件 def export(self, filename, worker=None): - logger.debug(f'Exporting scene to {filename}') - self.emit_begin_processing(worker, 1) - image = self.render_to_image() + logger.debug(f'Exporting scene to {filename}') # 记录导出场景的文件名 + self.emit_begin_processing(worker, 1) # 发送导出开始信号,参数为导出器实例和导出任务数量 + image = self.render_to_image() # 调用场景导出器基类的渲染方法,渲染场景到图像 + # 若导出器实例存在且取消导出标志为True if worker and worker.canceled: - logger.debug('Export canceled') - self.emit_finished(worker, filename, []) - return + logger.debug('Export canceled') # 记录导出被取消的信息 + self.emit_finished(worker, filename, []) # 发送导出完成信号,参数为导出器实例、导出文件名和空列表 + return # 若导出器实例存在且取消导出标志为True,则直接返回,不导出图像 + # 若导出图像保存失败 if not image.save(filename, quality=90): - self.handle_export_error(filename, 'Error writing file', worker) - return - - logger.debug('Export finished') - self.emit_progress(worker, 1) - self.emit_finished(worker, filename, []) + self.handle_export_error(filename, 'Error writing file', worker) # 处理导出错误,参数为导出文件名、错误信息和导出器实例 + return # 返回,不继续导出 + logger.debug('Export finished') # 记录导出完成的信息 + self.emit_progress(worker, 1) # 发送导出进度信号,参数为导出器实例和导出进度值1 + self.emit_finished(worker, filename, []) # 发送导出完成信号,参数为导出器实例、导出文件名和空列表 +# 注册场景到SVG, @register_exporter class SceneToSVGExporter(SceneExporterBase): - TYPE = 'svg' + TYPE = 'svg' # 场景导出器基类的导出类型属性,值为'svg',表示导出为SVG格式 + # 场景导出器基类的用户输入方法,用于获取用户输入的导出大小 def get_user_input(self, parent): - self.size = self.default_size - return True + self.size = self.default_size # 将场景导出器基类的默认导出大小赋值给场景导出器基类的属性 + return True # 返回True表示用户输入成功 + # 场景导出器基类的文本样式方法,用于获取项的字体样式 def _get_textstyles(self, item): + # 字体样式映射字典,将QFont的字体样式枚举值映射为SVG的字体样式字符串 fontstylemap = { - QtGui.QFont.Style.StyleNormal: 'normal', - QtGui.QFont.Style.StyleItalic: 'italic', - QtGui.QFont.Style.StyleOblique: 'oblique', + QtGui.QFont.Style.StyleNormal: 'normal', # 将QFont的字体样式枚举值StyleNormal映射为SVG的字体样式字符串normal + QtGui.QFont.Style.StyleItalic: 'italic', # 将QFont的字体样式枚举值StyleItalic映射为SVG的字体样式字符串italic + QtGui.QFont.Style.StyleOblique: 'oblique', # 将QFont的字体样式枚举值StyleOblique映射为SVG的字体样式字符串oblique } - font = item.font() - fontsize = font.pointSize() * item.scale() - families = ', '.join(font.families()) - fontstyle = fontstylemap[font.style()] + font = item.font() # 获取项的字体 + fontsize = font.pointSize() * item.scale() # 计算项的字体大小,取字体点大小乘以项的缩放比例 + families = ', '.join(font.families()) # 取字体的所有字体家族,用逗号分隔 + fontstyle = fontstylemap[font.style()] # 取字体的字体样式,根据字体样式映射表映射为SVG的字体样式字符串 + # 返回CSS样式元组 return ('white-space:pre', - f'font-size:{fontsize}pt', - f'font-family:{families}', - f'font-weight:{font.weight()}', - f'font-stretch:{font.stretch()}', - f'font-style:{fontstyle}') + f'font-size:{fontsize}pt', # 取项的字体大小,取字体点大小乘以项的缩放比例 + f'font-family:{families}', # 取项的字体家族,用逗号分隔 + f'font-weight:{font.weight()}', # 取项的字体重量 + f'font-stretch:{font.stretch()}', # 取项的字体拉伸 + f'font-style:{fontstyle}') # 取项的字体样式,根据字体样式映射表映射为SVG的字体样式字符串 + # 场景导出器基类的导出方法,用于将场景导出为SVG文件 def render_to_svg(self, worker=None): - svg = ET.Element( + svg = ET.Element( # 创建SVG元素,参数为元素标签名svg 'svg', - attrib={'width': str(self.size.width()), - 'height': str(self.size.height()), - 'xmlns': 'http://www.w3.org/2000/svg', - 'xmlns:xlink': 'http://www.w3.org/1999/xlink', + attrib={'width': str(self.size.width()), # 设置SVG元素的宽度属性,值为场景导出器基类的属性size的宽度 + 'height': str(self.size.height()), # 设置SVG元素的高度属性,值为场景导出器基类的属性size的高度 + 'xmlns': 'http://www.w3.org/2000/svg', # 设置SVG元素的XML命名空间属性,值为http://www.w3.org/2000/svg + 'xmlns:xlink': 'http://www.w3.org/1999/xlink', # 设置SVG元素的XML链接命名空间属性,值为http://www.w3.org/1999/xlink }) - rect = self.scene.itemsBoundingRect() - offset = rect.topLeft() - QtCore.QPointF(self.margin, self.margin) + rect = self.scene.itemsBoundingRect() # 获取场景中所有项的边界矩形 + offset = rect.topLeft() - QtCore.QPointF(self.margin, self.margin)# 计算项的位置偏移量,取场景中所有项的边界矩形的左上角坐标减去导出器基类的属性margin + # 按z值排序项目并遍历 for i, item in enumerate(sorted(self.scene.items(), key=lambda x: x.zValue())): # z order in SVG specified via the order of elements in the tree - pos = item.pos() - offset - anchor = pos + pos = item.pos() - offset # 计算项的位置,取项的位置减去项的位置偏移量 + anchor = pos # 设置项的变换锚点,取项的位置 - if item.TYPE == 'text': - styles = self._get_textstyles(item) - element = ET.Element( - 'text', - attrib={'style': ';'.join(styles), - 'dominant-baseline': 'hanging'}) - element.text = item.toPlainText() - if item.TYPE == 'pixmap': - width = item.width * item.scale() - height = item.height * item.scale() - pixmap, imgformat = item.pixmap_to_bytes( - apply_grayscale=True, - apply_crop=True) - pixmap = base64.b64encode(pixmap).decode('ascii') - element = ET.Element( - 'image', - attrib={ - 'xlink:href': - f'data:image/{imgformat};base64,{pixmap}', - 'width': str(width), - 'height': str(height), - 'image-rendering': ('crisp-edges' if item.scale() > 2 + # 处理文本项 + if item.TYPE == 'text': # 若项的类型为文本项 + styles = self._get_textstyles(item) # 获取项的字体样式 + element = ET.Element( # 创建文本元素,参数为元素标签名text + 'text', # 设置文本元素的标签名,值为text + attrib={'style': ';'.join(styles), # 将样式列表用分号连接成CSS样式字符串,设置为元素的style属性 + 'dominant-baseline': 'hanging'}) # 设置文本元素的属性,值为hanging,指定文本的基线位置为悬挂基线 + element.text = item.toPlainText() # 设置文本元素的文本内容,值为项的纯文本内容 + # 处理图片项 + if item.TYPE == 'pixmap': # 若项的类型为图片项 + width = item.width * item.scale() # 计算项的宽度,取项的宽度乘以项的缩放比例 + height = item.height * item.scale() # 计算项的高度,取项的高度乘以项的缩放比例 + pixmap, imgformat = item.pixmap_to_bytes( # 将项的像素图转换为字节数组,参数为项的像素图,是否应用灰度化,是否应用裁剪 + apply_grayscale=True, # 是否应用灰度化,值为True + apply_crop=True) # 是否应用裁剪,值为True + pixmap = base64.b64encode(pixmap).decode('ascii')# 将项的像素图字节数组转换为Base64编码的字符串 + element = ET.Element( # 创建图片元素,参数为元素标签名image + 'image', # 设置图片元素的标签名,值为image + attrib={ # 设置图片元素的属性,值为项的像素图Base64编码的字符串 + 'xlink:href': # 设置图片元素的XML链接属性,值为项的像素图Base64编码的字符串 + f'data:image/{imgformat};base64,{pixmap}',# 设置图片元素的XML链接属性,值为项的像素图Base64编码的字符串 + 'width': str(width), # 设置图片元素的宽度属性,值为项的宽度乘以项的缩放比例 + 'height': str(height), # 设置图片元素的高度属性,值为项的高度乘以项的缩放比例 + 'image-rendering': ('crisp-edges' if item.scale() > 2# 设置图片元素的渲染属性,值为crisp-edges或optimizeQuality,根据项的缩放比例判断 else 'optimizeQuality')}) - pos = pos + item.crop.topLeft() + pos = pos + item.crop.topLeft() # 计算项的位置,取项的位置加上项的裁剪区域的左上角坐标 - transforms = [] - if item.flip() == -1: + #设置变换属性 + transforms = [] # 创建变换列表,用于存储项的变换操作 + if item.flip() == -1: # 若项的翻转方向为-1 # The following is not recognised by Inkscape and not an # official standard: # element.set('transform-origin', f'{anchor.x()} {anchor.y()}') # Thus we need to fix the origin manually - transforms.append(f'translate({anchor.x()} {anchor.y()})') - transforms.append(f'scale({item.flip()} 1)') - transforms.append(f'translate(-{anchor.x()} -{anchor.y()})') + transforms.append(f'translate({anchor.x()} {anchor.y()})') # 若项的翻转方向为-1,添加变换操作,将项的锚点平移到项的位置 + transforms.append(f'scale({item.flip()} 1)') # 添加变换操作,将项的像素图水平翻转 + transforms.append(f'translate(-{anchor.x()} -{anchor.y()})') # 添加变换操作,将项的锚点平移回项的位置 + + #添加变换操作,将项的像素图旋转项的旋转角度 transforms.append( - f'rotate({item.rotation()} {anchor.x()} {anchor.y()})') + f'rotate({item.rotation()} {anchor.x()} {anchor.y()})') #围绕项的锚点旋转项的旋转角度 - element.set('transform', ' '.join(transforms)) - element.set('x', str(pos.x())) - element.set('y', str(pos.y())) - element.set('opacity', str(item.opacity())) + #应用变换 + element.set('transform', ' '.join(transforms)) # 设置元素的变换属性,值为变换列表用空格连接成的字符串 + element.set('x', str(pos.x())) # 设置元素的x属性,值为项的位置的x坐标 + element.set('y', str(pos.y())) # 设置元素的y属性,值为项的位置的y坐标 + element.set('opacity', str(item.opacity())) # 设置元素的透明度属性,值为项的透明度 - svg.append(element) - self.emit_progress(worker, i) - if worker and worker.canceled: - return + svg.append(element) # 将元素添加到SVG根元素中 + self.emit_progress(worker, i) # 发送进度信号,参数为项的索引i + if worker and worker.canceled: # 若导出任务被取消 + return # 若导出任务被取消,直接返回 - return svg + return svg # 返回SVG根元素 + #导出场景到SVG文件 def export(self, filename, worker=None): - logger.debug(f'Exporting scene to {filename}') - self.emit_begin_processing(worker, len(self.scene.items())) - svg = self.render_to_svg(worker) + logger.debug(f'Exporting scene to {filename}') # 调试日志,输出导出场景到SVG文件的文件名 + self.emit_begin_processing(worker, len(self.scene.items())) # 发送开始处理信号,设置总进度为项的数量 + svg = self.render_to_svg(worker) # 渲染场景到SVG元素,参数为导出任务的工作线程 - if worker and worker.canceled: - logger.debug('Export canceled') - worker.finished.emit(filename, []) - return + if worker and worker.canceled: # 若导出任务被取消 + logger.debug('Export canceled') # 调试日志,输出导出任务被取消 + worker.finished.emit(filename, []) # 发送导出任务完成信号,参数为文件名和空列表 + return # 若导出任务被取消,直接返回 - tree = ET.ElementTree(svg) - ET.indent(tree, space=' ') + tree = ET.ElementTree(svg) # 创建XML元素树,参数为SVG根元素 + ET.indent(tree, space=' ') # 缩进XML元素树,参数为空格字符串 try: - with open(filename, 'w') as f: - tree.write(f, encoding='unicode', xml_declaration=True) - except OSError as e: - self.handle_export_error(filename, e, worker) - return + with open(filename, 'w') as f: # 以UTF-8编码打开文件,参数为文件名和写入模式 + tree.write(f, encoding='unicode', xml_declaration=True) # 将XML元素树写入文件,参数为文件对象,编码为unicode,XML声明为True + except OSError as e: # 若导出任务发生OS错误 + self.handle_export_error(filename, e, worker) # 处理导出任务错误,参数为文件名、错误对象和导出任务的工作线程 + return # 若导出任务发生OS错误,直接返回 - logger.debug('Export finished') - self.emit_finished(worker, filename, []) + logger.debug('Export finished') # 调试日志,输出导出任务完成 + self.emit_finished(worker, filename, []) # 发送导出任务完成信号,参数为导出任务的工作线程、文件名和空列表 +# 图像到目录导出器 class ImagesToDirectoryExporter(ExporterBase): """Export all images to a folder. @@ -283,75 +321,82 @@ class ImagesToDirectoryExporter(ExporterBase): not auto-detected by file extension. """ + #初始化图像到目录导出器 def __init__(self, scene, dirname): - self.scene = scene - self.dirname = dirname - self.items = list(self.scene.items_by_type(BeePixmapItem.TYPE)) - self.max_save_id = 0 - for item in self.items: - if item.save_id: - self.max_save_id = max(self.max_save_id, item.save_id) - self.num_total = len(self.items) - self.start_from = 0 - self.handle_existing = None + self.scene = scene # 场景对象 + self.dirname = dirname # 目录名 + self.items = list(self.scene.items_by_type(BeePixmapItem.TYPE)) # 获取所有像素图项的列表 + self.max_save_id = 0 # 最大保存ID + #查找当前最大保存ID + for item in self.items: # 遍历所有像素图项 + if item.save_id: # 若项有保存ID + self.max_save_id = max(self.max_save_id, item.save_id) # 更新最大保存ID为项的保存ID和当前最大保存ID中的较大值 + self.num_total = len(self.items) # 总项数 + self.start_from = 0 # 起始索引 + self.handle_existing = None # 处理已存在文件的方式,初始值为None + #导出场景中的图像到目录 def export(self, worker=None): - logger.debug(f'Exporting images to {self.dirname}') - logger.debug(f'Starting at {self.start_from}') - - self.emit_begin_processing(worker, self.num_total) - self.emit_progress(worker, self.start_from) + logger.debug(f'Exporting images to {self.dirname}') # 调试日志,输出导出图像到目录的目录名 + logger.debug(f'Starting at {self.start_from}') # 调试日志,输出导出任务的起始索引 + self.emit_begin_processing(worker, self.num_total) # 发送开始处理信号,参数为项的数量 + self.emit_progress(worker, self.start_from) # 发送当前进度信号,参数为起始索引 + + #遍历项目 for i, item in enumerate( - self.items[self.start_from:], start=self.start_from): - if worker and worker.canceled: - logger.debug('Export canceled') - worker.finished.emit(self.dirname, []) - return + self.items[self.start_from:], start=self.start_from): # 遍历从起始索引开始的项 + self.emit_progress(worker, i) # 发送当前进度信号,参数为项的索引i + if worker and worker.canceled: # 若导出任务被取消 + logger.debug('Export canceled') # 调试日志,输出导出任务被取消 + worker.finished.emit(self.dirname, []) # 发送导出任务完成信号,参数为目录名和空列表 + return # 若导出任务被取消,直接返回 - pixmap, imgformat = item.pixmap_to_bytes() + pixmap, imgformat = item.pixmap_to_bytes() # 获取项的像素图和图像格式 - if item.save_id: - filename = item.get_filename_for_export(imgformat) - else: - self.max_save_id += 1 - save_id = self.max_save_id - filename = item.get_filename_for_export(imgformat, save_id) + #确定文件名 + if item.save_id: # 若项有保存ID + filename = item.get_filename_for_export(imgformat) # 获取项的导出文件名,参数为图像格式 + else: # 若项没有保存ID + self.max_save_id += 1 # 最大保存ID加1 + save_id = self.max_save_id # 保存ID为最大保存ID + filename = item.get_filename_for_export(imgformat, save_id) # 获取项的导出文件名,参数为图像格式和保存ID try: - path = pathlib.Path(self.dirname) / filename - path_exists = path.exists() - except OSError as e: - self.handle_export_error(self.dirname, e, worker) - return + path = pathlib.Path(self.dirname) / filename # 构建文件路径,参数为目录名和文件名 + path_exists = path.exists() # 检查文件路径是否存在 + except OSError as e: # 若导出任务发生OS错误 + self.handle_export_error(self.dirname, e, worker) # 处理导出任务错误,参数为目录名、错误对象和导出任务的工作线程 + return # 直接返回 - if path_exists: - logger.debug(f'File already exists: {path}') - if self.handle_existing is None: - self.start_from = i - self.emit_user_input_required(worker, str(path)) - return - else: - if self.handle_existing == 'skip': - self.handle_existing = None - logger.debug('Skipping file') - continue - elif self.handle_existing == 'skip_all': - logger.debug('Skipping file') - continue - elif self.handle_existing == 'overwrite': - self.handle_existing = None - logger.debug('Overwrite file') - elif self.handle_existing == 'overwrite_all': - logger.debug('Overwrite file') + #处理文件已存在的情况 + if path_exists: # 若文件路径已存在 + logger.debug(f'File already exists: {path}') # 调试日志,输出文件已存在的路径 + if self.handle_existing is None: # 若处理已存在文件的方式为None + self.start_from = i # 更新起始索引为当前项的索引,记录当前位置 + self.emit_user_input_required(worker, str(path)) # 发送用户输入要求信号,请求用户输入 + return # 若导出任务发生OS错误,直接返回 + else: # 若处理已存在文件的方式不为None + if self.handle_existing == 'skip': # 若处理已存在文件的方式为跳过 + self.handle_existing = None # 处理已存在文件的方式设为None,记录当前位置 + logger.debug('Skipping file') # 调试日志,输出跳过文件 + continue # 若处理已存在文件的方式为跳过,继续下一项 + elif self.handle_existing == 'skip_all': # 若处理已存在文件的方式为跳过所有 + logger.debug('Skipping file') # 调试日志,输出跳过文件 + continue # 若处理已存在文件的方式为跳过所有,继续下一项 + elif self.handle_existing == 'overwrite': # 若处理已存在文件的方式为覆盖 + self.handle_existing = None # 处理已存在文件的方式设为None,记录当前位置 + logger.debug('Overwrite file') # 调试日志,输出覆盖文件 + elif self.handle_existing == 'overwrite_all':# 若处理已存在文件的方式为覆盖所有 + logger.debug('Overwrite file') # 调试日志,输出覆盖文件 - logger.debug(f'Writing file: {path}') + logger.debug(f'Writing file: {path}') # 调试日志,输出写入文件的路径 try: - path.write_bytes(pixmap) - except OSError as e: - self.handle_export_error(path, e, worker) - return + path.write_bytes(pixmap) # 写入像素图到文件路径,参数为像素图 + except OSError as e: # 若写入像素图到文件路径发生OS错误 + self.handle_export_error(path, e, worker) # 处理导出任务错误,参数为文件路径、错误对象和导出任务的工作线程 + return # 若导出任务发生OS错误,直接返回 - self.emit_progress(worker, i) + self.emit_progress(worker, i) # 发送进度信号,参数为当前项的索引,记录当前位置 - self.emit_finished(worker, self.dirname, []) + self.emit_finished(worker, self.dirname, []) # 发送导出任务完成信号,参数为目录名和空列表 diff --git a/beeref/fileio/image.py b/beeref/fileio/image.py index 9e6449c..7b8fec9 100644 --- a/beeref/fileio/image.py +++ b/beeref/fileio/image.py @@ -13,101 +13,118 @@ # You should have received a copy of the GNU General Public License # along with BeeRef. If not, see . -import logging -import os.path -import tempfile -from urllib.error import URLError -from urllib import parse, request +# 导入必要的模块 +import logging # 导入日志模块,用于记录运行时信息和错误 +import os.path # 导入os.path模块,用于处理文件路径 +import tempfile # 导入tempfile模块,用于创建临时文件 +from urllib.error import URLError # 导入URLError类,用于处理URL错误 +from urllib import parse, request # 导入urllib模块中的parse和request子模块,用于处理URL解析和请求 -from PyQt6 import QtGui - -import exif -from lxml import etree -import plum +from PyQt6 import QtGui # 导入QtGui模块,用于处理图像和绘图 +import exif # 导入exif模块,用于处理图像的EXIF数据 +from lxml import etree # 导入lxml模块中的etree子模块,用于解析HTML +import plum # 导入plum模块,用于处理图像的二进制数据 +# 创建日志记录器实例 logger = logging.getLogger(__name__) +# 定义函数exif_rotated_image,用于根据图像的EXIF数据进行旋转和镜像变换 def exif_rotated_image(path=None): """Returns a QImage that is transformed according to the source's orientation EXIF data. """ + # 创建QImage对象 img = QtGui.QImage(path) + # 检查图像是否为空(无法加载) if img.isNull(): - return img + return img # 如果图像为空,直接返回 + # 尝试读取EXIF数据 with open(path, 'rb') as f: try: - exifimg = exif.Image(f) - except (plum.exceptions.UnpackError, NotImplementedError): - logger.exception(f'Exif parser failed on image: {path}') - return img + exifimg = exif.Image(f) # 从文件对象创建EXIF图像对象 + except (plum.exceptions.UnpackError, NotImplementedError): # 处理EXIF解析错误 + logger.exception(f'Exif parser failed on image: {path}') # 记录EXIF解析错误日志 + return img # 如果EXIF解析错误,直接返回原始图像 try: - if 'orientation' in exifimg.list_all(): - orientation = exifimg.orientation + if 'orientation' in exifimg.list_all(): # 检查是否存在方向EXIF信息 + orientation = exifimg.orientation # 获取方向EXIF信息 else: - return img - except (NotImplementedError, ValueError): - logger.exception(f'Exif failed reading orientation of image: {path}') - return img + return img # 如果不存在方向EXIF信息,直接返回原始图像 + except (NotImplementedError, ValueError): # 处理其他EXIF读取错误 + logger.exception(f'Exif failed reading orientation of image: {path}') # 记录EXIF读取错误日志 + return img # 如果EXIF读取错误,直接返回原始图像 + # 创建变换对象 transform = QtGui.QTransform() - if orientation == exif.Orientation.TOP_RIGHT: + # 根据EXIF方向值进行相应的变换 + if orientation == exif.Orientation.TOP_RIGHT: # 方向值2:方向为顶部右侧,需要水平镜像 return img.mirrored(horizontal=True, vertical=False) - if orientation == exif.Orientation.BOTTOM_RIGHT: + if orientation == exif.Orientation.BOTTOM_RIGHT: # 方向值3:方向为底部右侧,需要旋转180度 transform.rotate(180) return img.transformed(transform) - if orientation == exif.Orientation.BOTTOM_LEFT: + if orientation == exif.Orientation.BOTTOM_LEFT: # 方向值4:方向为底部左侧,需要垂直镜像 return img.mirrored(horizontal=False, vertical=True) - if orientation == exif.Orientation.LEFT_TOP: + if orientation == exif.Orientation.LEFT_TOP: # 方向值5:方向为左侧顶部,需要顺时针旋转90度 transform.rotate(90) return img.transformed(transform).mirrored( horizontal=True, vertical=False) - if orientation == exif.Orientation.RIGHT_TOP: + if orientation == exif.Orientation.RIGHT_TOP: # 方向值6:方向为右侧顶部,需要逆时针旋转90度 transform.rotate(90) return img.transformed(transform) - if orientation == exif.Orientation.RIGHT_BOTTOM: + if orientation == exif.Orientation.RIGHT_BOTTOM: # 方向值7:方向为右侧底部,需要顺时针旋转270度 transform.rotate(270) return img.transformed(transform).mirrored( horizontal=True, vertical=False) - if orientation == exif.Orientation.LEFT_BOTTOM: + if orientation == exif.Orientation.LEFT_BOTTOM: # 方向值8:方向为左侧底部,需要逆时针旋转270度 transform.rotate(270) return img.transformed(transform) - return img + return img # 方向值1或其他未知值,返回原始图像 +# 加载图像的主要函数 def load_image(path): - if isinstance(path, str): - path = os.path.normpath(path) - return (exif_rotated_image(path), path) - if path.isLocalFile(): - path = os.path.normpath(path.toLocalFile()) - return (exif_rotated_image(path), path) + + # 处理本地文件路径(字符串形式) + if isinstance(path, str): # 检查路径是否为字符串类型 + path = os.path.normpath(path) # 规范路径格式,处理路径中的特殊字符和点号 + return (exif_rotated_image(path), path) # 加载图像并处理EXIF方向 + + # 处理QUrl对象指向的本地文件 + if path.isLocalFile(): # 检查路径是否为本地文件路径 + path = os.path.normpath(path.toLocalFile()) # 转化为本地路径并规范本地文件路径格式 + return (exif_rotated_image(path), path) # 加载图像并处理EXIF方向 - url = bytes(path.toEncoded()).decode() - domain = '.'.join(parse.urlparse(url).netloc.split(".")[-2:]) - img = exif_rotated_image() + # 处理远程URL + url = bytes(path.toEncoded()).decode() # 将QUrl对象转化为URL字符串并解码 + domain = '.'.join(parse.urlparse(url).netloc.split(".")[-2:]) # 从URL中提取域名(例如:'pinterest.com') + img = exif_rotated_image() # 创建一个空的QImage对象 + + # 对Pinterest特定处理 if domain == 'pinterest.com': - try: - page_data = request.urlopen(url).read() - root = etree.HTML(page_data) - url = root.xpath("//img")[0].get('src') - except Exception as e: - logger.debug(f'Pinterest image download failed: {e}') + try: # 尝试下载Pinterest页面HTML内容 + page_data = request.urlopen(url).read() # 下载Pinterest页面HTML内容 + root = etree.HTML(page_data) # 解析HTML内容为ElementTree对象 + url = root.xpath("//img")[0].get('src') # 从HTML中提取第一个img标签的src属性值(图像URL) + except Exception as e: # 处理下载Pinterest页面HTML内容时的异常 + logger.debug(f'Pinterest image download failed: {e}') # 如果Pinterest图片下载失败,记录Pinterest图像下载失败日志 + + # 尝试下载图像数据 try: - imgdata = request.urlopen(url).read() - except URLError as e: - logger.debug(f'Downloading image failed: {e.reason}') + imgdata = request.urlopen(url).read() # 下载图像数据 + except URLError as e: # 处理下载图像数据时的异常 + logger.debug(f'Downloading image failed: {e.reason}') # 如果图像下载失败,记录图像下载失败日志 else: - with tempfile.TemporaryDirectory() as tmp: - fname = os.path.join(tmp, 'img') - with open(fname, 'wb') as f: - f.write(imgdata) - logger.debug(f'Temporarily saved in: {fname}') - img = exif_rotated_image(fname) - return (img, url) + with tempfile.TemporaryDirectory() as tmp: # 创建一个临时文件用于存储下载成功的图像数据 + fname = os.path.join(tmp, 'img') # 构建临时文件路径(例如:'/tmp/img') + with open(fname, 'wb') as f: # 以二进制写入模式打开临时文件 + f.write(imgdata) # 将图像数据写入临时文件 + logger.debug(f'Temporarily saved in: {fname}') # 记录临时保存的图像路径 + img = exif_rotated_image(fname) # 加载临时图像文件并处理EXIF方向 + return (img, url) # 返回处理后的图像对象和原始URL diff --git a/beeref/fileio/schema.py b/beeref/fileio/schema.py index 6ca7f1b..99c001e 100644 --- a/beeref/fileio/schema.py +++ b/beeref/fileio/schema.py @@ -1,41 +1,48 @@ +# 数据库版本号,用于跟踪数据库结构的更改 USER_VERSION = 2 +# SQLite数据库的应用程序ID,用于标识特定应用程序的数据库文件 APPLICATION_ID = 2060242126 +# 数据库架构定义,包含创建表的SQL语句列表 SCHEMA = [ + # 创建items表,存储应用中的所有项目信息 """ CREATE TABLE items ( - id INTEGER PRIMARY KEY, - type TEXT NOT NULL, - x REAL DEFAULT 0, - y REAL DEFAULT 0, - z REAL DEFAULT 0, - scale REAL DEFAULT 1, - rotation REAL DEFAULT 0, - flip INTEGER DEFAULT 1, - data JSON + id INTEGER PRIMARY KEY, # 主键,唯一标识每个项目 + type TEXT NOT NULL, # 项目类型,如图片、文本等 + x REAL DEFAULT 0, # X坐标位置 + y REAL DEFAULT 0, # Y坐标位置 + z REAL DEFAULT 0, # Z轴位置,用于确定项目的显示顺序 + scale REAL DEFAULT 1, # 缩放比例 + rotation REAL DEFAULT 0, # 旋转角度(以度为单位) + flip INTEGER DEFAULT 1, # 翻转状态,用于水平或垂直翻转项目 + data JSON # JSON格式的数据,存储项目的其他属性 ) """, + # 创建sqlar表,用于存储与项目关联的二进制数据 """ CREATE TABLE sqlar ( - name TEXT PRIMARY KEY, - item_id INTEGER NOT NULL UNIQUE, - mode INT, - mtime INT default current_timestamp, - sz INT, - data BLOB, - FOREIGN KEY (item_id) + name TEXT PRIMARY KEY, # 文件名作为主键 + item_id INTEGER NOT NULL UNIQUE, # 关联的items表中的项目ID + mode INT, # 文件模式 + mtime INT default current_timestamp, # 修改时间戳 + sz INT, # 文件大小 + data BLOB, # 二进制数据内容 + FOREIGN KEY (item_id) # 外键约束,关联items表 REFERENCES items (id) - ON DELETE CASCADE - ON UPDATE NO ACTION + ON DELETE CASCADE # 级联删除,当items表中的记录被删除时,相关的sqlar记录也会被删除 + ON UPDATE NO ACTION # 禁止更新关联字段 ) """, ] +# 数据库迁移定义,键为目标版本号,值为实现该版本升级所需的SQL语句列表 MIGRATIONS = { + # 从版本1迁移到版本2的SQL语句 2: [ - "ALTER TABLE items ADD COLUMN data JSON", - "UPDATE items SET data = json_object('filename', filename)", + "ALTER TABLE items ADD COLUMN data JSON", # 添加JSON类型的data列 + "UPDATE items SET data = json_object('filename', filename)", # 将原有的filename数据迁移到新的JSON格式中 ], } diff --git a/beeref/fileio/sql.py b/beeref/fileio/sql.py index 9e2a2b5..59b3381 100644 --- a/beeref/fileio/sql.py +++ b/beeref/fileio/sql.py @@ -23,308 +23,354 @@ https://www.sqlite.org/appfileformat.html https://www.sqlite.org/sqlar.html """ -import json -import logging -import os -import pathlib -import shutil -import sqlite3 -import tempfile +# 导入必要的模块 +import json # 导入 JSON 模块,用于处理 JSON 数据 +import logging # 导入日志模块,用于记录程序运行时的信息 +import os # 导入 os 模块,用于处理文件路径 +import pathlib # 导入 pathlib 模块,用于处理文件路径 +import shutil # 导入 shutil 模块,用于文件操作 +import sqlite3 # 导入 sqlite3 模块,用于操作 SQLite 数据库 +import tempfile # 导入 tempfile 模块,用于创建临时文件 -from PyQt6 import QtGui - -from beeref import constants -from beeref.items import BeePixmapItem, BeeErrorItem -from .errors import BeeFileIOError, IMG_LOADING_ERROR_MSG -from .schema import SCHEMA, USER_VERSION, MIGRATIONS, APPLICATION_ID +from PyQt6 import QtGui # 导入 PyQt6 模块,用于 GUI 编程 +from beeref import constants # 导入 BeeRef 模块,用于定义常量 +from beeref.items import BeePixmapItem, BeeErrorItem # 导入 BeeRef 模块,用于定义项 +from .errors import BeeFileIOError, IMG_LOADING_ERROR_MSG # 导入 BeeRef 模块,用于定义错误 +from .schema import SCHEMA, USER_VERSION, MIGRATIONS, APPLICATION_ID # 导入 BeeRef 模块,用于定义数据库模式 +# 配置日志记录器 logger = logging.getLogger(__name__) +# 检查文件是否为bee文件 def is_bee_file(path): """Check whether the file at the given path is a bee file.""" - - return os.path.splitext(path)[1] == '.bee' + + return os.path.splitext(path)[1] == '.bee' # 使用os.path.splitext分割文件名和扩展名 +# 处理SQLite错误 def handle_sqlite_errors(func): def wrapper(self, *args, **kwargs): try: - func(self, *args, **kwargs) - except Exception as e: - logger.exception(f'Error while reading/writing {self.filename}') - try: + # 尝试执行原始函数 + func(self, *args, **kwargs) # 执行函数 + except Exception as e: # 捕获所有异常 + logger.exception(f'Error while reading/writing {self.filename}')# 记录异常日志,包含文件名信息 + try: # 尝试回滚事务 # Try to roll back transaction if there is any - if (hasattr(self, '_connection') - and self._connection.in_transaction): - self.ex('ROLLBACK') - logger.debug('Transaction rolled back') - except sqlite3.Error: - pass - self._close_connection() - if self.worker: - self.worker.finished.emit(self.filename, [str(e)]) - else: - raise BeeFileIOError(msg=str(e), filename=self.filename) from e - - return wrapper + if (hasattr(self, '_connection') # 检查是否有数据库连接属性 + and self._connection.in_transaction): # 检查是否有未提交的事务 + self.ex('ROLLBACK') # 执行SQL回滚命令 + logger.debug('Transaction rolled back') # 记录回滚信息 + except sqlite3.Error as e: # 捕获SQLite错误 + pass # 如果回滚失败,忽略SQLite错误 + self._close_connection() # 关闭数据库连接 + if self.worker: # 如果有工作线程,则向其发送完成信号并附带错误信息 + self.worker.finished.emit(self.filename, [str(e)]) # 发射工作线程完成信号 + else: # 如果没有工作线程 + raise BeeFileIOError(msg=str(e), filename=self.filename) from e# 抛出 BeeFileIOError 异常 + return wrapper # 返回包装后的函数 +# 定义SQLiteIO类,用于操作SQLite数据库文件 class SQLiteIO: + # 初始化SQLiteIO类 def __init__(self, filename, scene, create_new=False, readonly=False, worker=None): - self.scene = scene - self.create_new = create_new - self.filename = filename - self.readonly = readonly - self.worker = worker - self.retry = False + self.scene = scene # 保存场景引用,用于添加或获取项目 + self.create_new = create_new # 设置是否创建新文件的标志 + self.filename = filename # 保存数据库文件名 + self.readonly = readonly # 设置是否为只读模式 + self.worker = worker # 保存工作线程引用,用于进度更新 + self.retry = False # 初始化重试标志,用于错误恢复 + # 析构函数 def __del__(self): - self._close_connection() + self._close_connection() # 在对象被销毁时调用,确保资源被正确释放 + # 关闭数据库连接 def _close_connection(self): - if hasattr(self, '_connection'): - self._connection.close() - delattr(self, '_connection') - if hasattr(self, '_cursor'): - delattr(self, '_cursor') - if hasattr(self, '_tmpdir'): - self._tmpdir.cleanup() - delattr(self, '_tmpdir') + # 检查并关闭数据库连接 + if hasattr(self, '_connection'): # 检查是否有数据库连接属性 + self._connection.close() # 关闭数据库连接 + delattr(self, '_connection') # 删除数据库连接属性 + # 检查并删除游标引用 + if hasattr(self, '_cursor'): # 检查是否有游标属性 + delattr(self, '_cursor') # 删除游标属性 + # 检查并清理临时目录 + if hasattr(self, '_tmpdir'): # 检查是否有临时目录属性 + self._tmpdir.cleanup() # 清理临时目录 + delattr(self, '_tmpdir') # 删除临时目录属性 + # 建立数据库连接 def _establish_connection(self): - if (self.create_new - and not self.readonly - and os.path.exists(self.filename)): - os.remove(self.filename) + # 如果需要创建新文件且文件已存在,则删除现有文件 + if (self.create_new # 检查是否创建新文件 + and not self.readonly # 检查是否为只读模式 + and os.path.exists(self.filename)): # 检查文件是否已存在 + os.remove(self.filename) # 删除已存在的文件 - if self.create_new: - self.scene.clear_save_ids() + # 如果创建新文件,清除场景中所有项目的保存I + if self.create_new: # 检查是否创建新文件 + self.scene.clear_save_ids() # 清除场景中所有项目的保存ID - uri = pathlib.Path(self.filename).resolve().as_uri() - if self.readonly: - uri = f'{uri}?mode=rw' - self._connection = sqlite3.connect(uri, uri=True) - self._cursor = self.connection.cursor() - if not self.create_new: + uri = pathlib.Path(self.filename).resolve().as_uri()# 将文件路径转换为URI格式,确保跨平台兼容性 + if self.readonly: # 检查是否为只读模式 + uri = f'{uri}?mode=rw' # 如果是只读模式,添加查询参数以指定为读写模式 + self._connection = sqlite3.connect(uri, uri=True) # 建立数据库连接,使用URI格式 + self._cursor = self._connection.cursor() # 创建游标对象,用于执行SQL语句 + # 如果不是创建新文件,则尝试迁移数据库 + if not self.create_new: # 检查是否不是创建新文件 try: - self._migrate() - except Exception: + self._migrate() # 尝试迁移数据库 + except Exception as e: # 捕获所有异常 # Updating a file failed; try creating it from scratch instead - logger.exception('Error migrating bee file') - self.create_new = True - self._establish_connection() + logger.exception('Error migrating bee file: %s', e) # 记录异常日志,包含异常信息 + self.create_new = True # 标记为创建新文件 + self._establish_connection() # 重新建立数据库连接 + # 迁移数据库 def _migrate(self): """Migrate database if necessary.""" - version = self.fetchone('PRAGMA user_version')[0] - logger.debug(f'Found bee file version: {version}') - if version >= USER_VERSION: - logger.debug('Version ok; no migrations necessary') - return - + version = self.fetchone('PRAGMA user_version')[0] # 获取数据库用户版本号 + logger.debug(f'Found bee file version: {version}') # 记录数据库版本号 + if version >= USER_VERSION: # 检查数据库版本号是否大于等于用户版本号 + logger.debug('Version ok; no migrations necessary') # 如果版本号大于等于用户版本号,无需迁移 + return # 如果版本号大于等于用户版本号,无需迁移 + # 如果版本已经是最新的,不需要迁移 if self.readonly: try: # See whether file is writable so we can migrate it directly - self.ex('PRAGMA application_id=%s' % APPLICATION_ID) - except sqlite3.Error: - logger.debug('File not writable; use temporary copy instead') - self._connection.close() - self._tmpdir = tempfile.TemporaryDirectory( - prefix=constants.APPNAME) - tmpname = os.path.join(self._tmpdir.name, 'mig.bee') - shutil.copyfile(self.filename, tmpname) - self._connection = sqlite3.connect(tmpname) - self._cursor = self.connection.cursor() + self.ex('PRAGMA application_id=%s' % APPLICATION_ID) # 设置应用程序ID,确保数据库与应用程序兼容 + except sqlite3.Error as e: # 捕获 SQLite 错误异常 + logger.debug('File not writable; use temporary copy instead')# 记录文件不可写日志,建议使用临时副本 + self._connection.close() # 关闭数据库连接 + self._tmpdir = tempfile.TemporaryDirectory( # 创建临时目录,用于存储迁移后的数据库文件 + prefix=constants.APPNAME) # 设置临时目录前缀,确保与应用程序名称一致 + tmpname = os.path.join(self._tmpdir.name, 'mig.bee') # 构建临时数据库文件名 + shutil.copyfile(self.filename, tmpname) # 复制原始数据库文件到临时目录 + self._connection = sqlite3.connect(tmpname) # 连接到临时数据库文件 + self._cursor = self.connection.cursor() # 创建游标对象,用于执行SQL语句 - self.ex('BEGIN TRANSACTION') - for i in range(version, USER_VERSION): - logger.debug(f'Migrating from version {i} to {i + 1}...') - for migration in MIGRATIONS[i + 1]: - self.ex(migration) - self.write_meta() - self.connection.commit() - logger.debug('Migration finished') + self.ex('BEGIN TRANSACTION') # 开始事务,确保迁移的原子性 + for i in range(version, USER_VERSION): # 遍历需要迁移的版本号范围 + logger.debug(f'Migrating from version {i} to {i + 1}...') # 记录迁移信息,包含当前版本号和目标版本号 + for migration in MIGRATIONS[i + 1]: # 遍历当前版本号对应的迁移SQL语句列表 + self.ex(migration) # 执行迁移SQL语句 + self.write_meta() # 写入数据库元数据,包括应用程序ID和用户版本号 + self.connection.commit() # 提交事务,确保所有迁移操作都被永久保存 + logger.debug('Migration finished') # 记录迁移完成日志 @property + # 获取数据库连接属性 def connection(self): - if not hasattr(self, '_connection'): - self._establish_connection() - return self._connection + if not hasattr(self, '_connection'): # 检查是否没有数据库连接属性 + self._establish_connection() # 如果没有数据库连接属性,建立数据库连接 + return self._connection # 返回数据库连接属性 @property + # 获取数据库游标属性 def cursor(self): - if not hasattr(self, '_cursor'): - self._establish_connection() - return self._cursor + if not hasattr(self, '_cursor'): # 检查是否没有数据库游标属性 + self._establish_connection() # 如果没有数据库游标属性,建立数据库连接 + return self._cursor # 返回数据库游标属性 + # 执行SQL语句,返回执行结果 def ex(self, *args, **kwargs): return self.cursor.execute(*args, **kwargs) + # 执行批量SQL语句,返回执行结果 def exmany(self, *args, **kwargs): return self.cursor.executemany(*args, **kwargs) + # 从数据库游标中获取一条记录,返回记录元组 def fetchone(self, *args, **kwargs): - self.ex(*args, **kwargs) - return self.cursor.fetchone() + self.ex(*args, **kwargs) # 执行SQL语句,传入参数和关键字参数 + return self.cursor.fetchone() # 返回查询结果的第一行 + # 从数据库游标中获取所有记录,返回记录列表 def fetchall(self, *args, **kwargs): - self.ex(*args, **kwargs) - return self.cursor.fetchall() + self.ex(*args, **kwargs) # 执行SQL语句,传入参数和关键字参数 + return self.cursor.fetchall() # 从游标中获取所有记录,返回记录列表 + # 写入数据库元数据,包括应用程序ID和用户版本号 def write_meta(self): - self.ex('PRAGMA application_id=%s' % APPLICATION_ID) - self.ex('PRAGMA user_version=%s' % USER_VERSION) - self.ex('PRAGMA foreign_keys=ON') + self.ex('PRAGMA application_id=%s' % APPLICATION_ID) # 设置应用程序ID,确保数据库与应用程序兼容 + self.ex('PRAGMA user_version=%s' % USER_VERSION) # 设置用户版本,用于版本控制 + self.ex('PRAGMA foreign_keys=ON') # 启用外键约束,确保数据完整性 + # 创建新文件时创建数据库架构 def create_schema_on_new(self): - if self.create_new: - self.write_meta() - for schema in SCHEMA: - self.ex(schema) + if self.create_new: # 仅在创建新文件时执行 + self.write_meta() # 写入数据库元数据 + for schema in SCHEMA: # 执行所有架构创建SQL语句 + self.ex(schema) # 执行架构创建SQL语句 + # 从数据库中读取所有项,包括图片和文本项 @handle_sqlite_errors def read(self): + # 读取所有图像项目及其关联的二进制数据 rows = self.fetchall( 'SELECT items.id, type, x, y, z, scale, rotation, flip, ' 'items.data, sqlar.data ' 'FROM sqlar JOIN items on sqlar.item_id = items.id') # Avoid OUTER JOIN for performance reasons; fetch text items # separately instead + # 读取所有文本项目 rows.extend(self.fetchall( 'SELECT items.id, type, x, y, z, scale, rotation, flip, ' ' items.data, null as data ' 'FROM items ' 'WHERE items.type = "text"')) - if self.worker: - self.worker.begin_processing.emit(len(rows)) + if self.worker: # 如果有工作线程 + self.worker.begin_processing.emit(len(rows)) # 发送开始处理信号,包含项数 + # 遍历所有读取的项目数据 for i, row in enumerate(rows): + # 构建项目数据字典 data = { - 'save_id': row[0], - 'type': row[1], - 'x': row[2], - 'y': row[3], - 'z': row[4], - 'scale': row[5], - 'rotation': row[6], - 'flip': row[7], - 'data': json.loads(row[8]), + 'save_id': row[0], # 项目ID + 'type': row[1], # 项目类型 + 'x': row[2], # X坐标 + 'y': row[3], # Y坐标 + 'z': row[4], # Z坐标 + 'scale': row[5], # 缩放比例 + 'rotation': row[6], # 旋转角度 + 'flip': row[7], # 是否翻转 + 'data': json.loads(row[8]), # 项目数据 } - if data['type'] == 'pixmap': - item = BeePixmapItem(QtGui.QImage()) - item.pixmap_from_bytes(row[9]) + # 处理像素图项目 + if data['type'] == 'pixmap': # 如果项目类型为像素图 + item = BeePixmapItem(QtGui.QImage()) # 创建空的像素图项目 + item.pixmap_from_bytes(row[9]) # 从二进制数据创建像素图 + # 检查图像是否加载成功 if item.pixmap().isNull(): - item = data['data']['text'] = ( + # 如果图像加载失败,创建错误项目 + item = data['data']['text'] = ( f'Image could not be loaded: {item.filename}\n' + IMG_LOADING_ERROR_MSG) - data['type'] = BeeErrorItem.TYPE - data['item'] = item - - self.scene.add_item_later(data) + data['type'] = BeeErrorItem.TYPE # 将项目类型设置为错误项类型 + data['item'] = item # 将项目添加到数据字典中 + self.scene.add_item_later(data) # 稍后将项目添加到场景中 + # 如果有工作线程,更新进度并检查是否取消操作 if self.worker: - logger.trace(f'Emit progress: {i}') - self.worker.progress.emit(i) - if self.worker.canceled: - self.worker.finished.emit('', []) - return + logger.trace(f'Emit progress: {i}') # 记录进度信息 + self.worker.progress.emit(i) # 发送进度信号,包含当前项索引 + if self.worker.canceled: # 如果工作线程已取消 + self.worker.finished.emit('', []) # 发送完成信号,包含空字符串和空列表 + return # 退出循环,停止处理 # Give main thread time to process items: - self.worker.msleep(10) - if self.worker: - self.worker.finished.emit(self.filename, []) + self.worker.msleep(10) # 暂停10毫秒,允许主线程处理事件 + if self.worker: # 如果有工作线程 + self.worker.finished.emit(self.filename, [])# 发送完成信号,包含文件名和空列表 @handle_sqlite_errors + # 写入数据库中的所有项,包括图片和文本项 def write(self): - if self.readonly: - raise sqlite3.OperationalError( + # 检查是否为只读模式 + if self.readonly: # 如果数据库为只读模式 + raise sqlite3.OperationalError( # 抛出操作错误异常 'Attempt to write to a readonly database') - try: - self.create_schema_on_new() - self.write_data() - except Exception: - if self.retry: + try: # 尝试执行写入操作 + self.create_schema_on_new() # 创建新文件时创建数据库架构 + self.write_data() # 写入所有数据 + except Exception as e: # 捕获所有异常 + #处理写入错误 + if self.retry: # 如果允许重试 # Trying to recover failed - raise + raise # 如果已经重试过一次,则放弃并抛出异常 else: - self.retry = True + self.retry = True # 第一次失败后,标记为需要重试 # Try creating file from scratch and save again - logger.exception( - f'Updating to existing file {self.filename} failed') - self.create_new = True - self._close_connection() - self.write() + logger.exception( # 尝试从头创建文件并再次保存 + f'Updating to existing file {self.filename} failed') # 记录更新失败的异常信息 + self.create_new = True # 标记为创建新文件 + self._close_connection() # 关闭现有连接,以便重新建立连接 + self.write() # 递归调用写入方法,尝试重新创建文件并保存 + # 写入场景数据到数据库 def write_data(self): - to_delete = {row[0] for row in self.fetchall('SELECT id from ITEMS')} + to_delete = {row[0] for row in self.fetchall('SELECT id from ITEMS')} # 获取所有需要删除的项目ID # We don't want to touch existing items that are displayed as errors: keep = {item.original_save_id - for item in self.scene.items_by_type(BeeErrorItem.TYPE)} - logger.debug(f'Not saving error items: {keep}') - to_delete = to_delete - keep + for item in self.scene.items_by_type(BeeErrorItem.TYPE)} # 获取所有错误项的原始保存ID + logger.debug(f'Not saving error items: {keep}') # 记录不保存错误项的ID + to_delete = to_delete - keep # 从待删除列表中移除需要保留的项目 + # 获取需要保存的项目列表 to_save = list(self.scene.items_for_save()) + # 如果有工作线程,发送开始处理信号 if self.worker: self.worker.begin_processing.emit(len(to_save)) + # 遍历所有需要保存的项目 for i, item in enumerate(to_save): - logger.debug(f'Saving {item} with id {item.save_id}') - if item.save_id: - self.update_item(item) - to_delete.remove(item.save_id) - else: - self.insert_item(item) + logger.debug(f'Saving {item} with id {item.save_id}') # 记录正在保存的项目信息 + if item.save_id: # 如果项目已存在数据库中 + self.update_item(item) # 更新项目数据 + to_delete.remove(item.save_id) # 从待删除列表中移除已更新的项目ID + else: # 如果项目不在数据库中 + self.insert_item(item) # 插入新项目到数据库中 + # 如果有工作线程,发送进度信号,包含当前项索引 if self.worker: self.worker.progress.emit(i) + # 如果工作线程已取消,跳出循环,停止处理 if self.worker.canceled: break - self.delete_items(to_delete) - self.ex('VACUUM') - self.connection.commit() - if self.worker: - self.worker.finished.emit(self.filename, []) + self.delete_items(to_delete) # 删除不再需要的项目 + self.ex('VACUUM') # 执行VACUUM命令,优化数据库空间 + self.connection.commit() # 提交事务,确保所有更改生效 + if self.worker: # 如果有工作线程 + self.worker.finished.emit(self.filename, []) # 发送完成信号,包含文件名和空列表 + # 从数据库中删除指定的项目 def delete_items(self, to_delete): - to_delete = [(pk,) for pk in to_delete] - self.exmany('DELETE FROM items WHERE id=?', to_delete) - self.exmany('DELETE FROM sqlar WHERE item_id=?', to_delete) - self.connection.commit() + to_delete = [(pk,) for pk in to_delete] # 将待删除项目ID转换为元组列表 + self.exmany('DELETE FROM items WHERE id=?', to_delete) # 执行批量删除项目的SQL语句 + self.exmany('DELETE FROM sqlar WHERE item_id=?', to_delete) # 执行批量删除关联文件的SQL语句 + self.connection.commit() # 提交事务,确保所有更改生效 + # 向数据库中插入新项目 def insert_item(self, item): + # 插入项目基本信息到items表 self.ex( 'INSERT INTO items (type, x, y, z, scale, rotation, flip, ' 'data) ' 'VALUES (?, ?, ?, ?, ?, ?, ?, ?)', - (item.TYPE, item.pos().x(), item.pos().y(), item.zValue(), - item.scale(), item.rotation(), item.flip(), - json.dumps(item.get_extra_save_data()))) - item.save_id = self.cursor.lastrowid - + (item.TYPE, item.pos().x(), item.pos().y(), item.zValue(), # 插入项目的类型、位置、Z值、缩放比例、旋转角度和翻转状态 + item.scale(), item.rotation(), item.flip(), # 插入项目的缩放比例、旋转角度和翻转状态 + json.dumps(item.get_extra_save_data()))) # 插入项目的额外保存数据 + item.save_id = self.cursor.lastrowid # 获取新插入项目的ID + # 如果项目有像素图数据,也保存它 if hasattr(item, 'pixmap_to_bytes'): - pixmap, imgformat = item.pixmap_to_bytes() - name = item.get_filename_for_export(imgformat) + pixmap, imgformat = item.pixmap_to_bytes() # 获取像素图的二进制数据和图像格式 + name = item.get_filename_for_export(imgformat) # 获取导出文件名 + # 将二进制数据插入到sqlar表 self.ex( 'INSERT INTO sqlar (item_id, name, mode, sz, data) ' 'VALUES (?, ?, ?, ?, ?)', - (item.save_id, name, 0o644, len(pixmap), pixmap)) - self.connection.commit() + (item.save_id, name, 0o644, len(pixmap), pixmap)) # 插入像素图数据到sqlar表 + self.connection.commit() # 提交事务,确保所有更改生效 + # 更新数据库中的项目数据 def update_item(self, item): """Update item data. We only update the item data, not the pixmap data, as pixmap data never changes and is also time-consuming to save. """ + # 更新items表中的项目数据 self.ex( 'UPDATE items SET x=?, y=?, z=?, scale=?, rotation=?, flip=?, ' 'data=? ' 'WHERE id=?', - (item.pos().x(), item.pos().y(), item.zValue(), item.scale(), - item.rotation(), item.flip(), - json.dumps(item.get_extra_save_data()), - item.save_id)) - self.connection.commit() + (item.pos().x(), item.pos().y(), item.zValue(), item.scale(), # 更新项目的位置、Z值、缩放比例 + item.rotation(), item.flip(), # 更新项目的旋转角度和翻转状态 + json.dumps(item.get_extra_save_data()), # 更新项目的额外保存数据 + item.save_id)) # 更新项目的ID + self.connection.commit() # 提交事务,确保所有更改生效 diff --git a/beeref/items.py b/beeref/items.py index c42044a..cac87fb 100644 --- a/beeref/items.py +++ b/beeref/items.py @@ -17,31 +17,31 @@ text). """ -from collections import defaultdict -from functools import cached_property -import logging -import os.path +from collections import defaultdict # 导入collections模块中的defaultdict类,用于创建默认值的字典 +from functools import cached_property # 导入functools模块中的cached_property类,用于缓存属性的计算结果 +import logging # 导入logging模块,用于记录日志 +import os.path # 导入os.path模块,用于处理文件路径 -from PyQt6 import QtCore, QtGui, QtWidgets -from PyQt6.QtCore import Qt +from PyQt6 import QtCore, QtGui, QtWidgets # 导入PyQt6模块中的QtCore、QtGui、QtWidgets类,用于处理Qt的核心功能、图形界面和小部件 +from PyQt6.QtCore import Qt # 导入PyQt6.QtCore模块中的Qt类,用于处理Qt的核心功能 -from beeref import commands -from beeref.config import BeeSettings -from beeref.constants import COLORS -from beeref.selection import SelectableMixin +from beeref import commands # 导入beeref模块中的commands类,用于处理用户命令 +from beeref.config import BeeSettings # 导入beeref模块中的BeeSettings类,用于处理应用程序的配置 +from beeref.constants import COLORS # 导入beeref模块中的COLORS常量,用于定义颜色 +from beeref.selection import SelectableMixin # 导入beeref模块中的SelectableMixin类,用于处理可选择的项 -logger = logging.getLogger(__name__) +logger = logging.getLogger(__name__) # 创建一个日志记录器,用于记录当前模块的日志信息 -item_registry = {} +item_registry = {} # 创建一个空字典,用于存储注册的项类型和对应的类 -def register_item(cls): - item_registry[cls.TYPE] = cls - return cls +def register_item(cls): # 定义一个函数,用于注册项类型和对应的类 + item_registry[cls.TYPE] = cls # 将项类型作为键,对应的类作为值,存储到item_registry字典中 + return cls # 返回注册的类,用于支持链式调用 -def sort_by_filename(items): +def sort_by_filename(items): # 定义一个函数,用于根据文件名对项进行排序 """Order items by filename. Items with a filename (ordered by filename) first, then items @@ -50,114 +50,114 @@ def sort_by_filename(items): been inserted into the scene. """ - items_by_filename = [] - items_by_save_id = [] - items_remaining = [] + items_by_filename = [] # 创建一个空列表,用于存储有文件名的项 + items_by_save_id = [] # 创建一个空列表,用于存储有save_id的项 + items_remaining = [] # 创建一个空列表,用于存储剩余的项 - for item in items: - if getattr(item, 'filename', None): - items_by_filename.append(item) - elif getattr(item, 'save_id', None): - items_by_save_id.append(item) + for item in items: # 遍历所有项 + if getattr(item, 'filename', None): # 如果项有文件名属性 + items_by_filename.append(item) # 将项添加到有文件名的项列表中 + elif getattr(item, 'save_id', None): # 如果项有save_id属性 + items_by_save_id.append(item) # 将项添加到有save_id的项列表中 else: - items_remaining.append(item) + items_remaining.append(item) # 将项添加到剩余的项列表中 - items_by_filename.sort(key=lambda x: x.filename) - items_by_save_id.sort(key=lambda x: x.save_id) - return items_by_filename + items_by_save_id + items_remaining + items_by_filename.sort(key=lambda x: x.filename) # 对有文件名的项列表进行排序,根据文件名属性进行排序 + items_by_save_id.sort(key=lambda x: x.save_id) # 对有save_id的项列表进行排序,根据save_id属性进行排序 + return items_by_filename + items_by_save_id + items_remaining # 返回排序后的项列表,先有文件名的项,然后有save_id的项,最后是剩余的项 -class BeeItemMixin(SelectableMixin): +class BeeItemMixin(SelectableMixin): # 定义一个基类,用于所有用户添加的项 """Base for all items added by the user.""" - def set_pos_center(self, pos): + def set_pos_center(self, pos): # 定义一个方法,用于设置项的位置,使用项的中心作为原点 """Sets the position using the item's center as the origin point.""" - self.setPos(pos - self.center_scene_coords) + self.setPos(pos - self.center_scene_coords) # 将项的位置设置为传入的位置减去项的中心场景坐标 def has_selection_outline(self): - return self.isSelected() + return self.isSelected() # 返回项是否被选中 - def has_selection_handles(self): - return (self.isSelected() - and self.scene() - and self.scene().has_single_selection()) + def has_selection_handles(self): # 定义一个方法,用于判断项是否有选择句柄 + return (self.isSelected() # 返回项是否被选中 + and self.scene() # 返回项所属的场景 + and self.scene().has_single_selection()) # 返回场景是否只有一个项被选中 - def selection_action_items(self): + def selection_action_items(self): # 定义一个方法,用于返回项受选择操作影响的项列表 """The items affected by selection actions like scaling and rotating. """ - return [self] + return [self] # 返回项受选择操作影响的项列表,这里只有项本身 - def on_selected_change(self, value): - if (value and self.scene() - and not self.scene().has_selection() - and not self.scene().active_mode is None): - self.bring_to_front() + def on_selected_change(self, value): # 定义一个方法,用于处理项的选中状态变化 + if (value and self.scene() # 如果项被选中,并且项所属的场景存在 + and not self.scene().has_selection() # 场景没有其他项被选中 + and not self.scene().active_mode is None): # 场景没有活动模式 + self.bring_to_front() # 将项 bring to front - def update_from_data(self, **kwargs): - self.save_id = kwargs.get('save_id', self.save_id) - self.setPos(kwargs.get('x', self.pos().x()), - kwargs.get('y', self.pos().y())) - self.setZValue(kwargs.get('z', self.zValue())) - self.setScale(kwargs.get('scale', self.scale())) - self.setRotation(kwargs.get('rotation', self.rotation())) - if kwargs.get('flip', 1) != self.flip(): - self.do_flip() + def update_from_data(self, **kwargs): # 定义一个方法,用于从数据更新项的属性 + self.save_id = kwargs.get('save_id', self.save_id) # 更新项的save_id属性,使用传入的save_id值或当前值 + self.setPos(kwargs.get('x', self.pos().x()), + kwargs.get('y', self.pos().y())) # 更新项的位置属性,使用传入的x、y值或当前值 + self.setZValue(kwargs.get('z', self.zValue())) # 更新项的z值属性,使用传入的z值或当前值 + self.setScale(kwargs.get('scale', self.scale())) # 更新项的缩放属性,使用传入的scale值或当前值 + self.setRotation(kwargs.get('rotation', self.rotation())) # 更新项的旋转属性,使用传入的rotation值或当前值 + if kwargs.get('flip', 1) != self.flip(): # 如果传入的flip值与当前值不同 + self.do_flip() # 执行项的翻转操作 @register_item -class BeePixmapItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem): +class BeePixmapItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem): # 定义一个类,用于表示用户添加的图像项 """Class for images added by the user.""" - TYPE = 'pixmap' - CROP_HANDLE_SIZE = 15 + TYPE = 'pixmap' # 定义项的类型为'pixmap' + CROP_HANDLE_SIZE = 15 # 定义项的裁剪句柄大小为15 - def __init__(self, image, filename=None, **kwargs): - super().__init__(QtGui.QPixmap.fromImage(image)) - self.save_id = None - self.filename = filename - self.reset_crop() - logger.debug(f'Initialized {self}') - self.is_image = True - self.crop_mode = False - self.init_selectable() - self.settings = BeeSettings() - self.grayscale = False + def __init__(self, image, filename=None, **kwargs): # 定义类的初始化方法,用于创建图像项 + super().__init__(QtGui.QPixmap.fromImage(image)) # 调用父类的初始化方法,传入图像项的QPixmap对象 + self.save_id = None # 初始化项的save_id属性为None + self.filename = filename # 初始化项的filename属性为传入的filename值 + self.reset_crop() # 调用项的reset_crop方法,重置项的裁剪区域 + logger.debug(f'Initialized {self}') # 记录项的初始化信息 + self.is_image = True # 初始化项的is_image属性为True + self.crop_mode = False # 初始化项的crop_mode属性为False + self.init_selectable() # 调用项的init_selectable方法,初始化项的选择状态 + self.settings = BeeSettings() # 初始化项的settings属性为BeeSettings()对象 + self.grayscale = False # 初始化项的grayscale属性为False @classmethod - def create_from_data(self, **kwargs): - item = kwargs.pop('item') - data = kwargs.pop('data', {}) - item.filename = item.filename or data.get('filename') - if 'crop' in data: - item.crop = QtCore.QRectF(*data['crop']) - item.setOpacity(data.get('opacity', 1)) - item.grayscale = data.get('grayscale', False) - return item + def create_from_data(self, **kwargs): # 定义一个类方法,用于从数据创建图像项 + item = kwargs.pop('item') # 从kwargs中弹出项对象,赋值给item变量 + data = kwargs.pop('data', {}) # 从kwargs中弹出数据字典,赋值给data变量,默认值为空字典 + item.filename = item.filename or data.get('filename') # 如果项的filename属性为None,或者数据字典中没有'filename'键,将项的filename属性设置为数据字典中的'filename'值 + if 'crop' in data: # 如果数据字典中包含'crop'键 + item.crop = QtCore.QRectF(*data['crop']) # 将项的crop属性设置为数据字典中'crop'键对应的值,使用QRectF对象表示裁剪区域 + item.setOpacity(data.get('opacity', 1)) # 将项的不透明度属性设置为数据字典中'opacity'键对应的值,默认值为1 + item.grayscale = data.get('grayscale', False) # 将项的grayscale属性设置为数据字典中'grayscale'键对应的值,默认值为False + return item # 返回创建的项对象 - def __str__(self): - size = self.pixmap().size() - return (f'Image "{self.filename}" {size.width()} x {size.height()}') + def __str__(self): # 定义项的字符串表示方法 + size = self.pixmap().size() # 获取项的QPixmap对象的大小 + return (f'Image "{self.filename}" {size.width()} x {size.height()}') # 返回项的字符串表示,包含文件名和图像大小 @property - def crop(self): - return self._crop + def crop(self): # 定义项的裁剪区域属性的getter方法 + return self._crop # 返回项的裁剪区域属性值 @crop.setter - def crop(self, value): - logger.debug(f'Setting crop for {self} to {value}') - self.prepareGeometryChange() - self._crop = value - self.update() + def crop(self, value): # 定义项的裁剪区域属性的setter方法 + logger.debug(f'Setting crop for {self} to {value}') # 记录项的裁剪区域属性设置信息 + self.prepareGeometryChange() # 准备项的几何变化,通知场景项的位置、大小或旋转等属性已改变 + self._crop = value # 更新项的裁剪区域属性值为传入的value值 + self.update() # 更新项的显示,触发项的重绘操作 @property - def grayscale(self): - return self._grayscale + def grayscale(self): # 定义项的灰度属性的getter方法 + return self._grayscale # 返回项的灰度属性值 @grayscale.setter - def grayscale(self, value): - logger.debug('Setting grayscale for {self} to {value}') - self._grayscale = value + def grayscale(self, value): # 定义项的灰度属性的setter方法 + logger.debug('Setting grayscale for {self} to {value}') # 记录项的灰度属性设置信息 + self._grayscale = value # 更新项的灰度属性值为传入的value值 if value is True: # Using the grayscale image format to convert to grayscale # loses an image's tranparency. So the straightworward @@ -169,12 +169,12 @@ class BeePixmapItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem): # overlaps other images. The way we do it here only works # as long as the canvas colour is itself grayscale, # though. - img = QtGui.QImage( + img = QtGui.QImage( # 创建一个QImage对象,用于存储转换后的灰度图像 self.pixmap().size(), QtGui.QImage.Format.Format_Grayscale8) - img.fill(QtGui.QColor(*COLORS['Scene:Canvas'])) - painter = QtGui.QPainter(img) - painter.drawPixmap(0, 0, self.pixmap()) - painter.end() + img.fill(QtGui.QColor(*COLORS['Scene:Canvas'])) # 用当前场景的画布颜色填充图像的背景 + painter = QtGui.QPainter(img) # 创建一个QPainter对象,用于绘制图像 + painter.drawPixmap(0, 0, self.pixmap()) # 绘制项的QPixmap对象到图像上 + painter.end() # 结束绘制操作 self._grayscale_pixmap = QtGui.QPixmap.fromImage(img) # Alternative methods that have their own issues: @@ -192,114 +192,114 @@ class BeePixmapItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem): # # 3. Going through every pixel and doing it manually — bad # performance. - else: - self._grayscale_pixmap = None + else: # 如果灰度属性为False + self._grayscale_pixmap = None # 将项的灰度Pixmap属性设置为None - self.update() + self.update() # 更新项的显示,触发项的重绘操作 - def sample_color_at(self, pos): - ipos = self.mapFromScene(pos) - if self.grayscale: - pm = self._grayscale_pixmap - else: - pm = self.pixmap() - img = pm.toImage() + def sample_color_at(self, pos): # 定义项的采样颜色方法,用于获取项在指定位置的颜色值 + ipos = self.mapFromScene(pos) # 将场景坐标转换为项坐标 + if self.grayscale: # 如果项的灰度属性为True + pm = self._grayscale_pixmap # 将项的灰度Pixmap赋值给pm变量 + else: # 如果项的灰度属性为False + pm = self.pixmap() # 将项的QPixmap对象赋值给pm变量 + img = pm.toImage() # 将项的Pixmap对象转换为QImage对象 - color = img.pixelColor(int(ipos.x()), int(ipos.y())) - if color.alpha(): - return color + color = img.pixelColor(int(ipos.x()), int(ipos.y())) # 获取图像中指定位置的颜色值 + if color.alpha() > 0: # 如果颜色的alpha值大于0,即颜色不是完全透明的 + return color # 返回颜色值 - def bounding_rect_unselected(self): - if self.crop_mode: - return QtWidgets.QGraphicsPixmapItem.boundingRect(self) - else: - return self.crop + def bounding_rect_unselected(self): # 定义项的未选中状态下的边界矩形方法 + if self.crop_mode: # 如果项的裁剪模式为True + return QtWidgets.QGraphicsPixmapItem.boundingRect(self) # 返回项的QPixmap对象的边界矩形 + else: # 如果项的裁剪模式为False + return self.crop # 返回项的裁剪区域属性值 - def get_extra_save_data(self): + def get_extra_save_data(self): # 定义项的额外保存数据方法,用于获取项的额外保存信息 return {'filename': self.filename, 'opacity': self.opacity(), 'grayscale': self.grayscale, 'crop': [self.crop.topLeft().x(), self.crop.topLeft().y(), self.crop.width(), - self.crop.height()]} + self.crop.height()]} # 返回项的裁剪区域属性值的列表,包含左上角的x、y坐标、宽度和高度 - 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 + def get_filename_for_export(self, imgformat, save_id_default=None): # 定义项的导出文件名方法,用于获取项导出时的文件名 + save_id = self.save_id or save_id_default # 如果项的保存ID为None,则使用默认值 + assert save_id is not None # 断言项的保存ID不为None - if self.filename: - basename = os.path.splitext(os.path.basename(self.filename))[0] - return f'{save_id:04}-{basename}.{imgformat}' + if self.filename: # 如果项的文件名属性不为None + basename = os.path.splitext(os.path.basename(self.filename))[0] # 从项的文件名属性中提取文件名部分,不包含扩展名 + return f'{save_id:04}-{basename}.{imgformat}' # 返回导出文件名,格式为"{save_id:04}-{basename}.{imgformat}",其中save_id为项的保存ID,basename为项的文件名部分,imgformat为导出的图像格式 else: - return f'{save_id:04}.{imgformat}' + return f'{save_id:04}.{imgformat}' # 返回导出文件名,格式为"{save_id:04}.{imgformat}",其中save_id为项的保存ID,imgformat为导出的图像格式 - def get_imgformat(self, img): + def get_imgformat(self, img): # 定义项的图像格式方法,用于根据图像属性确定存储格式 """Determines the format for storing this image.""" - formt = self.settings.valueOrDefault('Items/image_storage_format') + formt = self.settings.valueOrDefault('Items/image_storage_format') # 从项的设置中获取图像存储格式选项,默认值为'best' - if formt == 'best': + if formt == 'best': # 如果图像存储格式选项为'best' # Images with alpha channel and small images are stored as png - if (img.hasAlphaChannel() - or (img.height() < 500 and img.width() < 500)): - formt = 'png' + if (img.hasAlphaChannel() # 如果图像有alpha通道 + or (img.height() < 500 and img.width() < 500)): # 如果图像高度或宽度小于500像素 + formt = 'png' # 如果图像有alpha通道或高度或宽度小于500像素,则存储为png格式 else: - formt = 'jpg' + formt = 'jpg' # 如果图像没有alpha通道且高度和宽度都大于等于500像素,则存储为jpg格式 - logger.debug(f'Found format {formt} for {self}') - return formt + logger.debug(f'Found format {formt} for {self}') # 记录调试信息,显示项的图像存储格式选项和项的文件名属性 + return formt # 返回项的图像存储格式选项 - def pixmap_to_bytes(self, apply_grayscale=False, apply_crop=False): + def pixmap_to_bytes(self, apply_grayscale=False, apply_crop=False): # 定义项的Pixmap转换为字节字符串方法,用于将项的Pixmap数据转换为PNG格式的字节字符串 """Convert the pixmap data to PNG bytestring.""" - barray = QtCore.QByteArray() - buffer = QtCore.QBuffer(barray) - buffer.open(QtCore.QIODevice.OpenModeFlag.WriteOnly) + barray = QtCore.QByteArray() # 创建一个空的QByteArray对象,用于存储转换后的字节字符串 + buffer = QtCore.QBuffer(barray) # 创建一个QBuffer对象,将barray作为参数传入,用于将Pixmap数据写入字节字符串 + buffer.open(QtCore.QIODevice.OpenModeFlag.WriteOnly) # 以只写模式打开QBuffer对象,用于将Pixmap数据写入字节字符串 if apply_grayscale and self.grayscale: - pm = self._grayscale_pixmap + pm = self._grayscale_pixmap # 如果应用灰度属性为True且项的灰度属性为True,则将项的灰度Pixmap赋值给pm变量 else: - pm = self.pixmap() + pm = self.pixmap() # 如果应用灰度属性为False或项的灰度属性为False,则将项的QPixmap对象赋值给pm变量 - if apply_crop: - pm = pm.copy(self.crop.toRect()) + if apply_crop: # 如果应用裁剪属性为True + pm = pm.copy(self.crop.toRect()) # 将项的Pixmap对象裁剪为指定区域,赋值给pm变量 - img = pm.toImage() - imgformat = self.get_imgformat(img) - img.save(buffer, imgformat.upper(), quality=90) - return (barray.data(), imgformat) + img = pm.toImage() # 将项的Pixmap对象转换为QImage对象 + imgformat = self.get_imgformat(img) # 根据图像属性确定存储格式 + img.save(buffer, imgformat.upper(), quality=90) # 将图像保存到QBuffer对象中,格式为PNG,质量为90 + return (barray.data(), imgformat) # 返回转换后的字节字符串和图像格式 - def setPixmap(self, pixmap): - super().setPixmap(pixmap) - self.reset_crop() + def setPixmap(self, pixmap): # 定义项的Pixmap设置方法,用于设置项的Pixmap对象 + super().setPixmap(pixmap) # 调用父类的setPixmap方法,将项的Pixmap对象设置为传入的Pixmap对象 + self.reset_crop() # 调用项的重置裁剪方法,将项的裁剪区域属性值重置为默认值 - def pixmap_from_bytes(self, data): + def pixmap_from_bytes(self, data): # 定义项的Pixmap从字节字符串设置方法,用于将项的Pixmap数据从PNG格式的字节字符串中设置 """Set image pimap from a bytestring.""" - pixmap = QtGui.QPixmap() - pixmap.loadFromData(data) - self.setPixmap(pixmap) + pixmap = QtGui.QPixmap() # 创建一个空的QPixmap对象,用于存储从字节字符串中加载的Pixmap数据 + pixmap.loadFromData(data) # 从字节字符串中加载Pixmap数据到QPixmap对象中 + self.setPixmap(pixmap) # 将项的Pixmap对象设置为加载的Pixmap对象 - def create_copy(self): - item = BeePixmapItem(QtGui.QImage(), self.filename) - item.setPixmap(self.pixmap()) - item.setPos(self.pos()) - item.setZValue(self.zValue()) - item.setScale(self.scale()) - item.setRotation(self.rotation()) - item.setOpacity(self.opacity()) - item.grayscale = self.grayscale + def create_copy(self): # 定义项的复制方法,用于创建项的副本 + item = BeePixmapItem(QtGui.QImage(), self.filename) # 创建一个空的Pixmap项对象,用于存储复制的Pixmap数据 + item.setPixmap(self.pixmap()) # 将项的Pixmap对象复制到新创建的Pixmap项对象中 + item.setPos(self.pos()) # 将项的位置属性值复制到新创建的Pixmap项对象中 + item.setZValue(self.zValue()) # 将项的Z值属性值复制到新创建的Pixmap项对象中 + item.setScale(self.scale()) # 将项的缩放属性值复制到新创建的Pixmap项对象中 + item.setRotation(self.rotation()) # 将项的旋转属性值复制到新创建的Pixmap项对象中 + item.setOpacity(self.opacity()) # 将项的不透明度属性值复制到新创建的Pixmap项对象中 + item.grayscale = self.grayscale # 将项的灰度属性值复制到新创建的Pixmap项对象中 if self.flip() == -1: - item.do_flip() - item.crop = self.crop - return item + item.do_flip() # 如果项的翻转属性值为-1,则调用项的翻转方法,将项的Pixmap对象水平翻转 + item.crop = self.crop # 将项的裁剪区域属性值复制到新创建的Pixmap项对象中 + return item # 返回新创建的Pixmap项对象 - @cached_property - def color_gamut(self): - logger.debug(f'Calculating color gamut for {self}') - gamut = defaultdict(int) - img = self.pixmap().toImage() + @cached_property + def color_gamut(self): # 定义项的颜色 Gamut 计算方法,用于计算项的颜色 Gamut + logger.debug(f'Calculating color gamut for {self}') # 记录调试信息,显示项的颜色 Gamut 计算方法和项的文件名属性 + gamut = defaultdict(int) # 创建一个默认值为整数的字典对象,用于存储项的颜色 Gamut 值 + img = self.pixmap().toImage() # 将项的Pixmap对象转换为QImage对象 # Don't evaluate every pixel for larger images: - step = max(1, int(max(img.width(), img.height()) / 1000)) - logger.debug(f'Considering every {step}. row/column') + step = max(1, int(max(img.width(), img.height()) / 1000)) # 计算步长,用于减少计算量,避免处理过大的图像 + logger.debug(f'Considering every {step}. row/column') # 记录调试信息,显示项的颜色 Gamut 计算方法和项的文件名属性 # Not actually faster than solution below :( # ptr = img.bits() @@ -314,425 +314,425 @@ class BeePixmapItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem): # rgb = QtGui.QColor(r, g, b) # gamut[rgb.hue(), rgb.saturation()] += 1 - for i in range(0, img.width(), step): - for j in range(0, img.height(), step): - rgb = img.pixelColor(i, j) - rgbtuple = (rgb.red(), rgb.blue(), rgb.green()) - if (5 < rgb.alpha() - and min(rgbtuple) < 250 and max(rgbtuple) > 5): + for i in range(0, img.width(), step):# 遍历图像的每一行,步长为step + for j in range(0, img.height(), step):# 遍历图像的每一列,步长为step + rgb = img.pixelColor(i, j) # 获取图像在(i, j)位置的像素颜色 + rgbtuple = (rgb.red(), rgb.blue(), rgb.green()) # 将像素颜色转换为RGB元组 + if (5 < rgb.alpha() # 如果像素的alpha值大于5 + and min(rgbtuple) < 250 and max(rgbtuple) > 5): # 如果像素的RGB值都在5到250之间,且不是接近透明、白色或黑色 # Only consider pixels that aren't close to # transparent, white or black - gamut[rgb.hue(), rgb.saturation()] += 1 + gamut[rgb.hue(), rgb.saturation()] += 1 # 如果像素的RGB值都在5到250之间,且不是接近透明、白色或黑色,则将像素的Hue值和Saturation值作为键,将1作为值,添加到颜色 Gamut 字典中 - logger.debug(f'Got {len(gamut)} color gamut values') - return gamut + logger.debug(f'Got {len(gamut)} color gamut values') # 记录调试信息,显示项的颜色 Gamut 计算方法和项的文件名属性 + return gamut # 返回项的颜色 Gamut 字典对象 - def copy_to_clipboard(self, clipboard): - clipboard.setPixmap(self.pixmap()) + def copy_to_clipboard(self, clipboard): # 定义项的复制到剪贴板方法,用于将项的Pixmap数据复制到剪贴板中 + clipboard.setPixmap(self.pixmap()) # 将项的Pixmap对象复制到剪贴板中 def reset_crop(self): self.crop = QtCore.QRectF( - 0, 0, self.pixmap().size().width(), self.pixmap().size().height()) + 0, 0, self.pixmap().size().width(), self.pixmap().size().height()) # 将项的裁剪区域属性值重置为默认值,即整个Pixmap图像 @property - def crop_handle_size(self): - return self.fixed_length_for_viewport(self.CROP_HANDLE_SIZE) + def crop_handle_size(self): # 定义项的裁剪区域句柄大小属性方法,用于计算项的裁剪区域句柄大小 + return self.fixed_length_for_viewport(self.CROP_HANDLE_SIZE) # 返回项的裁剪区域句柄大小属性值,根据当前视口大小进行缩放 - def crop_handle_topleft(self): - topleft = self.crop_temp.topLeft() - return QtCore.QRectF( + def crop_handle_topleft(self): # 定义项的裁剪区域句柄左上角方法,用于计算项的裁剪区域句柄左上角位置 + topleft = self.crop_temp.topLeft() # 获取项的裁剪区域临时属性值的左上角位置 + return QtCore.QRectF( # 返回项的裁剪区域句柄左上角位置的矩形对象 topleft.x(), topleft.y(), self.crop_handle_size, - self.crop_handle_size) + self.crop_handle_size) # 返回项的裁剪区域句柄左上角位置的矩形对象,宽度和高度都等于项的裁剪区域句柄大小属性值 - def crop_handle_bottomleft(self): - bottomleft = self.crop_temp.bottomLeft() - return QtCore.QRectF( + def crop_handle_bottomleft(self): # 定义项的裁剪区域句柄左下角方法,用于计算项的裁剪区域句柄左下角位置 + bottomleft = self.crop_temp.bottomLeft() # 获取项的裁剪区域临时属性值的左下角位置 + return QtCore.QRectF( # 返回项的裁剪区域句柄左下角位置的矩形对象 bottomleft.x(), bottomleft.y() - self.crop_handle_size, self.crop_handle_size, self.crop_handle_size) - def crop_handle_bottomright(self): - bottomright = self.crop_temp.bottomRight() - return QtCore.QRectF( + def crop_handle_bottomright(self): # 定义项的裁剪区域句柄右下角方法,用于计算项的裁剪区域句柄右下角位置 + bottomright = self.crop_temp.bottomRight() # 获取项的裁剪区域临时属性值的右下角位置 + return QtCore.QRectF( # 返回项的裁剪区域句柄右下角位置的矩形对象 bottomright.x() - self.crop_handle_size, bottomright.y() - self.crop_handle_size, self.crop_handle_size, self.crop_handle_size) - def crop_handle_topright(self): - topright = self.crop_temp.topRight() - return QtCore.QRectF( + def crop_handle_topright(self): # 定义项的裁剪区域句柄右上角方法,用于计算项的裁剪区域句柄右上角位置 + topright = self.crop_temp.topRight() # 获取项的裁剪区域临时属性值的右上角位置 + return QtCore.QRectF( # 返回项的裁剪区域句柄右上角位置的矩形对象 topright.x() - self.crop_handle_size, topright.y(), self.crop_handle_size, self.crop_handle_size) - def crop_handles(self): - return (self.crop_handle_topleft, - self.crop_handle_bottomleft, - self.crop_handle_bottomright, - self.crop_handle_topright) + def crop_handles(self): # 定义项的裁剪区域句柄方法,用于返回项的裁剪区域句柄列表 + return (self.crop_handle_topleft, # 返回项的裁剪区域句柄左上角方法 + self.crop_handle_bottomleft, # 返回项的裁剪区域句柄左下角方法 + self.crop_handle_bottomright, # 返回项的裁剪区域句柄右下角方法 + self.crop_handle_topright) # 返回项的裁剪区域句柄右上角方法 - def crop_edge_top(self): - topleft = self.crop_temp.topLeft() - return QtCore.QRectF( - topleft.x() + self.crop_handle_size, - topleft.y(), - self.crop_temp.width() - 2 * self.crop_handle_size, - self.crop_handle_size) + def crop_edge_top(self): # 定义项的裁剪区域顶部边缘方法,用于计算项的裁剪区域顶部边缘位置 + topleft = self.crop_temp.topLeft() # 获取项的裁剪区域临时属性值的顶部边缘位置 + return QtCore.QRectF( # 返回项的裁剪区域顶部边缘位置的矩形对象 + topleft.x() + self.crop_handle_size, # 顶部边缘位置的横坐标等于项的裁剪区域句柄大小属性值加上项的裁剪区域临时属性值的顶部边缘位置的横坐标 + topleft.y(), # 顶部边缘位置的纵坐标等于项的裁剪区域临时属性值的顶部边缘位置的纵坐标 + self.crop_temp.width() - 2 * self.crop_handle_size, # 顶部边缘位置的宽度等于项的裁剪区域临时属性值的宽度减去2倍的项的裁剪区域句柄大小属性值 + self.crop_handle_size) # 返回项的裁剪区域顶部边缘位置的矩形对象,高度等于项的裁剪区域句柄大小属性值 - def crop_edge_left(self): - topleft = self.crop_temp.topLeft() - return QtCore.QRectF( - topleft.x(), - topleft.y() + self.crop_handle_size, - self.crop_handle_size, - self.crop_temp.height() - 2 * self.crop_handle_size) + def crop_edge_left(self): # 定义项的裁剪区域左侧边缘方法,用于计算项的裁剪区域左侧边缘位置 + topleft = self.crop_temp.topLeft() # 获取项的裁剪区域临时属性值的左侧边缘位置 + return QtCore.QRectF( # 返回项的裁剪区域左侧边缘位置的矩形对象 + topleft.x(), # 左侧边缘位置的横坐标等于项的裁剪区域临时属性值的左侧边缘位置的横坐标 + topleft.y() + self.crop_handle_size, # 左侧边缘位置的纵坐标等于项的裁剪区域句柄大小属性值加上项的裁剪区域临时属性值的左侧边缘位置的纵坐标 + self.crop_handle_size, # 左侧边缘位置的宽度等于项的裁剪区域句柄大小属性值 + self.crop_temp.height() - 2 * self.crop_handle_size) # 返回项的裁剪区域左侧边缘位置的矩形对象,高度等于项的裁剪区域临时属性值的高度减去2倍的项的裁剪区域句柄大小属性值 - def crop_edge_bottom(self): - bottomleft = self.crop_temp.bottomLeft() - return QtCore.QRectF( - bottomleft.x() + self.crop_handle_size, - bottomleft.y() - self.crop_handle_size, - self.crop_temp.width() - 2 * self.crop_handle_size, - self.crop_handle_size) + def crop_edge_bottom(self): # 定义项的裁剪区域底部边缘方法,用于计算项的裁剪区域底部边缘位置 + bottomleft = self.crop_temp.bottomLeft() # 获取项的裁剪区域临时属性值的底部边缘位置 + return QtCore.QRectF( # 返回项的裁剪区域底部边缘位置的矩形对象 + bottomleft.x() + self.crop_handle_size, # 底部边缘位置的横坐标等于项的裁剪区域句柄大小属性值加上项的裁剪区域临时属性值的底部边缘位置的横坐标 + bottomleft.y() - self.crop_handle_size, # 底部边缘位置的纵坐标等于项的裁剪区域句柄大小属性值减去项的裁剪区域临时属性值的底部边缘位置的纵坐标 + self.crop_temp.width() - 2 * self.crop_handle_size, # 底部边缘位置的宽度等于项的裁剪区域临时属性值的宽度减去2倍的项的裁剪区域句柄大小属性值 + self.crop_handle_size) # 返回项的裁剪区域底部边缘位置的矩形对象,高度等于项的裁剪区域句柄大小属性值 - def crop_edge_right(self): - topright = self.crop_temp.topRight() - return QtCore.QRectF( - topright.x() - self.crop_handle_size, + def crop_edge_right(self): # 定义项的裁剪区域右侧边缘方法,用于计算项的裁剪区域右侧边缘位置 + topright = self.crop_temp.topRight() # 获取项的裁剪区域临时属性值的右侧边缘位置 + return QtCore.QRectF( # 返回项的裁剪区域右侧边缘位置的矩形对象 + topright.x() - self.crop_handle_size, # 右侧边缘位置的横坐标等于项的裁剪区域句柄大小属性值减去项的裁剪区域临时属性值的右侧边缘位置的横坐标 topright.y() + self.crop_handle_size, self.crop_handle_size, self.crop_temp.height() - 2 * self.crop_handle_size) - def crop_edges(self): - return (self.crop_edge_top, - self.crop_edge_left, - self.crop_edge_bottom, - self.crop_edge_right) + def crop_edges(self): # 定义项的裁剪区域边缘方法,用于返回项的裁剪区域边缘列表 + return (self.crop_edge_top, # 返回项的裁剪区域顶部边缘方法 + self.crop_edge_left, # 返回项的裁剪区域左侧边缘方法 + self.crop_edge_bottom, # 返回项的裁剪区域底部边缘方法 + self.crop_edge_right) # 返回项的裁剪区域右侧边缘方法 - def get_crop_handle_cursor(self, handle): + def get_crop_handle_cursor(self, handle): # 定义项的裁剪区域句柄光标方法,用于返回项的裁剪区域句柄光标 """Gets the crop cursor for the given handle.""" - is_topleft_or_bottomright = handle in ( + is_topleft_or_bottomright = handle in ( # 判断项的裁剪区域句柄是否为左上角或右下角句柄 self.crop_handle_topleft, self.crop_handle_bottomright) - return self.get_diag_cursor(is_topleft_or_bottomright) + return self.get_diag_cursor(is_topleft_or_bottomright) # 返回项的裁剪区域句柄光标,根据项的裁剪区域句柄是否为左上角或右下角句柄来判断光标类型 - def get_crop_edge_cursor(self, edge): + def get_crop_edge_cursor(self, edge): # 定义项的裁剪区域边缘光标方法,用于返回项的裁剪区域边缘光标 """Gets the crop edge cursor for the given edge.""" top_or_bottom = edge in ( - self.crop_edge_top, self.crop_edge_bottom) - sideways = (45 < self.rotation() < 135 + self.crop_edge_top, self.crop_edge_bottom) # 判断项的裁剪区域边缘是否为顶部或底部边缘 + sideways = (45 < self.rotation() < 135 # 判断项的旋转角度是否为45度到135度或225度到315度之间 or 225 < self.rotation() < 315) - if top_or_bottom is sideways: - return Qt.CursorShape.SizeHorCursor + if top_or_bottom is sideways: # 如果项的裁剪区域边缘为顶部或底部边缘且项的旋转角度为45度到135度或225度到315度之间 + return Qt.CursorShape.SizeHorCursor # 返回水平光标类型 else: - return Qt.CursorShape.SizeVerCursor + return Qt.CursorShape.SizeVerCursor # 返回垂直光标类型 - def draw_crop_rect(self, painter, rect): + def draw_crop_rect(self, painter, rect): # 定义项的裁剪区域矩形绘制方法,用于绘制项的裁剪区域矩形 """Paint a dotted rectangle for the cropping UI.""" - pen = QtGui.QPen(QtGui.QColor(255, 255, 255)) - pen.setWidth(2) - pen.setCosmetic(True) - painter.setPen(pen) - painter.drawRect(rect) - pen.setColor(QtGui.QColor(0, 0, 0)) - pen.setStyle(Qt.PenStyle.DotLine) - painter.setPen(pen) - painter.drawRect(rect) + pen = QtGui.QPen(QtGui.QColor(255, 255, 255)) # 创建白色画笔对象 + pen.setWidth(2) # 设置画笔宽度为2像素 + pen.setCosmetic(True) # 设置画笔为无宽度模式,用于绘制虚线矩形 + painter.setPen(pen) # 设置画笔为白色画笔对象 + painter.drawRect(rect) # 绘制项的裁剪区域矩形 + pen.setColor(QtGui.QColor(0, 0, 0)) # 设置画笔颜色为黑色 + pen.setStyle(Qt.PenStyle.DotLine) # 设置画笔样式为虚线 + painter.setPen(pen) # 设置画笔为黑色虚线画笔对象 + painter.drawRect(rect) # 绘制项的裁剪区域矩形 - def paint(self, painter, option, widget): - if abs(painter.combinedTransform().m11()) < 2: + def paint(self, painter, option, widget): # 定义项的绘制方法,用于绘制项 + if abs(painter.combinedTransform().m11()) < 2: # 检查缩放比例是否小于2倍 # We want image smoothing, but only for images where we # are not zoomed in a lot. This is to ensure that for # example icons and pixel sprites can be viewed correctly. - painter.setRenderHint(painter.RenderHint.SmoothPixmapTransform) + painter.setRenderHint(painter.RenderHint.SmoothPixmapTransform) # 设置图像平滑渲染提示 - if self.crop_mode: - self.paint_debug(painter, option, widget) + if self.crop_mode: # 如果处于裁剪模式 + self.paint_debug(painter, option, widget) # 绘制调试信息 # Darken image outside of cropped area - painter.drawPixmap(0, 0, self.pixmap()) - path = QtWidgets.QGraphicsPixmapItem.shape(self) - path.addRect(self.crop_temp) - color = QtGui.QColor(0, 0, 0) - color.setAlpha(100) - painter.setBrush(QtGui.QBrush(color)) - painter.setPen(Qt.PenStyle.NoPen) - painter.drawPath(path) - painter.setBrush(QtGui.QBrush()) + painter.drawPixmap(0, 0, self.pixmap()) # 绘制完整图像 + path = QtWidgets.QGraphicsPixmapItem.shape(self) # 获取项目形状路径 + path.addRect(self.crop_temp) # 向路径添加临时裁剪矩形 + color = QtGui.QColor(0, 0, 0) # 创建黑色 + color.setAlpha(100) # 设置透明度 + painter.setBrush(QtGui.QBrush(color)) # 设置画刷 + painter.setPen(Qt.PenStyle.NoPen) # 设置无笔 + painter.drawPath(path) # 绘制路径(使裁剪区域外变暗) + painter.setBrush(QtGui.QBrush()) # 重置画刷 - for handle in self.crop_handles(): - self.draw_crop_rect(painter, handle()) - self.draw_crop_rect(painter, self.crop_temp) - else: - pm = self._grayscale_pixmap if self.grayscale else self.pixmap() - painter.drawPixmap(self.crop, pm, self.crop) - self.paint_selectable(painter, option, widget) + for handle in self.crop_handles(): # 遍历所有裁剪手柄 + self.draw_crop_rect(painter, handle()) # 绘制裁剪手柄矩形 + self.draw_crop_rect(painter, self.crop_temp) # 绘制临时裁剪矩形 + else: # 非裁剪模式 + pm = self._grayscale_pixmap if self.grayscale else self.pixmap() # 根据灰度模式选择像素图 + painter.drawPixmap(self.crop, pm, self.crop) # 绘制裁剪区域 + self.paint_selectable(painter, option, widget) # 绘制选择效果 - def enter_crop_mode(self): - logger.debug(f'Entering crop mode on {self}') - self.prepareGeometryChange() - self.crop_mode = True - self.crop_temp = QtCore.QRectF(self.crop) - self.crop_mode_move = None - self.crop_mode_event_start = None - self.grabKeyboard() - self.update() - self.scene().crop_item = self + def enter_crop_mode(self): # 进入裁剪模式 + logger.debug(f'Entering crop mode on {self}') # 记录进入裁剪模式的日志 + self.prepareGeometryChange() # 通知Qt几何形状将要改变 + self.crop_mode = True # 启用裁剪模式 + self.crop_temp = QtCore.QRectF(self.crop) # 复制当前裁剪区域作为临时裁剪区域 + self.crop_mode_move = None # 初始化裁剪移动类型 + self.crop_mode_event_start = None # 初始化裁剪事件起始位置 + self.grabKeyboard() # 抓取键盘输入 + self.update() # 更新项目显示 + self.scene().crop_item = self # 设置场景的当前裁剪项目 - def exit_crop_mode(self, confirm): - logger.debug(f'Exiting crop mode with {confirm} on {self}') - if confirm and self.crop != self.crop_temp: - self.scene().undo_stack.push( + def exit_crop_mode(self, confirm): # 退出裁剪模式,参数confirm表示是否确认裁剪 + logger.debug(f'Exiting crop mode with {confirm} on {self}') # 记录退出裁剪模式的日志 + if confirm and self.crop != self.crop_temp: # 如果确认且裁剪区域有变化 + self.scene().undo_stack.push( # 将裁剪操作添加到撤销栈 commands.CropItem(self, self.crop_temp)) - self.prepareGeometryChange() - self.crop_mode = False - self.crop_temp = None - self.crop_mode_move = None - self.crop_mode_event_start = None - self.ungrabKeyboard() - self.update() - self.scene().crop_item = None + self.prepareGeometryChange() # 通知Qt几何形状将要改变 + self.crop_mode = False # 禁用裁剪模式 + self.crop_temp = None # 清除临时裁剪区域 + self.crop_mode_move = None # 清除裁剪移动类型 + self.crop_mode_event_start = None # 清除裁剪事件起始位置 + self.ungrabKeyboard() # 释放键盘抓取 + self.update() # 更新项目显示 + self.scene().crop_item = None # 清除场景的当前裁剪项目 - def keyPressEvent(self, event): - if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): - self.exit_crop_mode(confirm=True) - elif event.key() == Qt.Key.Key_Escape: - self.exit_crop_mode(confirm=False) - else: - super().keyPressEvent(event) + def keyPressEvent(self, event): # 键盘按键事件处理 + if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): # 如果按下Enter键 + self.exit_crop_mode(confirm=True) # 确认并退出裁剪模式 + elif event.key() == Qt.Key.Key_Escape: # 如果按下Escape键 + self.exit_crop_mode(confirm=False) # 取消并退出裁剪模式 + else: # 其他按键 + super().keyPressEvent(event) # 调用父类处理 - def hoverMoveEvent(self, event): - if not self.crop_mode: - return super().hoverMoveEvent(event) + def hoverMoveEvent(self, event): # 鼠标悬停移动事件处理 + if not self.crop_mode: # 如果不是裁剪模式 + return super().hoverMoveEvent(event) # 调用父类处理 - for handle in self.crop_handles(): - if handle().contains(event.pos()): - self.set_cursor(self.get_crop_handle_cursor(handle)) + for handle in self.crop_handles(): # 遍历所有裁剪手柄 + if handle().contains(event.pos()): # 如果鼠标在手柄内 + self.set_cursor(self.get_crop_handle_cursor(handle)) # 设置对应的光标 return - for edge in self.crop_edges(): - if edge().contains(event.pos()): - self.set_cursor(self.get_crop_edge_cursor(edge)) + for edge in self.crop_edges(): # 遍历所有裁剪边缘 + if edge().contains(event.pos()): # 如果鼠标在边缘内 + self.set_cursor(self.get_crop_edge_cursor(edge)) # 设置对应的光标 return - self.unset_cursor() + self.unset_cursor() # 重置光标 - def mousePressEvent(self, event): - if not self.crop_mode: - return super().mousePressEvent(event) + def mousePressEvent(self, event): # 鼠标按下事件处理 + if not self.crop_mode: # 如果不是裁剪模式 + return super().mousePressEvent(event) # 调用父类处理 - event.accept() - for handle in self.crop_handles(): + event.accept() # 接受事件 + for handle in self.crop_handles(): # 遍历所有裁剪手柄 # Click into a handle? - if handle().contains(event.pos()): - self.crop_mode_event_start = event.pos() - self.crop_mode_move = handle + if handle().contains(event.pos()): # 如果点击位置在手柄内 + self.crop_mode_event_start = event.pos() # 记录事件起始位置 + self.crop_mode_move = handle # 设置移动类型为该手柄 return - for edge in self.crop_edges(): + for edge in self.crop_edges(): # 遍历所有裁剪边缘 # Click into an edge handle? - if edge().contains(event.pos()): - self.crop_mode_event_start = event.pos() - self.crop_mode_move = edge + if edge().contains(event.pos()): # 如果点击位置在边缘内 + self.crop_mode_event_start = event.pos() # 记录事件起始位置 + self.crop_mode_move = edge # 设置移动类型为该边缘 return - # Click not in handle, end cropping mode: + # Click not in handle, end cropping mode: 如果点击不在手柄或边缘上,结束裁剪模式 self.exit_crop_mode( - confirm=self.crop_temp.contains(event.pos())) + confirm=self.crop_temp.contains(event.pos())) # 根据点击位置决定是否确认裁剪 - def ensure_point_within_crop_bounds(self, point, handle): + def ensure_point_within_crop_bounds(self, point, handle): # 确保点在裁剪边界内 """Returns the point, or the nearest point within the pixmap.""" - if handle == self.crop_handle_topleft: - topleft = QtCore.QPointF(0, 0) - bottomright = self.crop_temp.bottomRight() - if handle == self.crop_handle_bottomleft: - topleft = QtCore.QPointF(0, self.crop_temp.top()) - bottomright = QtCore.QPointF( + if handle == self.crop_handle_topleft: # 如果是左上角手柄 + topleft = QtCore.QPointF(0, 0) # 左上角边界 + bottomright = self.crop_temp.bottomRight() # 右下角边界(临时裁剪区域的右下角) + if handle == self.crop_handle_bottomleft: # 如果是左下角手柄 + topleft = QtCore.QPointF(0, self.crop_temp.top()) # 左上角边界 + bottomright = QtCore.QPointF( # 右下角边界 self.crop_temp.right(), self.pixmap().size().height()) - if handle == self.crop_handle_bottomright: - topleft = self.crop_temp.topLeft() - bottomright = QtCore.QPointF( + if handle == self.crop_handle_bottomright: # 如果是右下角手柄 + topleft = self.crop_temp.topLeft() # 左上角边界(临时裁剪区域的左上角) + bottomright = QtCore.QPointF( # 右下角边界(整个像素图的右下角) self.pixmap().size().width(), self.pixmap().size().height()) - if handle == self.crop_handle_topright: - topleft = QtCore.QPointF(self.crop_temp.left(), 0) - bottomright = QtCore.QPointF( + if handle == self.crop_handle_topright: # 如果是右上角手柄 + topleft = QtCore.QPointF(self.crop_temp.left(), 0) # 左上角边界 + bottomright = QtCore.QPointF( # 右下角边界 self.pixmap().size().width(), self.crop_temp.bottom()) - if handle == self.crop_edge_top: - topleft = QtCore.QPointF(0, 0) - bottomright = QtCore.QPointF( + if handle == self.crop_edge_top: # 如果是顶部边缘 + topleft = QtCore.QPointF(0, 0) # 左上角边界 + bottomright = QtCore.QPointF( # 右下角边界 self.pixmap().size().width(), self.crop_temp.bottom()) - if handle == self.crop_edge_bottom: - topleft = QtCore.QPointF(0, self.crop_temp.top()) - bottomright = QtCore.QPointF( + if handle == self.crop_edge_bottom: # 如果是底部边缘 + topleft = QtCore.QPointF(0, self.crop_temp.top()) # 左上角边界 + bottomright = QtCore.QPointF( # 右下角边界 self.pixmap().size().width(), self.pixmap().size().height()) - if handle == self.crop_edge_left: - topleft = QtCore.QPointF(0, 0) - bottomright = QtCore.QPointF( + if handle == self.crop_edge_left: # 如果是左侧边缘 + topleft = QtCore.QPointF(0, 0) # 左上角边界 + bottomright = QtCore.QPointF( # 右下角边界 self.crop_temp.right(), self.pixmap().size().height()) - if handle == self.crop_edge_right: - topleft = QtCore.QPointF(self.crop_temp.left(), 0) - bottomright = QtCore.QPointF( + if handle == self.crop_edge_right: # 如果是右侧边缘 + topleft = QtCore.QPointF(self.crop_temp.left(), 0) # 左上角边界 + bottomright = QtCore.QPointF( # 右下角边界 self.pixmap().size().width(), self.pixmap().size().height()) - point.setX(min(bottomright.x(), max(topleft.x(), point.x()))) - point.setY(min(bottomright.y(), max(topleft.y(), point.y()))) + point.setX(min(bottomright.x(), max(topleft.x(), point.x()))) # 限制点的X坐标在边界内 + point.setY(min(bottomright.y(), max(topleft.y(), point.y()))) # 限制点的Y坐标在边界内 - return point + return point # 返回限制后的点 - def mouseMoveEvent(self, event): - if self.crop_mode and self.crop_mode_event_start: - diff = event.pos() - self.crop_mode_event_start - if self.crop_mode_move == self.crop_handle_topleft: - new = self.ensure_point_within_crop_bounds( + def mouseMoveEvent(self, event): # 鼠标移动事件处理 + if self.crop_mode and self.crop_mode_event_start: # 如果处于裁剪模式且有起始位置 + diff = event.pos() - self.crop_mode_event_start # 计算位置差异 + if self.crop_mode_move == self.crop_handle_topleft: # 如果移动左上角手柄 + new = self.ensure_point_within_crop_bounds( # 确保新位置在边界内 self.crop_temp.topLeft() + diff, self.crop_mode_move) - self.crop_temp.setTopLeft(new) - if self.crop_mode_move == self.crop_handle_bottomleft: - new = self.ensure_point_within_crop_bounds( + self.crop_temp.setTopLeft(new) # 更新左上角 + if self.crop_mode_move == self.crop_handle_bottomleft: # 如果移动左下角手柄 + new = self.ensure_point_within_crop_bounds( # 确保新位置在边界内 self.crop_temp.bottomLeft() + diff, self.crop_mode_move) - self.crop_temp.setBottomLeft(new) - if self.crop_mode_move == self.crop_handle_bottomright: - new = self.ensure_point_within_crop_bounds( + self.crop_temp.setBottomLeft(new) # 更新左下角 + if self.crop_mode_move == self.crop_handle_bottomright: # 如果移动右下角手柄 + new = self.ensure_point_within_crop_bounds( # 确保新位置在边界内 self.crop_temp.bottomRight() + diff, self.crop_mode_move) - self.crop_temp.setBottomRight(new) - if self.crop_mode_move == self.crop_handle_topright: - new = self.ensure_point_within_crop_bounds( + self.crop_temp.setBottomRight(new) # 更新右下角 + if self.crop_mode_move == self.crop_handle_topright: # 如果移动右上角手柄 + new = self.ensure_point_within_crop_bounds( # 确保新位置在边界内 self.crop_temp.topRight() + diff, self.crop_mode_move) - self.crop_temp.setTopRight(new) - if self.crop_mode_move == self.crop_edge_top: - new = self.ensure_point_within_crop_bounds( + self.crop_temp.setTopRight(new) # 更新右上角 + if self.crop_mode_move == self.crop_edge_top: # 如果移动顶部边缘 + new = self.ensure_point_within_crop_bounds( # 确保新位置在边界内 self.crop_temp.topLeft() + diff, self.crop_mode_move) - self.crop_temp.setTop(new.y()) - if self.crop_mode_move == self.crop_edge_left: - new = self.ensure_point_within_crop_bounds( + self.crop_temp.setTop(new.y()) # 更新顶部 + if self.crop_mode_move == self.crop_edge_left: # 如果移动左侧边缘 + new = self.ensure_point_within_crop_bounds( # 确保新位置在边界内 self.crop_temp.topLeft() + diff, self.crop_mode_move) - self.crop_temp.setLeft(new.x()) - if self.crop_mode_move == self.crop_edge_bottom: - new = self.ensure_point_within_crop_bounds( + self.crop_temp.setLeft(new.x()) # 更新左侧 + if self.crop_mode_move == self.crop_edge_bottom: # 如果移动底部边缘 + new = self.ensure_point_within_crop_bounds( # 确保新位置在边界内 self.crop_temp.bottomLeft() + diff, self.crop_mode_move) - self.crop_temp.setBottom(new.y()) - if self.crop_mode_move == self.crop_edge_right: - new = self.ensure_point_within_crop_bounds( + self.crop_temp.setBottom(new.y()) # 更新底部 + if self.crop_mode_move == self.crop_edge_right: # 如果移动右侧边缘 + new = self.ensure_point_within_crop_bounds( # 确保新位置在边界内 self.crop_temp.topRight() + diff, self.crop_mode_move) - self.crop_temp.setRight(new.x()) - self.update() - self.crop_mode_event_start = event.pos() - event.accept() - else: - super().mouseMoveEvent(event) + self.crop_temp.setRight(new.x()) # 更新右侧 + self.update() # 更新项目显示 + self.crop_mode_event_start = event.pos() # 更新事件起始位置 + event.accept() # 接受事件 + else: # 非裁剪模式或没有起始位置 + super().mouseMoveEvent(event) # 调用父类处理 - def mouseReleaseEvent(self, event): - if self.crop_mode: - self.crop_mode_move = None - self.crop_mode_event_start = None - event.accept() - else: - super().mouseReleaseEvent(event) + def mouseReleaseEvent(self, event): # 鼠标释放事件处理 + if self.crop_mode: # 如果处于裁剪模式 + self.crop_mode_move = None # 清除移动类型 + self.crop_mode_event_start = None # 清除事件起始位置 + event.accept() # 接受事件 + else: # 非裁剪模式 + super().mouseReleaseEvent(event) # 调用父类处理 -@register_item -class BeeTextItem(BeeItemMixin, QtWidgets.QGraphicsTextItem): +@register_item # 使用装饰器注册该类 +class BeeTextItem(BeeItemMixin, QtWidgets.QGraphicsTextItem): # 文本项目类,继承自BeeItemMixin和QtWidgets.QGraphicsTextItem """Class for text added by the user.""" - TYPE = 'text' + TYPE = 'text' # 项目类型标识符 - def __init__(self, text=None, **kwargs): - super().__init__(text or "Text") - self.save_id = None - logger.debug(f'Initialized {self}') - self.is_image = False - self.init_selectable() - self.is_editable = True - self.edit_mode = False - self.setDefaultTextColor(QtGui.QColor(*COLORS['Scene:Text'])) + def __init__(self, text=None, **kwargs): # 初始化文本项目 + super().__init__(text or "Text") # 调用父类构造函数,默认文本为"Text" + self.save_id = None # 保存ID,用于标识项目 + logger.debug(f'Initialized {self}') # 记录初始化日志 + self.is_image = False # 标记为非图像类型 + self.init_selectable() # 初始化可选择属性 + self.is_editable = True # 标记为可编辑 + self.edit_mode = False # 编辑模式标志 + self.setDefaultTextColor(QtGui.QColor(*COLORS['Scene:Text'])) # 设置默认文本颜色 - @classmethod + @classmethod # 类方法,从数据创建项目 def create_from_data(cls, **kwargs): - data = kwargs.get('data', {}) - item = cls(**data) - return item + data = kwargs.get('data', {}) # 获取项目数据 + item = cls(**data) # 创建项目实例 + return item # 返回创建的项目 - def __str__(self): - txt = self.toPlainText()[:40] - return (f'Text "{txt}"') + def __str__(self): # 返回项目的字符串表示 + txt = self.toPlainText()[:40] # 获取文本前40个字符 + return (f'Text "{txt}"') # 返回格式化的字符串 - def get_extra_save_data(self): - return {'text': self.toPlainText()} + def get_extra_save_data(self): # 获取需要保存的额外数据 + return {'text': self.toPlainText()} # 返回文本内容 - def contains(self, point): - return self.boundingRect().contains(point) + def contains(self, point): # 检查点是否在项目内 + return self.boundingRect().contains(point) # 检查点是否在边界矩形内 - def paint(self, painter, option, widget): - painter.setPen(Qt.PenStyle.NoPen) - color = QtGui.QColor(0, 0, 0) - color.setAlpha(40) - brush = QtGui.QBrush(color) - painter.setBrush(brush) - painter.drawRect(QtWidgets.QGraphicsTextItem.boundingRect(self)) - option.state = QtWidgets.QStyle.StateFlag.State_Enabled - super().paint(painter, option, widget) - self.paint_selectable(painter, option, widget) + def paint(self, painter, option, widget): # 绘制项目 + painter.setPen(Qt.PenStyle.NoPen) # 设置无笔 + color = QtGui.QColor(0, 0, 0) # 创建黑色 + color.setAlpha(40) # 设置透明度 + brush = QtGui.QBrush(color) # 创建画刷 + painter.setBrush(brush) # 设置画刷 + painter.drawRect(QtWidgets.QGraphicsTextItem.boundingRect(self)) # 绘制背景矩形 + option.state = QtWidgets.QStyle.StateFlag.State_Enabled # 设置选项状态为启用 + super().paint(painter, option, widget) # 调用父类绘制文本 + self.paint_selectable(painter, option, widget) # 绘制选择效果 - def create_copy(self): - item = BeeTextItem(self.toPlainText()) - item.setPos(self.pos()) - item.setZValue(self.zValue()) - item.setScale(self.scale()) - item.setRotation(self.rotation()) - if self.flip() == -1: - item.do_flip() - return item + def create_copy(self): # 创建项目的副本 + item = BeeTextItem(self.toPlainText()) # 创建新项目,文本内容相同 + item.setPos(self.pos()) # 设置相同的位置 + item.setZValue(self.zValue()) # 设置相同的Z值 + item.setScale(self.scale()) # 设置相同的缩放比例 + item.setRotation(self.rotation()) # 设置相同的旋转角度 + if self.flip() == -1: # 如果当前是翻转状态 + item.do_flip() # 翻转新项目 + return item # 返回创建的副本 - def enter_edit_mode(self): - logger.debug(f'Entering edit mode on {self}') - self.edit_mode = True - self.old_text = self.toPlainText() - self.setTextInteractionFlags( + def enter_edit_mode(self): # 进入编辑模式 + logger.debug(f'Entering edit mode on {self}') # 记录进入编辑模式的日志 + self.edit_mode = True # 启用编辑模式 + self.old_text = self.toPlainText() # 保存当前文本,用于取消编辑 + self.setTextInteractionFlags( # 设置文本交互标志为编辑交互 Qt.TextInteractionFlag.TextEditorInteraction) - self.scene().edit_item = self + self.scene().edit_item = self # 设置场景的当前编辑项目 - def exit_edit_mode(self, commit=True): - logger.debug(f'Exiting edit mode on {self}') - self.edit_mode = False - # reset selection: - self.setTextCursor(QtGui.QTextCursor(self.document())) - self.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction) - self.scene().edit_item = None - if commit: - self.scene().undo_stack.push( + def exit_edit_mode(self, commit=True): # 退出编辑模式,参数commit表示是否确认修改 + logger.debug(f'Exiting edit mode on {self}') # 记录退出编辑模式的日志 + self.edit_mode = False # 禁用编辑模式 + # reset selection: 重置选择 + self.setTextCursor(QtGui.QTextCursor(self.document())) # 重置文本光标 + self.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction) # 禁用文本交互 + self.scene().edit_item = None # 清除场景的当前编辑项目 + if commit: # 如果确认编辑 + self.scene().undo_stack.push( # 将文本更改添加到撤销栈 commands.ChangeText(self, self.toPlainText(), self.old_text)) - if not self.toPlainText().strip(): - logger.debug('Removing empty text item') - self.scene().undo_stack.push( + if not self.toPlainText().strip(): # 如果文本为空 + logger.debug('Removing empty text item') # 记录删除空文本项目的日志 + self.scene().undo_stack.push( # 将删除操作添加到撤销栈 commands.DeleteItems(self.scene(), [self])) - else: - self.setPlainText(self.old_text) + else: # 如果取消编辑 + self.setPlainText(self.old_text) # 恢复旧文本 - def has_selection_handles(self): + 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) + def keyPressEvent(self, event): # 键盘按键事件处理 + if (event.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return) # 如果按下Enter键且没有修饰键 and event.modifiers() == Qt.KeyboardModifier.NoModifier): - self.exit_edit_mode() + self.exit_edit_mode() # 退出编辑模式并确认 event.accept() return - if (event.key() == Qt.Key.Key_Escape + if (event.key() == Qt.Key.Key_Escape # 如果按下Escape键且没有修饰键 and event.modifiers() == Qt.KeyboardModifier.NoModifier): - self.exit_edit_mode(commit=False) + self.exit_edit_mode(commit=False) # 退出编辑模式并取消 event.accept() return - super().keyPressEvent(event) + super().keyPressEvent(event) # 其他按键,调用父类处理 - def copy_to_clipboard(self, clipboard): - clipboard.setText(self.toPlainText()) + def copy_to_clipboard(self, clipboard): # 将项目复制到剪贴板 + clipboard.setText(self.toPlainText()) # 设置剪贴板内容为文本 -@register_item -class BeeErrorItem(BeeItemMixin, QtWidgets.QGraphicsTextItem): +@register_item # 使用装饰器注册该类 +class BeeErrorItem(BeeItemMixin, QtWidgets.QGraphicsTextItem): # 错误项目类,继承自BeeItemMixin和QtWidgets.QGraphicsTextItem """Class for displaying error messages when an item can't be loaded from a bee file. @@ -742,65 +742,65 @@ class BeeErrorItem(BeeItemMixin, QtWidgets.QGraphicsTextItem): is saved. """ - TYPE = 'error' + TYPE = 'error' # 项目类型标识符 - def __init__(self, text=None, **kwargs): - super().__init__(text or "Text") - self.original_save_id = None - logger.debug(f'Initialized {self}') - self.is_image = False - self.init_selectable() - self.is_editable = False - self.setDefaultTextColor(QtGui.QColor(*COLORS['Scene:Text'])) + def __init__(self, text=None, **kwargs): # 初始化错误项目 + super().__init__(text or "Text") # 调用父类构造函数 + self.original_save_id = None # 原始项目的保存ID + logger.debug(f'Initialized {self}') # 记录初始化日志 + self.is_image = False # 标记为非图像类型 + self.init_selectable() # 初始化可选择属性 + self.is_editable = False # 标记为不可编辑 + self.setDefaultTextColor(QtGui.QColor(*COLORS['Scene:Text'])) # 设置默认文本颜色 - @classmethod + @classmethod # 类方法,从数据创建项目 def create_from_data(cls, **kwargs): - data = kwargs.get('data', {}) - item = cls(**data) - return item + data = kwargs.get('data', {}) # 获取项目数据 + item = cls(**data) # 创建项目实例 + return item # 返回创建的项目 - def __str__(self): - txt = self.toPlainText()[:40] - return (f'Error "{txt}"') + def __str__(self): # 返回项目的字符串表示 + txt = self.toPlainText()[:40] # 获取文本前40个字符 + return (f'Error "{txt}"') # 返回格式化的字符串 - def contains(self, point): - return self.boundingRect().contains(point) + def contains(self, point): # 检查点是否在项目内 + return self.boundingRect().contains(point) # 检查点是否在边界矩形内 - def paint(self, painter, option, widget): - painter.setPen(Qt.PenStyle.NoPen) - color = QtGui.QColor(200, 0, 0) - brush = QtGui.QBrush(color) - painter.setBrush(brush) - painter.drawRect(QtWidgets.QGraphicsTextItem.boundingRect(self)) - option.state = QtWidgets.QStyle.StateFlag.State_Enabled - super().paint(painter, option, widget) - self.paint_selectable(painter, option, widget) + def paint(self, painter, option, widget): # 绘制项目 + painter.setPen(Qt.PenStyle.NoPen) # 设置无笔 + color = QtGui.QColor(200, 0, 0) # 创建红色 + brush = QtGui.QBrush(color) # 创建画刷 + painter.setBrush(brush) # 设置画刷 + painter.drawRect(QtWidgets.QGraphicsTextItem.boundingRect(self)) # 绘制红色背景矩形 + option.state = QtWidgets.QStyle.StateFlag.State_Enabled # 设置选项状态为启用 + super().paint(painter, option, widget) # 调用父类绘制文本 + self.paint_selectable(painter, option, widget) # 绘制选择效果 - def update_from_data(self, **kwargs): - self.original_save_id = kwargs.get('save_id', self.original_save_id) - self.setPos(kwargs.get('x', self.pos().x()), - kwargs.get('y', self.pos().y())) - self.setZValue(kwargs.get('z', self.zValue())) - self.setScale(kwargs.get('scale', self.scale())) - self.setRotation(kwargs.get('rotation', self.rotation())) + def update_from_data(self, **kwargs): # 从数据更新项目属性 + self.original_save_id = kwargs.get('save_id', self.original_save_id) # 更新原始保存ID + self.setPos(kwargs.get('x', self.pos().x()), # 更新X坐标 + kwargs.get('y', self.pos().y())) # 更新Y坐标 + self.setZValue(kwargs.get('z', self.zValue())) # 更新Z值 + self.setScale(kwargs.get('scale', self.scale())) # 更新缩放比例 + self.setRotation(kwargs.get('rotation', self.rotation())) # 更新旋转角度 - def create_copy(self): - item = BeeErrorItem(self.toPlainText()) - item.setPos(self.pos()) - item.setZValue(self.zValue()) - item.setScale(self.scale()) - item.setRotation(self.rotation()) - return item + def create_copy(self): # 创建项目的副本 + item = BeeErrorItem(self.toPlainText()) # 创建新项目,文本内容相同 + item.setPos(self.pos()) # 设置相同的位置 + item.setZValue(self.zValue()) # 设置相同的Z值 + item.setScale(self.scale()) # 设置相同的缩放比例 + item.setRotation(self.rotation()) # 设置相同的旋转角度 + return item # 返回创建的副本 - def flip(self, *args, **kwargs): + def flip(self, *args, **kwargs): # 返回翻转值(错误项目永远不翻转) """Returns the flip value (1 or -1)""" # Never display error messages flipped return 1 - def do_flip(self, *args, **kwargs): + def do_flip(self, *args, **kwargs): # 翻转项目(错误项目永远不翻转) """Flips the item.""" # Never flip error messages pass - def copy_to_clipboard(self, clipboard): - clipboard.setText(self.toPlainText()) + def copy_to_clipboard(self, clipboard): # 将项目复制到剪贴板 + clipboard.setText(self.toPlainText()) # 设置剪贴板内容为文本 \ No newline at end of file diff --git a/beeref/main_controls.py b/beeref/main_controls.py index 4848a2f..0344681 100644 --- a/beeref/main_controls.py +++ b/beeref/main_controls.py @@ -23,10 +23,10 @@ from beeref.items import BeePixmapItem from beeref import fileio -logger = logging.getLogger(__name__) +logger = logging.getLogger(__name__) # 主控件日志记录器,用于记录主控件的日志信息 -class MainControlsMixin: +class MainControlsMixin: # 主控件混合类,用于处理主窗口和欢迎叠加层的基本控件 """Basic controls shared by the main view and the welcome overlay: * Right-click menu @@ -34,114 +34,114 @@ class MainControlsMixin: * Moving the window without title bar """ - def init_main_controls(self, main_window): - self.main_window = main_window - self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) - self.customContextMenuRequested.connect( - self.control_target.on_context_menu) - self.setAcceptDrops(True) - self.movewin_active = False + def init_main_controls(self, main_window): # 初始化主控件,用于处理主窗口和欢迎叠加层的基本控件 + self.main_window = main_window # 主窗口引用,用于访问主窗口的属性和方法 + self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) # 设置上下文菜单策略为自定义上下文菜单,用于处理右键点击事件 + self.customContextMenuRequested.connect( # 自定义上下文菜单请求信号槽函数,用于处理右键点击事件 + self.control_target.on_context_menu) # 自定义上下文菜单请求信号槽函数,用于处理右键点击事件,调用控制目标的上下文菜单槽函数 + self.setAcceptDrops(True) # 设置接受拖放事件,用于处理文件拖放事件 + self.movewin_active = False - def on_action_movewin_mode(self): - if self.movewin_active: + def on_action_movewin_mode(self): # 移动窗口模式槽函数,用于处理移动窗口模式的切换 + if self.movewin_active: # 如果移动窗口模式已激活 # Pressing the same shortcut again should end the action - self.exit_movewin_mode() + self.exit_movewin_mode() # 退出移动窗口模式槽函数,用于处理移动窗口模式的切换 else: - self.enter_movewin_mode() + self.enter_movewin_mode() # 进入移动窗口模式槽函数,用于处理移动窗口模式的切换 - @property - def viewport_or_self(self): - if hasattr(self, 'viewport'): - return self.viewport() - return self + @property # viewport_or_self属性,用于返回视口或自身,用于处理移动窗口模式的切换 + def viewport_or_self(self): # viewport_or_self属性,用于返回视口或自身,用于处理移动窗口模式的切换 + if hasattr(self, 'viewport'): # 如果主控件有视口属性 + return self.viewport() # 返回视口 + return self # 返回自身 - def enter_movewin_mode(self): - logger.debug('Entering movewin mode') - self.setMouseTracking(True) - self.movewin_active = True - self.viewport_or_self.setCursor(Qt.CursorShape.SizeAllCursor) - self.event_start = QtCore.QPointF(self.cursor().pos()) - if hasattr(self, 'disable_mouse_events'): - self.disable_mouse_events() + def enter_movewin_mode(self): # 进入移动窗口模式槽函数,用于处理移动窗口模式的切换 + logger.debug('Entering movewin mode') # 进入移动窗口模式槽函数,用于处理移动窗口模式的切换 + self.setMouseTracking(True) # 设置鼠标跟踪,用于处理移动窗口模式的切换 + self.movewin_active = True # 移动窗口模式已激活 + self.viewport_or_self.setCursor(Qt.CursorShape.SizeAllCursor) # 设置视口或自身的光标为大小调整光标,用于处理移动窗口模式的切换 + self.event_start = QtCore.QPointF(self.cursor().pos()) # 记录鼠标点击位置,用于处理移动窗口模式的切换 + if hasattr(self, 'disable_mouse_events'): # 如果主控件有禁用鼠标事件属性 + self.disable_mouse_events() # 禁用鼠标事件,用于处理移动窗口模式的切换 - def exit_movewin_mode(self): - logger.debug('Exiting movewin mode') - self.setMouseTracking(False) - self.movewin_active = False - self.viewport_or_self.unsetCursor() - if hasattr(self, 'enable_mouse_events'): - self.enable_mouse_events() + def exit_movewin_mode(self): # 退出移动窗口模式槽函数,用于处理移动窗口模式的切换 + logger.debug('Exiting movewin mode') # 退出移动窗口模式槽函数,用于处理移动窗口模式的切换 + self.setMouseTracking(False) # 退出移动窗口模式槽函数,用于处理移动窗口模式的切换 + self.movewin_active = False # 移动窗口模式未激活 + self.viewport_or_self.unsetCursor() # 退出移动窗口模式槽函数,用于处理移动窗口模式的切换 + if hasattr(self, 'enable_mouse_events'): # 如果主控件有启用鼠标事件属性 + self.enable_mouse_events() # 启用鼠标事件,用于处理移动窗口模式的切换 - def dragEnterEvent(self, event): - mimedata = event.mimeData() - logger.debug(f'Drag enter event: {mimedata.formats()}') - if mimedata.hasUrls(): - event.acceptProposedAction() - elif mimedata.hasImage(): - event.acceptProposedAction() + def dragEnterEvent(self, event): # 拖入事件槽函数,用于处理拖入事件 + mimedata = event.mimeData() # 获取拖入事件的MIME数据,用于处理拖入事件 + logger.debug(f'Drag enter event: {mimedata.formats()}') # 拖入事件槽函数,用于处理拖入事件,记录拖入事件的MIME数据格式 + if mimedata.hasUrls(): # 如果拖入事件的MIME数据包含URL + event.acceptProposedAction() # 接受拖入事件的建议操作,用于处理拖入事件 + elif mimedata.hasImage(): # 如果拖入事件的MIME数据包含图像 + event.acceptProposedAction() # 接受拖入事件的建议操作,用于处理拖入事件 else: - msg = 'Attempted drop not an image or image too big' - logger.info(msg) - widgets.BeeNotification(self.control_target, msg) + msg = 'Attempted drop not an image or image too big' # 拖入事件槽函数,用于处理拖入事件,记录拖入事件的MIME数据格式 + logger.info(msg) # 拖入事件槽函数,用于处理拖入事件,记录拖入事件的MIME数据格式 + widgets.BeeNotification(self.control_target, msg) # 拖入事件槽函数,用于处理拖入事件,显示拖入事件的MIME数据格式 - def dragMoveEvent(self, event): - event.acceptProposedAction() + def dragMoveEvent(self, event): # 拖动移动事件槽函数,用于处理拖动移动事件 + event.acceptProposedAction() # 接受拖动移动事件的建议操作,用于处理拖动移动事件 - def dropEvent(self, event): - mimedata = event.mimeData() - logger.debug(f'Handling file drop: {mimedata.formats()}') - pos = QtCore.QPoint(round(event.position().x()), - round(event.position().y())) - if mimedata.hasUrls(): - logger.debug(f'Found dropped urls: {mimedata.urls()}') - if not self.control_target.scene.items(): + def dropEvent(self, event): # 拖放事件槽函数,用于处理拖放事件 + mimedata = event.mimeData() # 获取拖放事件的MIME数据,用于处理拖放事件 + logger.debug(f'Handling file drop: {mimedata.formats()}') # 拖放事件槽函数,用于处理拖放事件,记录拖放事件的MIME数据格式 + pos = QtCore.QPoint(round(event.position().x()), # 记录拖放事件的位置,用于处理拖放事件 + round(event.position().y())) # 记录拖放事件的位置,用于处理拖放事件 + if mimedata.hasUrls(): # 如果拖放事件的MIME数据包含URL + logger.debug(f'Found dropped urls: {mimedata.urls()}') # 拖放事件槽函数,用于处理拖放事件,记录拖放事件的URL + if not self.control_target.scene.items(): # 如果场景中没有项目(即没有插入的图片项) # Check if we have a bee file we can open directly - path = mimedata.urls()[0] - if (path.isLocalFile() - and fileio.is_bee_file(path.toLocalFile())): - self.control_target.open_from_file(path.toLocalFile()) - return - self.control_target.do_insert_images(mimedata.urls(), pos) - elif mimedata.hasImage(): - img = QtGui.QImage(mimedata.imageData()) - item = BeePixmapItem(img) - pos = self.control_target.mapToScene(pos) + path = mimedata.urls()[0] # 获取拖放事件的URL,用于处理拖放事件 + if (path.isLocalFile() # 如果拖放事件的URL是本地文件 + and fileio.is_bee_file(path.toLocalFile())): # 如果拖放事件的URL是本地文件,且是BeeRef文件 + self.control_target.open_from_file(path.toLocalFile()) # 打开拖放事件的URL,用于处理拖放事件 + return # 如果拖放事件的URL是本地文件,且是BeeRef文件,直接打开文件,不进行插入图片项操作 + self.control_target.do_insert_images(mimedata.urls(), pos) # 插入图片项槽函数,用于处理拖放事件,插入拖放事件的URL对应的图片项 + elif mimedata.hasImage(): # 如果拖放事件的MIME数据包含图像 + img = QtGui.QImage(mimedata.imageData()) # 获取拖放事件的图像数据,用于处理拖放事件 + item = BeePixmapItem(img) # 创建图片项,用于处理拖放事件 + pos = self.control_target.mapToScene(pos) # 记录拖放事件的位置,用于处理拖放事件 self.control_target.undo_stack.push( - commands.InsertItems(self.control_target.scene, [item], pos)) + commands.InsertItems(self.control_target.scene, [item], pos)) # 插入图片项槽函数,用于处理拖放事件,插入拖放事件的URL对应的图片项 else: - logger.info('Drop not an image') + logger.info('Drop not an image') # 拖放事件槽函数,用于处理拖放事件,记录拖放事件的MIME数据格式 - def mousePressEventMainControls(self, event): - if self.movewin_active: - self.exit_movewin_mode() - event.accept() - return True + def mousePressEventMainControls(self, event): # 鼠标按下事件槽函数,用于处理鼠标按下事件 + if self.movewin_active: # 如果移动窗口模式激活 + self.exit_movewin_mode() # 退出移动窗口模式槽函数,用于处理移动窗口模式的切换 + event.accept() # 接受鼠标按下事件的建议操作,用于处理鼠标按下事件 + return True # 如果移动窗口模式激活,直接返回True,不进行其他操作 action, inverted =\ - self.control_target.keyboard_settings.mouse_action_for_event(event) + self.control_target.keyboard_settings.mouse_action_for_event(event) # 获取鼠标按下事件对应的操作,用于处理鼠标按下事件 if action == 'movewindow': - self.enter_movewin_mode() - event.accept() - return True + self.enter_movewin_mode() # 进入移动窗口模式槽函数,用于处理移动窗口模式的切换 + event.accept() # 接受鼠标按下事件的建议操作,用于处理鼠标按下事件 + return True # 如果移动窗口模式激活,直接返回True,不进行其他操作 - def mouseMoveEventMainControls(self, event): - if self.movewin_active: - pos = self.mapToGlobal(event.position()) - delta = pos - self.event_start - self.event_start = pos - self.main_window.move(self.main_window.x() + int(delta.x()), - self.main_window.y() + int(delta.y())) - event.accept() - return True + def mouseMoveEventMainControls(self, event): # 鼠标移动事件槽函数,用于处理鼠标移动事件 + if self.movewin_active: # 如果移动窗口模式激活 + pos = self.mapToGlobal(event.position()) # 获取鼠标移动事件的全局位置,用于处理鼠标移动事件 + delta = pos - self.event_start # 计算鼠标移动事件的 delta 向量,用于处理鼠标移动事件 + self.event_start = pos # 更新鼠标移动事件的起始位置,用于处理鼠标移动事件 + self.main_window.move(self.main_window.x() + int(delta.x()), # 移动主窗口槽函数,用于处理移动窗口模式的切换 + self.main_window.y() + int(delta.y())) # 移动主窗口槽函数,用于处理移动窗口模式的切换 + event.accept() # 接受鼠标移动事件的建议操作,用于处理鼠标移动事件 + return True # 如果移动窗口模式激活,直接返回True,不进行其他操作 - def mouseReleaseEventMainControls(self, event): - if self.movewin_active: - self.exit_movewin_mode() - event.accept() - return True + def mouseReleaseEventMainControls(self, event): # 鼠标释放事件槽函数,用于处理鼠标释放事件 + if self.movewin_active: # 如果移动窗口模式激活 + self.exit_movewin_mode() # 退出移动窗口模式槽函数,用于处理移动窗口模式的切换 + event.accept() # 接受鼠标释放事件的建议操作,用于处理鼠标释放事件 + return True # 如果移动窗口模式激活,直接返回True,不进行其他操作 - def keyPressEventMainControls(self, event): - if self.movewin_active: - self.exit_movewin_mode() - event.accept() - return True + def keyPressEventMainControls(self, event): # 键盘按下事件槽函数,用于处理键盘按下事件 + if self.movewin_active: # 如果移动窗口模式激活 + self.exit_movewin_mode() # 退出移动窗口模式槽函数,用于处理移动窗口模式的切换 + event.accept() # 接受键盘按下事件的建议操作,用于处理键盘按下事件 + return True # 如果移动窗口模式激活,直接返回True,不进行其他操作 diff --git a/beeref/scene.py b/beeref/scene.py index 56325c2..eeda235 100644 --- a/beeref/scene.py +++ b/beeref/scene.py @@ -13,552 +13,555 @@ # You should have received a copy of the GNU General Public License # along with BeeRef. If not, see . -from functools import partial -import logging -import math -from queue import Queue +from functools import partial # 导入偏函数,用于固定函数参数创建新函数 +import logging # 导入日志模块,用于记录程序运行时的日志信息 +import math # 导入数学模块,用于数学计算(如三角函数、平方根等) +from queue import Queue # 导入队列数据结构,用于线程间通信或任务排队 -from PyQt6 import QtCore, QtWidgets, QtGui -from PyQt6.QtCore import Qt +from PyQt6 import QtCore, QtWidgets, QtGui # 导入PyQt6的核心模块、控件模块和GUI模块 +from PyQt6.QtCore import Qt # 导入Qt常量定义(如对齐方式、事件类型等) -import rpack +import rpack # 导入矩形打包库,用于高效排列矩形元素 -from beeref import commands -from beeref.config import BeeSettings -from beeref.items import item_registry, BeeErrorItem, sort_by_filename -from beeref.selection import MultiSelectItem, RubberbandItem +from beeref import commands # 导入beeref项目的命令模块,处理用户操作的命令实现 +from beeref.config import BeeSettings # 导入配置设置类,管理应用程序的用户配置 +from beeref.items import item_registry, BeeErrorItem, sort_by_filename # 导入项目项相关组件:注册表、错误项和文件名排序函数 +from beeref.selection import MultiSelectItem, RubberbandItem # 导入选择相关类:多选项和橡皮筋选择框 -logger = logging.getLogger(__name__) +logger = logging.getLogger(__name__) # 创建当前模块的日志记录器 -class BeeGraphicsScene(QtWidgets.QGraphicsScene): - cursor_changed = QtCore.pyqtSignal(QtGui.QCursor) - cursor_cleared = QtCore.pyqtSignal() +class BeeGraphicsScene(QtWidgets.QGraphicsScene): # 定义场景类,继承自QGraphicsScene + cursor_changed = QtCore.pyqtSignal(QtGui.QCursor) # 定义光标变化信号 + cursor_cleared = QtCore.pyqtSignal() # 定义光标清除信号 - MOVE_MODE = 1 - RUBBERBAND_MODE = 2 + MOVE_MODE = 1 # 定义移动模式常量 + RUBBERBAND_MODE = 2 # 定义橡皮筋选择模式常量 - def __init__(self, undo_stack): - super().__init__() - self.active_mode = None - self.undo_stack = undo_stack - self.max_z = 0 - self.min_z = 0 - self.Z_STEP = 0.001 - self.selectionChanged.connect(self.on_selection_change) - self.changed.connect(self.on_change) - self.items_to_add = Queue() - self.edit_item = None - self.crop_item = None - self.settings = BeeSettings() - self.clear() - self._clear_ongoing = False + def __init__(self, undo_stack): # 初始化方法,接收撤销栈参数 + super().__init__() # 调用父类的初始化方法 + self.active_mode = None # 初始化当前活动模式为None + self.undo_stack = undo_stack # 保存撤销栈引用 + self.max_z = 0 # 初始化最大Z轴值 + self.min_z = 0 # 初始化最小Z轴值 + self.Z_STEP = 0.001 # 设置Z轴步长 + self.selectionChanged.connect(self.on_selection_change) # 连接选择变化信号到处理函数 + self.changed.connect(self.on_change) # 连接场景变化信号到处理函数 + self.items_to_add = Queue() # 初始化待添加项目队列 + self.edit_item = None # 初始化当前编辑项目为None + self.crop_item = None # 初始化裁剪项目为None + self.settings = BeeSettings() # 初始化设置对象 + self.clear() # 清除场景内容 + self._clear_ongoing = False # 初始化清除操作标志为False def clear(self): - self._clear_ongoing = True - super().clear() - self.internal_clipboard = [] - self.rubberband_item = RubberbandItem() - self.multi_select_item = MultiSelectItem() - self._clear_ongoing = False + self._clear_ongoing = True # 设置清理进行中标志,避免清理过程中触发其他依赖状态的逻辑 + super().clear() # 调用父类的清除方法,清除场景中的所有项目 + self.internal_clipboard = [] # 初始化内部剪贴板为空列表,用于存储复制到剪贴板的项目 + self.rubberband_item = RubberbandItem() # 重新创建橡皮筋选择框实例,重置选择框状态 + self.multi_select_item = MultiSelectItem() # 重新创建多选项实例,重置多选项状态 + self._clear_ongoing = False # 清除操作完成,重置清除进行中标志 - def addItem(self, item): - logger.debug(f'Adding item {item}') - super().addItem(item) + def addItem(self, item): # 添加项目到场景 + logger.debug(f'Adding item {item}') # 记录添加项目的调试日志 + super().addItem(item) # 调用父类的添加项目方法,将项目添加到场景中 - def removeItem(self, item): - logger.debug(f'Removing item {item}') - super().removeItem(item) + def removeItem(self, item): # 从场景中移除项目 + logger.debug(f'Removing item {item}') # 记录移除项目的调试日志 + super().removeItem(item) # 调用父类的移除项目方法,将项目从场景中移除 - def cancel_active_modes(self): + def cancel_active_modes(self): # 取消所有活动模式 """Cancels ongoing crop modes, rubberband modes etc, if there are any. """ - self.cancel_crop_mode() - self.end_rubberband_mode() + self.cancel_crop_mode() # 取消当前的裁剪模式 + self.end_rubberband_mode() # 结束当前的橡皮筋选择模式 - def end_rubberband_mode(self): - if self.rubberband_item.scene(): - logger.debug('Ending rubberband selection') - self.removeItem(self.rubberband_item) - self.active_mode = None + def end_rubberband_mode(self): # 结束橡皮筋选择模式 + if self.rubberband_item.scene(): # 如果橡皮筋选择框存在于场景中 + logger.debug('Ending rubberband selection') # 记录结束橡皮筋选择模式的调试日志 + self.removeItem(self.rubberband_item) # 从场景中移除橡皮筋选择框 + self.active_mode = None # 重置当前活动模式为None - def cancel_crop_mode(self): - """Cancels an ongoing crop mode, if there is any.""" - if self.crop_item: - self.crop_item.exit_crop_mode(confirm=False) + def cancel_crop_mode(self): # 取消当前的裁剪模式 + """Cancels an ongoing crop mode, if there is any.""" + if self.crop_item: # 如果存在裁剪项目 + self.crop_item.exit_crop_mode(confirm=False) # 退出裁剪模式,不确认裁剪 - def copy_selection_to_internal_clipboard(self): - self.internal_clipboard = [] - for item in self.selectedItems(user_only=True): - self.internal_clipboard.append(item) + def copy_selection_to_internal_clipboard(self): # 复制当前选择的项目到内部剪贴板 + self.internal_clipboard = [] # 清空内部剪贴板 + for item in self.selectedItems(user_only=True): # 遍历当前用户选择的项目 + self.internal_clipboard.append(item) # 将项目添加到内部剪贴板 - def paste_from_internal_clipboard(self, position): - copies = [] + def paste_from_internal_clipboard(self, position): # 从内部剪贴板粘贴项目到指定位置 + copies = [] # 创建一个空列表,用于存储复制的项目 for item in self.internal_clipboard: - copy = item.create_copy() - copies.append(copy) - self.undo_stack.push(commands.InsertItems(self, copies, position)) + copy = item.create_copy() # 创建当前项目的副本 + copies.append(copy) # 将副本添加到复制列表中 + self.undo_stack.push(commands.InsertItems(self, copies, position)) # 将复制操作添加到撤销栈中 - def raise_to_top(self): - self.cancel_active_modes() - items = self.selectedItems(user_only=True) - z_values = map(lambda i: i.zValue(), items) - delta = self.max_z + self.Z_STEP - min(z_values) - logger.debug(f'Raise to top, delta: {delta}') - for item in items: - item.setZValue(item.zValue() + delta) + def raise_to_top(self): # 将当前选择的项目提升到顶部 + self.cancel_active_modes() # 取消所有活动模式,确保没有正在进行的操作干扰 + items = self.selectedItems(user_only=True) # 获取当前用户选择的项目 + z_values = map(lambda i: i.zValue(), items) # 提取项目的Z轴值 + delta = self.max_z + self.Z_STEP - min(z_values) # 计算需要提升的Z轴值增量 + logger.debug(f'Raise to top, delta: {delta}') # 记录提升到顶部的调试日志 + for item in items: # 遍历选择的项目 + item.setZValue(item.zValue() + delta) # 提升项目的Z轴值,将其提升到顶部 - def lower_to_bottom(self): - self.cancel_active_modes() - items = self.selectedItems(user_only=True) - z_values = map(lambda i: i.zValue(), items) - delta = self.min_z - self.Z_STEP - max(z_values) - logger.debug(f'Lower to bottom, delta: {delta}') + def lower_to_bottom(self): # 将当前选择的项目降低到底部 + self.cancel_active_modes() # 取消所有活动模式,确保没有正在进行的操作干扰 + items = self.selectedItems(user_only=True) # 获取当前用户选择的项目 + z_values = map(lambda i: i.zValue(), items) # 提取项目的Z轴值 + delta = self.min_z - self.Z_STEP - max(z_values) # 计算需要降低的Z轴值增量 + logger.debug(f'Lower to bottom, delta: {delta}') # 记录降低到底部的调试日志 - for item in items: - item.setZValue(item.zValue() + delta) + for item in items: # 遍历选择的项目 + item.setZValue(item.zValue() + delta) # 降低项目的Z轴值,将其降低到底部 - def normalize_width_or_height(self, mode): + def normalize_width_or_height(self, mode): # 归一化选择项目的宽度或高度 """Scale the selected images to have the same width or height, as specified by ``mode``. :param mode: "width" or "height". """ - self.cancel_active_modes() - values = [] - items = self.selectedItems(user_only=True) - for item in items: - rect = self.itemsBoundingRect(items=[item]) - values.append(getattr(rect, mode)()) - if len(values) < 2: - return - avg = sum(values) / len(values) - logger.debug(f'Calculated average {mode} {avg}') + self.cancel_active_modes() # 取消所有活动模式,确保没有正在进行的操作干扰 + values = [] # 创建一个空列表,用于存储选择项目的宽度或高度值 + items = self.selectedItems(user_only=True) # 获取当前用户选择的项目 + for item in items: # 遍历选择的项目 + rect = self.itemsBoundingRect(items=[item]) # 获取项目的边界矩形 + values.append(getattr(rect, mode)()) # 提取矩形的宽度或高度值,并添加到列表中 + if len(values) < 2: # 如果选择的项目数量小于2 + return # 直接返回,无需归一化 + avg = sum(values) / len(values) # 计算宽度或高度的平均值 + logger.debug(f'Calculated average {mode} {avg}') # 记录归一化的平均值调试日志 - scale_factors = [] - for item in items: - rect = self.itemsBoundingRect(items=[item]) - scale_factors.append(avg / getattr(rect, mode)()) + scale_factors = [] # 创建一个空列表,用于存储每个项目的归一化缩放因子 + for item in items: # 遍历选择的项目 + rect = self.itemsBoundingRect(items=[item]) # 获取项目的边界矩形 + scale_factors.append(avg / getattr(rect, mode)()) # 计算项目的归一化缩放因子,并添加到列表中 self.undo_stack.push( - commands.NormalizeItems(items, scale_factors)) + commands.NormalizeItems(items, scale_factors)) # 将归一化操作添加到撤销栈中 - def normalize_height(self): + def normalize_height(self): # 归一化选择项目的高度 """Scale selected images to the same height.""" - return self.normalize_width_or_height('height') + return self.normalize_width_or_height('height') # 归一化选择项目的高度 - def normalize_width(self): + def normalize_width(self): # 归一化选择项目的宽度 """Scale selected images to the same width.""" - return self.normalize_width_or_height('width') + return self.normalize_width_or_height('width') # 归一化选择项目的宽度 - def normalize_size(self): + def normalize_size(self): # 归一化选择项目的大小 """Scale selected images to the same size. Size meaning the area = widh * height. """ - self.cancel_active_modes() - sizes = [] - items = self.selectedItems(user_only=True) - for item in items: - rect = self.itemsBoundingRect(items=[item]) - sizes.append(rect.width() * rect.height()) + self.cancel_active_modes() # 取消所有活动模式,确保没有正在进行的操作干扰 + sizes = [] # 创建一个空列表,用于存储选择项目的面积值 + items = self.selectedItems(user_only=True) # 获取当前用户选择的项目 + for item in items: # 遍历选择的项目 + rect = self.itemsBoundingRect(items=[item]) # 获取项目的边界矩形 + sizes.append(rect.width() * rect.height()) # 计算项目的面积值,并添加到列表中 - if len(sizes) < 2: - return + if len(sizes) < 2: # 如果选择的项目数量小于2 + return # 直接返回,无需归一化 - avg = sum(sizes) / len(sizes) - logger.debug(f'Calculated average size {avg}') + avg = sum(sizes) / len(sizes) # 计算选择项目的面积平均值 + logger.debug(f'Calculated average size {avg}') # 记录归一化的平均值调试日志 - scale_factors = [] - for item in items: - rect = self.itemsBoundingRect(items=[item]) - scale_factors.append(math.sqrt(avg / rect.width() / rect.height())) + scale_factors = [] # 创建一个空列表,用于存储每个项目的归一化缩放因子 + for item in items: # 遍历选择的项目 + rect = self.itemsBoundingRect(items=[item]) # 获取项目的边界矩形 + scale_factors.append(math.sqrt(avg / rect.width() / rect.height())) # 计算项目的归一化缩放因子,并添加到列表中 self.undo_stack.push( - commands.NormalizeItems(items, scale_factors)) + commands.NormalizeItems(items, scale_factors)) # 将归一化操作添加到撤销栈中 - def arrange_default(self): - default = self.settings.valueOrDefault('Items/arrange_default') + + + def arrange_default(self): # 按默认方式排列选择的项目 + default = self.settings.valueOrDefault('Items/arrange_default') # 获取默认的排列方式 MAPPING = { - 'optimal': self.arrange_optimal, - 'horizontal': self.arrange, - 'vertical': partial(self.arrange, vertical=True), - 'square': self.arrange_square, + 'optimal': self.arrange_optimal, # 最优排列方式 + 'horizontal': self.arrange, # 水平排列方式 + 'vertical': partial(self.arrange, vertical=True), # 垂直排列方式 + 'square': self.arrange_square, # 正方形排列方式 } - MAPPING[default]() + MAPPING[default]() # 调用默认的排列方式 - def arrange(self, vertical=False): - """Arrange items in a line (horizontally or vertically).""" + def arrange(self, vertical=False): # 按指定方式排列选择的项目 + """Arrange items in a line (horizontally or vertically).""" # 按指定方式排列选择的项目 - self.cancel_active_modes() + self.cancel_active_modes() # 取消所有活动模式,确保没有正在进行的操作干扰 - items = sort_by_filename(self.selectedItems(user_only=True)) - if len(items) < 2: - return + items = sort_by_filename(self.selectedItems(user_only=True)) # 获取当前用户选择的项目,并按文件名排序 + if len(items) < 2:# 如果选择的项目数量小于2 + return # 直接返回,无需排列 - gap = self.settings.valueOrDefault('Items/arrange_gap') - center = self.get_selection_center() - positions = [] - rects = [] - for item in items: - rects.append({ - 'rect': self.itemsBoundingRect(items=[item]), - 'item': item}) + gap = self.settings.valueOrDefault('Items/arrange_gap') # 获取项目之间的间距 + center = self.get_selection_center() # 获取选择项目的中心位置 + positions = [] # 创建一个空列表,用于存储每个项目的新位置 + rects = [] # 创建一个空列表,用于存储每个项目的边界矩形和项目本身 + for item in items: # 遍历选择的项目 + rects.append({ # 为每个项目创建一个字典,包含项目的边界矩形和项目本身 + 'rect': self.itemsBoundingRect(items=[item]), # 获取项目的边界矩形 + 'item': item}) # 包含项目的边界矩形和项目本身 - if vertical: - rects.sort(key=lambda r: r['rect'].topLeft().y()) - sum_height = sum(map(lambda r: r['rect'].height(), rects)) - y = round(center.y() - sum_height/2) - for rect in rects: - positions.append( - QtCore.QPointF( - round(center.x() - rect['rect'].width()/2), y)) - y += rect['rect'].height() + gap + if vertical: # 如果按垂直方向排列 + rects.sort(key=lambda r: r['rect'].topLeft().y()) # 按项目的顶部坐标排序 + sum_height = sum(map(lambda r: r['rect'].height(), rects)) # 计算所有项目的总高度 + y = round(center.y() - sum_height/2) # 计算垂直方向上的起始位置 + for rect in rects: # 遍历每个项目 + positions.append( # 为每个项目计算新的位置 + QtCore.QPointF( # 计算每个项目的新位置 + round(center.x() - rect['rect'].width()/2), y)) # 计算每个项目的新位置 + y += rect['rect'].height() + gap # 更新垂直方向上的位置,考虑项目高度和间距 - else: - rects.sort(key=lambda r: r['rect'].topLeft().x()) - sum_width = sum(map(lambda r: r['rect'].width(), rects)) - x = round(center.x() - sum_width/2) - for rect in rects: - positions.append( - QtCore.QPointF( - x, round(center.y() - rect['rect'].height()/2))) - x += rect['rect'].width() + gap + else: # 如果按水平方向排列 + rects.sort(key=lambda r: r['rect'].topLeft().x()) # 按项目的左侧坐标排序 + sum_width = sum(map(lambda r: r['rect'].width(), rects)) # 计算所有项目的总宽度 + x = round(center.x() - sum_width/2) # 计算水平方向上的起始位置 + for rect in rects: # 遍历每个项目 + positions.append( # 为每个项目计算新的位置 + QtCore.QPointF( # 计算每个项目的新位置 + x, round(center.y() - rect['rect'].height()/2))) # 计算每个项目的新位置 + x += rect['rect'].width() + gap # 更新水平方向上的位置,考虑项目宽度和间距 - self.undo_stack.push( - commands.ArrangeItems(self, - [r['item'] for r in rects], - positions)) + self.undo_stack.push( # 将排列操作添加到撤销栈中 + commands.ArrangeItems(self, # 创建一个排列项目的命令对象 + [r['item'] for r in rects], # 包含所有要排列的项目 + positions)) # 包含所有项目的新位置 - def arrange_optimal(self): - self.cancel_active_modes() + def arrange_optimal(self): # 按最优方式排列选择的项目 + self.cancel_active_modes() # 取消所有活动模式,确保没有正在进行的操作干扰 - items = self.selectedItems(user_only=True) - if len(items) < 2: - return + items = self.selectedItems(user_only=True) # 获取当前用户选择的项目 + if len(items) < 2: # 如果选择的项目数量小于2 + return # 直接返回,无需排列 - gap = self.settings.valueOrDefault('Items/arrange_gap') + gap = self.settings.valueOrDefault('Items/arrange_gap') # 获取项目之间的间距 - sizes = [] - for item in items: - rect = self.itemsBoundingRect(items=[item]) - sizes.append((round(rect.width() + gap), - round(rect.height() + gap))) + sizes = [] # 创建一个空列表,用于存储每个项目的宽度和高度 + for item in items: # 遍历选择的项目 + rect = self.itemsBoundingRect(items=[item]) # 获取项目的边界矩形 + sizes.append((round(rect.width() + gap), # 包含每个项目的宽度和高度 + round(rect.height() + gap))) # 包含每个项目的宽度和高度 # The minimal area the items need if they could be packed optimally; # we use this as a starting shape for the packing algorithm - min_area = sum(map(lambda s: s[0] * s[1], sizes)) - width = math.ceil(math.sqrt(min_area)) + min_area = sum(map(lambda s: s[0] * s[1], sizes)) # 计算所有项目的最小面积 + width = math.ceil(math.sqrt(min_area)) # 计算最小面积的平方根,向上取整作为初始宽度 - positions = None - while not positions: - try: - positions = rpack.pack( + positions = None # 初始化位置列表为None + while not positions: # 循环直到成功打包项目 + try: # 尝试使用当前宽度打包项目 + positions = rpack.pack( sizes, max_width=width, max_height=width) except rpack.PackingImpossibleError: - width = math.ceil(width * 1.2) + width = math.ceil(width * 1.2) # 如果打包失败,将宽度增加20%,并向上取整 # We want the items to center around the selection's center, # not (0, 0) - center = self.get_selection_center() - bounds = rpack.bbox_size(sizes, positions) - diff = center - QtCore.QPointF(bounds[0]/2, bounds[1]/2) - positions = [QtCore.QPointF(*pos) + diff for pos in positions] + center = self.get_selection_center() # 获取选择项目的中心位置 + bounds = rpack.bbox_size(sizes, positions) # 计算打包后的边界矩形大小 + diff = center - QtCore.QPointF(bounds[0]/2, bounds[1]/2) # 计算中心位置与边界矩形中心位置的差值 + positions = [QtCore.QPointF(*pos) + diff for pos in positions] # 为每个项目计算新的位置,考虑中心位置的偏移 - self.undo_stack.push(commands.ArrangeItems(self, items, positions)) + self.undo_stack.push(commands.ArrangeItems(self, items, positions)) # 将排列操作添加到撤销栈中 - def arrange_square(self): - self.cancel_active_modes() - max_width = 0 - max_height = 0 - gap = self.settings.valueOrDefault('Items/arrange_gap') - items = sort_by_filename(self.selectedItems(user_only=True)) + def arrange_square(self): # 按正方形方式排列选择的项目 + self.cancel_active_modes() # 取消所有活动模式,确保没有正在进行的操作干扰 + max_width = 0 # 初始化最大宽度为0 + max_height = 0 # 初始化最大高度为0 + gap = self.settings.valueOrDefault('Items/arrange_gap') # 获取项目之间的间距 + items = sort_by_filename(self.selectedItems(user_only=True)) # 获取当前用户选择的项目,并按文件名排序 - if len(items) < 2: - return + if len(items) < 2: # 如果选择的项目数量小于2 + return # 直接返回,无需排列 - for item in items: - rect = self.itemsBoundingRect(items=[item]) - max_width = max(max_width, rect.width() + gap) - max_height = max(max_height, rect.height() + gap) + for item in items: # 遍历选择的项目 + rect = self.itemsBoundingRect(items=[item]) # 获取项目的边界矩形 + max_width = max(max_width, rect.width() + gap) # 更新最大宽度,考虑项目宽度和间距 + max_height = max(max_height, rect.height() + gap) # 更新最大高度,考虑项目高度和间距 # We want the items to center around the selection's center, # not (0, 0) - num_rows = math.ceil(math.sqrt(len(items))) - center = self.get_selection_center() - diff = center - num_rows/2 * QtCore.QPointF(max_width, max_height) + num_rows = math.ceil(math.sqrt(len(items))) # 计算需要的行数,向上取整 + center = self.get_selection_center() # 获取选择项目的中心位置 + diff = center - num_rows/2 * QtCore.QPointF(max_width, max_height) # 计算中心位置与边界矩形中心位置的差值 - iter_items = iter(items) - positions = [] - for j in range(num_rows): - for i in range(num_rows): + iter_items = iter(items) # 创建一个迭代器,用于遍历选择的项目 + positions = [] # 创建一个空列表,用于存储每个项目的新位置 + for j in range(num_rows):# 遍历行数 + for i in range(num_rows):# 遍历列数 try: - item = next(iter_items) - rect = self.itemsBoundingRect(items=[item]) - point = QtCore.QPointF( - i * max_width + (max_width - rect.width())/2, - j * max_height + (max_height - rect.height())/2) - positions.append(point + diff) - except StopIteration: - break + item = next(iter_items) # 从迭代器中获取下一个项目 + rect = self.itemsBoundingRect(items=[item]) # 获取项目的边界矩形 + point = QtCore.QPointF( # 计算项目的新位置 + i * max_width + (max_width - rect.width())/2, # 计算项目在当前行的水平位置 + j * max_height + (max_height - rect.height())/2) # 计算项目在当前列的垂直位置 + positions.append(point + diff) # 计算项目的新位置,考虑中心位置的偏移 + except StopIteration:# 当迭代器没有更多项目时,跳出循环 + break # 跳出当前列的循环,继续下一行 - self.undo_stack.push(commands.ArrangeItems(self, items, positions)) + self.undo_stack.push(commands.ArrangeItems(self, items, positions)) # 将排列操作添加到撤销栈中 - def flip_items(self, vertical=False): + def flip_items(self, vertical=False): # 垂直或水平翻转选择的项目 """Flip selected items.""" - self.cancel_active_modes() + self.cancel_active_modes() # 取消所有活动模式,确保没有正在进行的操作干扰 self.undo_stack.push( commands.FlipItems(self.selectedItems(user_only=True), self.get_selection_center(), - vertical=vertical)) + vertical=vertical)) # 将翻转操作添加到撤销栈中 - def crop_items(self): + def crop_items(self): # 对选择的项目进行裁剪 """Crop selected item.""" - if self.crop_item: + if self.crop_item:## 如果当前有项目正在裁剪状态 return - if self.has_single_image_selection(): + if self.has_single_image_selection():# 如果选择的项目是单个图像 item = self.selectedItems(user_only=True)[0] if item.is_image: - item.enter_crop_mode() + item.enter_crop_mode() # 进入图像裁剪模式 - def sample_color_at(self, position): - item_at_pos = self.itemAt(position, self.views()[0].transform()) + def sample_color_at(self, position): # 采样指定位置的颜色 + item_at_pos = self.itemAt(position, self.views()[0].transform()) # 获取指定位置上的项目 if item_at_pos: - return item_at_pos.sample_color_at(position) + return item_at_pos.sample_color_at(position) # 如果项目存在,采样该位置的颜色 - def select_all_items(self): - self.cancel_active_modes() - path = QtGui.QPainterPath() - path.addRect(self.itemsBoundingRect()) + def select_all_items(self): # 选择所有项目 + self.cancel_active_modes() # 取消所有活动模式,确保没有正在进行的操作干扰 + path = QtGui.QPainterPath() # 创建一个空的绘图路径 + path.addRect(self.itemsBoundingRect()) # 创建一个矩形路径,包含所有项目的边界矩形 + path.addRect(self.itemsBoundingRect()) + path.addRect(self.itemsBoundingRect()) # This is faster than looping through all items and calling setSelected - self.setSelectionArea(path) + self.setSelectionArea(path)# 设置选择区域为创建的路径,包含所有项目的边界矩形 - def deselect_all_items(self): - self.cancel_active_modes() - self.clearSelection() + def deselect_all_items(self):# 取消所有项目的选择 + self.cancel_active_modes() # 取消所有活动模式,确保没有正在进行的操作干扰 + self.clearSelection() # 清除所有项目的选择状态 - def has_selection(self): + def has_selection(self):# 检查是否有项目被选择 """Checks whether there are currently items selected.""" - return bool(self.selectedItems(user_only=True)) + return bool(self.selectedItems(user_only=True)) # 返回当前用户选择的项目是否为空 - def has_single_selection(self): + def has_single_selection(self):# 检查是否有单个项目被选择 """Checks whether there's currently exactly one item selected.""" - return len(self.selectedItems(user_only=True)) == 1 + return len(self.selectedItems(user_only=True)) == 1 # 返回当前用户选择的项目是否只有一个 - def has_multi_selection(self): + def has_multi_selection(self):# 检查是否有多个项目被选择 """Checks whether there are currently more than one items selected.""" - return len(self.selectedItems(user_only=True)) > 1 + return len(self.selectedItems(user_only=True)) > 1 # 返回当前用户选择的项目是否有多个 - def has_single_image_selection(self): + def has_single_image_selection(self):# 检查是否有单个图像项目被选择 """Checks whether the current selection is a single image.""" - if self.has_single_selection(): - return self.selectedItems(user_only=True)[0].is_image - return False + if self.has_single_selection(): # 如果只有一个项目被选择 + return self.selectedItems(user_only=True)[0].is_image # 检查该项目是否为图像 + return False # 如果不是图像或没有项目被选择,返回False - def mousePressEvent(self, event): - if event.button() == Qt.MouseButton.RightButton: + def mousePressEvent(self, event):# 处理鼠标按下事件 + if event.button() == Qt.MouseButton.RightButton: # 如果是右键点击 # Right-click invokes the context menu on the # GraphicsView. We don't need it here. return - if event.button() == Qt.MouseButton.LeftButton: - self.event_start = event.scenePos() + if event.button() == Qt.MouseButton.LeftButton: # 如果是左键点击 + self.event_start = event.scenePos() # 记录鼠标按下时的场景位置 item_at_pos = self.itemAt( - event.scenePos(), self.views()[0].transform()) + event.scenePos(), self.views()[0].transform()) # 获取当前鼠标位置上的项目 - if self.edit_item: - if item_at_pos != self.edit_item: - self.edit_item.exit_edit_mode() - else: - super().mousePressEvent(event) - return - if self.crop_item: - if item_at_pos != self.crop_item: - self.cancel_crop_mode() - else: - super().mousePressEvent(event) - return - if item_at_pos: - self.active_mode = self.MOVE_MODE - elif self.items(): - self.active_mode = self.RUBBERBAND_MODE + if self.edit_item and item_at_pos != self.edit_item: # 如果当前有项目正在编辑状态,且点击的项目不是当前编辑项目 + self.edit_item.exit_edit_mode() # 退出当前编辑项目的编辑状态 + elif self.edit_item and item_at_pos == self.edit_item: # 如果当前有项目正在编辑状态,且点击的项目是当前编辑项目 + super().mousePressEvent(event) # 调用父类的鼠标按下事件处理方法,确保正常编辑操作 + return # 结束当前方法,避免后续重复处理 + if self.crop_item and item_at_pos != self.crop_item: # 如果当前有项目正在裁剪状态,且点击的项目不是当前裁剪项目 + self.cancel_crop_mode() # 取消当前裁剪项目的裁剪状态 + elif self.crop_item and item_at_pos == self.crop_item: # 如果当前有项目正在裁剪状态,且点击的项目是当前裁剪项目 + super().mousePressEvent(event) # 调用父类的鼠标按下事件处理方法,确保正常裁剪操作 + return # 结束当前方法,避免后续重复处理 + if item_at_pos: # 如果点击的项目存在 + self.active_mode = self.MOVE_MODE # 切换到移动模式 + elif self.items(): # 如果场景中存在其他项目 + self.active_mode = self.RUBBERBAND_MODE # 切换到橡皮筋选择模式 - super().mousePressEvent(event) + super().mousePressEvent(event)# 调用父类的鼠标按下事件处理方法,确保正常处理 - def mouseDoubleClickEvent(self, event): - self.cancel_active_modes() - item = self.itemAt(event.scenePos(), self.views()[0].transform()) - if item: - if not item.isSelected(): - item.setSelected(True) - if item.is_editable: - item.enter_edit_mode() - self.mousePressEvent(event) - else: + def mouseDoubleClickEvent(self, event):# 处理鼠标双击事件 + self.cancel_active_modes() # 取消所有活动模式,确保没有正在进行的操作干扰 + item = self.itemAt(event.scenePos(), self.views()[0].transform()) # 获取当前鼠标位置上的项目 + if item: # 如果点击的项目存在 + if not item.isSelected(): # 如果项目未被选择 + item.setSelected(True) # 选择该项目 + if item.is_editable: # 如果项目可编辑 + item.enter_edit_mode() # 进入编辑模式 + self.mousePressEvent(event) # 模拟鼠标按下事件,确保编辑模式正常启动 + else: # 如果项目不可编辑 self.views()[0].fit_rect( self.itemsBoundingRect(items=[item]), - toggle_item=item) + toggle_item=item) # 调整场景视图,确保点击的项目在可见区域 return - super().mouseDoubleClickEvent(event) + super().mouseDoubleClickEvent(event) # 调用父类的鼠标双击事件处理方法,确保正常处理 - def mouseMoveEvent(self, event): - if self.active_mode == self.RUBBERBAND_MODE: - if not self.rubberband_item.scene(): - logger.debug('Activating rubberband selection') - self.addItem(self.rubberband_item) - self.rubberband_item.bring_to_front() - self.rubberband_item.fit(self.event_start, event.scenePos()) - self.setSelectionArea(self.rubberband_item.shape()) - self.views()[0].reset_previous_transform() - super().mouseMoveEvent(event) + def mouseMoveEvent(self, event): # 处理鼠标移动事件 + if self.active_mode == self.RUBBERBAND_MODE: # 如果当前模式是橡皮筋选择模式 + if not self.rubberband_item.scene(): # 如果橡皮筋选择项目不在场景中 + logger.debug('Activating rubberband selection') # 记录调试信息,激活橡皮筋选择模式 + self.addItem(self.rubberband_item) # 将橡皮筋选择项目添加到场景中 + self.rubberband_item.bring_to_front() # 将橡皮筋选择项目 bring to front,确保可见 + self.rubberband_item.fit(self.event_start, event.scenePos()) # 调整橡皮筋选择项目的形状,以适应鼠标移动 + self.setSelectionArea(self.rubberband_item.shape()) # 设置场景的选择区域为橡皮筋选择项目的形状 + self.views()[0].reset_previous_transform() # 重置场景视图的前一个变换,确保正常显示 + super().mouseMoveEvent(event) # 调用父类的鼠标移动事件处理方法,确保正常处理 - def mouseReleaseEvent(self, event): - if self.active_mode == self.RUBBERBAND_MODE: - self.end_rubberband_mode() - if (self.active_mode == self.MOVE_MODE - and self.has_selection() - and self.multi_select_item.active_mode is None - and self.selectedItems()[0].active_mode is None): - delta = event.scenePos() - self.event_start - if not delta.isNull(): + def mouseReleaseEvent(self, event): # 处理鼠标释放事件 + if self.active_mode == self.RUBBERBAND_MODE: # 如果当前模式是橡皮筋选择模式 + self.end_rubberband_mode() # 结束橡皮筋选择模式 + if (self.active_mode == self.MOVE_MODE # 如果当前模式是移动模式 + and self.has_selection() # 如果场景中存在选择项目 + and self.multi_select_item.active_mode is None # 如果多选项目的活动模式为空 + and self.selectedItems()[0].active_mode is None): # 如果选择项目的活动模式为空 + delta = event.scenePos() - self.event_start # 计算鼠标移动的距离 + if not delta.isNull(): # 如果鼠标移动距离不为零 self.undo_stack.push( commands.MoveItemsBy(self.selectedItems(), delta, - ignore_first_redo=True)) - self.active_mode = None - super().mouseReleaseEvent(event) + ignore_first_redo=True)) # 记录移动操作到撤销栈,忽略第一次重做 + self.active_mode = None # 切换到无活动模式 + super().mouseReleaseEvent(event) # 调用父类的鼠标释放事件处理方法,确保正常处理 - def selectedItems(self, user_only=False): + def selectedItems(self, user_only=False): # 返回场景中当前选择的项目 """If ``user_only`` is set to ``True``, only return items added by the user (i.e. no multi select outlines and other UI items). User items are items that have a ``save_id`` attribute. """ - items = super().selectedItems() + items = super().selectedItems() # 获取场景中当前选择的所有项目 if user_only: - return list(filter(lambda i: hasattr(i, 'save_id'), items)) - return items + return list(filter(lambda i: hasattr(i, 'save_id'), items)) # 如果只返回用户项目,过滤掉没有 save_id 属性的项目 + return items # 返回所有选择项目 - def items_by_type(self, itype): + def items_by_type(self, itype): # 返回场景中所有指定类型的项目 """Returns all items of the given type.""" return filter(lambda i: getattr(i, 'TYPE', None) == itype, - self.items()) - - def items_for_save(self): + self.items()) # 返回所有类型为 itype 的项目 + def items_for_save(self): # 返回场景中所有可保存的项目 """Returns the items that are to be saved. Items to be saved are items that have a save_id attribute. """ return filter(lambda i: hasattr(i, 'save_id'), - self.items(order=Qt.SortOrder.AscendingOrder)) + self.items(order=Qt.SortOrder.AscendingOrder)) # 返回所有有 save_id 属性的项目 - def clear_save_ids(self): - for item in self.items_for_save(): - item.save_id = None + def clear_save_ids(self): # 清除场景中所有项目的 save_id 属性 + for item in self.items_for_save():# 遍历所有可保存项目 + item.save_id = None # 清除项目的 save_id 属性 - def on_view_scale_change(self): - for item in self.selectedItems(): - item.on_view_scale_change() + def on_view_scale_change(self): # 处理场景视图缩放变化事件 + for item in self.selectedItems(): # 遍历当前选择的项目 + item.on_view_scale_change() # 调用项目的视图缩放变化处理方法,确保正常显示 - def itemsBoundingRect(self, selection_only=False, items=None): + def itemsBoundingRect(self, selection_only=False, items=None): # 返回场景中项目的边界矩形 """Returns the bounding rect of the scene's items; either all of them or only selected ones, or the items givin in ``items``. Re-implemented to not include the items's selection handles. """ - def filter_user_items(ilist): - return list(filter(lambda i: hasattr(i, 'save_id'), ilist)) + def filter_user_items(ilist):# 过滤出场景中所有用户项目(有 save_id 属性的项目) + return list(filter(lambda i: hasattr(i, 'save_id'), ilist)) # 过滤出场景中所有用户项目(有 save_id 属性的项目) - if selection_only: - base = filter_user_items(self.selectedItems()) - elif items: - base = items - else: - base = filter_user_items(self.items()) - if not base: - return QtCore.QRectF(0, 0, 0, 0) - x = [] - y = [] + if selection_only: # 如果只返回选择项目的边界矩形 + base = filter_user_items(self.selectedItems()) # 过滤出当前选择的用户项目 + elif items: # 如果指定了项目列表 + base = items # 使用指定的项目列表 + else: # 如果没有指定项目列表 + base = filter_user_items(self.items()) # 过滤出所有用户项目 - for item in base: - for corner in item.corners_scene_coords: - x.append(corner.x()) - y.append(corner.y()) + if not base: # 如果没有项目可计算边界矩形 + return QtCore.QRectF(0, 0, 0, 0) # 返回空矩形 + + x = [] # 存储所有项目的 x 坐标 + y = [] # 存储所有项目的 y 坐标 + + for item in base: # 遍历所有项目 + for corner in item.corners_scene_coords: # 遍历项目的所有场景坐标 + x.append(corner.x()) # 存储项目的 x 坐标 + y.append(corner.y()) # 存储项目的 y 坐标 return QtCore.QRectF( - QtCore.QPointF(min(x), min(y)), - QtCore.QPointF(max(x), max(y))) + QtCore.QPointF(min(x), min(y)), # 计算所有项目的最小坐标点 + QtCore.QPointF(max(x), max(y))) # 计算所有项目的最大坐标点 - def get_selection_center(self): - rect = self.itemsBoundingRect(selection_only=True) - return (rect.topLeft() + rect.bottomRight()) / 2 + def get_selection_center(self): # 返回场景中当前选择项目的中心坐标 + rect = self.itemsBoundingRect(selection_only=True) # 获取当前选择项目的边界矩形 + return (rect.topLeft() + rect.bottomRight()) / 2 # 返回矩形的中心坐标 - def on_selection_change(self): + def on_selection_change(self): # 处理场景选择变化事件 if self._clear_ongoing: # Ignore events while clearing the scene since the # multiselect item will get cleared, too return - if self.has_multi_selection(): - self.multi_select_item.fit_selection_area( - self.itemsBoundingRect(selection_only=True)) - if self.has_multi_selection() and not self.multi_select_item.scene(): - self.addItem(self.multi_select_item) - self.multi_select_item.bring_to_front() - if not self.has_multi_selection() and self.multi_select_item.scene(): - self.removeItem(self.multi_select_item) + if self.has_multi_selection():# 如果有多个项目被选择 + self.multi_select_item.fit_selection_area( + self.itemsBoundingRect(selection_only=True)) # 调整多选框的大小以适应选择项目的边界矩形 + if self.has_multi_selection() and not self.multi_select_item.scene(): # 如果有多个项目被选择且多选框不在场景中 + self.addItem(self.multi_select_item) # 将多选框添加到场景中 + self.multi_select_item.bring_to_front() # 将多选框 bring_to_front 到最前面 + if not self.has_multi_selection() and self.multi_select_item.scene(): # 如果没有项目被选择且多选框在场景中 + self.removeItem(self.multi_select_item) # 从场景中移除多选框 - def on_change(self, region): - if self._clear_ongoing: + def on_change(self, region): # 处理场景变化事件 + if self._clear_ongoing: # 如果场景正在清除中 # Ignore events while clearing the scene since the # multiselect item will get cleared, too return - if (self.multi_select_item.scene() - and self.multi_select_item.active_mode is None): + if (self.multi_select_item.scene() # 如果多选框在场景中 + and self.multi_select_item.active_mode is None): # 且多选框未激活任何模式 self.multi_select_item.fit_selection_area( - self.itemsBoundingRect(selection_only=True)) + self.itemsBoundingRect(selection_only=True)) # 调整多选框的大小以适应选择项目的边界矩形 - def add_item_later(self, itemdata, selected=False): + def add_item_later(self, itemdata, selected=False): # 保持一个项目稍后添加,通过 ``add_queued_items`` 添加 """Keep an item for adding later via ``add_queued_items`` :param dict itemdata: Defines the item's data :param bool selected: Whether the item is initialised as selected """ - self.items_to_add.put((itemdata, selected)) + self.items_to_add.put((itemdata, selected)) # 将项目数据和选择状态添加到稍后添加队列中 - def add_queued_items(self): + def add_queued_items(self): # 添加稍后添加队列中的项目 """Adds items added via ``add_item_later``""" - while not self.items_to_add.empty(): - data, selected = self.items_to_add.get() - typ = data.pop('type') - cls = item_registry.get(typ) - if not cls: + while not self.items_to_add.empty(): # 循环直到稍后添加队列为空 + data, selected = self.items_to_add.get() # 从稍后添加队列中获取项目数据和选择状态 + typ = data.pop('type') # 从项目数据中弹出项目类型 + cls = item_registry.get(typ) # 从项目类型中获取项目类 + if not cls: # 如果项目类型未知 # Just in case we add new item types in future versions - logger.warning(f'Encountered item of unknown type: {typ}') - cls = BeeErrorItem - data['data'] = {'text': f'Item of unknown type: {typ}'} - item = cls.create_from_data(**data) + logger.warning(f'Encountered item of unknown type: {typ}') # 记录警告日志,提示未知项目类型 + cls = BeeErrorItem # 默认使用 BeeErrorItem 类 + data['data'] = {'text': f'Item of unknown type: {typ}'} # 为未知项目类型添加默认文本数据 + item = cls.create_from_data(**data) # 创建项目实例 # Set the values common to all item types: - item.update_from_data(**data) - self.addItem(item) + item.update_from_data(**data) # 更新项目实例的属性值 + self.addItem(item) # 将项目实例添加到场景中 # Force recalculation of min/max z values: - item.setZValue(item.zValue()) + item.setZValue(item.zValue()) # 强制重新计算项目实例的 z 值 if selected: - item.setSelected(True) - item.bring_to_front() + item.setSelected(True) # 设置项目实例为选中状态 + item.bring_to_front() # 将项目实例 bring_to_front 到最前面 diff --git a/beeref/view.py b/beeref/view.py index 9e40532..a83be7c 100644 --- a/beeref/view.py +++ b/beeref/view.py @@ -34,737 +34,754 @@ from beeref.main_controls import MainControlsMixin from beeref.scene import BeeGraphicsScene from beeref.utils import get_file_extension_from_format, qcolor_to_hex - +# 全局命令行参数 commandline_args = CommandlineArgs() logger = logging.getLogger(__name__) - +# 主视图类,继承自QGraphicsView并混入自定义功能 +# 负责处理用户界面交互、图形渲染和场景管理 class BeeGraphicsView(MainControlsMixin, QtWidgets.QGraphicsView, ActionsMixin): - - PAN_MODE = 1 - ZOOM_MODE = 2 - SAMPLE_COLOR_MODE = 3 + + # 视图模式常量 + PAN_MODE = 1 # 平移模式 + ZOOM_MODE = 2 # 缩放模式 + SAMPLE_COLOR_MODE = 3 # 取色模式 def __init__(self, app, parent=None): - super().__init__(parent) - self.app = app - self.parent = parent - self.settings = BeeSettings() - self.keyboard_settings = KeyboardSettings() - self.welcome_overlay = widgets.welcome_overlay.WelcomeOverlay(self) + + super().__init__(parent)# 初始化父类 + self.app = app# 应用程序对象 + self.parent = parent # 父窗口部件 + self.settings = BeeSettings() # 应用程序设置 + self.keyboard_settings = KeyboardSettings() # 键盘设置 + self.welcome_overlay = widgets.welcome_overlay.WelcomeOverlay(self) # 欢迎 overlay 层 self.setBackgroundBrush( - QtGui.QBrush(QtGui.QColor(*constants.COLORS['Scene:Canvas']))) - self.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing) - self.setFrameShape(QtWidgets.QFrame.Shape.NoFrame) - - self.undo_stack = QtGui.QUndoStack(self) - self.undo_stack.setUndoLimit(100) - self.undo_stack.canRedoChanged.connect(self.on_can_redo_changed) + QtGui.QBrush(QtGui.QColor(*constants.COLORS['Scene:Canvas']))) # 设置场景背景为画布颜色 + self.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing) # 开启反锯齿渲染 + self.setFrameShape(QtWidgets.QFrame.Shape.NoFrame) # 移除边框 + + self.undo_stack = QtGui.QUndoStack(self) # 初始化撤销栈 + self.undo_stack.setUndoLimit(100) # 设置撤销栈最大操作数为 100 + self.undo_stack.canRedoChanged.connect(self.on_can_redo_changed) + # 连接撤销栈的 canRedoChanged 信号到 on_can_redo_changed 槽函数 self.undo_stack.canUndoChanged.connect(self.on_can_undo_changed) + # 连接撤销栈的 canUndoChanged 信号到 on_can_undo_changed 槽函数 self.undo_stack.cleanChanged.connect(self.on_undo_clean_changed) + # 连接撤销栈的 cleanChanged 信号到 on_undo_clean_changed 槽函数 - self.filename = None - self.previous_transform = None - self.active_mode = None + self.filename = None# 当前打开的文件名 + self.previous_transform = None # 上一次的变换矩阵 + self.active_mode = None # 当前激活的模式 - self.scene = BeeGraphicsScene(self.undo_stack) - self.scene.changed.connect(self.on_scene_changed) - self.scene.selectionChanged.connect(self.on_selection_changed) - self.scene.cursor_changed.connect(self.on_cursor_changed) - self.scene.cursor_cleared.connect(self.on_cursor_cleared) - self.setScene(self.scene) + self.scene = BeeGraphicsScene(self.undo_stack) # 初始化场景 + self.scene.changed.connect(self.on_scene_changed) + # 连接场景的 changed 信号到 on_scene_changed 槽函数 + self.scene.selectionChanged.connect(self.on_selection_changed) + # 连接场景的 selectionChanged 信号到 on_selection_changed 槽函数 + self.scene.cursor_changed.connect(self.on_cursor_changed) + # 连接场景的 cursor_changed 信号到 on_cursor_changed 槽函数 + self.scene.cursor_cleared.connect(self.on_cursor_cleared) + # 连接场景的 cursor_cleared 信号到 on_cursor_cleared 槽函数 + self.setScene(self.scene) # 设置场景 # Context menu and actions - self.build_menu_and_actions() - self.control_target = self - self.init_main_controls(main_window=parent) + self.build_menu_and_actions() # 构建菜单和操作 + self.control_target = self # 设置控制目标为自身 + self.init_main_controls(main_window=parent)# 初始化主控件 # Load files given via command line - if commandline_args.filenames: - fn = commandline_args.filenames[0] - if os.path.splitext(fn)[1] == '.bee': - self.open_from_file(fn) + if commandline_args.filenames: # 如果命令行参数有文件名 + fn = commandline_args.filenames[0] # 获取第一个文件名 + if os.path.splitext(fn)[1] == '.bee': # 如果文件扩展名是 .bee + self.open_from_file(fn) # 从文件打开 else: - self.do_insert_images(commandline_args.filenames) + self.do_insert_images(commandline_args.filenames) # 否则插入图像 - self.update_window_title() + self.update_window_title() # 更新窗口标题 - @property - def filename(self): - return self._filename + @property # 文件名属性 + def filename(self): # 获取当前打开的文件名 + return self._filename # 返回当前打开的文件名 - @filename.setter - def filename(self, value): - self._filename = value - self.update_window_title() + @filename.setter # 文件名属性设置器 + def filename(self, value): # 设置当前打开的文件名 + self._filename = value # 设置当前打开的文件名 + self.update_window_title() # 更新窗口标题 if value: - self.settings.update_recent_files(value) - self.update_menu_and_actions() + self.settings.update_recent_files(value) # 更新最近打开的文件列表 + self.update_menu_and_actions() # 更新菜单和操作 - def cancel_active_modes(self): - self.scene.cancel_active_modes() - self.cancel_sample_color_mode() - self.active_mode = None + def cancel_active_modes(self): # 取消所有激活的模式 + self.scene.cancel_active_modes() # 取消场景中所有激活的模式 + self.cancel_sample_color_mode() # 取消取色模式 + self.active_mode = None # 重置当前激活的模式为 None - def cancel_sample_color_mode(self): - logger.debug('Cancel sample color mode') - self.active_mode = None - self.viewport().unsetCursor() + def cancel_sample_color_mode(self): # 取消取色模式 + logger.debug('Cancel sample color mode') # 调试日志:取消取色模式 + self.active_mode = None # 重置当前激活的模式为 None + self.viewport().unsetCursor() # 取消设置视口光标 if hasattr(self, 'sample_color_widget'): - self.sample_color_widget.hide() - del self.sample_color_widget + self.sample_color_widget.hide() # 隐藏取色小部件 + del self.sample_color_widget # 删除取色小部件 if self.scene.has_multi_selection(): - self.scene.multi_select_item.bring_to_front() + self.scene.multi_select_item.bring_to_front()# 将多选项 bring to front - def update_window_title(self): - clean = self.undo_stack.isClean() - if clean and not self.filename: - title = constants.APPNAME + def update_window_title(self): # 更新窗口标题 + clean = self.undo_stack.isClean() # 获取撤销栈是否为干净状态 + if clean and not self.filename: # 如果撤销栈为干净状态且没有文件名 + title = constants.APPNAME # 窗口标题为应用程序名称 else: - name = os.path.basename(self.filename or '[Untitled]') - clean = '' if clean else '*' - title = f'{name}{clean} - {constants.APPNAME}' - self.parent.setWindowTitle(title) + name = os.path.basename(self.filename or '[Untitled]') # 获取文件名(如果有),否则为 '[Untitled]' + clean = '' if clean else '*' # 如果撤销栈为干净状态,则不显示 '*',否则显示 '*' + title = f'{name}{clean} - {constants.APPNAME}' + # 窗口标题为文件名(如果有),否则为 '[Untitled]',后跟 '*'(如果撤销栈为不干净状态),最后为应用程序名称 + self.parent.setWindowTitle(title) # 设置父窗口标题为更新后的标题 - def on_scene_changed(self, region): - if not self.scene.items(): - logger.debug('No items in scene') - self.setTransform(QtGui.QTransform()) - self.welcome_overlay.setFocus() - self.clearFocus() - self.welcome_overlay.show() - self.actiongroup_set_enabled('active_when_items_in_scene', False) + def on_scene_changed(self, region): # 场景变化槽函数 + if not self.scene.items(): # 如果场景中没有物品 + logger.debug('No items in scene') # 调试日志:场景中没有物品 + self.setTransform(QtGui.QTransform()) # 重置变换矩阵为单位矩阵 + self.welcome_overlay.setFocus() # 设置欢迎叠加层为焦点 + self.clearFocus() # 清除当前焦点 + self.welcome_overlay.show() # 显示欢迎叠加层 + self.actiongroup_set_enabled('active_when_items_in_scene', False) # 禁用场景中物品相关操作 else: - self.setFocus() - self.welcome_overlay.clearFocus() - self.welcome_overlay.hide() - self.actiongroup_set_enabled('active_when_items_in_scene', True) - self.recalc_scene_rect() + self.setFocus(QtCore.Qt.PopupFocusReason) # 设置场景为焦点,使用 PopupFocusReason 原因 + self.welcome_overlay.clearFocus()# 清除欢迎叠加层焦点 + self.welcome_overlay.hide() # 隐藏欢迎叠加层 + self.actiongroup_set_enabled('active_when_items_in_scene', True) # 启用场景中物品相关操作 + self.recalc_scene_rect() # 重新计算场景矩形 - def on_can_redo_changed(self, can_redo): - self.actiongroup_set_enabled('active_when_can_redo', can_redo) + def on_can_redo_changed(self, can_redo): # 可以重做变化槽函数 + self.actiongroup_set_enabled('active_when_can_redo', can_redo) # 根据 can_redo 启用或禁用 'active_when_can_redo' 操作组 - def on_can_undo_changed(self, can_undo): - self.actiongroup_set_enabled('active_when_can_undo', can_undo) + def on_can_undo_changed(self, can_undo): # 可以撤销变化槽函数 + self.actiongroup_set_enabled('active_when_can_undo', can_undo) # 根据 can_undo 启用或禁用 'active_when_can_undo' 操作组 - def on_undo_clean_changed(self, clean): - self.update_window_title() + def on_undo_clean_changed(self, clean): # 撤销栈干净状态变化槽函数 + self.update_window_title() # 更新窗口标题 - def on_context_menu(self, point): - self.context_menu.exec(self.mapToGlobal(point)) + def on_context_menu(self, point): # 上下文菜单槽函数 + self.context_menu.exec(self.mapToGlobal(point)) # 在全局坐标中执行上下文菜单 - def get_supported_image_formats(self, cls): + def get_supported_image_formats(self, cls): # 获取支持的图像格式 formats = [] - for f in cls.supportedImageFormats(): - string = f'*.{f.data().decode()}' - formats.extend((string, string.upper())) - return ' '.join(formats) + for f in cls.supportedImageFormats(): # 遍历支持的图像格式 + string = f'*.{f.data().decode()}' # 转换为字符串格式 + formats.extend((string, string.upper())) # 扩展格式列表,包含小写和大写格式 + return ' '.join(formats) # 返回空格分隔的格式字符串 - def get_view_center(self): + def get_view_center(self): # 获取视图中心坐标 return QtCore.QPoint(round(self.size().width() / 2), - round(self.size().height() / 2)) + round(self.size().height() / 2)) # 返回视图中心坐标 - def clear_scene(self): - logging.debug('Clearing scene...') - self.cancel_active_modes() - self.scene.clear() - self.undo_stack.clear() - self.filename = None - self.setTransform(QtGui.QTransform()) + def clear_scene(self): # 清除场景 + logging.debug('Clearing scene...') # 调试日志:清除场景 + self.cancel_active_modes() # 取消所有激活的模式 + self.scene.clear() # 清除场景中的所有物品 + self.undo_stack.clear() # 清除撤销栈 + self.filename = None # 重置文件名为 None + self.setTransform(QtGui.QTransform()) # 重置变换矩阵为单位矩阵 - def reset_previous_transform(self, toggle_item=None): - if (self.previous_transform - and self.previous_transform['toggle_item'] != toggle_item): - self.previous_transform = None + def reset_previous_transform(self, toggle_item=None): # 重置之前的变换 + if (self.previous_transform # 如果有之前的变换 + and self.previous_transform['toggle_item'] != toggle_item): # 如果之前的变换与当前物品不同 + self.previous_transform = None # 重置之前的变换为 None + # 如果之前的变换与当前物品不同,将之前的变换重置为 None - def fit_rect(self, rect, toggle_item=None): - if toggle_item and self.previous_transform: - logger.debug('Fit view: Reset to previous') - self.setTransform(self.previous_transform['transform']) - self.centerOn(self.previous_transform['center']) - self.previous_transform = None + def fit_rect(self, rect, toggle_item=None): # 适合矩形 + if toggle_item and self.previous_transform: # 如果有之前的变换且与当前物品不同 + logger.debug('Fit view: Reset to previous') # 调试日志:重置到之前的变换 + self.setTransform(self.previous_transform['transform']) # 设置变换矩阵为之前的变换矩阵 + self.centerOn(self.previous_transform['center']) # 中心视图到之前的变换中心 + self.previous_transform = None # 重置之前的变换为 None return - if toggle_item: - self.previous_transform = { - 'toggle_item': toggle_item, - 'transform': QtGui.QTransform(self.transform()), - 'center': self.mapToScene(self.get_view_center()), + if toggle_item: # 如果有物品 + self.previous_transform = { # 存储当前变换 + 'toggle_item': toggle_item, # 存储当前物品 + 'transform': QtGui.QTransform(self.transform()), # 存储当前变换矩阵 + 'center': self.mapToScene(self.get_view_center()), # 存储当前视图中心坐标 } else: - self.previous_transform = None + self.previous_transform = None # 重置之前的变换为 None - logger.debug(f'Fit view: {rect}') - self.fitInView(rect, Qt.AspectRatioMode.KeepAspectRatio) - self.recalc_scene_rect() + logger.debug(f'Fit view: {rect}') # 调试日志:适合矩形 + self.fitInView(rect, Qt.AspectRatioMode.KeepAspectRatio) # 适合矩形,保持纵横比 + self.recalc_scene_rect() # 重新计算场景矩形 # It seems to be more reliable when we fit a second time # Sometimes a changing scene rect can mess up the fitting - self.fitInView(rect, Qt.AspectRatioMode.KeepAspectRatio) + self.fitInView(rect, Qt.AspectRatioMode.KeepAspectRatio) # 适合矩形,保持纵横比 + self.recalc_scene_rect() # 重新计算场景矩形 logger.trace('Fit view done') - def get_confirmation_unsaved_changes(self, msg): - confirm = self.settings.valueOrDefault('Save/confirm_close_unsaved') - if confirm and not self.undo_stack.isClean(): - answer = QtWidgets.QMessageBox.question( + def get_confirmation_unsaved_changes(self, msg): # 获取未保存更改确认 + confirm = self.settings.valueOrDefault('Save/confirm_close_unsaved')# 获取是否确认关闭未保存更改 + if confirm and not self.undo_stack.isClean(): # 如果确认关闭未保存更改且撤销栈不为干净状态 + answer = QtWidgets.QMessageBox.question( # 获取未保存更改确认 self, - 'Discard unsaved changes?', + 'Discard unsaved changes?', # 确认关闭未保存更改标题 msg, - QtWidgets.QMessageBox.StandardButton.Yes | - QtWidgets.QMessageBox.StandardButton.Cancel) + QtWidgets.QMessageBox.StandardButton.Yes | + QtWidgets.QMessageBox.StandardButton.Cancel) return answer == QtWidgets.QMessageBox.StandardButton.Yes return True - def on_action_new_scene(self): - confirm = self.get_confirmation_unsaved_changes( - 'There are unsaved changes. ' - 'Are you sure you want to open a new scene?') - if confirm: - self.clear_scene() + def on_action_new_scene(self): # 新建场景槽函数 + confirm = self.get_confirmation_unsaved_changes( # 获取新建场景确认 + 'There are unsaved changes. ' # 确认关闭未保存更改消息 + 'Are you sure you want to open a new scene?') # 确认新建场景消息 + if confirm: # 如果确认新建场景 + self.clear_scene() # 清除场景 - def on_action_fit_scene(self): - self.fit_rect(self.scene.itemsBoundingRect()) + def on_action_fit_scene(self): # 适合场景槽函数 + self.fit_rect(self.scene.itemsBoundingRect()) # 适合场景矩形 - def on_action_fit_selection(self): - self.fit_rect(self.scene.itemsBoundingRect(selection_only=True)) + def on_action_fit_selection(self): # 适合选择槽函数 + self.fit_rect(self.scene.itemsBoundingRect(selection_only=True)) # 适合选择矩形 - def on_action_fullscreen(self, checked): - if checked: - self.parent.showFullScreen() + def on_action_fullscreen(self, checked): # 全屏槽函数 + if checked: # 如果选中全屏 + self.parent.showFullScreen() # 显示全屏 else: - self.parent.showNormal() + self.parent.showNormal() # 显示正常 - def on_action_always_on_top(self, checked): - self.parent.setWindowFlag( - Qt.WindowType.WindowStaysOnTopHint, on=checked) - self.parent.destroy() - self.parent.create() - self.parent.show() + def on_action_always_on_top(self, checked): # 总是置顶槽函数 + self.parent.setWindowFlag( # 设置窗口标志 + Qt.WindowType.WindowStaysOnTopHint, on=checked) # 设置窗口置顶标志 + self.parent.destroy() # 销毁窗口 + self.parent.create() # 创建窗口 + self.parent.show() # 显示窗口 - def on_action_show_scrollbars(self, checked): - if checked: - self.setHorizontalScrollBarPolicy( - Qt.ScrollBarPolicy.ScrollBarAsNeeded) - self.setVerticalScrollBarPolicy( - Qt.ScrollBarPolicy.ScrollBarAsNeeded) - else: - self.setHorizontalScrollBarPolicy( - Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self.setVerticalScrollBarPolicy( - Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + def on_action_show_scrollbars(self, checked): # 显示滚动条槽函数 + if checked: # 如果选中显示滚动条 + self.setHorizontalScrollBarPolicy( # 设置水平滚动条策略 + Qt.ScrollBarPolicy.ScrollBarAsNeeded) # 设置水平滚动条为按需显示 + self.setVerticalScrollBarPolicy( # 设置垂直滚动条策略 + Qt.ScrollBarPolicy.ScrollBarAsNeeded) # 设置垂直滚动条为按需显示 + else: # 如果未选中显示滚动条 + self.setHorizontalScrollBarPolicy( # 设置水平滚动条策略 + Qt.ScrollBarPolicy.ScrollBarAlwaysOff) # 设置水平滚动条为始终关闭 + self.setVerticalScrollBarPolicy( # 设置垂直滚动条策略 + Qt.ScrollBarPolicy.ScrollBarAlwaysOff) # 设置垂直滚动条为始终关闭 - def on_action_show_menubar(self, checked): - if checked: - self.parent.setMenuBar(self.create_menubar()) - else: - self.parent.setMenuBar(None) + def on_action_show_menubar(self, checked): # 显示菜单条槽函数 + if checked: # 如果选中显示菜单条 + self.parent.setMenuBar(self.create_menubar()) # 设置菜单条为创建的菜单条 + else: # 如果未选中显示菜单条 + self.parent.setMenuBar(None) # 设置菜单条为 None - def on_action_show_titlebar(self, checked): - self.parent.setWindowFlag( - Qt.WindowType.FramelessWindowHint, on=not checked) - self.parent.destroy() - self.parent.create() - self.parent.show() + def on_action_show_titlebar(self, checked): # 显示标题栏槽函数 + self.parent.setWindowFlag( # 设置窗口标志 + Qt.WindowType.FramelessWindowHint, on=not checked) # 设置窗口无框架标志 + self.parent.destroy() # 销毁窗口 + self.parent.create() # 创建窗口 + self.parent.show() # 显示窗口 - def on_action_move_window(self): - if self.welcome_overlay.isHidden(): - self.on_action_movewin_mode() - else: - self.welcome_overlay.on_action_movewin_mode() + def on_action_move_window(self): # 移动窗口槽函数 + if self.welcome_overlay.isHidden(): # 如果欢迎覆盖层隐藏 + self.on_action_movewin_mode() # 执行移动窗口模式 + else: # 如果欢迎覆盖层显示 + self.welcome_overlay.on_action_movewin_mode() # 执行欢迎覆盖层移动窗口模式 - def on_action_undo(self): - logger.debug('Undo: %s' % self.undo_stack.undoText()) + def on_action_undo(self): # 撤销槽函数 + logger.debug('Undo: %s' % self.undo_stack.undoText()) # 调试输出撤销文本 self.cancel_active_modes() self.undo_stack.undo() - def on_action_redo(self): - logger.debug('Redo: %s' % self.undo_stack.redoText()) + def on_action_redo(self): # 重做槽函数 + logger.debug('Redo: %s' % self.undo_stack.redoText()) # 调试输出重做文本 self.cancel_active_modes() self.undo_stack.redo() - def on_action_select_all(self): - self.scene.select_all_items() + def on_action_select_all(self): # 选择所有槽函数 + self.scene.select_all_items() # 选择所有项 - def on_action_deselect_all(self): - self.scene.deselect_all_items() + def on_action_deselect_all(self): # 取消选择所有槽函数 + self.scene.deselect_all_items() # 取消选择所有项 - def on_action_delete_items(self): - logger.debug('Deleting items...') - self.cancel_active_modes() - self.undo_stack.push( - commands.DeleteItems( - self.scene, self.scene.selectedItems(user_only=True))) + def on_action_delete_items(self): # 删除项槽函数 + logger.debug('Deleting items...') # 调试输出删除项消息 + self.cancel_active_modes() # 取消活动模式 + self.undo_stack.push( # 推送撤销栈 + commands.DeleteItems( # 删除项命令 + self.scene, self.scene.selectedItems(user_only=True))) # 删除用户选择的项 - def on_action_cut(self): - logger.debug('Cutting items...') - self.on_action_copy() - self.undo_stack.push( - commands.DeleteItems( - self.scene, self.scene.selectedItems(user_only=True))) + def on_action_cut(self): # 剪切槽函数 + logger.debug('Cutting items...') # 调试输出剪切项消息 + self.on_action_copy() # 执行复制操作 + self.undo_stack.push( # 推送撤销栈 + commands.DeleteItems( # 删除项命令 + self.scene, self.scene.selectedItems(user_only=True))) # 删除用户选择的项 - def on_action_raise_to_top(self): - self.scene.raise_to_top() + def on_action_raise_to_top(self): # Bring to Front槽函数 + self.scene.raise_to_top() # Bring to Front项 - def on_action_lower_to_bottom(self): - self.scene.lower_to_bottom() + def on_action_lower_to_bottom(self): # Send to Back槽函数 + self.scene.lower_to_bottom() # Send to Back项 - def on_action_normalize_height(self): - self.scene.normalize_height() + def on_action_normalize_height(self): # 归一化高度槽函数 + self.scene.normalize_height() # 归一化高度 - def on_action_normalize_width(self): - self.scene.normalize_width() + def on_action_normalize_width(self): # 归一化宽度槽函数 + self.scene.normalize_width() # 归一化宽度 - def on_action_normalize_size(self): - self.scene.normalize_size() + def on_action_normalize_size(self): # 归一化大小槽函数 + self.scene.normalize_size() # 归一化大小 - def on_action_arrange_horizontal(self): - self.scene.arrange() + def on_action_arrange_horizontal(self): # 水平排列槽函数 + self.scene.arrange() # 水平排列项 - def on_action_arrange_vertical(self): - self.scene.arrange(vertical=True) + def on_action_arrange_vertical(self): # 垂直排列槽函数 + self.scene.arrange(vertical=True) # 垂直排列项 - def on_action_arrange_optimal(self): - self.scene.arrange_optimal() + def on_action_arrange_optimal(self): # 最优排列槽函数 + self.scene.arrange_optimal() # 最优排列项 - def on_action_arrange_square(self): - self.scene.arrange_square() + def on_action_arrange_square(self): # 方排列槽函数 + self.scene.arrange_square() # 方排列项 - def on_action_change_opacity(self): - images = list(filter( - lambda item: item.is_image, - self.scene.selectedItems(user_only=True))) - widgets.ChangeOpacityDialog(self, images, self.undo_stack) + def on_action_change_opacity(self): # 改变不透明度槽函数 + images = list(filter( # 过滤出用户选择的图像项 + lambda item: item.is_image, # 过滤出图像项 + self.scene.selectedItems(user_only=True))) # 过滤出用户选择的图像项 + widgets.ChangeOpacityDialog(self, images, self.undo_stack) # 显示改变不透明度对话框 - def on_action_grayscale(self, checked): - images = list(filter( - lambda item: item.is_image, - self.scene.selectedItems(user_only=True))) + def on_action_grayscale(self, checked): # 灰度槽函数 + images = list(filter( # 过滤出用户选择的图像项 + lambda item: item.is_image, # 过滤出图像项 + self.scene.selectedItems(user_only=True))) # 过滤出用户选择的图像项 if images: self.undo_stack.push( - commands.ToggleGrayscale(images, checked)) + commands.ToggleGrayscale(images, checked)) # 推送撤销栈,执行切换灰度命令 - def on_action_crop(self): - self.scene.crop_items() + def on_action_crop(self): # 裁剪槽函数 + self.scene.crop_items() # 裁剪项 - def on_action_flip_horizontally(self): - self.scene.flip_items(vertical=False) + def on_action_flip_horizontally(self): # 水平翻转槽函数 + self.scene.flip_items(vertical=False) # 水平翻转项 - def on_action_flip_vertically(self): - self.scene.flip_items(vertical=True) + def on_action_flip_vertically(self): # 垂直翻转槽函数 + self.scene.flip_items(vertical=True) # 垂直翻转项 - def on_action_reset_scale(self): - self.cancel_active_modes() - self.undo_stack.push(commands.ResetScale( - self.scene.selectedItems(user_only=True))) + def on_action_reset_scale(self): # 重置缩放槽函数 + self.cancel_active_modes() # 取消活动模式 + self.undo_stack.push(commands.ResetScale( # 推送撤销栈,执行重置缩放命令 + self.scene.selectedItems(user_only=True))) # 推送撤销栈,执行重置缩放命令 + def on_action_reset_rotation(self): # 重置旋转槽函数 + self.cancel_active_modes() # 取消活动模式 + self.undo_stack.push(commands.ResetRotation( # 推送撤销栈,执行重置旋转命令 + self.scene.selectedItems(user_only=True))) # 推送撤销栈,执行重置旋转命令 - def on_action_reset_rotation(self): - self.cancel_active_modes() - self.undo_stack.push(commands.ResetRotation( - self.scene.selectedItems(user_only=True))) - - def on_action_reset_flip(self): - self.cancel_active_modes() - self.undo_stack.push(commands.ResetFlip( - self.scene.selectedItems(user_only=True))) - - def on_action_reset_crop(self): - self.cancel_active_modes() - self.undo_stack.push(commands.ResetCrop( - self.scene.selectedItems(user_only=True))) + def on_action_reset_flip(self): # 重置翻转槽函数 + self.cancel_active_modes() # 取消活动模式 + self.undo_stack.push(commands.ResetFlip( # 推送撤销栈,执行重置翻转命令 + self.scene.selectedItems(user_only=True))) # 推送撤销栈,执行重置翻转命令 + def on_action_reset_crop(self): # 重置裁剪槽函数 + self.cancel_active_modes() # 取消活动模式 + self.undo_stack.push(commands.ResetCrop( # 推送撤销栈,执行重置裁剪命令 + self.scene.selectedItems(user_only=True))) # 推送撤销栈,执行重置裁剪命令 def on_action_reset_transforms(self): - self.cancel_active_modes() - self.undo_stack.push(commands.ResetTransforms( - self.scene.selectedItems(user_only=True))) + self.cancel_active_modes() # 取消活动模式 + self.undo_stack.push(commands.ResetTransforms( # 推送撤销栈,执行重置变换命令 + self.scene.selectedItems(user_only=True))) # 推送撤销栈,执行重置变换命令 - def on_action_show_color_gamut(self): - widgets.color_gamut.GamutDialog(self, self.scene.selectedItems()[0]) + def on_action_show_color_gamut(self): # 显示颜色 gamut 槽函数 + widgets.color_gamut.GamutDialog(self, self.scene.selectedItems()[0]) # 显示颜色 gamut 对话框 - def on_action_sample_color(self): - self.cancel_active_modes() - logger.debug('Entering sample color mode') - self.viewport().setCursor(Qt.CursorShape.CrossCursor) - self.active_mode = self.SAMPLE_COLOR_MODE + def on_action_sample_color(self): # 采样颜色槽函数 + self.cancel_active_modes() # 取消活动模式 + logger.debug('Entering sample color mode') # 调试输出采样颜色消息 + self.viewport().setCursor(Qt.CursorShape.CrossCursor) # 设置鼠标光标为十字光标 + self.active_mode = self.SAMPLE_COLOR_MODE # 设置活动模式为采样颜色模式 - if self.scene.has_multi_selection(): + if self.scene.has_multi_selection(): # 如果有多个选择项 # We don't want to sample the multi select item, so # temporarily send it to the back: - self.scene.multi_select_item.lower_behind_selection() + self.scene.multi_select_item.lower_behind_selection() # 临时将多选项发送到后台 - pos = self.mapFromGlobal(self.cursor().pos()) + pos = self.mapFromGlobal(self.cursor().pos()) # 获取鼠标光标在场景中的位置 self.sample_color_widget = widgets.SampleColorWidget( self, pos, - self.scene.sample_color_at(self.mapToScene(pos))) + self.scene.sample_color_at(self.mapToScene(pos))) # 创建采样颜色小部件 - def on_items_loaded(self, value): - logger.debug('On items loaded: add queued items') - self.scene.add_queued_items() + def on_items_loaded(self, value): # 加载项槽函数 + logger.debug('On items loaded: add queued items') # 调试输出加载项消息 + self.scene.add_queued_items() # 添加队列中的项 - def on_loading_finished(self, filename, errors): - if errors: - QtWidgets.QMessageBox.warning( + def on_loading_finished(self, filename, errors): # 加载完成槽函数 + if errors: # 如果有错误 + QtWidgets.QMessageBox.warning( # 显示警告对话框 self, 'Problem loading file', ('

Problem loading file %s

' - '

Not accessible or not a proper bee file

') % filename) + '

Not accessible or not a proper bee file

') % filename) # 显示警告对话框,提示文件不可访问或不是 proper bee 文件 else: - self.filename = filename - self.scene.add_queued_items() - self.on_action_fit_scene() + self.filename = filename # 设置文件名 + self.scene.add_queued_items() # 添加队列中的项 + self.on_action_fit_scene() # 拟合场景 - def on_action_open_recent_file(self, filename): - confirm = self.get_confirmation_unsaved_changes( + def on_action_open_recent_file(self, filename):# 打开最近文件槽函数 + confirm = self.get_confirmation_unsaved_changes( # 获取确认未保存更改消息 'There are unsaved changes. ' 'Are you sure you want to open a new scene?') - if confirm: - self.open_from_file(filename) + if confirm: # 如果确认 + self.open_from_file(filename) # 从文件打开场景 - def open_from_file(self, filename): - logger.info(f'Opening file {filename}') - self.clear_scene() - self.worker = fileio.ThreadedIO( - fileio.load_bee, filename, self.scene) - self.worker.progress.connect(self.on_items_loaded) - self.worker.finished.connect(self.on_loading_finished) - self.progress = widgets.BeeProgressDialog( - f'Loading {filename}', - worker=self.worker, - parent=self) - self.worker.start() + def open_from_file(self, filename): # 从文件打开场景槽函数 + logger.info(f'Opening file {filename}') # 调试输出打开文件消息 + self.clear_scene() # 清除场景 + self.worker = fileio.ThreadedIO( # 创建线程化 IO 工作器 + fileio.load_bee, filename, self.scene) # 加载 bee 文件,将项添加到场景中 + self.worker.progress.connect(self.on_items_loaded) # 连接进度信号到加载项槽函数 + self.worker.finished.connect(self.on_loading_finished) # 连接完成信号到加载完成槽函数 + self.progress = widgets.BeeProgressDialog( # 创建进度对话框 + f'Loading {filename}', # 进度对话框标题 + worker=self.worker, # 关联线程化 IO 工作器 + parent=self) # 设置进度对话框父窗口为主窗口 + self.worker.start() # 启动线程化 IO 工作器,开始加载文件 - def on_action_open(self): - confirm = self.get_confirmation_unsaved_changes( - 'There are unsaved changes. ' - 'Are you sure you want to open a new scene?') - if not confirm: - return + def on_action_open(self): # 打开文件槽函数 + confirm = self.get_confirmation_unsaved_changes( # 获取确认未保存更改消息 + 'There are unsaved changes. ' # 提示有未保存更改 + 'Are you sure you want to open a new scene?') # 提示确认打开新场景 + if not confirm: # 如果取消 + return # 如果取消,返回 - self.cancel_active_modes() - filename, f = QtWidgets.QFileDialog.getOpenFileName( - parent=self, - caption='Open file', - filter=f'{constants.APPNAME} File (*.bee)') - if filename: - filename = os.path.normpath(filename) - self.open_from_file(filename) - self.filename = filename + self.cancel_active_modes() # 取消活动模式 + filename, f = QtWidgets.QFileDialog.getOpenFileName( # 获取打开文件对话框 + parent=self, # 设置进度对话框父窗口为主窗口 + caption='Open file', # 打开文件对话框标题 + filter=f'{constants.APPNAME} File (*.bee)') # 打开文件对话框筛选器,仅显示 bee 文件 + if filename: # 如果选择了文件 + filename = os.path.normpath(filename) # 规范文件名路径 + self.open_from_file(filename) # 从文件打开场景 + self.filename = filename # 设置文件名 - def on_saving_finished(self, filename, errors): - if errors: - QtWidgets.QMessageBox.warning( - self, - 'Problem saving file', - ('

Problem saving file %s

' - '

File/directory not accessible

') % filename) + def on_saving_finished(self, filename, errors): # 保存完成槽函数 + if errors: # 如果有错误 + QtWidgets.QMessageBox.warning( # 显示警告对话框 + self, # 警告对话框父窗口为主窗口 + 'Problem saving file', # 警告对话框标题 + ('

Problem saving file %s

' # 警告对话框内容,提示保存文件时出现问题 + '

File/directory not accessible

') % filename) # 警告对话框内容,提示文件或目录不可访问 else: - self.filename = filename - self.undo_stack.setClean() + self.filename = filename # 设置文件名 + self.undo_stack.setClean() # 设置撤销栈为干净状态 - def do_save(self, filename, create_new): - if not fileio.is_bee_file(filename): - filename = f'{filename}.bee' - self.worker = fileio.ThreadedIO( - fileio.save_bee, filename, self.scene, create_new=create_new) - self.worker.finished.connect(self.on_saving_finished) - self.progress = widgets.BeeProgressDialog( - f'Saving {filename}', - worker=self.worker, - parent=self) - self.worker.start() + def do_save(self, filename, create_new): # 保存场景槽函数 + if not fileio.is_bee_file(filename): # 如果不是 bee 文件 + filename = f'{filename}.bee' # 添加 bee 文件扩展名 + self.worker = fileio.ThreadedIO( # 创建线程化 IO 工作器 + fileio.save_bee, filename, self.scene, create_new=create_new) # 保存 bee 文件,将场景项保存到文件中 + self.worker.finished.connect(self.on_saving_finished) # 连接完成信号到保存完成槽函数 + self.progress = widgets.BeeProgressDialog( # 创建进度对话框 + f'Saving {filename}', # 进度对话框标题 + worker=self.worker, # 关联线程化 IO 工作器 + parent=self) # 设置进度对话框父窗口为主窗口 + self.worker.start() # 启动线程化 IO 工作器,开始保存文件 - def on_action_save_as(self): - self.cancel_active_modes() - directory = os.path.dirname(self.filename) if self.filename else None - filename, f = QtWidgets.QFileDialog.getSaveFileName( - parent=self, - caption='Save file', - directory=directory, - filter=f'{constants.APPNAME} File (*.bee)') - if filename: - self.do_save(filename, create_new=True) + def on_action_save_as(self): # 保存为槽函数 + self.cancel_active_modes() # 取消活动模式 + directory = os.path.dirname(self.filename) if self.filename else None # 如果有文件名,获取文件名所在目录,否则为 None + filename, f = QtWidgets.QFileDialog.getSaveFileName( # 获取保存文件对话框 + parent=self, # 设置进度对话框父窗口为主窗口 + caption='Save file', # 保存文件对话框标题 + directory=directory, # 保存文件对话框默认目录为当前文件名所在目录 + filter=f'{constants.APPNAME} File (*.bee)') # 保存文件对话框筛选器,仅显示 bee 文件 + if filename: # 如果选择了文件 + self.do_save(filename, create_new=True) # 保存场景为新文件 - def on_action_save(self): - self.cancel_active_modes() - if not self.filename: - self.on_action_save_as() + def on_action_save(self): # 保存槽函数 + self.cancel_active_modes() # 取消活动模式 + if not self.filename: # 如果没有文件名 + self.on_action_save_as() # 调用保存为槽函数 else: - self.do_save(self.filename, create_new=False) + self.do_save(self.filename, create_new=False) # 保存场景到当前文件名,不创建新文件 - def on_action_export_scene(self): - directory = os.path.dirname(self.filename) if self.filename else None - filename, formatstr = QtWidgets.QFileDialog.getSaveFileName( - parent=self, - caption='Export Scene to Image', - directory=directory, + def on_action_export_scene(self): # 导出场景槽函数 + directory = os.path.dirname(self.filename) if self.filename else None # 如果有文件名,获取文件名所在目录,否则为 None + filename, formatstr = QtWidgets.QFileDialog.getSaveFileName( # 获取保存文件对话框 + parent=self, # 设置进度对话框父窗口为主窗口 + caption='Export Scene to Image', # 导出场景对话框标题 + directory=directory, # 导出场景对话框默认目录为当前文件名所在目录 filter=';;'.join(('Image Files (*.png *.jpg *.jpeg *.svg)', 'PNG (*.png)', 'JPEG (*.jpg *.jpeg)', 'SVG (*.svg)'))) - if not filename: - return + if not filename: # 如果没有文件名 + return # 如果没有文件名,返回 - name, ext = os.path.splitext(filename) - if not ext: - ext = get_file_extension_from_format(formatstr) - filename = f'{filename}.{ext}' - logger.debug(f'Got export filename {filename}') + name, ext = os.path.splitext(filename) # 从文件名中分离出文件名和扩展名 + if not ext: # 如果没有扩展名 + ext = get_file_extension_from_format(formatstr) # 从格式字符串中获取文件扩展名 + filename = f'{filename}.{ext}' # 添加文件扩展名 + logger.debug(f'Got export filename {filename}') # 调试日志,提示导出文件名 - exporter_cls = exporter_registry[ext] - exporter = exporter_cls(self.scene) - if not exporter.get_user_input(self): - return + exporter_cls = exporter_registry[ext] # 从导出器注册表中获取导出器类 + exporter = exporter_cls(self.scene) # 创建导出器实例,将场景项传递给导出器 + if not exporter.get_user_input(self): # 如果导出器需要用户输入 + return # 如果导出器需要用户输入,返回 - self.worker = fileio.ThreadedIO(exporter.export, filename) - self.worker.finished.connect(self.on_export_finished) - self.progress = widgets.BeeProgressDialog( - f'Exporting {filename}', - worker=self.worker, - parent=self) - self.worker.start() + self.worker = fileio.ThreadedIO(exporter.export, filename) # 创建线程化 IO 工作器,将导出函数和文件名传递给工作器 + self.worker.finished.connect(self.on_export_finished) # 连接完成信号到导出完成槽函数 + self.progress = widgets.BeeProgressDialog( # 创建进度对话框 + f'Exporting {filename}', # 进度对话框标题 + worker=self.worker, # 关联线程化 IO 工作器 + parent=self) # 设置进度对话框父窗口为主窗口 + self.worker.start() # 启动线程化 IO 工作器,开始导出文件 - def on_export_finished(self, filename, errors): - if errors: - err_msg = '
'.join(str(errors)) - QtWidgets.QMessageBox.warning( - self, - 'Problem writing file', - f'

Problem writing file {filename}

{err_msg}

') + def on_export_finished(self, filename, errors): # 导出完成槽函数 + if errors: # 如果有错误 + err_msg = '
'.join(str(errors)) # 将错误列表转换为字符串,每个错误之间用换行符分隔 + QtWidgets.QMessageBox.warning( # 显示警告对话框 + self, # 警告对话框父窗口为主窗口 + 'Problem writing file', # 警告对话框标题 + f'

Problem writing file {filename}

{err_msg}

') # 警告对话框内容,提示导出文件时出现问题,包含文件名和错误信息 - def on_action_export_images(self): - directory = os.path.dirname(self.filename) if self.filename else None - directory = QtWidgets.QFileDialog.getExistingDirectory( - parent=self, - caption='Export Images', - directory=directory) + def on_action_export_images(self): # 导出图像槽函数 + directory = os.path.dirname(self.filename) if self.filename else None # 如果有文件名,获取文件名所在目录,否则为 None + directory = QtWidgets.QFileDialog.getExistingDirectory( # 获取导出图像对话框 + parent=self, # 设置进度对话框父窗口为主窗口 + caption='Export Images', # 导出图像对话框标题 + directory=directory) # 获取导出图像对话框默认目录为当前文件名所在目录 - if not directory: - return + if not directory: # 如果没有目录 + return # 如果没有目录,返回 - logger.debug(f'Got export directory {directory}') - self.exporter = ImagesToDirectoryExporter(self.scene, directory) - self.worker = fileio.ThreadedIO(self.exporter.export) - self.worker.user_input_required.connect( - self.on_export_images_file_exists) - self.worker.finished.connect(self.on_export_finished) - self.progress = widgets.BeeProgressDialog( - f'Exporting to {directory}', - worker=self.worker, - parent=self) - self.worker.start() + logger.debug(f'Got export directory {directory}') # 调试日志,提示导出目录 + self.exporter = ImagesToDirectoryExporter(self.scene, directory) # 创建导出器实例,将场景项和目录传递给导出器 + self.worker = fileio.ThreadedIO(self.exporter.export) # 创建线程化 IO 工作器,将导出函数传递给工作器 + self.worker.user_input_required.connect( # 连接用户输入信号到导出图像文件存在槽函数 + self.on_export_images_file_exists) # 连接用户输入信号到导出图像文件存在槽函数 + self.worker.finished.connect(self.on_export_finished) # 连接完成信号到导出完成槽函数 + self.progress = widgets.BeeProgressDialog( # 创建进度对话框 + f'Exporting to {directory}', # 进度对话框标题 + worker=self.worker, # 关联线程化 IO 工作器 + parent=self) # 设置进度对话框父窗口为主窗口 + self.worker.start() # 启动线程化 IO 工作器,开始导出文件 - def on_export_images_file_exists(self, filename): - dlg = widgets.ExportImagesFileExistsDialog(self, filename) - if dlg.exec() == QtWidgets.QDialog.DialogCode.Accepted: - self.exporter.handle_existing = dlg.get_answer() - directory = self.exporter.dirname - self.progress = widgets.BeeProgressDialog( - f'Exporting to {directory}', - worker=self.worker, - parent=self) - self.worker.start() + def on_export_images_file_exists(self, filename): # 导出图像文件存在槽函数 + dlg = widgets.ExportImagesFileExistsDialog(self, filename) # 创建导出图像文件存在对话框,将主窗口和文件名传递给对话框 + if dlg.exec() == QtWidgets.QDialog.DialogCode.Accepted: # 如果用户点击了接受按钮 + self.exporter.handle_existing = dlg.get_answer() # 获取用户选择的处理方式,将其赋值给导出器的 handle_existing 属性 + directory = self.exporter.dirname # 获取导出器的目录名,将其赋值给 directory 变量 + self.progress = widgets.BeeProgressDialog( # 创建进度对话框 + f'Exporting to {directory}', # 进度对话框标题 + worker=self.worker, # 关联线程化 IO 工作器 + parent=self) # 设置进度对话框父窗口为主窗口 + self.worker.start() # 启动线程化 IO 工作器,开始导出文件 - def on_action_quit(self): - confirm = self.get_confirmation_unsaved_changes( - 'There are unsaved changes. Are you sure you want to quit?') - if confirm: - logger.info('User quit. Exiting...') - self.app.quit() + def on_action_quit(self): # 退出槽函数 + confirm = self.get_confirmation_unsaved_changes( # 获取确认未保存更改对话框 + 'There are unsaved changes. Are you sure you want to quit?') # 获取确认未保存更改对话框,将提示信息传递给对话框 + if confirm: # 如果用户点击了接受按钮 + logger.info('User quit. Exiting...') # 调试日志,提示用户退出 + self.app.quit() # 退出应用程序 - def on_action_settings(self): - widgets.settings.SettingsDialog(self) + def on_action_settings(self): # 设置槽函数 + widgets.settings.SettingsDialog(self) # 创建设置对话框,将主窗口传递给对话框 - def on_action_keyboard_settings(self): - widgets.controls.ControlsDialog(self) + def on_action_keyboard_settings(self): # 键盘设置槽函数 + widgets.controls.ControlsDialog(self) # 创建键盘设置对话框,将主窗口传递给对话框 - def on_action_help(self): - widgets.HelpDialog(self) + def on_action_help(self): # 帮助槽函数 + widgets.HelpDialog(self) # 创建帮助对话框,将主窗口传递给对话框 - def on_action_about(self): - QtWidgets.QMessageBox.about( - self, - f'About {constants.APPNAME}', - (f'

{constants.APPNAME} {constants.VERSION}

' - f'

{constants.APPNAME_FULL}

' - f'

{constants.COPYRIGHT}

' - f'

' - f'Visit the {constants.APPNAME} website

')) + def on_action_about(self): # 关于槽函数 + QtWidgets.QMessageBox.about( # 显示关于对话框 + self, # 关于对话框父窗口为主窗口 + f'About {constants.APPNAME}', # 关于对话框标题 + (f'

{constants.APPNAME} {constants.VERSION}

' # 关于对话框内容,包含应用程序名称、版本、完整名称、版权信息和网站链接 + f'

{constants.APPNAME_FULL}

' # 关于对话框内容,包含应用程序完整名称 + f'

{constants.COPYRIGHT}

' # 关于对话框内容,包含应用程序版权信息 + f'

' # 关于对话框内容,包含应用程序网站链接 + f'Visit the {constants.APPNAME} website

')) # 关于对话框内容,包含应用程序网站链接 - def on_action_debuglog(self): - widgets.DebugLogDialog(self) + def on_action_debuglog(self): # 调试日志槽函数 + widgets.DebugLogDialog(self) # 创建设置对话框,将主窗口传递给对话框 - def on_insert_images_finished(self, new_scene, filename, errors): - """Callback for when loading of images is finished. + def on_insert_images_finished(self, new_scene, filename, errors): # 插入图像完成槽函数 + """Callback for when loading of images is finished. # :param new_scene: True if the scene was empty before, else False :param filename: Not used, for compatibility only :param errors: List of filenames that couldn't be loaded """ + + logger.debug('Insert images finished. Errors: %s', errors) # 调试日志,提示插入图像完成,包含错误文件名列表 + if errors: # 如果有错误文件名 + errornames = [ # 生成错误文件名列表,每个文件名前添加
  • 标签 + f'
  • {fn}
  • ' for fn in errors] # 生成错误文件名列表,每个文件名前添加
  • 标签 + errornames = '
      %s
    ' % '\n'.join(errornames) # 生成错误文件名列表,每个文件名前添加
  • 标签,并用
      标签包裹起来 + num = len(errors) # 计算错误文件名列表的长度,即错误文件名的数量 + msg = f'{num} image(s) could not be opened.
      ' # 提示信息,包含错误文件名数量 + QtWidgets.QMessageBox.warning( # 显示警告对话框 + self, # 关于对话框父窗口为主窗口 + 'Problem loading images', # 关于对话框标题,提示加载图像时出现问题 + msg + IMG_LOADING_ERROR_MSG + errornames) # 关于对话框内容,包含错误文件名列表,提示加载图像时出现问题 + self.scene.add_queued_items() # 添加队列中的项目到场景中 + self.scene.arrange_default() # 对场景中的项目进行默认布局 + self.undo_stack.endMacro() # 结束宏操作,将所有操作合并为一个操作 + if new_scene: # 如果场景为空 + self.on_action_fit_scene() # 调用拟合场景槽函数,将场景中的项目调整到合适的大小和位置 - logger.debug('Insert images finished') - if errors: - errornames = [ - f'
    • {fn}
    • ' for fn in errors] - errornames = '
        %s
      ' % '\n'.join(errornames) - num = len(errors) - msg = f'{num} image(s) could not be opened.
      ' - QtWidgets.QMessageBox.warning( - self, - 'Problem loading images', - msg + IMG_LOADING_ERROR_MSG + errornames) - self.scene.add_queued_items() - self.scene.arrange_default() - self.undo_stack.endMacro() - if new_scene: - self.on_action_fit_scene() + def do_insert_images(self, filenames, pos=None): # 插入图像槽函数 + """Insert images into the scene. # 插入图像槽函数,将图像插入场景中 - def do_insert_images(self, filenames, pos=None): - if not pos: - pos = self.get_view_center() - self.scene.deselect_all_items() - self.undo_stack.beginMacro('Insert Images') - self.worker = fileio.ThreadedIO( - fileio.load_images, - filenames, - self.mapToScene(pos), - self.scene) - self.worker.progress.connect(self.on_items_loaded) - self.worker.finished.connect( - partial(self.on_insert_images_finished, - not self.scene.items())) - self.progress = widgets.BeeProgressDialog( - 'Loading images', - worker=self.worker, - parent=self) - self.worker.start() + :param filenames: List of filenames to insert + :param pos: Position to insert the images at, or None for center + """ + if not pos: # 如果未指定插入位置 + pos = self.get_view_center() # 获取视图中心位置 + self.scene.deselect_all_items() # 取消选择场景中的所有项目 + self.undo_stack.beginMacro('Insert Images') # 开始宏操作,将所有操作合并为一个操作 + self.worker = fileio.ThreadedIO( # 创建线程化 IO 工作器,用于加载图像 + fileio.load_images, # 加载图像函数 + filenames, # 图像文件名列表 + self.mapToScene(pos), # 将插入位置映射到场景坐标 + self.scene) # 场景对象 + self.worker.progress.connect(self.on_items_loaded) # 连接线程化 IO 工作器的 progress 信号到 on_items_loaded 槽函数,用于更新进度条 + self.worker.finished.connect( # 连接线程化 IO 工作器的 finished 信号到 on_insert_images_finished 槽函数 + partial(self.on_insert_images_finished, # 插入图像完成槽函数,将图像插入场景中 + not self.scene.items())) # 插入图像完成槽函数,将图像插入场景中,参数为场景是否为空 + self.progress = widgets.BeeProgressDialog( # 创建进度对话框,用于显示加载图像进度 + 'Loading images', # 进度对话框标题,提示加载图像进度 + worker=self.worker, # 线程化 IO 工作器,用于加载图像 + parent=self) # 进度对话框父窗口为主窗口 + self.worker.start() # 启动线程化 IO 工作器,开始加载图像 - def on_action_insert_images(self): - self.cancel_active_modes() - formats = self.get_supported_image_formats(QtGui.QImageReader) - logger.debug(f'Supported image types for reading: {formats}') - filenames, f = QtWidgets.QFileDialog.getOpenFileNames( - parent=self, - caption='Select one or more images to open', - filter=f'Images ({formats})') - self.do_insert_images(filenames) + def on_action_insert_images(self): # 插入图像槽函数,将图像插入场景中 + self.cancel_active_modes() # 取消激活模式槽函数,将所有激活模式取消激活 + formats = self.get_supported_image_formats(QtGui.QImageReader) # 获取支持的图像格式列表,用于文件对话框筛选 + logger.debug(f'Supported image types for reading: {formats}') # 调试日志,提示支持的图像格式列表 + filenames, f = QtWidgets.QFileDialog.getOpenFileNames( # 获取打开的图像文件名列表,用于插入场景中 + parent=self, # 插入图像槽函数,将图像插入场景中,参数为主窗口 + caption='Select one or more images to open', # 插入图像槽函数,将图像插入场景中,参数为打开图像文件对话框标题 + filter=f'Images ({formats})') # 插入图像槽函数,将图像插入场景中,参数为打开图像文件对话框筛选器,包含支持的图像格式列表 + self.do_insert_images(filenames) # 插入图像槽函数,将图像插入场景中,参数为图像文件名列表 - def on_action_insert_text(self): - self.cancel_active_modes() - item = BeeTextItem() - pos = self.mapToScene(self.mapFromGlobal(self.cursor().pos())) - item.setScale(1 / self.get_scale()) - self.undo_stack.push(commands.InsertItems(self.scene, [item], pos)) + def on_action_insert_text(self): # 插入文本槽函数,将文本插入场景中 + self.cancel_active_modes() # 取消激活模式槽函数,将所有激活模式取消激活 + item = BeeTextItem() # 创建文本项对象,用于插入场景中 + pos = self.mapToScene(self.mapFromGlobal(self.cursor().pos())) # 获取当前光标位置映射到场景坐标,作为文本项插入位置 + item.setScale(1 / self.get_scale()) # 设置文本项缩放比例,根据当前缩放比例计算得到 + self.undo_stack.push(commands.InsertItems(self.scene, [item], pos)) # 插入文本项到场景中,参数为场景对象、文本项列表、插入位置 - def on_action_copy(self): - logger.debug('Copying to clipboard...') - self.cancel_active_modes() - clipboard = QtWidgets.QApplication.clipboard() - items = self.scene.selectedItems(user_only=True) + def on_action_copy(self): # 复制槽函数,将选中的项目复制到剪贴板中 + logger.debug('Copying to clipboard...') # 调试日志,提示复制到剪贴板 + self.cancel_active_modes() # 取消激活模式槽函数,将所有激活模式取消激活 + clipboard = QtWidgets.QApplication.clipboard() # 获取应用程序剪贴板对象,用于复制项目到剪贴板中 + items = self.scene.selectedItems(user_only=True) # 获取场景中选中的项目列表,参数为仅包含用户选择的项目 # 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) + items[0].copy_to_clipboard(clipboard) # 复制选中的项目到剪贴板中,参数为应用程序剪贴板对象 # However, we can copy all items to the internal clipboard: - self.scene.copy_selection_to_internal_clipboard() + self.scene.copy_selection_to_internal_clipboard() # 复制选中的项目到内部剪贴板中,参数为场景对象 # We set a marker for ourselves in the global clipboard so # that we know to look up the internal clipboard when pasting: - clipboard.mimeData().setData( - 'beeref/items', QtCore.QByteArray.number(len(items))) + clipboard.mimeData().setData( # 设置应用程序剪贴板对象的自定义数据,用于标识复制的项目数量 + 'beeref/items', QtCore.QByteArray.number(len(items))) # 设置自定义数据,参数为数据类型('beeref/items')和数据值(项目数量的字节数组表示) - def on_action_paste(self): - self.cancel_active_modes() - logger.debug('Pasting from clipboard...') - clipboard = QtWidgets.QApplication.clipboard() - pos = self.mapToScene(self.mapFromGlobal(self.cursor().pos())) + def on_action_paste(self): # 粘贴槽函数,将剪贴板中的项目粘贴到场景中 + self.cancel_active_modes() # 取消激活模式槽函数,将所有激活模式取消激活 + logger.debug('Pasting from clipboard...') # 调试日志,提示从剪贴板粘贴 + clipboard = QtWidgets.QApplication.clipboard() # 获取应用程序剪贴板对象,用于粘贴项目到场景中 + pos = self.mapToScene(self.mapFromGlobal(self.cursor().pos())) # 获取当前光标位置映射到场景坐标,作为粘贴位置 # See if we need to look up the internal clipboard: - data = clipboard.mimeData().data('beeref/items') - logger.debug(f'Custom data in clipboard: {data}') - if data and self.scene.internal_clipboard: + data = clipboard.mimeData().data('beeref/items') # 获取应用程序剪贴板对象的自定义数据,用于标识复制的项目数量 + logger.debug(f'Custom data in clipboard: {data}') # 调试日志,提示自定义数据在剪贴板中 + if data and self.scene.internal_clipboard: # 如果自定义数据在剪贴板中且场景对象有内部剪贴板 # Checking that internal clipboard exists since the user # may have opened a new scene since copying. - self.scene.paste_from_internal_clipboard(pos) + self.scene.paste_from_internal_clipboard(pos) # 从内部剪贴板中粘贴项目到场景中,参数为粘贴位置 return - img = clipboard.image() - if not img.isNull(): - item = BeePixmapItem(img) - self.undo_stack.push(commands.InsertItems(self.scene, [item], pos)) - if len(self.scene.items()) == 1: + img = clipboard.image() # 获取应用程序剪贴板对象的图像数据 + if not img.isNull(): # 如果图像数据不是空的 + item = BeePixmapItem(img) # 创建图片项对象,参数为图像数据 + self.undo_stack.push(commands.InsertItems(self.scene, [item], pos)) # 插入图片项到场景中,参数为场景对象、图片项列表、插入位置 + if len(self.scene.items()) == 1: # 如果场景中只有一个项目(即插入的图片项) + if len(self.scene.items()) == 1: # # This is the first image in the scene - self.on_action_fit_scene() + self.on_action_fit_scene() # 缩放场景槽函数,将场景缩放以适应所有项目 return - text = clipboard.text() - if text: - item = BeeTextItem(text) - item.setScale(1 / self.get_scale()) - self.undo_stack.push(commands.InsertItems(self.scene, [item], pos)) + text = clipboard.text() # 获取应用程序剪贴板对象的文本数据 + if text: # 如果文本数据不是空的 + item = BeeTextItem(text) # 创建文本项对象,参数为文本数据 + item.setScale(1 / self.get_scale()) # 设置文本项缩放比例,根据当前缩放比例计算得到 + self.undo_stack.push(commands.InsertItems(self.scene, [item], pos)) # 插入文本项到场景中,参数为场景对象、文本项列表、插入位置 return msg = 'No image data or text in clipboard or image too big' - logger.info(msg) - widgets.BeeNotification(self, msg) + logger.info(msg) # 信息日志,提示没有图像数据或文本数据在剪贴板中或图像太大 + widgets.BeeNotification(self, msg) # 显示通知槽函数,参数为父窗口对象(self)和通知消息(msg) - def on_action_open_settings_dir(self): - dirname = os.path.dirname(self.settings.fileName()) + def on_action_open_settings_dir(self): # 打开设置目录槽函数,用于打开应用程序的设置目录 + dirname = os.path.dirname(self.settings.fileName()) # 获取应用程序设置文件的目录路径 QtGui.QDesktopServices.openUrl( - QtCore.QUrl.fromLocalFile(dirname)) + QtCore.QUrl.fromLocalFile(dirname)) # 打开目录路径对应的本地文件资源 - def on_selection_changed(self): + 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()) + 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) # 获取选中项的灰度属性,默认值为False + actions.actions['grayscale'].qaction.setChecked(grayscale) # 设置灰度操作项的复选框状态,根据选中项的灰度属性来设置 + self.viewport().repaint() # 重绘视口槽函数,用于刷新视口显示,确保选中项的灰度属性复选框状态正确显示 - def on_cursor_changed(self, cursor): - if self.active_mode is None: - self.viewport().setCursor(cursor) + def on_cursor_changed(self, cursor): # 光标改变槽函数,用于处理场景中光标位置的变化 + if self.active_mode is None: # 如果当前激活模式为None + self.viewport().setCursor(cursor) # 设置视口光标为参数cursor - def on_cursor_cleared(self): - if self.active_mode is None: - self.viewport().unsetCursor() + def on_cursor_cleared(self): # 光标清除槽函数,用于处理场景中光标位置的清除 + if self.active_mode is None: # 如果当前激活模式为None + self.viewport().unsetCursor() # 清除视口光标 - def recalc_scene_rect(self): + def recalc_scene_rect(self): # 重新计算场景矩形槽函数,用于调整场景矩形以适应所有项目 """Resize the scene rectangle so that it is always one view width wider than all items' bounding box at each side and one view width higher on top and bottom. This gives the impression of an infinite canvas.""" - if self.previous_transform: + if self.previous_transform: # 如果之前有变换矩阵 return - logger.trace('Recalculating scene rectangle...') + logger.trace('Recalculating scene rectangle...') # 跟踪日志,提示重新计算场景矩形 try: - topleft = self.mapFromScene( - self.scene.itemsBoundingRect().topLeft()) - topleft = self.mapToScene(QtCore.QPoint( - topleft.x() - self.size().width(), - topleft.y() - self.size().height())) - bottomright = self.mapFromScene( - self.scene.itemsBoundingRect().bottomRight()) - bottomright = self.mapToScene(QtCore.QPoint( - bottomright.x() + self.size().width(), - bottomright.y() + self.size().height())) - self.setSceneRect(QtCore.QRectF(topleft, bottomright)) - except OverflowError: - logger.info('Maximum scene size reached') - logger.trace('Done recalculating scene rectangle') + topleft = self.mapFromScene( # 映射场景矩形的左上角到视口坐标 + self.scene.itemsBoundingRect().topLeft()) # 映射场景矩形的左上角到视口坐标 + topleft = self.mapToScene(QtCore.QPoint( # 映射视口坐标的左上角到场景坐标 + topleft.x() - self.size().width() / 2, # 减去视口宽度的一半,确保场景矩形在视口宽度的一半宽度内 + topleft.y() - self.size().height() / 2)) # 减去视口高度的一半,确保场景矩形在视口高度的一半高度内 + bottomright = self.mapFromScene( # 映射场景矩形的右下角到视口坐标 + self.scene.itemsBoundingRect().bottomRight()) # 映射场景矩形的右下角到视口坐标 + bottomright = self.mapToScene(QtCore.QPoint( # 映射视口坐标的右下角到场景坐标 + bottomright.x() + self.size().width() / 2, # 加上视口宽度的一半,确保场景矩形在视口宽度的一半宽度内 + bottomright.y() + self.size().height() / 2)) # 加上视口高度的一半,确保场景矩形在视口高度的一半高度内 + self.setSceneRect(QtCore.QRectF(topleft, bottomright)) # 设置场景矩形为新计算的矩形 + except OverflowError: # 处理溢出错误,当场景矩形超出最大尺寸时触发 + logger.info('Maximum scene size reached') # 信息日志,提示最大场景尺寸已达到 + logger.trace('Done recalculating scene rectangle') # 跟踪日志,提示完成重新计算场景矩形 - def get_zoom_size(self, func): + def get_zoom_size(self, func): # 获取缩放尺寸槽函数,用于计算场景中所有项目的边界框尺寸 """Calculates the size of all items' bounding box in the view's coordinates. @@ -776,34 +793,34 @@ class BeeGraphicsView(MainControlsMixin, arguments and turns it into a number, for ex. ``min`` or ``max``. """ - topleft = self.mapFromScene( - self.scene.itemsBoundingRect().topLeft()) - bottomright = self.mapFromScene( - self.scene.itemsBoundingRect().bottomRight()) - return func(bottomright.x() - topleft.x(), - bottomright.y() - topleft.y()) + topleft = self.mapFromScene( # 映射场景矩形的左上角到视口坐标 + self.scene.itemsBoundingRect().topLeft()) # 映射场景矩形的左上角到视口坐标 + bottomright = self.mapFromScene( # 映射场景矩形的右下角到视口坐标 + self.scene.itemsBoundingRect().bottomRight()) # 映射场景矩形的右下角到视口坐标 + return func(bottomright.x() - topleft.x(), # 计算场景中所有项目的边界框宽度 + bottomright.y() - topleft.y()) # 计算场景中所有项目的边界框高度 - def scale(self, *args, **kwargs): - super().scale(*args, **kwargs) - self.scene.on_view_scale_change() - self.recalc_scene_rect() + def scale(self, *args, **kwargs): # 缩放槽函数,用于处理场景中项目的缩放 + super().scale(*args, **kwargs) # 调用父类的缩放槽函数,用于处理场景中项目的缩放 + self.scene.on_view_scale_change() # 调用场景中的视图缩放变化槽函数,用于处理场景中项目的缩放变化 + self.recalc_scene_rect() # 重新计算场景矩形槽函数,用于调整场景矩形以适应所有项目 def get_scale(self): - return self.transform().m11() + return self.transform().m11() # 返回当前变换矩阵的缩放比例因子 - def pan(self, delta): - if not self.scene.items(): - logger.debug('No items in scene; ignore pan') + def pan(self, delta): # 平移槽函数,用于处理场景中项目的平移 + if not self.scene.items(): # 如果场景中没有项目 + logger.debug('No items in scene; ignore pan') # 调试日志,提示场景中没有项目,忽略平移 return - hscroll = self.horizontalScrollBar() - hscroll.setValue(int(hscroll.value() + delta.x())) - vscroll = self.verticalScrollBar() - vscroll.setValue(int(vscroll.value() + delta.y())) + hscroll = self.horizontalScrollBar() # 获取水平滚动条 + hscroll.setValue(int(hscroll.value() + delta.x())) # 设置水平滚动条的值为当前值加上平移量的整数部分 + vscroll = self.verticalScrollBar() # 获取垂直滚动条 + vscroll.setValue(int(vscroll.value() + delta.y())) # 设置垂直滚动条的值为当前值加上平移量的整数部分 - def zoom(self, delta, anchor): - if not self.scene.items(): - logger.debug('No items in scene; ignore zoom') + def zoom(self, delta, anchor): # 缩放槽函数,用于处理场景中项目的缩放 + if not self.scene.items(): # 如果场景中没有项目 + logger.debug('No items in scene; ignore zoom') # 调试日志,提示场景中没有项目,忽略缩放 return # We calculate where the anchor is before and after the zoom @@ -811,150 +828,150 @@ class BeeGraphicsView(MainControlsMixin, # We can't use QGraphicsView's AnchorUnderMouse since it # uses the current cursor position while we need the initial mouse # press position for zooming with Ctrl + Middle Drag - anchor = QtCore.QPoint(round(anchor.x()), - round(anchor.y())) - ref_point = self.mapToScene(anchor) - if delta == 0: - return - factor = 1 + abs(delta / 1000) - if delta > 0: - if self.get_zoom_size(max) < 10000000: - self.scale(factor, factor) - else: - logger.debug('Maximum zoom size reached') - return + anchor = QtCore.QPoint(round(anchor.x()), # 四舍五入 anchor 的 x 坐标 + round(anchor.y())) # 四舍五入 anchor 的 y 坐标 + ref_point = self.mapToScene(anchor) # 映射 anchor 到场景坐标 + if delta == 0: # 如果缩放量为 0 + return # 如果缩放量为 0,忽略缩放 + factor = 1 + abs(delta / 1000) # 计算缩放因子 + if delta > 0: # 如果缩放量大于 0 + if self.get_zoom_size(max) < 10000000: # 如果缩放后场景中所有项目的边界框宽度和高度都小于 10000000 + self.scale(factor, factor) # 缩放场景中所有项目的边界框宽度和高度 + else: # 如果缩放后场景中所有项目的边界框宽度和高度都大于等于 10000000 + logger.debug('Maximum zoom size reached') # 调试日志,提示最大缩放尺寸已达到 + return # 如果缩放后场景中所有项目的边界框宽度和高度都大于等于 10000000,忽略缩放 else: - if self.get_zoom_size(min) > 50: - self.scale(1/factor, 1/factor) + if self.get_zoom_size(min) > 50: # 如果缩放后场景中所有项目的边界框宽度和高度都大于 50 + self.scale(1/factor, 1/factor) # 缩放场景中所有项目的边界框宽度和高度 else: - logger.debug('Minimum zoom size reached') - return + logger.debug('Minimum zoom size reached') # 调试日志,提示最小缩放尺寸已达到 + return # 如果缩放后场景中所有项目的边界框宽度和高度都大于 50,忽略缩放 - self.pan(self.mapFromScene(ref_point) - anchor) - self.reset_previous_transform() + self.pan(self.mapFromScene(ref_point) - anchor) # 平移场景中所有项目的边界框宽度和高度 + self.reset_previous_transform() # 重置前一个变换矩阵,用于处理场景中项目的缩放变化 - def wheelEvent(self, event): + def wheelEvent(self, event): # 滚轮事件槽函数,用于处理场景中项目的缩放和平移 action, inverted\ - = self.keyboard_settings.mousewheel_action_for_event(event) + = self.keyboard_settings.mousewheel_action_for_event(event) # 获取滚轮事件的缩放和平移操作 - delta = event.angleDelta().y() - if inverted: - delta = delta * -1 + delta = event.angleDelta().y() # 获取滚轮事件的缩放增量 + if inverted: # 如果滚轮事件的缩放增量为负数 + delta = delta * -1 # 取反缩放增量 - if action == 'zoom': - self.zoom(delta, event.position()) - event.accept() + if action == 'zoom': # 如果滚轮事件的缩放操作 + self.zoom(delta, event.position()) # 调用缩放槽函数,用于处理场景中项目的缩放 + event.accept() # 接受滚轮事件,防止事件冒泡到父类 + return + if action == 'pan_horizontal': # 如果滚轮事件的水平平移操作 + self.pan(QtCore.QPointF(0, 0.5 * delta)) # 调用平移槽函数,用于处理场景中项目的水平平移 + event.accept() # 接受滚轮事件,防止事件冒泡到父类 return - if action == 'pan_horizontal': - self.pan(QtCore.QPointF(0, 0.5 * delta)) - event.accept() - return - if action == 'pan_vertical': - self.pan(QtCore.QPointF(0.5 * delta, 0)) - event.accept() + if action == 'pan_vertical': # 如果滚轮事件的垂直平移操作 + self.pan(QtCore.QPointF(0.5 * delta, 0)) # 调用平移槽函数,用于处理场景中项目的垂直平移 + event.accept() # 接受滚轮事件,防止事件冒泡到父类 return - def mousePressEvent(self, event): - if self.mousePressEventMainControls(event): + def mousePressEvent(self, event): # 鼠标按下事件槽函数,用于处理场景中项目的缩放和平移 + if self.mousePressEventMainControls(event): # 如果鼠标按下事件是主控件的事件 return - if self.active_mode == self.SAMPLE_COLOR_MODE: - if (event.button() == Qt.MouseButton.LeftButton): - color = self.scene.sample_color_at( - self.mapToScene(event.pos())) - if color: - name = qcolor_to_hex(color) - clipboard = QtWidgets.QApplication.clipboard() - clipboard.setText(name) - self.scene.internal_clipboard = [] - msg = f'Copied color to clipboard: {name}' - logger.debug(msg) - widgets.BeeNotification(self, msg) + if self.active_mode == self.SAMPLE_COLOR_MODE: # 如果当前模式是采样颜色模式 + if (event.button() == Qt.MouseButton.LeftButton): # 如果鼠标按下事件是左键 + color = self.scene.sample_color_at( # 调用场景中的采样颜色函数,用于获取场景中指定位置的颜色 + self.mapToScene(event.pos())) # 映射鼠标按下事件的位置到场景坐标 + if color: # 如果获取到颜色 + name = qcolor_to_hex(color) # 将颜色转换为十六进制字符串 + clipboard = QtWidgets.QApplication.clipboard() # 获取应用程序的剪贴板对象 + clipboard.setText(name) # 将颜色的十六进制字符串复制到剪贴板 + self.scene.internal_clipboard = [] # 清空场景中的内部剪贴板 + msg = f'Copied color to clipboard: {name}' + logger.debug(msg) # 调试日志,提示复制颜色到剪贴板 + widgets.BeeNotification(self, msg) # 显示通知消息,提示复制颜色到剪贴板 else: - logger.debug('No color found') - self.cancel_sample_color_mode() - event.accept() + logger.debug('No color found at %s', event.pos()) # 调试日志,提示未找到颜色 + self.cancel_sample_color_mode() # 取消采样颜色模式 + event.accept() # 接受鼠标按下事件,防止事件冒泡到父类 return - action, inverted = self.keyboard_settings.mouse_action_for_event(event) + action, inverted = self.keyboard_settings.mouse_action_for_event(event) # 获取鼠标按下事件的缩放和平移操作 - if action == 'zoom': - self.active_mode = self.ZOOM_MODE - self.event_start = event.position() - self.event_anchor = event.position() - self.event_inverted = inverted - event.accept() + if action == 'zoom': # 如果鼠标按下事件的缩放操作 + self.active_mode = self.ZOOM_MODE # 设置当前模式为缩放模式 + self.event_start = event.position() # 记录鼠标按下事件的位置 + self.event_anchor = event.position() # 记录鼠标按下事件的位置 + self.event_inverted = inverted # 记录鼠标按下事件的缩放增量是否为负数 + event.accept() # 接受鼠标按下事件,防止事件冒泡到父类 return - if action == 'pan': + if action == 'pan': # 如果鼠标按下事件的平移操作 logger.trace('Begin pan') - self.active_mode = self.PAN_MODE - self.event_start = event.position() - self.viewport().setCursor(Qt.CursorShape.ClosedHandCursor) + self.active_mode = self.PAN_MODE # 设置当前模式为平移模式 + self.event_start = event.position() # 记录鼠标按下事件的位置 + self.viewport().setCursor(Qt.CursorShape.ClosedHandCursor) # 设置视口的光标为关闭手型光标 # ClosedHandCursor and OpenHandCursor don't work, but I # don't know if that's only on my system or a general # problem. It works with other cursors. - event.accept() + event.accept() # 接受鼠标按下事件,防止事件冒泡到父类 return - super().mousePressEvent(event) + super().mousePressEvent(event) # 调用父类的鼠标按下事件槽函数,用于处理其他鼠标按下事件 - def mouseMoveEvent(self, event): - if self.active_mode == self.PAN_MODE: - self.reset_previous_transform() - pos = event.position() - self.pan(self.event_start - pos) - self.event_start = pos - event.accept() + def mouseMoveEvent(self, event): # 鼠标移动事件槽函数,用于处理场景中项目的缩放和平移 + if self.active_mode == self.PAN_MODE: # 如果当前模式是平移模式 + self.reset_previous_transform() # 重置前一次变换,用于处理场景中项目的缩放和平移 + pos = event.position() # 获取鼠标移动事件的位置 + self.pan(self.event_start - pos) # 调用平移槽函数,用于处理场景中项目的平移 + self.event_start = pos # 更新鼠标按下事件的位置 + event.accept() # 接受鼠标移动事件,防止事件冒泡到父类 return - if self.active_mode == self.ZOOM_MODE: - self.reset_previous_transform() - pos = event.position() - delta = (self.event_start - pos).y() - if self.event_inverted: - delta *= -1 - self.event_start = pos - self.zoom(delta * 20, self.event_anchor) - event.accept() + if self.active_mode == self.ZOOM_MODE: # 如果当前模式是缩放模式 + self.reset_previous_transform() # 重置前一次变换,用于处理场景中项目的缩放和平移 + pos = event.position() # 获取鼠标移动事件的位置 + delta = (self.event_start - pos).y() # 计算鼠标移动事件的垂直增量 + if self.event_inverted: # 如果鼠标移动事件的缩放增量为负数 + delta *= -1 # 取反,用于处理场景中项目的缩放 + self.event_start = pos # 更新鼠标按下事件的位置 + self.zoom(delta * 20, self.event_anchor) # 调用缩放槽函数,用于处理场景中项目的缩放 + event.accept() # 接受鼠标移动事件,防止事件冒泡到父类 return - if self.active_mode == self.SAMPLE_COLOR_MODE: - self.sample_color_widget.update( - event.position(), - self.scene.sample_color_at(self.mapToScene(event.pos()))) - event.accept() + if self.active_mode == self.SAMPLE_COLOR_MODE: # 如果当前模式是采样颜色模式 + self.sample_color_widget.update( # 更新采样颜色小部件的显示 + event.position(), # 更新采样颜色小部件的显示位置 + self.scene.sample_color_at(self.mapToScene(event.pos()))) # 更新采样颜色小部件的显示颜色 + event.accept() # 接受鼠标移动事件,防止事件冒泡到父类 return - if self.mouseMoveEventMainControls(event): + if self.mouseMoveEventMainControls(event): # 如果主控件处理了鼠标移动事件 return - super().mouseMoveEvent(event) + super().mouseMoveEvent(event) # 调用父类的鼠标移动事件槽函数,用于处理其他鼠标移动事件 - def mouseReleaseEvent(self, event): - if self.active_mode == self.PAN_MODE: - logger.trace('End pan') - self.viewport().unsetCursor() - self.active_mode = None - event.accept() + def mouseReleaseEvent(self, event): # 鼠标释放事件槽函数,用于处理场景中项目的缩放和平移 + if self.active_mode == self.PAN_MODE: # 如果当前模式是平移模式 + logger.trace('End pan') # 调试日志,提示结束平移 + self.viewport().unsetCursor() # 重置视口的光标为默认光标 + self.active_mode = None # 重置当前模式为 None + event.accept() # 接受鼠标释放事件,防止事件冒泡到父类 return - if self.active_mode == self.ZOOM_MODE: - self.active_mode = None - event.accept() + if self.active_mode == self.ZOOM_MODE: # 如果当前模式是缩放模式 + self.active_mode = None # 重置当前模式为 None + event.accept() # 接受鼠标释放事件,防止事件冒泡到父类 return - if self.mouseReleaseEventMainControls(event): + if self.mouseReleaseEventMainControls(event): # 如果主控件处理了鼠标释放事件 return - super().mouseReleaseEvent(event) + super().mouseReleaseEvent(event) # 调用父类的鼠标释放事件槽函数,用于处理其他鼠标释放事件 - def resizeEvent(self, event): - super().resizeEvent(event) - self.recalc_scene_rect() - self.welcome_overlay.resize(self.size()) + def resizeEvent(self, event): # 场景视图的大小调整事件槽函数,用于处理场景视图的大小调整 + super().resizeEvent(event) # 调用父类的大小调整事件槽函数,用于处理其他大小调整事件 + self.recalc_scene_rect() # 重新计算场景矩形,用于处理场景中项目的缩放和平移 + self.welcome_overlay.resize(self.size()) # 调整欢迎叠加层的大小,用于处理场景视图的大小调整 - def keyPressEvent(self, event): - if self.keyPressEventMainControls(event): + def keyPressEvent(self, event): # 键盘按下事件槽函数,用于处理场景中项目的缩放和平移 + if self.keyPressEventMainControls(event): # 如果主控件处理了键盘按下事件 return - if self.active_mode == self.SAMPLE_COLOR_MODE: - self.cancel_sample_color_mode() - event.accept() + if self.active_mode == self.SAMPLE_COLOR_MODE: # 如果当前模式是采样颜色模式 + self.cancel_sample_color_mode() # 取消采样颜色模式 + event.accept() # 接受键盘按下事件,防止事件冒泡到父类 return - super().keyPressEvent(event) + super().keyPressEvent(event) # 调用父类的键盘按下事件槽函数,用于处理其他键盘按下事件