本文目录
- PyQt5桌面应用系列
- 桌面程序基本布局
- QMainWindow概况与使用
- 主窗体
- 菜单栏
- 工具栏
- 停靠窗
- 状态栏
- 代码编辑器的例子
- 总结
PyQt5桌面应用系列
- PyQt5桌面应用开发(1):需求分析
- PyQt5桌面应用开发(2):事件循环
- PyQt5桌面应用开发(3):并行设计
- PyQt5桌面应用开发(4):界面设计
- PyQt5桌面应用开发(5):对话框
- PyQt5桌面应用开发(6):文件对话框
- PyQt5桌面应用开发(7):文本编辑+语法高亮与行号
- PyQt5桌面应用开发(8):从QInputDialog转进到函数参数传递
- PyQt5桌面应用开发(9):经典布局QMainWindow
桌面程序基本布局
传统的桌面程序的布局,已经有很多优秀的软件开发这探索很多年。以IDEA和Pycharm为例,基本形成菜单、工具栏、可停靠工具窗口、主窗口、状态栏的布局。当然,最近JetBrain在逐步推动新的布局方式,进一步减少干扰项,突出编辑代码的主窗体。另外,在桌面工具程序中,还有很多类似于Windows 11版本中推动的更加简洁的布局形式。
布局方式的演进和发展是一个大的趋势,被新的潮流和审美所推动,也推动不同的软件使用。但这种现在称为传统、以前称为新潮的菜单、工具栏、可停靠工具窗口、主窗口、状态栏的布局依然是严肃桌面应用的一个很好的起点,也是开发过程中可以作为第一选项来进行开发,然后根据软件需求相应改造的基线方案。因此,各个桌面软件开发包都提供了对这一布局的便利性支持。JavaFX有一个BorderLayout;Qt提供了QMainWindow。
上图取自Qt官网doc.Qt.io。这几个不同的区域,设计目的大概是这样的。
- 主窗口:提供与数据交互和展示数据的主要区域;
- 菜单栏:提供所有功能的菜单;
- 工具栏:提供常用功能的便捷性工具按钮;
- 可停靠工具窗口:提供与主窗体关联的工具;
- 状态栏:提示状态信息。
从上面的图中可以看到,工具栏、可停靠窗口的位置是可以移动的。在下面会详细给出例子。
QMainWindow概况与使用
QMainWindow直接继承自QWidget,在QWdiget里面增添了菜单、工具栏、工具窗口、主窗口和状态栏的接口。
- 主窗体:QMainWindow.setCentralWidget
- 菜单栏:QMainWindow.setMenuBar
- 工具栏:QMainWindow.addToolBar
- 停靠窗:QMainWindow.addDockWidget
- 状态栏:QMainWindow.setStatusBar
从PyQt动词的是用来分类,菜单栏、主窗体和状态栏用的是set,表示只会有一个;工具栏、停靠窗使用的是add,说明可以增加多个。
主窗体
主窗体没什么好说的。展示核心的数据报表,提供核心的数据交互。设置方式调用,setCentralWidget,参数就是一个以QMainWindow为父节点的QWidget。
菜单栏
菜单栏有两个层级的结构,QMenuBar包含多个Menu,每个Menu包含多个Action。从下面的例子可以看到这里所有的动词都是用的add,这也是PyQt5接口一致性的体现。QAction文件对话框里面讲过,不再赘述。
下面的例子中,所有的Action都是关闭主窗体……实际中,把self.close连接到相应的槽函数即可。
def _set_menubar(self: QMainWindow):
mb = QMenuBar(parent=self)
file_menu = mb.addMenu('&File')
for a in ['&New', '&Open', '&Save', '&Save as']:
file_menu.addAction(QIcon("icon.png"), a, self.close)
file_menu.addSeparator()
file_menu.addAction(self.style().standardIcon(QStyle.SP_DialogCloseButton), '&Quit', self.close)
self.setMenuBar(mb)
工具栏
工具栏是一个可以拖动,放于上下左右的,可以包含工具按钮和其他控件的长条形(左右为纵向,上下为横向)窗体。工具栏是否可以拖动有一个函数setMovable来控制,还可以通过isAreaAllowed来设定允许区域。
这个允许区域是一个枚举类型(enum)来描述,包括左右上下全否。
- Qt.LeftToolBarArea 左边允许
- Qt.RightToolBarArea 右边允许
- Qt.TopToolBarArea 上方允许
- Qt.BottomToolBarArea 下方允许
- Qt.AllToolBarAreas 所有区域都允许
- Qt.NoToolBarArea 所有区域都不允许
此外,还可以通过setToolButtonStyle来设定显示样式,具体来说就是文字和图标的显示。具体的设置同样是Qt的枚举类型。
- Qt::ToolButtonIconOnly 仅显示图标
- Qt::ToolButtonTextOnly 仅显示文字
- Qt::ToolButtonTextBesideIcon 文字在图标侧方
- Qt::ToolButtonTextUnderIcon 文字在图标下方
- Qt::ToolButtonFollowStyle 按照StyleHint来设置(这里不深入)
def _set_toolbar(self: QMainWindow):
tb = QToolBar(self)
for a in ['New', 'Open', 'Save', 'Save as']:
tb.addAction(QIcon("icon.png"), a, self.close)
tb.addSeparator()
tb.addAction(self.style().standardIcon(QStyle.SP_DialogCloseButton), "Close", self.close)
tb.setToolButtonStyle(Qt.ToolButtonTextUnderIcon)
tb.setMovable(False)
self.addToolBar(tb)
停靠窗
停靠窗的位置也可以上上下左右。其设置方式是包括三步:
- 创建一个QDockWidget对象;
- 调用QDockWidget对象的setWidget方法;
- 调用QMainWindow的addDockWidget方法。
停靠窗的允许位置同样采用Qt的枚举来设置,设置方法是setAllowedAreas。停靠窗是否可以移动要用QDockWidget的枚举量来设置,设置方法是setFeatures,当然这里不仅仅是是否可以移动,还包括是否可以关闭、是否可以浮动等等。
停靠窗的位置包括一下几个。
- Qt::LeftDockWidgetArea 左边
- Qt::RightDockWidgetArea 右边
- Qt::TopDockWidgetArea 上方
- Qt::BottomDockWidgetArea 下方
- Qt::AllDockWidgetAreas 所有都允许
- Qt::NoDockWidgetArea 所有都不允许
枚举DockWidgetFeature包括以下几个:
- QDockWidget::DockWidgetClosable 是否可以关闭
- QDockWidget::DockWidgetMovable 是否可以移动
- QDockWidget::DockWidgetFloatable 是否可以漂浮
- QDockWidget::DockWidgetVerticalTitleBar 标题栏纵向放置
- QDockWidget::NoDockWidgetFeatures 没有特性
def _set_left_dock(self: QMainWindow):
dock = QDockWidget('Project structure', self)
tv = QTreeWidget(dock)
tv.setColumnCount(4)
# tv.setHeaderHidden(True)
tv.setHeaderLabels(['', '', 'name', 'path'])
root = self._get_file_tree(".", tv)
root.setExpanded(True)
tv.addTopLevelItem(root)
dock.setWidget(tv)
dock.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea)
dock.setFeatures(QDockWidget.DockWidgetMovable)
self.addDockWidget(Qt.LeftDockWidgetArea, dock)
self.tv = tv
self.tv.clicked.connect(self.load_item)
状态栏
状态栏最为简单,创建一个QStatusBar对象,调用QMainWindow.setStatusBar就可以,后面调用QMainWindow.statusBar就能随时得到状态栏句柄,然后调用showMessage即可。
def _set_statusbar(self: QMainWindow):
sb = QStatusBar(self)
sb.showMessage("Hello!")
self.setStatusBar(sb)
代码编辑器的例子
基本的信息介绍完毕,下面可以实现一个代码编辑器的例子。
数据报表
- 显示python源程序的内容,包括行号、高亮
- 统计源文件的行数、字符数
数据来源,源文件从文件树中读取。这就给交互提供了依据:
- 查看文件树
- 打开文件
- 编辑文件
- 保存文件
最终实现的代码如下。
from PyQt5.QtCore import Qt, QDir, QModelIndex
from PyQt5.QtGui import QIcon, QTextDocument
from PyQt5.QtWidgets import QApplication, QTreeWidgetItem, QToolBar, QStatusBar, QStyle, QFormLayout, QLineEdit
from PyQt5.QtWidgets import QDockWidget
from PyQt5.QtWidgets import QMainWindow, QWidget, QMenuBar, QTreeWidget
from code_editor import QCodeEditor
from py_syntax_highlighter import PythonHighlighter
class MyMainWindow(QMainWindow):
def __init__(self):
super(MyMainWindow, self).__init__()
self.setWindowIcon(QIcon('icon.png'))
self.setWindowTitle('PyQt5 Editor')
self.setMinimumSize(1440, 768)
self.editor = QCodeEditor(display_line_numbers=True,
highlight_current_line=True,
syntax_high_lighter=PythonHighlighter)
self.setCentralWidget(self.editor)
self.editor.setPlainText(open("mainwindow.py", encoding="utf-8").read())
self._set_menubar()
self._set_toolbar()
self._set_statusbar()
self._set_left_dock()
self._set_right_dock()
def _set_menubar(self: QMainWindow):
mb = QMenuBar(parent=self)
file_menu = mb.addMenu('&File')
for a in ['&New', '&Open', '&Save', '&Save as']:
file_menu.addAction(QIcon("icon.png"), a, self.close)
file_menu.addSeparator()
file_menu.addAction(self.style().standardIcon(QStyle.SP_DialogCloseButton), '&Quit', self.close)
self.setMenuBar(mb)
def _set_toolbar(self: QMainWindow):
tb = QToolBar(self)
for a in ['New', 'Open', 'Save', 'Save as']:
tb.addAction(QIcon("icon.png"), a, self.close)
tb.addSeparator()
tb.addAction(self.style().standardIcon(QStyle.SP_DialogCloseButton), "Close", self.close)
tb.setToolButtonStyle(Qt.ToolButtonFollowStyle)
# tb.setMovable(False)
self.addToolBar(tb)
def _set_statusbar(self: QMainWindow):
sb = QStatusBar(self)
sb.showMessage("Hello!")
self.setStatusBar(sb)
def _set_left_dock(self: QMainWindow):
dock = QDockWidget('Project structure', self)
tv = QTreeWidget(dock)
tv.setColumnCount(4)
# tv.setHeaderHidden(True)
tv.setHeaderLabels(['', '', 'name', 'path'])
root = self._get_file_tree(".", tv)
root.setExpanded(True)
tv.addTopLevelItem(root)
dock.setWidget(tv)
dock.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea)
dock.setFeatures(QDockWidget.DockWidgetMovable)
self.addDockWidget(Qt.LeftDockWidgetArea, dock)
self.tv = tv
self.tv.clicked.connect(self.load_item)
def load_item(self, index: QModelIndex):
item = self.tv.itemFromIndex(index)
fn = item.text(3)
if QDir(fn).exists():
return
if fn.endswith(".py"):
try:
self.editor.setPlainText(open(fn, encoding="utf-8").read())
doc: QTextDocument = self.editor.document()
self.statusBar().showMessage(f"Open file {fn}, length: {doc.characterCount()}.")
doc.blockCountChanged.connect(lambda count: self.line_edit.setText(f"{count}"))
doc.contentsChanged.connect(lambda: self.character_edit.setText(f"{doc.characterCount()}"))
doc.blockCountChanged.emit(doc.blockCount())
doc.contentsChanged.emit()
except Exception as e:
print(e)
def _set_right_dock(self: QMainWindow):
dock = QDockWidget('File information', self)
info = QWidget(dock)
layout = QFormLayout(info)
self.line_edit = QLineEdit(f"{self.editor.document().blockCount()}")
self.line_edit.setEnabled(False)
self.character_edit = QLineEdit(f"{self.editor.document().characterCount()}")
self.character_edit.setEnabled(False)
layout.addRow("Block count", self.line_edit)
layout.addRow("Character count", self.character_edit)
info.setLayout(layout)
dock.setWidget(info)
dock.setFeatures(QDockWidget.DockWidgetMovable)
self.addDockWidget(Qt.RightDockWidgetArea, dock)
doc: QTextDocument = self.editor.document()
doc.blockCountChanged.connect(lambda count: self.line_edit.setText(f"{count}"))
doc.contentsChanged.connect(lambda: self.character_edit.setText(f"{doc.characterCount()}"))
def _get_file_tree(self, root_dir, parent) -> QTreeWidgetItem:
d = QDir(QDir(root_dir).absolutePath())
item = QTreeWidgetItem(parent)
item.setIcon(1, self.style().standardIcon(QStyle.SP_DirIcon))
item.setText(2, d.dirName())
item.setText(3, d.canonicalPath())
for f in d.entryInfoList(QDir.NoDot | QDir.NoDotDot | QDir.Dirs):
if f.isDir():
self._get_file_tree(f.canonicalFilePath(), item)
for f in d.entryInfoList(["*.py"], QDir.NoDot | QDir.NoDotDot | QDir.Files):
fi = QTreeWidgetItem(item)
fi.setText(2, f.fileName())
fi.setText(3, f.canonicalFilePath())
if f.fileName().endswith("py"):
fi.setIcon(1, QIcon("icon.png"))
return item
if __name__ == '__main__':
import sys
app = QApplication(sys.argv)
main_window = MyMainWindow()
main_window.show()
sys.exit(app.exec_())
上面的程序跟前面数篇最大的区别在于,我这种面向过程的顽固分子,也不得不向面向对象屈服,因为实在太香。
总结
- 经典的不一定是最好,但绝对是能成为一个好基础的;
- QMainWindow可以作为桌面应用开发的第一个版本,作为迭代的基础;
- QMainWindow实现了几个典型的用户界面区域,菜单、工具栏、停靠窗、状态栏。