Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

一个基于 PySide6 的轻量桌面待办工具,提供任务管理、截止时间、提醒与推迟、系统托盘、深浅色主题和本地数据保护。

当前版本为 **v2.1.3**,版本号的唯一来源是 `todo_app/constants.py` 中的 `APP_VERSION`。
当前版本为 **v2.1.4**,版本号的唯一来源是 `todo_app/constants.py` 中的 `APP_VERSION`。

## 功能概览

Expand All @@ -16,6 +16,7 @@

## v2.x 近期变化

- **v2.1.4**:任务增删改、完成切换与提醒处置改用差量列表协调,复用未变化卡片并保持筛选、排序和滚动位置。
- **v2.1.3**:空闲秒级刷新只在卡片计时或完成状态实际变化时更新界面,避免重复图标、样式与列表布局工作。
- **v2.1.2**:通知行改用分裂式“推迟1h”按钮,主区域一键推迟,箭头保留其他时长选项。
- **v2.1.1**:拆分用户可见名称与稳定的 `QSettings` 命名空间,界面不再显示过期的 v1 标识,已有窗口几何和状态继续兼容。
Expand Down Expand Up @@ -125,7 +126,7 @@ macOS/Linux 使用 PyInstaller 时,需要将 `--add-data` 的分隔符从 `;`

- 手动触发、推送到 `main` 或推送 `v*` 标签时,在 Windows Runner 生成单文件 `TODOList.exe`。
- 所有构建都会上传名称包含来源和短提交 SHA、保留 7 天的 Actions Artifact。
- `main` 的最新构建会按 `APP_VERSION` 更新可移动的预发布标签,例如 `2.1.3` 对应 `pre2.1.3`,并更新不作为 Latest 的 Pre-release。
- `main` 的最新构建会按 `APP_VERSION` 更新可移动的预发布标签,例如 `2.1.4` 对应 `pre2.1.4`,并更新不作为 Latest 的 Pre-release。
- 手动构建只产生临时 Artifact,不修改标签或 Release。
- 只有发布者显式推送、保持不可变的 `v*` 标签才创建或更新正式 Release;同一标签的工作流重跑可以覆盖同名资产以恢复失败发布。

Expand Down
6 changes: 4 additions & 2 deletions anchor.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
- `feature` → 提升次版本号。
- `bugfix` → 提升修订号。
- 仅文档与注释变更默认不触发版本号递增,除非影响发布说明或行为约定。
- 当前约定版本:`v2.1.3`。
- 当前约定版本:`v2.1.4`。

## 数据约束
- 所有待办保存在项目根目录下的 `todos.json`,结构为列表,元素为字典;打包版运行时会改存至用户数据目录(Windows `%APPDATA%\TODOList`,其他平台 `~/.todolist/`)。
Expand Down Expand Up @@ -68,7 +68,8 @@
- 相邻任务卡片的可见外边界固定保留 8px 透明列表间距,item 高度必须与当前卡片动态高度一致且不得小于卡片最小高度;卡片、边框、计时文字和优先级标识按主题形成轻量层次,操作浮层使用不透明主题背景遮住底层计时,编辑/删除按钮默认保持中性,仅在 hover、focus 或 pressed 时分别强化主题强调与危险语义。
- 列表纵向滚动条固定为 8px 紧凑宽度,轨道透明、滑块跟随主题配色;窗口左侧外边距等于“滚动条宽度 + 滚动条右侧外边距”,当前参数为 `15px = 8px + 7px`。滚动条隐藏时,列表 viewport 在同一边界保留 8px gutter;滚动条出现时释放 gutter 给真实滚动条,使可见卡片左右外边界到主内容边界的留白始终对称,取整误差不超过 1px。仅列表向右延伸,顶部筛选和标题行仍保持 15px 右外边距;状态切换不得残留旧几何、触发横向滚动条或造成卡片裁切。
- 已完成任务只通过勾选状态、线框及配色区分,编辑按钮始终可用,由主窗口逻辑负责根据任务 ID 处理编辑请求。
- 提醒流程:`master_timer` 每秒触发 `tick_update` 扫描完整 `self.todos`,提醒不受当前列表筛选影响。卡片先计算最终计时呈现,并分别缓存完成状态与计时文本/样式;只有最终状态变化时才写入 Qt 控件并刷新卡片布局,空闲 Tick 不重复加载完成图标、设置字体/样式或触发列表级布局,新建卡片只执行一次完整计时呈现。一轮提醒请求先写入去重字段,再汇总到任意时刻唯一的非模态软件内 `NotificationDialog`,同一任务按 ID 去重且“已到期”覆盖“提前提醒”。同批任务只播放一次 `play_sound_effect` 软件提醒音,窗口打开期间的新批次追加到原窗口,不创建 Windows 系统任务通知、Toast 或任务到期托盘气泡。提醒唤醒时优先调用原生接口恢复并前置主窗口,若平台不支持则临时添加 `WindowStaysOnTopHint` 保障可见,之后自动回退。通知窗口不提供复选框或底部批量操作;每条任务只通过自己的行内“完成”“推迟1h”“忽略”处置,推迟按钮主区域一键推迟 1 小时,只有箭头区域展开 15 分钟、1 小时、晚上 8 点和次日上午 9 点选项,“忽略”清除时间约束。每次处置由主窗口统一持久化并刷新列表;主窗口隐藏到托盘时同步隐藏提醒窗口但保留批次,恢复主窗口时重新显示同一批次,任务全部处理、用户主动关闭提醒窗口或真正退出后释放 Qt 对象与主题信号连接。
- 主窗口维护稳定的任务 ID 到 `QListWidgetItem` / `TodoItemWidget` 映射;新增、编辑、完成切换、删除、提醒处置及筛选排序通过差量协调复用未变化卡片,只创建、更新、移动或释放必要目标,并保留可见滚动锚点。正常操作不得调用 `QListWidget.clear()`,只有映射失配的异常恢复路径允许完整重建。
- 提醒流程:`master_timer` 每秒触发 `tick_update` 扫描完整 `self.todos`,提醒不受当前列表筛选影响。卡片先计算最终计时呈现,并分别缓存完成状态与计时文本/样式;只有最终状态变化时才写入 Qt 控件并刷新卡片布局,空闲 Tick 不重复加载完成图标、设置字体/样式或触发列表级布局,新建卡片只执行一次完整计时呈现。一轮提醒请求先写入去重字段,再汇总到任意时刻唯一的非模态软件内 `NotificationDialog`,同一任务按 ID 去重且“已到期”覆盖“提前提醒”。同批任务只播放一次 `play_sound_effect` 软件提醒音,窗口打开期间的新批次追加到原窗口,不创建 Windows 系统任务通知、Toast 或任务到期托盘气泡。提醒唤醒时优先调用原生接口恢复并前置主窗口,若平台不支持则临时添加 `WindowStaysOnTopHint` 保障可见,之后自动回退。通知窗口不提供复选框或底部批量操作;每条任务只通过自己的行内“完成”“推迟1h”“忽略”处置,推迟按钮主区域一键推迟 1 小时,只有箭头区域展开 15 分钟、1 小时、晚上 8 点和次日上午 9 点选项,“忽略”清除时间约束。每次处置由主窗口统一持久化并只协调受影响任务;主窗口隐藏到托盘时同步隐藏提醒窗口但保留批次,恢复主窗口时重新显示同一批次,任务全部处理、用户主动关闭提醒窗口或真正退出后释放 Qt 对象与主题信号连接。
- 推迟流程:推迟会同步更新 `snoozeUntil` 与可编辑的 `dueDate`;若原截止时间已早于推迟目标,默认截止时间自动推进到推迟目标。编辑保存按同一时刻而非 ISO 字符串判断截止时间是否变化,普通内容与优先级修改保留延后的新时间及提醒状态,只有实际修改时间或提醒偏移时才清理旧调度状态。
- 忽略语义:通知中的“忽略”表示保留任务但清除其时间约束;主窗口将 `dueDate` 与 `snoozeUntil` 置为 `None`,将 `notifiedForReminder` 与 `notifiedForDue` 重置为 `False`,保留 `reminderOffset`、`completed` 与 `lastNotifiedAt`。无截止时间时任务不显示超时且不会触发提醒;以后重新设置截止时间时继续使用原提醒偏好。本语义不提供撤销或历史恢复。
- 截止时间编辑:新增任务的默认截止时间沿绝对时间线取本地当前时间一小时后,日期与时间来自同一目标时刻并按可见分钟保存;未改默认日期与分钟时保留该目标的 UTC 实例,避免夏令时重复小时丢失 offset。时间使用支持滚轮和上下键微调的 `QTimeEdit`,日期使用低频内联 `QDateEdit` 日历下拉。选择日期直接应用,不再创建独立日期确认窗口。编辑已有任务时,未改日期与分钟则保留原截止时间的完整精度,实际调整后秒与毫秒归零。
Expand All @@ -80,6 +81,7 @@
- 若确认无变更,提交说明需写明“锚点已复盘,无需更新”。

## 最近约定变更
- 2026-08-14:bugfix,任务 CRUD、完成切换、提醒处置及筛选排序改用基于任务 ID 映射的差量列表协调,复用未变化卡片并保持滚动锚点,版本更新至 `v2.1.4`。
- 2026-08-13:bugfix,卡片缓存完成态与最终计时呈现,空闲 Tick 不再重复写入 Qt 控件或触发列表级布局,并将卡片初始化收敛为一次完整计时呈现,版本更新至 `v2.1.3`。
- 2026-08-12:bugfix,通知行改用分裂式“推迟1h”按钮,主区域一键执行默认时长,箭头保留其他推迟选项,版本更新至 `v2.1.2`。
- 2026-08-11:bugfix,拆分用户可见应用名与稳定的 `QSettings` 命名空间,移除窗口和托盘中过期的 v1 标识并无损保留已有窗口状态,版本更新至 `v2.1.1`。
Expand Down
2 changes: 1 addition & 1 deletion tests/test_app_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def setUpClass(cls) -> None:

def test_visible_identity_targets_v2_without_changing_settings_namespace(self) -> None:
self.assertEqual(APP_NAME, "桌面待办事项")
self.assertEqual(APP_VERSION, "2.1.3")
self.assertEqual(APP_VERSION, "2.1.4")
self.assertNotIn("v1", APP_NAME)
self.assertEqual(SETTINGS_ORGANIZATION, "MyProductiveApp")
self.assertEqual(SETTINGS_APPLICATION, "桌面待办事项 v1")
Expand Down
9 changes: 9 additions & 0 deletions tests/test_notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,9 @@ def test_requested_actions_update_only_requested_tasks_once(self) -> None:
self.assertEqual(dialog.task_ids(), [3])
self.assertEqual(save_mock.call_count, 1)
self.assertEqual(window.update_list_widget.call_count, 1)
window.update_list_widget.assert_called_once_with(
changed_ids={1, 2}
)

window.toggle_complete_todo(3)
self.assertEqual(dialog.task_ids(), [])
Expand Down Expand Up @@ -534,6 +537,7 @@ def test_snooze_handler_keeps_other_tasks_unchanged(self) -> None:
self.assertEqual(dialog.task_ids(), [1])
self.assertEqual(save_mock.call_count, 1)
self.assertEqual(window.update_list_widget.call_count, 1)
window.update_list_widget.assert_called_once_with(changed_ids={2})

def test_tasks_can_be_snoozed_independently_with_different_durations(self) -> None:
first = make_todo(1, "任务1")
Expand Down Expand Up @@ -562,6 +566,10 @@ def test_tasks_can_be_snoozed_independently_with_different_durations(self) -> No
self.assertEqual(dialog.task_ids(), [])
self.assertEqual(save_mock.call_count, 2)
self.assertEqual(window.update_list_widget.call_count, 2)
self.assertEqual(
[call.kwargs for call in window.update_list_widget.call_args_list],
[{"changed_ids": {1}}, {"changed_ids": {2}}],
)

def test_ignore_clears_schedule_but_preserves_task_and_preference(self) -> None:
task = make_todo(1, "保留但清除时间")
Expand Down Expand Up @@ -601,6 +609,7 @@ def test_ignore_clears_schedule_but_preserves_task_and_preference(self) -> None:
self.assertEqual(dialog.task_ids(), [])
self.assertEqual(save_mock.call_count, 1)
self.assertEqual(window.update_list_widget.call_count, 1)
window.update_list_widget.assert_called_once_with(changed_ids={1})

window.tick_update()

Expand Down
163 changes: 163 additions & 0 deletions tests/test_widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -859,6 +859,169 @@ def test_task_count_transition_preserves_outer_margin_contract(self) -> None:
self.assertLessEqual(abs(left_margin - right_margin), 1)
self.assertFalse(window.list_widget.horizontalScrollBar().isVisible())

def test_differential_add_and_delete_reuse_unchanged_cards(self) -> None:
now = datetime(2026, 8, 14, 2, 0, tzinfo=timezone.utc)
todos = [
{
"id": todo_id,
"text": f"任务{todo_id}",
"priority": "中",
"completed": False,
"dueDate": (now + timedelta(days=todo_id)).isoformat(),
"createdAt": (now - timedelta(minutes=todo_id)).isoformat(),
"snoozeUntil": None,
}
for todo_id in (1, 2, 3)
]
window = self._create_window(todos=todos)
original_items = dict(window._todo_items_by_id)
original_widgets = dict(window._todo_widgets_by_id)
new_todo = {
**todos[0],
"id": 4,
"text": "新增任务",
"createdAt": (now + timedelta(minutes=1)).isoformat(),
}

with patch.object(window.list_widget, "clear") as clear_mock:
window.todos.append(new_todo)
window.update_list_widget(changed_ids={4})
window.todos = [todo for todo in window.todos if todo["id"] != 2]
window.update_list_widget(changed_ids={2})

clear_mock.assert_not_called()
self.assertEqual(
[
window.list_widget.itemWidget(window.list_widget.item(row)).todo_item[
"id"
]
for row in range(window.list_widget.count())
],
[4, 1, 3],
)
for todo_id in (1, 3):
with self.subTest(todo_id=todo_id):
self.assertIs(window._todo_items_by_id[todo_id], original_items[todo_id])
self.assertIs(
window._todo_widgets_by_id[todo_id],
original_widgets[todo_id],
)
self.assertNotIn(2, window._todo_items_by_id)
self.assertNotIn(2, window._todo_widgets_by_id)

def test_differential_edit_moves_only_target_card(self) -> None:
now = datetime(2026, 8, 14, 2, 0, tzinfo=timezone.utc)
todos = [
{
"id": 1,
"text": "高优先级",
"priority": "高",
"completed": False,
"dueDate": (now + timedelta(days=1)).isoformat(),
"createdAt": now.isoformat(),
"snoozeUntil": None,
},
{
"id": 2,
"text": "中优先级",
"priority": "中",
"completed": False,
"dueDate": (now + timedelta(days=2)).isoformat(),
"createdAt": now.isoformat(),
"snoozeUntil": None,
},
{
"id": 3,
"text": "低优先级",
"priority": "低",
"completed": False,
"dueDate": (now + timedelta(hours=1)).isoformat(),
"createdAt": now.isoformat(),
"snoozeUntil": None,
},
]
window = self._create_window(todos=todos)
window.sort_combo.setCurrentText("优先级 (高->低)")
original_widgets = dict(window._todo_widgets_by_id)
original_items = dict(window._todo_items_by_id)

with patch.object(window.list_widget, "clear") as clear_mock:
todos[2].update({"text": "提升后的任务", "priority": "高"})
window.update_list_widget(changed_ids={3})
self.app.sendPostedEvents(None, QEvent.Type.DeferredDelete)
self.app.processEvents()

clear_mock.assert_not_called()
self.assertIs(window._todo_widgets_by_id[3], original_widgets[3])
self.assertIs(window._todo_items_by_id[3], original_items[3])
self.assertEqual(window._todo_widgets_by_id[3].original_text, "提升后的任务")
self.assertEqual(window._todo_widgets_by_id[3].todo_item["priority"], "高")
self.assertEqual(window.list_widget.row(original_items[3]), 0)
for todo_id in (1, 2):
self.assertIs(window._todo_widgets_by_id[todo_id], original_widgets[todo_id])
self.assertIs(window._todo_items_by_id[todo_id], original_items[todo_id])

def test_completion_filter_removes_and_restores_only_target_card(self) -> None:
window = self._create_window(todo_count=3)
window.filter_combo.setCurrentText("未完成")
stable_widget = window._todo_widgets_by_id[2]
stable_item = window._todo_items_by_id[2]

with (
patch.object(window.list_widget, "clear") as clear_mock,
patch("todo_app.main_window.save_todos"),
):
window.toggle_complete_todo(1)
self.assertNotIn(1, window._todo_widgets_by_id)
window.toggle_complete_todo(1)

clear_mock.assert_not_called()
self.assertIn(1, window._todo_widgets_by_id)
self.assertIs(window._todo_widgets_by_id[2], stable_widget)
self.assertIs(window._todo_items_by_id[2], stable_item)
self.assertFalse(window._todo_widgets_by_id[1].todo_item["completed"])

def test_differential_insert_preserves_visible_scroll_anchor(self) -> None:
now = datetime(2026, 8, 14, 2, 0, tzinfo=timezone.utc)
todos = [
{
"id": todo_id,
"text": f"滚动任务{todo_id}",
"priority": "中",
"completed": False,
"dueDate": (now + timedelta(days=1)).isoformat(),
"createdAt": (now - timedelta(minutes=todo_id)).isoformat(),
"snoozeUntil": None,
}
for todo_id in range(1, 21)
]
window = self._create_window(todos=todos)
window.resize(420, 560)
window.show()
self._settle_list_layout(window)
scrollbar = window.list_widget.verticalScrollBar()
scrollbar.setValue(scrollbar.maximum() // 2)
self.app.processEvents()
anchor_id, anchor_top, _ = window._capture_scroll_anchor()
self.assertIsNotNone(anchor_id)

new_todo = {
**todos[0],
"id": 21,
"text": "顶部新增任务",
"createdAt": (now + timedelta(minutes=1)).isoformat(),
}
with patch.object(window.list_widget, "clear") as clear_mock:
window.todos.append(new_todo)
window.update_list_widget(changed_ids={21})
self.app.processEvents()

clear_mock.assert_not_called()
anchor_item = window._todo_items_by_id[anchor_id]
restored_top = window.list_widget.visualItemRect(anchor_item).top()
self.assertLessEqual(abs(restored_top - anchor_top), 1)
self.assertGreater(scrollbar.value(), 0)

def test_per_line_elision_tracks_viewport_across_rapid_resizes(self) -> None:
now = datetime.now(timezone.utc)
multiline_text = (
Expand Down
2 changes: 1 addition & 1 deletion todo_app/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ class ThemeColors:

# --- 基本信息 ---
APP_NAME = "桌面待办事项"
APP_VERSION = "2.1.3"
APP_VERSION = "2.1.4"

# QSettings 命名空间属于持久化兼容契约,不应随用户可见名称变化。
SETTINGS_ORGANIZATION = "MyProductiveApp"
Expand Down
Loading
Loading