警告

本部分包含自动从 C++ 翻译成 Python 的代码片段,可能存在错误。

笔记示例#

笔记示例展示了如何重新实现一些 QWidget 的事件处理程序以接收应用程序控件生成的事件。

我们重新实现了鼠标事件处理程序以实现绘图,重新实现了绘制事件处理程序以更新应用程序,并重新实现了大小调整事件处理程序以优化应用程序的外观。此外,我们还重新实现了关闭事件处理程序以在终止应用程序之前拦截关闭事件。

示例还展示了如何使用 QPainter 在实时中绘制图像以及重绘小部件。

../_images/scribble-example.png

使用笔记应用程序,用户可以绘制图像。文件菜单允许用户打开和编辑现有图像文件,保存图像并退出应用程序。在绘图时,选项菜单允许用户选择笔颜色和笔宽度,以及清除屏幕。此外,帮助菜单为用户提供有关笔记示例特别是 Qt 的一般信息。

本例包含两个类

  • ScribbleArea 是一个自定义小部件,显示 QImage 并允许用户在其上绘制。

  • MainWindow 提供了一个在 ScribbleArea 之上的菜单。

我们将首先回顾 ScribbleArea 类。然后我们将回顾使用 ScribbleAreaMainWindow 类。

ScribbleArea 类定义#

class ScribbleArea(QWidget):

    Q_OBJECT
# public
    ScribbleArea(QWidget parent = None)
    openImage = bool(QString fileName)
    saveImage = bool(QString fileName, char fileFormat)
    def setPenColor(newColor):
    def setPenWidth(newWidth):
    bool isModified() { return modified; }
    QColor penColor() { return myPenColor; }
    int penWidth() { return myPenWidth; }
# public slots
    def clearImage():
    def print():
# protected
    def mousePressEvent(event):
    def mouseMoveEvent(event):
    def mouseReleaseEvent(event):
    def paintEvent(event):
    def resizeEvent(event):
# private
    def drawLineTo(endPoint):
    def resizeImage(image, newSize):
    modified = False
    scribbling = False
    myPenWidth = 1
    myPenColor = Qt.blue()
    image = QImage()
    lastPoint = QPoint()

ScribbleArea 类从 QWidget 继承。我们重新实现了 mousePressEvent()mouseMoveEvent()mouseReleaseEvent() 函数以实现绘图。我们重新实现了 paintEvent() 函数以更新笔记区域,并重新实现了 resizeEvent() 函数以确保我们在任何时刻绘制的 QImage 至少与小部件一样大。

我们需要几个公共函数: openImage() 从文件中加载图像到草图区域,允许用户编辑图像; save() 将当前显示的图像写入文件; clearImage() 空闲槽清除草图区域显示的图像。我们需要私有的 drawLineTo() 函数来实际绘图,以及 resizeImage() 来更改 QImage 的大小。 print() 空闲槽处理打印。

我们还需要以下私有变量

  • modified 在草图区域显示的图像有未保存的更改时为 true

  • scribbling 在用户在草图区域内按下鼠标左键时为 true

  • penWidthpenColor 存储当前应用程序中用于笔的设置宽度颜色。

  • image 存储用户绘制的图像。

  • lastPoint 保存最后一次鼠标点击或鼠标移动事件的光标位置。

草图区域类实现#

def __init__(self, parent):
    super().__init__(parent)

    setAttribute(Qt.WA_StaticContents)

在构造函数中,我们为小部件设置 Qt::WA_StaticContents 属性,表示小部件内容根植于左上角,并在小部件调整大小时不会改变。Qt 使用此属性来优化调整大小时的绘图事件。这纯粹是一种优化,并且仅应用于内容是静态且根植于左上角的小部件。

def openImage(self, QString fileName):
loadedImage = QImage()
if not loadedImage.load(fileName):
    return False
newSize = loadedImage.size().expandedTo(size())
resizeImage(loadedImage, newSize)
image = loadedImage
modified = False
update()
return True

openImage() 函数中,我们加载给定的图像。然后使用私有的 resizeImage() 函数将加载的 QImage 调整大小,使其在两个方向上至少与小部件一样大,并将 image 成员变量设置为加载的图像。最后,我们调用 update() 来安排重绘。

def saveImage(self, QString fileName, char fileFormat):
visibleImage = image
resizeImage(visibleImage, size())
if visibleImage.save(fileName, fileFormat):
    modified = False
    return True

return False

saveImage() 函数创建一个仅覆盖实际 image 可见部分的 QImage 对象,并使用 QImage::save() 进行保存。如果图像成功保存,我们将草图区域的 modified 变量设置为 false,因为没有未保存的数据。

def setPenColor(self, newColor):
myPenColor = newColor

def setPenWidth(self, newWidth):
myPenWidth = newWidth

setPenColor()setPenWidth() 函数设置了当前的笔颜色和宽度。这些值将用于未来的绘图操作。

def clearImage(self):
image.fill(qRgb(255, 255, 255))
modified = True
update()

公共 clearImage() 空闲槽清除草图区域显示的图像。我们简单地使用白色填充整个图像,对应 RGB 值(255,255,255)。像通常修改图像一样,我们将 modified 设置为 true 并安排重绘。

def mousePressEvent(self, event):
    if event.button() == Qt.LeftButton:
        lastPoint = event.position().toPoint()
        scribbling = True


def mouseMoveEvent(self, event):

    if (event.buttons()  Qt.LeftButton) and scribbling:
        drawLineTo(event.position().toPoint())

def mouseReleaseEvent(self, event):

    if event.button() == Qt.LeftButton and scribbling:
        drawLineTo(event.position().toPoint())
        scribbling = False

对于鼠标点击和鼠标释放事件,我们使用 QMouseEvent::button() 函数找出哪个按钮引发了事件。对于鼠标移动事件,我们使用 QMouseEvent::buttons() 找出当前按下的哪个按钮(作为 OR 组合)。

如果用户按下鼠标左键,我们将鼠标光标的位置存储在lastPoint中。我们还记录用户目前正在涂鸦。(scribbling变量是必要的,因为我们不能假设鼠标移动和鼠标释放事件总是由同一小部件上的鼠标按下事件引起的。)

如果用户按下左键移动鼠标或释放按钮,我们调用私有的drawLineTo()函数进行绘制。

def paintEvent(self, event):
painter = QPainter(self)
dirtyRect = event.rect()
painter.drawImage(dirtyRect, image, dirtyRect)

在重新实现paintEvent()函数时,我们只需为涂鸦区域创建一个QPainter,并绘制图像。

此时,你可能会想知道为什么我们不直接在控件上绘制,而是在QImage中绘制并在paintEvent()中将QImage复制到屏幕上。至少有以下三个很好的理由

  • 操作系统要求我们能够随时重新绘制控件。例如,如果窗口被最小化并恢复,操作系统可能已经忘记了窗口控件的内容,并发送给我们一个绘制事件。换句话说,我们不能依赖操作系统记住我们的图像。

  • Qt通常不允许我们在paintEvent()之外进行绘制。特别是我们无法从鼠标事件处理器中进行绘制。(尽管可以通过使用Qt::WA_PaintOnScreen控件属性来更改此行为。)

  • 如果正确初始化,QImage每个颜色通道(红、绿、蓝和alpha)保证使用8位,而QWidget的颜色深度可能更低,取决于显示器的配置。这意味着如果我们加载一个24位或32位图像并将其绘制到一个QWidget中,然后再次将QWidget复制到QImage中,我们可能会丢失一些信息。

def resizeEvent(self, event):
if width() > image.width() or height() > image.height():
    newWidth = qMax(width() + 128, image.width())
    newHeight = qMax(height() + 128, image.height())
    resizeImage(image, QSize(newWidth, newHeight))
    update()

QWidget.resizeEvent(event)

当用户启动Scribble应用时,会生成一个调整大小事件,并在涂鸦区域创建并显示一个图像。我们使这个初始图像比应用程序的主窗口和涂鸦区域略微大一些,以避免在用户调整主窗口大小时一直调整图像大小(这将非常低效)。但是当主窗口变得比这个初始大小更大时,需要调整图像的大小。

def drawLineTo(self, endPoint):
painter = QPainter(image)
painter.setPen(QPen(myPenColor, myPenWidth, Qt.SolidLine, Qt.RoundCap,
                    Qt.RoundJoin))
painter.drawLine(lastPoint, endPoint)
modified = True
rad = (myPenWidth / 2) + 2
update(QRect(lastPoint, endPoint).normalized()
                                 .adjusted(-rad, -rad, +rad, +rad))
lastPoint = endPoint

drawLineTo()中,我们从上一次鼠标按下或鼠标移动发生时鼠标所在的位置画一条线,我们将modified设为true,我们生成一个重绘事件,并更新lastPoint,这样下次调用drawLineTo()时,我们就从上次停止的地方继续绘制。

我们可以调用不带参数的update()函数,但作为一个简单的优化,我们传递一个QRect,指定需要更新的涂鸦区域内的矩形,以避免整个控件的完整重绘。

def resizeImage(self, image, newSize):
if image.size() == newSize:
    return
newImage = QImage(newSize, QImage.Format_RGB32)
newImage.fill(qRgb(255, 255, 255))
painter = QPainter(newImage)
painter.drawImage(QPoint(0, 0), image)
image = newImage

QImage没有好的API来进行图像缩放。存在一个QImage::copy()函数可以起到这个效果,但当用于扩展图像时,它会用黑色填充新的区域,而我们希望填充白色。

因此,秘诀是创建一个新的QImage对象,设置其正确的尺寸,用白色填充它,然后使用QPainter在上面绘制旧图像。新的图像被赋予QImage::Format_RGB32格式,这意味着每个像素都存储为0xffRRGGBB(其中RR、GG和BB分别是红、绿和蓝色通道,ff是十六进制的255)。

打印由print()槽处理

def print(self):

#if defined(QT_PRINTSUPPORT_LIB) and QT_CONFIG(printdialog)
    printer = QPrinter(QPrinter.HighResolution)
    printDialog = QPrintDialog(printer, self)

我们构造一个用于所需输出格式的High Resolution QPrinter对象,使用QPrintDialog询问用户指定页面大小并指示输出如何在页面上格式化。

如果对话框被接受,我们执行将任务打印到绘画设备的打印操作

    if printDialog.exec() == QDialog.Accepted:
        painter = QPainter(printer)
        rect = painter.viewport()
        size = image.size()
        size.scale(rect.size(), Qt.KeepAspectRatio)
        painter.setViewport(rect.x(), rect.y(), size.width(), size.height())
        painter.setWindow(image.rect())
        painter.drawImage(0, 0, image)

#endif // QT_CONFIG(printdialog)

以这种方式将图像打印到文件,只是简单地将它绘制到QPrinter上。在绘制到绘画设备之前,我们首先将图像缩放以适应页面上可用的空间。

MainWindow类定义#

class MainWindow(QMainWindow):

    Q_OBJECT
# public
    MainWindow(QWidget parent = None)
# protected
    def closeEvent(event):
# private slots
    def open():
    def save():
    def penColor():
    def penWidth():
    def about():
# private
    def createActions():
    def createMenus():
    maybeSave = bool()
    saveFile = bool(QByteArray fileFormat)
    scribbleArea = ScribbleArea()
    saveAsMenu = QMenu()
    fileMenu = QMenu()
    optionMenu = QMenu()
    helpMenu = QMenu()
    openAct = QAction()
*> = QList<QAction()
    exitAct = QAction()
    penColorAct = QAction()
    penWidthAct = QAction()
    printAct = QAction()
    clearScreenAct = QAction()
    aboutAct = QAction()
    aboutQtAct = QAction()

MainWindow类从QMainWindow继承而来。我们重新实现了来自QWidgetcloseEvent()处理程序。与菜单条目对应的open()save()penColor()penWidth()槽。此外,我们创建了四个私有函数。

我们使用布尔类型的maybeSave()函数来检查是否有任何未保存的更改。如果有未保存的更改,我们允许用户保存这些更改。如果用户点击取消,该函数返回false。我们使用saveFile()函数让用户保存当前显示在涂鸦区域中的图像。

MainWindow类实现#

def __init__(self, parent):
    super().__init__(parent)
    self.scribbleArea = ScribbleArea(self)

    setCentralWidget(scribbleArea)
    createActions()
    createMenus()
    setWindowTitle(tr("Scribble"))
    resize(500, 500)

在构造函数中,我们创建了一个涂鸦区域,使其成为MainWindow小部件的中心部件。然后我们创建了相关的动作和菜单。

def closeEvent(self, event):
    if maybeSave():
        event.accept()
else:
        event.ignore()

关闭事件被发送到用户想要关闭的小部件,通常通过点击文件|退出或点击X标题栏按钮来完成。通过实现事件处理程序,我们可以截获尝试关闭应用程序的请求。

在这个例子中,我们使用关闭事件来询问用户保存任何未保存的更改。这个逻辑位于maybeSave()函数中。如果maybeSave()返回true,则没有修改或用户成功保存了它们,此时我们接受事件。然后应用程序可以正常终止。如果maybeSave()返回false,则用户点击了取消,因此我们“忽略”这个事件,让应用程序不受其影响。

def open(self):
if maybeSave():
    fileName = QFileDialog.getOpenFileName(self,()
                               tr("Open File"), QDir.currentPath())
    if not fileName.isEmpty():
        scribbleArea.openImage(fileName)

open() 槽中,我们首先让用户有机会保存当前显示的图像的任何修改,然后再将新图像加载到涂鸦区域。然后我们要求用户选择一个文件,并在 ScribbleArea 中加载该文件。

def save(self):
action = QAction(sender())
fileFormat = action.data().toByteArray()
saveFile(fileFormat)

当用户选择“另存为”菜单项,并从格式菜单中选择一个条目时,会调用 save() 槽。我们需要做的第一件事是使用 QObject::sender() 函数找出是哪个操作发送了信号。这个函数返回一个作为 QObject 指针的发送者。既然我们知道发送者是一个操作对象,我们可以安全地将 QObject 强制转换。我们可以使用 C 风格的转换或 C++ 的 static_cast<>(),但作为一种防御性编程技术,我们使用 qobject_cast()。优点是如果对象类型不正确,返回一个空指针。空指针导致的崩溃比不安全的转换导致的崩溃更容易诊断。

一旦我们有了操作,我们就使用 QAction::data() 提取所选的格式。(当创建动作时,我们使用 QAction::setData() 来设置与动作关联的自定义数据,作为一个 QVariant。更多关于这个内容将在我们回顾 createActions() 时介绍。)

现在我们知道格式后,我们调用私有的 saveFile() 函数来保存当前显示的图像。

def penColor(self):
newColor = QColorDialog.getColor(scribbleArea.penColor())
if newColor.isValid():
    scribbleArea.setPenColor(newColor)

我们使用 penColor() 槽通过一个 QColorDialog 来从用户处获取新颜色。如果用户选择了一个新颜色,我们就将其设置为涂鸦区域的颜色。

def penWidth(self):
ok = bool()
newWidth = QInputDialog.getInt(self, tr("Scribble"),()
                                    tr("Select pen width:"),
                                    scribbleArea.penWidth(),
                                    1, 50, 1, ok)
if ok:
    scribbleArea.setPenWidth(newWidth)

penWidth() 槽中获取新的笔宽,我们使用 QInputDialogQInputDialog 类提供了一个简单的便利对话框来从用户那里获取单个值。我们使用静态 getInt() 函数,结合了一个 QLabel 和一个 QSpinBoxQSpinBox 初始化为涂鸦区域的笔宽,允许从 1 到 50 的范围,步长为 1(意味着上和下箭头键增加或减少值 1)。

布尔变量 ok 将在被设置为 true 如果用户点击了“确定”,如果用户按下了“取消”,则设置为 false

def about(self):
QMessageBox.about(self, tr("About Scribble"),
        tr("<p>The <b>Scribble</b> example shows how to use QMainWindow as the "
           "base widget for an application, and how to reimplement some of "
           "QWidget's event handlers to receive the events generated for "
           "the application's widgets:</p><p> We reimplement the mouse event "
           "handlers to facilitate drawing, the paint event handler to "
           "update the application and the resize event handler to optimize "
           "the application's appearance. In addition we reimplement the "
           "close event handler to intercept the close events before "
           "terminating the application.</p><p> The example also demonstrates "
           "how to use QPainter to draw an image in real time, as well as "
           "to repaint widgets.</p>"))

我们实现了 about() 槽来创建一个消息框,描述示例设计用来展示的内容。

def createActions(self):
openAct = QAction(tr("Open..."), self)
openAct.setShortcuts(QKeySequence.Open)
openAct.triggered.connect(self.open)
imageFormats = QImageWriter.supportedImageFormats()
for format in imageFormats:
    text = tr("%1...").arg(QString.fromLatin1(format).toUpper())
    action = QAction(text, self)
    action.setData(format)
    action.triggered.connect(self.save)
    saveAsActs.append(action)

printAct = QAction(tr("Print..."), self)
printAct.triggered.connect(scribbleArea.print)
exitAct = QAction(tr("Exit"), self)
exitAct.setShortcuts(QKeySequence.Quit)
exitAct.triggered.connect(self.close)
penColorAct = QAction(tr("Pen Color..."), self)
penColorAct.triggered.connect(self.penColor)
penWidthAct = QAction(tr("Pen Width..."), self)
penWidthAct.triggered.connect(self.penWidth)
clearScreenAct = QAction(tr("Clear Screen"), self)
clearScreenAct.setShortcut(tr("Ctrl+L"))
clearScreenAct.triggered.connect(
        scribbleArea.clearImage)
aboutAct = QAction(tr("About"), self)
aboutAct.triggered.connect(self.about)
aboutQtAct = QAction(tr("About Qt"), self)
aboutQtAct.triggered.connect(qApp.aboutQt)

createAction() 函数中,我们创建了表示菜单条目并连接到相应槽位的动作。特别是我们创建了“另存为”子菜单中的动作。我们使用 QImageWriter::supportedImageFormats() 获取支持格式列表(作为一个 QList<QByteArray>)。

然后我们遍历列表,为每种格式创建一个动作。我们调用 QAction::setData() 并传入文件格式,这样我们可以在以后通过 QAction::data() 恢复它。我们还可以从动作的文本中推断文件格式,通过截断“…”,但这会有点不优雅。

def createMenus(self):
saveAsMenu = QMenu(tr("Save As"), self)
for action in saveAsActs:
    saveAsMenu.addAction(action)
fileMenu = QMenu(tr("File"), self)
fileMenu.addAction(openAct)
fileMenu.addMenu(saveAsMenu)
fileMenu.addAction(printAct)
fileMenu.addSeparator()
fileMenu.addAction(exitAct)
optionMenu = QMenu(tr("Options"), self)
optionMenu.addAction(penColorAct)
optionMenu.addAction(penWidthAct)
optionMenu.addSeparator()
optionMenu.addAction(clearScreenAct)
helpMenu = QMenu(tr("Help"), self)
helpMenu.addAction(aboutAct)
helpMenu.addAction(aboutQtAct)
menuBar().addMenu(fileMenu)
menuBar().addMenu(optionMenu)
menuBar().addMenu(helpMenu)

createMenu() 函数中,我们将之前创建的格式动作添加到 saveAsMenu。然后我们将其他动作以及 saveAsMenu 子菜单添加到“文件”、“选项”和“帮助”菜单。

QMenu 类为菜单栏、上下文菜单和其他弹出菜单提供了一个菜单小部件。 QMenuBar 类提供了一个水平菜单栏,包含一系列下拉 QMenu。最后我们将文件和选项菜单放入 MainWindow 的菜单栏,我们使用 menuBar() 函数来获取它。

def maybeSave(self):
if scribbleArea.isModified():
   QMessageBox.StandardButton ret
   ret = QMessageBox.warning(self, tr("Scribble"),
                      tr("The image has been modified.\n"
                         "Do you want to save your changes?"),
                      QMessageBox.Save | QMessageBox.Discard
                      | QMessageBox.Cancel)
    if ret == QMessageBox.Save:
        return saveFile("png")
    elif ret == QMessageBox.Cancel:
        return False

return True

mayBeSave() 中,我们检查是否有未保存的更改。如果有,我们使用 QMessageBox 警告用户图像已被修改并有机会保存修改。

QColorDialogQFileDialog 一样,创建一个 QMessageBox 最简单的方式是使用其静态函数。 QMessageBox 提供了不同类型的消息,排列在两个轴上:严重性(问题、信息、警告和严重)和复杂性(必要响应按钮的数量)。这里我们使用 warning() 函数,因为消息相当重要。

如果用户选择保存,我们将调用私有的 saveFile() 函数。为了简单起见,我们使用 PNG 作为文件格式;用户随时可以按取消并使用其他格式保存文件。

如果用户点击取消,maybeSave() 函数会返回 false;否则返回 true

def saveFile(self, QByteArray fileFormat):
initialPath = QDir.currentPath() + "/untitled." + fileFormat()
fileName = QFileDialog.getSaveFileName(self, tr("Save As"),()
                           initialPath,
                           tr("%1 Files (*.%2);;All Files (*)")
                           .arg(QString.fromLatin1(fileFormat.toUpper()))
                           .arg(QString.fromLatin1(fileFormat)))
if fileName.isEmpty():
    return False
return scribbleArea.saveImage(fileName, fileFormat.constData())

saveFile() 函数中,我们弹出一个文件对话框,并提出文件名建议。静态函数 getSaveFileName() 返回用户选择的一个文件名。该文件不必须存在。

示例项目 @ code.qt.io