ToDoApp
框架选择
一个简单的GUI程序,可以使用pyqt完成。pyqt是qt的python实现版本。
界面搭建
设计一个美观
简洁的界面
class ToDoApp(QWidget):
def __init__(self):
super().__init__()
# 设置窗口属性
self.setWindowTitle("Daily To Do List")
self.setGeometry(100, 100, 400, 400)
# 初始化主布局
self.main_layout = QVBoxLayout()
# 创建输入和添加按钮
self.input_layout = QGridLayout()
# 标题输入
self.title_label = QLabel("标题:")
self.title_input = QLineEdit()
self.input_layout.addWidget(self.title_label, 0, 0)
self.input_layout.addWidget(self.title_input, 0, 1)
# 描述输入
self.description_label = QLabel("描述:")
self.description_input = QLineEdit()
self.input_layout.addWidget(self.description_label, 1, 0)
self.input_layout.addWidget(self.description_input, 1, 1)
# 水平按钮布局
self.add_layout = QHBoxLayout()
self.add_layout.setSpacing(20)
# 导入
self.import_button = QPushButton('批量导入')
self.import_button.clicked.connect(self.import_item)
self.add_layout.addWidget(self.import_button)
self.add_button = QPushButton("添加")
self.add_button.clicked.connect(self.add_item)
self.add_layout.addWidget(self.add_button)
# 任务列表
self.to_do_list = QListWidget()
self.to_do_list.setStyleSheet("padding: 10px;")
self.to_do_list.setVerticalScrollMode(QListWidget.ScrollPerPixel)
# 创建操作按钮
self.buttons_layout = QHBoxLayout()
self.mark_done_button = QPushButton("标记完成")
self.mark_done_button.clicked.connect(self.mark_item_done)
self.delete_button = QPushButton("删除")
self.delete_button.clicked.connect(self.delete_item)
self.buttons_layout.addWidget(self.mark_done_button)
self.buttons_layout.addWidget(self.delete_button)
# 将布局添加到主窗口
self.main_layout.addLayout(self.input_layout)
self.main_layout.addLayout(self.add_layout)
self.main_layout.addWidget(self.to_do_list)
self.main_layout.addLayout(self.buttons_layout)
# 设置窗口布局
self.setLayout(self.main_layout)
QGridLayout的使用
QGridLayout
是网格布局,在添加子窗口时可以设定位置(行、列)和占据的大小(占几行几列)
CSDN文章
import sys
from PyQt5.QtWidgets import QApplication, QWidget, QGridLayout, QLabel, QPushButton
class GridExample(QWidget):
def __init__(self):
super().__init__()
self.init_ui()
def init_ui(self):
self.setWindowTitle("PyQt QGridLayout Example")
self.setGeometry(100, 100, 400, 300)
# 创建 QGridLayout
layout = QGridLayout(self)
# 创建一个标题标签
title_label = QLabel("Header Label", self)
title_label.setStyleSheet("font-size: 18px; font-weight: bold; color: blue;")
# 将标题设置为占据第一行的所有列
layout.addWidget(title_label, 0, 0, 1, 3) # 行, 列, 占据行数, 占据列数
# 创建一个 3x3 的按钮网格
for i in range(3):
for j in range(3):
button = QPushButton(f"Button {i*3 + j +1}", self)
layout.addWidget(button, i + 1, j) # 按钮从行1开始
# 创建一个右侧按钮占据一列
right_button = QPushButton("Right Button", self)
# 设置右侧按钮占据第3列的所有行
layout.addWidget(right_button, 1, 3, 3, 1) # 行1到3, 列3
# 创建一个底部按钮占据一行
bottom_button = QPushButton("Bottom Button", self)
# 设置底部按钮占据第4行的所有列
layout.addWidget(bottom_button, 4, 0, 1, 4) # 行4, 列0-3
self.setLayout(layout)
if __name__ == "__main__":
app = QApplication(sys.argv)
window = GridExample()
window.show()
sys.exit(app.exec_())
强制子窗口独立
在 PyQt 中,当给窗口(QWidget
或子类)设置 parent
后窗口不显示,通常是因为 子窗口被嵌入到了父窗口的布局中,而非作为独立窗口显示。以下是常见原因和解决方案:
1. 根本原因:parent
的作用
parent
的作用:在 PyQt 中,parent
表示窗口的父控件。若设置parent
:- 子窗口会嵌入到父窗口中,成为父窗口的一部分(类似按钮、文本框等控件)。
- 子窗口的生命周期与父窗口绑定(父窗口销毁时,子窗口自动销毁)。
- 子窗口默认不会作为独立窗口弹出,而是跟随父窗口的布局显示。
关键区别:
# 独立窗口(无 parent) child_window = QWidget() child_window.show() # 嵌入父窗口(设置 parent) child_window = QWidget(parent=main_window) # 不会独立显示,而是嵌入到 main_window 中
2. 常见场景和解决方法
场景 1:希望子窗口作为独立窗口弹出
错误写法:
parent_window = QWidget() child_window = QWidget(parent=parent_window) # 设置 parent child_window.show() # ❌ 不会显示独立窗口!
原因:
child_window
已成为parent_window
的子控件,必须通过父窗口的布局显示(例如将child_window
添加到父窗口的QVBoxLayout
中)。解决方法:不要设置
parent
,让子窗口独立:parent_window = QWidget() child_window = QWidget() # 无 parent child_window.show() # ✅ 作为独立窗口显示
场景 2:希望子窗口作为模态对话框弹出
错误写法:
parent_window = QWidget() child_window = QWidget(parent=parent_window) child_window.setWindowModality(Qt.ApplicationModal) # 设置为模态 child_window.show() # ❌ 仍然不显示!
原因:
child_window
是parent_window
的子控件,必须通过父窗口布局显示,或明确设置为独立窗口。解决方法:使用
Qt.Window
标志强制子窗口成为独立窗口:child_window = QWidget(parent=parent_window) child_window.setWindowFlags(Qt.Window) # 关键:强制为独立窗口 child_window.setWindowModality(Qt.ApplicationModal) child_window.show() # ✅ 作为模态对话框弹出
场景 3:子窗口被正确添加到父窗口布局但仍不显示
错误写法:
parent_window = QWidget() child_window = QWidget(parent=parent_window) parent_window.show() # ❌ 只显示 parent_window,但 child_window 未添加到布局中
原因:
child_window
需要被添加到父窗口的布局管理器(如QVBoxLayout
),或手动设置其位置。解决方法:将子窗口添加到父窗口布局:
parent_window = QWidget() layout = QVBoxLayout(parent_window) # 父窗口设置布局 child_window = QWidget() layout.addWidget(child_window) # 添加到布局 parent_window.show() # ✅ 父窗口和子控件均显示
3. 通用检查列表
如果子窗口不显示,按以下步骤排查:
- 是否设置
parent
:- 若设置
parent
,子窗口需要添加到父窗口的布局中。
- 若设置
- 父窗口是否已显示:
- 父窗口调用
show()
后,子控件才会显示。
- 父窗口调用
- 窗口标志是否正确:
- 使用
setWindowFlags(Qt.Window)
强制子窗口独立。
- 使用
- 布局是否正确:
- 确保子窗口被添加到父窗口的布局管理器(如
addWidget(child)
)。
- 确保子窗口被添加到父窗口的布局管理器(如
- 生命周期问题:
- 确保父窗口未被提前销毁(例如在局部作用域中被垃圾回收)。
4. 完整示例对比
示例 1:子窗口嵌入父窗口(正确写法)
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QPushButton, QApplication
app = QApplication([])
# 父窗口
parent = QWidget()
layout = QVBoxLayout(parent)
# 子窗口(作为控件嵌入父窗口)
child = QPushButton("我是子控件", parent=parent)
layout.addWidget(child)
parent.show()
app.exec_()
示例 2:子窗口作为独立窗口(正确写法)
from PyQt5.QtWidgets import QWidget, QApplication
app = QApplication([])
# 父窗口
parent = QWidget()
parent.show()
# 子窗口(独立窗口,无 parent)
child = QWidget()
child.setWindowTitle("我是独立子窗口")
child.show()
app.exec_()
5. 特殊场景:动态创建子窗口
若通过按钮点击动态创建子窗口,需确保子窗口的引用不被销毁:
from PyQt5.QtWidgets import QWidget, QPushButton, QVBoxLayout, QApplication
app = QApplication([])
class MainWindow(QWidget):
def __init__(self):
super().__init__()
self.button = QPushButton("打开子窗口", self)
self.button.clicked.connect(self.open_child)
self.setLayout(QVBoxLayout())
self.layout().addWidget(self.button)
def open_child(self):
self.child = QWidget() # 必须保存为成员变量,否则会被垃圾回收!
self.child.setWindowTitle("子窗口")
self.child.show()
window = MainWindow()
window.show()
app.exec_()
通过理解 parent
的作用和布局机制,可以灵活控制窗口的显示方式。
模型设计
每一个待办事项有
- 标题
- 描述信息
- 生成时间
- 是否完成标识
- 截止时间
class ToDoItem:
def __init__(self, title, description,deadline_time=None,is_completed=False):
self.title = title
self.description = description
self.deadline_time = deadline_time
self.is_completed = False
自定义信号
from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QVBoxLayout, QMessageBox
from PyQt5.QtCore import pyqtSignal
import sys
class MyWidget(QWidget):
# 定义一个自定义信号
custom_signal = pyqtSignal(str)
def __init__(self):
super().__init__()
self.init_ui()
def init_ui(self):
self.setWindowTitle("PyQt Custom Signal Example")
self.setGeometry(100, 100, 300, 200)
# 创建一个按钮
self.button = QPushButton("Click Me", self)
self.button.clicked.connect(self.emit_custom_signal)
# 设置布局
layout = QVBoxLayout()
layout.addWidget(self.button)
self.setLayout(layout)
# 连接自定义信号到槽
self.custom_signal.connect(self.handle_custom_signal)
def emit_custom_signal(self):
"""发射自定义信号"""
self.custom_signal.emit("Hello from custom signal!")
def handle_custom_signal(self, message):
"""处理自定义信号"""
QMessageBox.information(self, "Custom Signal", message)
if __name__ == "__main__":
app = QApplication(sys.argv)
widget = MyWidget()
widget.show()
sys.exit(app.exec_())
批量导入功能
使用json
格式的字符串进行批量导入,大致格式如下
{
"items": [
{
"title": "测试标题1",
"description": "测试1",
"deadline_time": "2024",
"is_completed": false
},
{
"title": "测试标题2",
"description": "测试2",
"deadline_time": "2025",
"is_completed": true
}
]
}
大致就是新开一个窗口,创建一个QTextEdit
输入对应的json数据
,然后通过json.loads()
方法解析对应数据,
逐个使用add_item()
接口添加,这要求add_item()
能够处理多种情况:按钮点击触发无需参数
和批量导入中需要传递参数
。
而在python中实现类似重载的效果可以给
参数一个默认值None
,再在函数内部分情况处理
class ImportWidget(QWidget):
# 自定义信号
import_finished = pyqtSignal(dict)
def __init__(self,parent):
super().__init__(parent)
self.setFixedSize = (500,500)
self.setWindowFlags(Qt.Window) # 关键:强制为独立窗口
self.input_field = QTextEdit()
self.main_layout = QVBoxLayout()
self.btn_layout = QHBoxLayout()
self.confirm_button = QPushButton("导入")
self.cancel_button = QPushButton("取消")
self.btn_layout.addWidget(self.confirm_button)
self.btn_layout.addWidget(self.cancel_button)
self.confirm_button.clicked.connect(self.read_json_data)
# 连接自定义信号到槽
self.import_finished.connect(self.close)
self.main_layout.addLayout(self.btn_layout)
self.main_layout.addWidget(self.input_field)
self.setLayout(self.main_layout)
def read_json_data(self):
text = self.input_field.toPlainText() # 获取输入框的文本
# print(f"原始文本内容: {text}") # 调试:打印原始文本内容
try:
# 将输入的文本解析为 JSON 数据
json_data = json.loads(text.strip()) # 使用 strip() 去除首尾空白字符
# print(f"解析后的 JSON 数据: {json_data}")
self.json_data = json_data
self.import_finished.emit(json_data)
except json.JSONDecodeError as e:
# 如果 JSON 格式不正确,打印错误信息
print(f"JSON 解析失败: {e}")
self.json_data = None
class ToDoApp(QWidget):
def batch_import(self,json_data):
print(json_data['items'])
items = json_data['items']
for item in items:
self.add_item(item['title'],item['description'])
def add_item(self, checked,title=None, description=None):
# 如果 title 和 description 是传入的参数
if title is not None or description is not None:
# 使用传入的参数
title = title.strip() if title else ""
description = description.strip() if description else ""
else:
# 获取输入框的文本
title = self.title_input.text().strip()
print(title, description)
description = self.description_input.text().strip()
# TODO 优化时间显示居右
current_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 创建 ToDoItem 实例
todo_item = ToDoItem(title, description, current_time)
# 创建新的列表项 创建自定义Widget
item_widget = QWidget()
layout = QHBoxLayout()
# 标题部分
title_label = QLabel(todo_item.title)
title_label.setStyleSheet("QLabel{padding:0px}")
title_label.setAlignment(Qt.AlignmentFlag.AlignLeft| Qt.AlignmentFlag.AlignVCenter)
# 时间部分
time_label = QLabel(todo_item.created_time)
time_label.setStyleSheet("QLabel{padding:0px}") # 添加padding设置,Qlabel有默认padding,不设置话,会将文字截断
time_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
# 添加控件到布局
layout.addWidget(title_label)
layout.addWidget(time_label)
item_widget.setLayout(layout)
# 创建ListWidgetItem
item = QListWidgetItem()
item.setSizeHint(item_widget.sizeHint()) # 设置每一项的宽高
item.setToolTip(todo_item.description) # 设置悬浮提示
item.setData(Qt.UserRole, todo_item) # 保存任务对象
item.setFlags(item.flags() | Qt.ItemIsSelectable | Qt.ItemIsEnabled)
item.setCheckState(Qt.CheckState.Unchecked)
self.to_do_list.addItem(item)
self.to_do_list.setItemWidget(item, item_widget)
# 清空输入框
self.title_input.clear()
self.description_input.clear()
bug解析
- 使用按钮连接点击信号至槽函数,发现槽函数add_item接收到的title参数不是预期的输入值None(因为点击事件的槽函数一般不带参数),而是False。
PyQt 的
clicked
信号默认会传递一个布尔值:QPushButton
的clicked
信号默认会发送一个checked
参数(表示按钮的选中状态)。- 如果你没有显式处理这个参数,它会传递到槽函数中,导致
title
参数被赋值为False
(因为默认未选中)。
槽函数定义与信号参数不匹配:
你定义的add_item
方法有两个可选参数:def add_item(self, title=None, description=None):
- 当通过
self.add_button.clicked.connect(self.add_item)
连接信号时,clicked
信号的checked
参数(布尔值)会传递给title
参数。 - 因此,点击按钮时
title
实际接收到的是False
,而不是预期的None
。
- 当通过
方法 1:显式接收并忽略 checked
参数
修改槽函数,增加一个参数接收 checked
值,但不在内部使用它:
def add_item(self, checked, title=None, description=None): # 增加 checked 参数
# 如果 title 和 description 是传入的参数
if title is not None or description is not None:
print("not null", title, description)
title = title.strip() if title else ""
description = description.strip() if description else ""
else:
# 获取输入框的文本
title = self.title_input.text().strip()
description = self.description_input.text().strip()
# 其他逻辑...
方法 2:使用 lambda
阻止参数传递
在连接信号时,通过 lambda
屏蔽 clicked
信号的参数:
self.add_button.clicked.connect(lambda: self.add_item()) # 不传递任何参数
此时 title
和 description
将保持 None
,代码会从输入框中读取值。
关键点解释
信号参数传递机制:
clicked
信号默认发送checked
(布尔值),而QPushButton
默认不可选中,因此总是发送False
。- 如果槽函数参数数量不匹配,第一个参数会接收这个
False
。
参数优先级问题:
- 如果调用
add_item
时传递了参数(如add_item(title="测试")
),title
会被正确赋值。 - 若未传递参数,
title
会被错误地赋值为False
(来自checked
参数)。
- 如果调用
导出
既然有批量导入
功能,就有导出功能
剪切板 QClipboard
在 PyQt 中,可以使用 QApplication.clipboard()
来访问系统剪贴板,并通过 QClipboard
类的方法将数据复制到剪贴板
def export_to_clipboard(self):
# 获取所有任务
items = []
for i in range(self.to_do_list.count()):
item = self.to_do_list.item(i)
if item:
todo_item = item.data(Qt.UserRole)
items.append({
"title": todo_item.title,
"description": todo_item.description,
"deadline_time": todo_item.deadline_time,
"is_completed": todo_item.is_completed
})
# 转换为 JSON 格式
json_data = {
"items": items
}
json_str = json.dumps(json_data, indent=4, ensure_ascii=False) # 格式化 JSON 字符串
# 复制到剪切板
clipboard = QApplication.clipboard()
clipboard.setText(json_str)
# 弹出提示
QMessageBox.information(self, "提示", "已复制到剪切板")
常用的 QClipboard
方法
setText(text)
: 将文本复制到剪贴板。setPixmap(pixmap)
: 将图片复制到剪贴板。setMimeData(mimeData)
: 将 MIME 数据(如 HTML)复制到剪贴板。clear()
: 清除剪贴板内容。
持久化存储
- 数据库sqlite
- 文件保存
直接写入文件,不使用数据库了,重写
关闭事件
,保存代办到文件,并在初始化的时候读取文件
def init_from_file(self, file_path=None):
# 默认初始化文件为当前目录下的 to_do.json
if file_path is None:
file_path = "./to_do.json"
# 读取文件内容
with open(file_path, "r", encoding="utf-8") as f:
text = f.read()
self.batch_import(json.loads(text))
def closeEvent(self, event):
# 关闭窗口时保存数据
with open("./to_do.json", "w", encoding="utf-8") as f:
self.export_to_clipboard(True)
f.write(QApplication.clipboard().text())
QApplication.clipboard().clear()
event.accept()
排序功能
- 截止时间ddl排序
def sort_by_ddl(self):
if self.sort_value == "asc":
self.sort_value = "desc"
else:
self.sort_value = "asc"
# 按 DDL 排序
items = []
for i in range(self.to_do_list.count()):
item = self.to_do_list.item(i)
if item:
todo_item = item.data(Qt.UserRole)
items.append(todo_item)
# 根据self.sort_value决定排序方向
if self.sort_value == "asc":
items.sort(key=self.sort_key)
else:
items.sort(key=self.sort_key, reverse=True)
# 清空列表
self.to_do_list.clear()
# 重新添加排序后的任务
for item in items:
self.add_item(item.title, item.description, item.deadline_time, item.is_completed)
def sort_key(self, item):
item.deadline_time.replace(":",":")
if item.deadline_time == "未知":
return datetime.datetime.max
else:
return datetime.datetime.strptime(item.deadline_time.replace(":",":"), "%Y-%m-%d %H:%M")
自定义排序规则
在Python中自定义排序规则,你可以使用内置的sorted()
函数或者列表对象的sort()
方法,并通过key
参数指定一个函数来定义排序规则。这个函数会对每个元素进行处理,并返回一个值,排序将根据这个返回值进行。
按字符串长度排序:
strings = ["apple", "banana", "cherry", "date"] sorted_strings = sorted(strings, key=len) print(sorted_strings) # 输出: ['date', 'apple', 'banana', 'cherry']
使用lambda函数按字符串的最后一个字符排序:
strings = ["apple", "banana", "cherry", "date"] sorted_strings = sorted(strings, key=lambda x: x[-1]) print(sorted_strings) # 输出: ['banana', 'apple', 'date', 'cherry']
复杂排序规则,先按字符串长度排序,再按字母顺序排序:
strings = ["apple", "banana", "cherry", "date"] sorted_strings = sorted(strings, key=lambda x: (len(x), x)) print(sorted_strings) # 输出: ['date', 'apple', 'banana', 'cherry']
使用
cmp_to_key
将传统比较函数转换为key函数:from functools import cmp_to_key def compare(x, y): if x < y: return -1 elif x > y: return 1 else: return 0 numbers = [3, 2, 5, 4, 1] sorted_numbers = sorted(numbers, key=cmp_to_key(compare)) print(sorted_numbers) # 输出: [1, 2, 3, 4, 5]
优化条目显示
添加一个标题布局,显示列表的标题
=> 放一个水平布局在QListWidget上对齐就可以
分离显示与数据(QlistWidget
):
- 不再直接使用 QListWidgetItem(text),而是通过
setItemWidget
绑定自定义Widget - 数据仍存储在 ToDoItem 对象中,界面仅负责展示
自定义Widget布局控制:
- 使用 QHBoxLayout 实现水平分列
- setAlignment 控制对齐方向
- setContentsMargins 调整内容间距
Bug解析
文字出现了上下截断的情况,尝试过设置
延伸策略
,给item设置固定宽高
都不能根治
发现随着高度的变大,显示的内容越来越多,所以猜测是
QLabel有默认的padding
,所以截断了文字
最后设置QStyleSheet
成功解决
# TODO 优化时间显示居右
# 创建 ToDoItem 实例
todo_item = ToDoItem(title, description,deadline_time,is_completed)
# 创建新的列表项 创建自定义Widget
item_widget = QWidget()
layout = QHBoxLayout()
# 标题部分
title_label = QLabel(todo_item.title)
title_label.setStyleSheet("QLabel{padding:0px}")
title_label.setAlignment(Qt.AlignmentFlag.AlignLeft| Qt.AlignmentFlag.AlignVCenter)
# 时间部分
time_label = QLabel(todo_item.deadline_time)
time_label.setStyleSheet("QLabel{padding:0px}") # 添加padding设置,Qlabel有默认padding,不设置话,会将文字截断
time_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
# 添加控件到布局
layout.addWidget(title_label)
layout.addWidget(time_label)
item_widget.setLayout(layout)
# 创建ListWidgetItem
item = QListWidgetItem()
item.setSizeHint(item_widget.sizeHint()) # 设置每一项的宽高
item.setToolTip(todo_item.description) # 设置悬浮提示
item.setData(Qt.UserRole, todo_item) # 保存任务对象
item.setFlags(item.flags() | Qt.ItemIsSelectable | Qt.ItemIsEnabled |Qt.ItemIsUserCheckable)
if todo_item.is_completed:
item.setCheckState(Qt.CheckState.Checked)
else:
item.setCheckState(Qt.CheckState.Unchecked)
self.to_do_list.addItem(item)
self.to_do_list.setItemWidget(item, item_widget)
# 清空输入框
self.title_input.clear()
self.description_input.clear()
self.deadline_input.clear()
- 绑定自定义widget后,点击无法改变
item
的checkState
解决方法有很多种,这里采用连接父ListWidget的双击信号
self.to_do_list.doubleClicked.connect(self.on_double_clicked)
def on_double_clicked(self, index: QModelIndex):
print(index.row()) # 打印行号
print(index.column()) # 打印列号(通常为 0)
item = self.to_do_list.itemFromIndex(index) # 获取 QListWidgetItem
if item.checkState() == Qt.CheckState.Unchecked:
item.setCheckState(Qt.CheckState.Checked)
elif item.checkState() == Qt.CheckState.Checked :
item.setCheckState(Qt.CheckState.Unchecked)
print(item.text()) # 打印项的文本
添加动画效果
最终代码
https://github.com/0zxm/ToDoApp/tree/master