From a98cdacb836e982499f20d7e58005500f2e4c05d Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Tue, 20 Oct 2026 19:13:38 +0800 Subject: [PATCH 01/11] feat(linux): enable native multi-selection and keyboard task activation --- linux/src/main.cpp | 58 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/linux/src/main.cpp b/linux/src/main.cpp index ea905121..e5b2ee79 100644 --- a/linux/src/main.cpp +++ b/linux/src/main.cpp @@ -2066,19 +2066,14 @@ void on_row_gesture_pressed(GtkGestureClick* gesture, gint n_press, auto const is_user_list = user_data != nullptr; if (button == 3 && n_press == 1) { show_task_menu(row, x, y, is_user_list); - } else if (button == 1 && n_press == 2) { - if (is_user_list) { - // Double-click on a list row jumps to the edit dialog (macOS M4). - auto const id = static_cast(GPOINTER_TO_INT( - g_object_get_data(G_OBJECT(row), "list-id"))); - auto* parameter = g_variant_new_string(std::to_string(id).c_str()); - g_action_group_activate_action( - G_ACTION_GROUP(g_state.window), "list-edit", parameter); - } else { - auto const id = static_cast(GPOINTER_TO_INT( - g_object_get_data(G_OBJECT(row), "task-id"))); - toggle_task_editor(GTK_LIST_BOX_ROW(row), id, false); - } + } else if (button == 1 && n_press == 2 && is_user_list) { + // Task rows use GtkListBox's native row-activated signal instead: + // handling both double-click gestures would toggle the editor twice. + auto const id = static_cast(GPOINTER_TO_INT( + g_object_get_data(G_OBJECT(row), "list-id"))); + auto* parameter = g_variant_new_string(std::to_string(id).c_str()); + g_action_group_activate_action( + G_ACTION_GROUP(g_state.window), "list-edit", parameter); } } @@ -2419,6 +2414,19 @@ void rebuild_task_list() { } } + // The regular snapshot poll rebuilds every row. Preserve keyboard/shift + // selections by stable task id so the focus model does not reset on poll. + std::unordered_set selected_task_ids; + auto* selected_rows = gtk_list_box_get_selected_rows(g_state.task_list); + for (auto* node = selected_rows; node != nullptr; node = node->next) { + auto* selected = GTK_LIST_BOX_ROW(node->data); + auto const value = g_object_get_data(G_OBJECT(selected), "task-id"); + if (value != nullptr) { + selected_task_ids.insert( + static_cast(GPOINTER_TO_INT(value))); + } + } + g_list_free(selected_rows); gtk_list_box_remove_all(g_state.task_list); // Empty states (PRODUCT-SPEC §4): disconnected → hint; connected and @@ -2451,6 +2459,9 @@ void rebuild_task_list() { auto insert_row = [&](rivet_app::Task const& task) { auto* row = make_task_row(task); gtk_list_box_insert(g_state.task_list, row, -1); + if (selected_task_ids.find(task.id) != selected_task_ids.end()) { + gtk_list_box_select_row(g_state.task_list, GTK_LIST_BOX_ROW(row)); + } if (editor_carry && task.id == g_state.open_editor_id) { set_task_editor_expanded(GTK_LIST_BOX_ROW(row), task.id, true, false, &editor_snapshot); @@ -3027,6 +3038,14 @@ gboolean on_window_key_pressed(GtkEventControllerKey*, guint keyval, gtk_editable_set_text(GTK_EDITABLE(g_state.search_box), ""); return TRUE; } + // Esc after collapsing an editor clears the task selection. Avoid + // eating Esc when no task is selected, so focused fields keep GTK defaults. + auto* selected = gtk_list_box_get_selected_rows(g_state.task_list); + if (selected != nullptr) { + g_list_free(selected); + gtk_list_box_unselect_all(g_state.task_list); + return TRUE; + } return FALSE; } @@ -3421,8 +3440,19 @@ void on_activate(GtkApplication* app, gpointer) { GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); gtk_widget_set_vexpand(task_scroll, TRUE); auto* task_list = gtk_list_box_new(); + // GTK provides Ctrl-click, Shift-click and keyboard range selection, + // plus Up/Down traversal, without reimplementing platform focus rules. gtk_list_box_set_selection_mode(GTK_LIST_BOX(task_list), - GTK_SELECTION_NONE); + GTK_SELECTION_MULTIPLE); + gtk_list_box_set_activate_on_single_click(GTK_LIST_BOX(task_list), FALSE); + g_signal_connect( + task_list, "row-activated", + G_CALLBACK(+[](GtkListBox*, GtkListBoxRow* row, gpointer) { + auto const data = g_object_get_data(G_OBJECT(row), "task-id"); + if (data == nullptr) return; // completed-section header + auto const id = static_cast(GPOINTER_TO_INT(data)); + toggle_task_editor(row, id, false); + }), nullptr); gtk_widget_add_css_class(task_list, "task-row-list"); gtk_widget_set_valign(GTK_WIDGET(task_list), GTK_ALIGN_START); gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(task_scroll), task_list); From cb2c8459d2cf072c88159f14f632cf3cb8a9da14 Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Tue, 20 Oct 2026 19:13:59 +0800 Subject: [PATCH 02/11] docs: track Linux native task selection and remaining bulk actions --- docs/UX-BENCHMARKS.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/UX-BENCHMARKS.md b/docs/UX-BENCHMARKS.md index 2af03f0f..96408208 100644 --- a/docs/UX-BENCHMARKS.md +++ b/docs/UX-BENCHMARKS.md @@ -34,8 +34,10 @@ Things 几乎一切操作无需鼠标:全局快速录入、⌘N 新任务、 - ✅ 已落地(Linux 宿主):Ctrl+1…4 切视图、Ctrl+N 聚焦快速添加、Ctrl+F 聚焦 搜索、Ctrl+Shift+C 切换显示已完成(PRODUCT-SPEC §8 的加速器全量接线); Esc 阶梯(编辑器 → 搜索 → 放行)。 -- 🔜 短期:↑/↓ 在任务列表内的行导航 + Enter 展开/收起编辑器。GTK 的焦点链 - 已支持一部分,需要把行激活语义接上。 +- ✅ Linux 任务列表:GTK 原生多选支持 Ctrl/Shift 点选和方向键焦点移动; + Enter(或双击)激活选中行的行内编辑,Esc 先关闭编辑、再清除任务选择。 + 任务列表轮询重建时按任务 ID 恢复选中状态。 +- 🔜 下一步:批量移动/完成/删除和跨视图选中策略仍须单独实现并实机验收。 ### 1.3 "安静"的哲学(quiet software) From bad005af20841cd3d29af534655f41f5500da92b Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Tue, 20 Oct 2026 19:15:42 +0800 Subject: [PATCH 03/11] style(linux): use quiet design-token background for selected tasks --- linux/src/main.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/linux/src/main.cpp b/linux/src/main.cpp index e5b2ee79..a0e881c7 100644 --- a/linux/src/main.cpp +++ b/linux/src/main.cpp @@ -752,6 +752,7 @@ void apply_css(Palette const& p) { css += "listbox.task-row-list { background: " + std::string(p.background) + "; }\n"; css += "listbox.task-row-list > row { min-height: 44px; padding: 9px 16px; background: " + std::string(p.background) + "; }\n"; css += "listbox.task-row-list > row:hover { background: " + std::string(p.hover) + "; }\n"; + css += "listbox.task-row-list > row:selected { background: " + std::string(p.selected) + "; color: " + std::string(p.text) + "; }\n"; css += "listbox.sidebar-list { background: transparent; }\n"; css += "listbox.sidebar-list > row { min-height: 34px; border-radius: 8px; margin: 1px 4px; padding: 5px 8px; background: " + std::string(p.surface) + "; border: 1px solid " + std::string(p.divider) + "; box-shadow: none; }\n"; css += "listbox.sidebar-list > row:hover { background: " + std::string(p.hover) + "; }\n"; From c0163513a02c9b7b462c3a4b087cc25c0e8a177a Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Tue, 20 Oct 2026 22:25:38 +0800 Subject: [PATCH 04/11] feat(linux): batch complete/move selected tasks in list and global search --- linux/src/main.cpp | 137 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 135 insertions(+), 2 deletions(-) diff --git a/linux/src/main.cpp b/linux/src/main.cpp index a0e881c7..9dc62d57 100644 --- a/linux/src/main.cpp +++ b/linux/src/main.cpp @@ -394,6 +394,10 @@ struct AppState { GtkLabel* quick_add_preview{nullptr}; GtkButton* quick_plus{nullptr}; GtkListBox* task_list{nullptr}; + GtkBox* bulk_bar{nullptr}; + GtkLabel* bulk_count{nullptr}; + GtkButton* bulk_complete{nullptr}; + GtkButton* bulk_move{nullptr}; GtkStack* list_stack{nullptr}; GtkLabel* empty_icon{nullptr}; GtkLabel* empty_text{nullptr}; @@ -417,6 +421,7 @@ struct AppState { rivet_app::SmartCounts current_counts; std::vector lists; std::vector tasks; + std::vector search_results; std::string current_search; std::string language{"zh"}; std::uint64_t preview_sequence{0}; @@ -444,6 +449,8 @@ Palette const* g_state_palette = &kLight; GtkWidget* make_task_row(rivet_app::Task const& task); void rebuild_sidebar(); void rebuild_task_list(); +void refresh_bulk_bar(); +std::vector selected_task_ids(); void reload_snapshot(); void save_setting(char const* key, std::string const& value); void apply_language(); @@ -920,6 +927,60 @@ void save_task_edits(rivet_app::Task updated) { }); } +std::vector selected_task_ids() { + std::vector ids; + if (g_state.task_list == nullptr) return ids; + auto* selected = gtk_list_box_get_selected_rows(g_state.task_list); + for (auto* node = selected; node != nullptr; node = node->next) { + auto* row = GTK_LIST_BOX_ROW(node->data); + auto const pointer = g_object_get_data(G_OBJECT(row), "task-id"); + if (pointer != nullptr) { + ids.push_back(static_cast(GPOINTER_TO_INT(pointer))); + } + } + g_list_free(selected); + return ids; +} + +rivet_app::Task const* find_selected_task(std::int64_t id) { + // The global search result set is independent of the active list view. + if (!g_state.current_search.empty()) { + if (auto const* task = find_by_id(g_state.search_results, id)) return task; + } + return find_by_id(g_state.tasks, id); +} + +void refresh_bulk_bar() { + if (g_state.bulk_bar == nullptr) return; + auto const count = selected_task_ids().size(); + gtk_widget_set_visible(GTK_WIDGET(g_state.bulk_bar), count > 1); + if (count > 1) { + gtk_label_set_text(g_state.bulk_count, std::to_string(count).c_str()); + gtk_widget_set_sensitive(GTK_WIDGET(g_state.bulk_move), + g_state.lists.size() > 1); + } +} + +void batch_complete_selected() { + // Capture IDs before the first changed-event callback re-renders rows. + for (auto const id : selected_task_ids()) { + if (auto const* task = find_selected_task(id)) { + if (!task->completed) toggle_task(id, true); + } + } +} + +void batch_move_selected(std::int64_t target) { + for (auto const id : selected_task_ids()) { + if (auto const* task = find_selected_task(id)) { + if (task->list_id == target) continue; + auto updated = *task; // Preserve all other fields. + updated.list_id = target; + save_task_edits(std::move(updated)); + } + } +} + void run_search(std::string const& keyword) { if (g_state.api == nullptr) return; g_state.api->search_tasks_async( @@ -930,11 +991,18 @@ void run_search(std::string const& keyword) { [keyword](std::vector const& results) { // Drop stale completions: only the newest keyword renders. if (g_state.current_search != keyword) return; + std::unordered_set selected_ids; + for (auto id : selected_task_ids()) selected_ids.insert(id); + g_state.search_results = results; gtk_list_box_remove_all(g_state.task_list); for (auto const& task : results) { - gtk_list_box_insert(g_state.task_list, make_task_row(task), - -1); + auto* row = make_task_row(task); + gtk_list_box_insert(g_state.task_list, row, -1); + if (selected_ids.find(task.id) != selected_ids.end()) { + gtk_list_box_select_row(g_state.task_list, GTK_LIST_BOX_ROW(row)); + } } + refresh_bulk_bar(); }); }); } @@ -2869,6 +2937,10 @@ void apply_language() { gtk_accessible_update_property( GTK_ACCESSIBLE(g_state.quick_add), GTK_ACCESSIBLE_PROPERTY_LABEL, tr("taskListInputHint").c_str(), -1); + if (g_state.bulk_complete != nullptr) { + gtk_button_set_label(g_state.bulk_complete, tr("navCompleted").c_str()); + gtk_button_set_label(g_state.bulk_move, tr("menuMoveToList").c_str()); + } gtk_button_set_label( GTK_BUTTON(g_state.show_completed_toggle), (g_state.show_completed ? tr("hideCompletedToggle") @@ -3011,6 +3083,7 @@ void on_search_changed(GtkSearchEntry* entry, gpointer) { std::string(gtk_editable_get_text(GTK_EDITABLE(entry))); if (keyword.empty()) { g_state.current_search.clear(); + g_state.search_results.clear(); rebuild_task_list(); return; } @@ -3436,6 +3509,58 @@ void on_activate(GtkApplication* app, gpointer) { gtk_widget_add_css_class(quick_divider, "taskly-divider"); gtk_box_append(GTK_BOX(pane), quick_divider); + // Shared batch actions for the list and global-search results. Keep the + // toolbar completely hidden until two or more tasks are selected. + auto* bulk_bar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + gtk_widget_set_margin_start(bulk_bar, 16); + gtk_widget_set_margin_end(bulk_bar, 16); + gtk_widget_set_margin_top(bulk_bar, 4); + gtk_widget_set_margin_bottom(bulk_bar, 4); + gtk_widget_set_visible(bulk_bar, FALSE); + auto* bulk_count = gtk_label_new(""); + gtk_widget_add_css_class(bulk_count, "pane-subtitle"); + gtk_box_append(GTK_BOX(bulk_bar), bulk_count); + auto* bulk_complete = gtk_button_new_with_label(tr("navCompleted").c_str()); + g_signal_connect( + bulk_complete, "clicked", + G_CALLBACK(+[](GtkButton*, gpointer) { batch_complete_selected(); }), + nullptr); + gtk_box_append(GTK_BOX(bulk_bar), bulk_complete); + auto* bulk_move = gtk_button_new_with_label(tr("menuMoveToList").c_str()); + g_signal_connect( + bulk_move, "clicked", + G_CALLBACK(+[](GtkButton* button, gpointer) { + auto* old = static_cast( + g_object_get_data(G_OBJECT(button), "batch-popover")); + if (old != nullptr) gtk_widget_unparent(old); + auto* popover = gtk_popover_new(); + auto* options = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + for (auto const& list : g_state.lists) { + auto* option = gtk_button_new_with_label( + (list.icon.value_or("") + " " + list.name).c_str()); + gtk_widget_add_css_class(option, "flat"); + g_signal_connect( + option, "clicked", + G_CALLBACK(+[](GtkButton*, gpointer data) { + auto const target = static_cast( + GPOINTER_TO_INT(data)); + batch_move_selected(target); + auto* popover = static_cast( + g_object_get_data(G_OBJECT(g_state.bulk_move), "batch-popover")); + if (popover) gtk_popover_popdown(GTK_POPOVER(popover)); + }), + GINT_TO_POINTER(static_cast(list.id))); + gtk_box_append(GTK_BOX(options), option); + } + gtk_popover_set_child(GTK_POPOVER(popover), options); + gtk_widget_set_parent(popover, GTK_WIDGET(button)); + g_object_set_data(G_OBJECT(button), "batch-popover", popover); + gtk_popover_popup(GTK_POPOVER(popover)); + }), + nullptr); + gtk_box_append(GTK_BOX(bulk_bar), bulk_move); + gtk_box_append(GTK_BOX(pane), bulk_bar); + auto* task_scroll = gtk_scrolled_window_new(); gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(task_scroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); @@ -3446,6 +3571,10 @@ void on_activate(GtkApplication* app, gpointer) { gtk_list_box_set_selection_mode(GTK_LIST_BOX(task_list), GTK_SELECTION_MULTIPLE); gtk_list_box_set_activate_on_single_click(GTK_LIST_BOX(task_list), FALSE); + g_signal_connect( + task_list, "selected-rows-changed", + G_CALLBACK(+[](GtkListBox*, gpointer) { refresh_bulk_bar(); }), + nullptr); g_signal_connect( task_list, "row-activated", G_CALLBACK(+[](GtkListBox*, GtkListBoxRow* row, gpointer) { @@ -3523,6 +3652,10 @@ void on_activate(GtkApplication* app, gpointer) { g_state.quick_add_preview = GTK_LABEL(preview); g_state.quick_plus = GTK_BUTTON(quick_plus); g_state.task_list = GTK_LIST_BOX(task_list); + g_state.bulk_bar = GTK_BOX(bulk_bar); + g_state.bulk_count = GTK_LABEL(bulk_count); + g_state.bulk_complete = GTK_BUTTON(bulk_complete); + g_state.bulk_move = GTK_BUTTON(bulk_move); g_state.list_stack = GTK_STACK(list_stack); g_state.empty_icon = GTK_LABEL(empty_icon); g_state.empty_text = GTK_LABEL(empty_text); From 7f85775c91b2e0db78130d380e12f6f1d8c6303b Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Tue, 20 Oct 2026 22:25:58 +0800 Subject: [PATCH 05/11] docs: track Linux batch complete/move and remaining interaction parity --- docs/UX-BENCHMARKS.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/UX-BENCHMARKS.md b/docs/UX-BENCHMARKS.md index 96408208..5164c16e 100644 --- a/docs/UX-BENCHMARKS.md +++ b/docs/UX-BENCHMARKS.md @@ -80,7 +80,10 @@ Scheduled(有日期的)。 - ✅ macOS 已落地:⌘/⇧ 多选 + 批量完成/排期/移动/删除,删除走单条撤销横幅 (PRODUCT-SPEC §5b 已收录批量语义)。 -- 🔜 短期:Windows/Linux 跟进同一套多选与批量语义(见 README 平台状态)。 +- ✅ Linux:基于 GTK 原生多选(#33)增加可见的批量完成和批量移动操作栏, + 同时适用于当前列表和全局搜索。刷新时按任务 ID 保留选择。 +- 🔜 待补:Windows/Linux 的批量排期、批量删除与可靠撤销; + Windows 的行内编辑及三平台手动快捷键回归验收。 ### 2.5 Quick Find From a8ca636ba8dc29f54f5cd42425b0d85570afdd44 Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Tue, 20 Oct 2026 22:40:07 +0800 Subject: [PATCH 06/11] fix(linux): always show owning list in global search results --- linux/src/main.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/linux/src/main.cpp b/linux/src/main.cpp index 9dc62d57..0a1aaf09 100644 --- a/linux/src/main.cpp +++ b/linux/src/main.cpp @@ -2205,7 +2205,10 @@ GtkWidget* make_task_row(rivet_app::Task const& task) { // Meta line: owning list in multi-list views + due chip + notes preview. std::string meta; - if (g_state.view != ViewKind::List && task.list_name.has_value()) { + // Search is global even when a named list is selected. Always show + // actual ownership so a result from 工作 is never mistaken for 生活. + if ((g_state.view != ViewKind::List || !g_state.current_search.empty()) && + task.list_name.has_value()) { meta = *task.list_name; } auto const due = due_chip_text(task); From b6fcd1b19e904a4021d0f73eab17e1d26f175e07 Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Wed, 21 Oct 2026 00:30:03 +0800 Subject: [PATCH 07/11] fix(linux): resolve single-task edit, due and delete from global search --- linux/src/main.cpp | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/linux/src/main.cpp b/linux/src/main.cpp index 0a1aaf09..5e80db4b 100644 --- a/linux/src/main.cpp +++ b/linux/src/main.cpp @@ -1885,7 +1885,7 @@ struct TaskEditor { void on_editor_save_clicked(GtkButton*, gpointer user_data) { auto* editor = static_cast(user_data); - auto const* stored = find_by_id(g_state.tasks, editor->task_id); + auto const* stored = find_selected_task(editor->task_id); if (stored == nullptr) return; auto updated = *stored; updated.text = gtk_editable_get_text(GTK_EDITABLE(editor->text)); @@ -1931,7 +1931,7 @@ void set_task_editor_expanded(GtkListBoxRow* row, std::int64_t id, if (revealer == nullptr) { auto* editor = new TaskEditor(); editor->task_id = id; - auto const* task = find_by_id(g_state.tasks, id); + auto const* task = find_selected_task(id); editor->text = GTK_ENTRY(gtk_entry_new()); editor->notes = GTK_ENTRY(gtk_entry_new()); @@ -2702,7 +2702,7 @@ std::int64_t action_id(GVariant* parameter) { void on_action_task_due_today(GSimpleAction*, GVariant* parameter, gpointer) { auto const id = action_id(parameter); - if (auto const* task = find_by_id(g_state.tasks, id)) { + if (auto const* task = find_selected_task(id)) { auto updated = *task; updated.due_date = date_string(0); save_task_edits(updated); @@ -2712,7 +2712,7 @@ void on_action_task_due_today(GSimpleAction*, GVariant* parameter, gpointer) { void on_action_task_due_tomorrow(GSimpleAction*, GVariant* parameter, gpointer) { auto const id = action_id(parameter); - if (auto const* task = find_by_id(g_state.tasks, id)) { + if (auto const* task = find_selected_task(id)) { auto updated = *task; updated.due_date = date_string(1); save_task_edits(updated); @@ -2721,7 +2721,7 @@ void on_action_task_due_tomorrow(GSimpleAction*, GVariant* parameter, void on_action_task_due_clear(GSimpleAction*, GVariant* parameter, gpointer) { auto const id = action_id(parameter); - if (auto const* task = find_by_id(g_state.tasks, id)) { + if (auto const* task = find_selected_task(id)) { auto updated = *task; updated.due_date = std::nullopt; updated.due_time = std::nullopt; @@ -2740,7 +2740,7 @@ void on_action_task_edit(GSimpleAction*, GVariant* parameter, gpointer) { void on_action_task_delete(GSimpleAction*, GVariant* parameter, gpointer) { auto const id = action_id(parameter); - auto const* task = find_by_id(g_state.tasks, id); + auto const* task = find_selected_task(id); if (task == nullptr) return; show_undo_banner(*task); flash_status(tr("statusTaskDeleted")); From 81ff66b375916a458f741fce4769c3933b35e098 Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Wed, 21 Oct 2026 00:38:30 +0800 Subject: [PATCH 08/11] fix(linux): freeze batch move selection and preserve 64-bit destination IDs --- linux/src/main.cpp | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/linux/src/main.cpp b/linux/src/main.cpp index 5e80db4b..b9008f85 100644 --- a/linux/src/main.cpp +++ b/linux/src/main.cpp @@ -970,8 +970,14 @@ void batch_complete_selected() { } } -void batch_move_selected(std::int64_t target) { - for (auto const id : selected_task_ids()) { +struct BatchMoveCommand { + std::vector ids; + std::int64_t target; +}; + +void batch_move_selected(BatchMoveCommand const& command) { + for (auto const id : command.ids) { + auto const target = command.target; if (auto const* task = find_selected_task(id)) { if (task->list_id == target) continue; auto updated = *task; // Preserve all other fields. @@ -3538,21 +3544,28 @@ void on_activate(GtkApplication* app, gpointer) { if (old != nullptr) gtk_widget_unparent(old); auto* popover = gtk_popover_new(); auto* options = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + auto const ids = selected_task_ids(); for (auto const& list : g_state.lists) { auto* option = gtk_button_new_with_label( (list.icon.value_or("") + " " + list.name).c_str()); gtk_widget_add_css_class(option, "flat"); + // Capture the original selected IDs and full 64-bit list ID. + // Reading the current selection after the popover opens can + // accidentally move a different task after a snapshot refresh. + auto* command = new BatchMoveCommand{ids, list.id}; + g_object_set_data_full( + G_OBJECT(option), "batch-move-command", command, + +[](gpointer data) { delete static_cast(data); }); g_signal_connect( option, "clicked", - G_CALLBACK(+[](GtkButton*, gpointer data) { - auto const target = static_cast( - GPOINTER_TO_INT(data)); - batch_move_selected(target); + G_CALLBACK(+[](GtkButton* pressed, gpointer) { + auto* command = static_cast( + g_object_get_data(G_OBJECT(pressed), "batch-move-command")); + if (command != nullptr) batch_move_selected(*command); auto* popover = static_cast( g_object_get_data(G_OBJECT(g_state.bulk_move), "batch-popover")); if (popover) gtk_popover_popdown(GTK_POPOVER(popover)); - }), - GINT_TO_POINTER(static_cast(list.id))); + }), nullptr); gtk_box_append(GTK_BOX(options), option); } gtk_popover_set_child(GTK_POPOVER(popover), options); From bf7831724cbfb249968b6fc21eb6fdfdf72fd1c9 Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Wed, 21 Oct 2026 02:20:55 +0800 Subject: [PATCH 09/11] fix(linux): protect batch move callback from preprocessor comma splitting --- linux/src/main.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/linux/src/main.cpp b/linux/src/main.cpp index b9008f85..98534364 100644 --- a/linux/src/main.cpp +++ b/linux/src/main.cpp @@ -3538,7 +3538,7 @@ void on_activate(GtkApplication* app, gpointer) { auto* bulk_move = gtk_button_new_with_label(tr("menuMoveToList").c_str()); g_signal_connect( bulk_move, "clicked", - G_CALLBACK(+[](GtkButton* button, gpointer) { + G_CALLBACK((+[](GtkButton* button, gpointer) { auto* old = static_cast( g_object_get_data(G_OBJECT(button), "batch-popover")); if (old != nullptr) gtk_widget_unparent(old); @@ -3572,7 +3572,7 @@ void on_activate(GtkApplication* app, gpointer) { gtk_widget_set_parent(popover, GTK_WIDGET(button)); g_object_set_data(G_OBJECT(button), "batch-popover", popover); gtk_popover_popup(GTK_POPOVER(popover)); - }), + })), nullptr); gtk_box_append(GTK_BOX(bulk_bar), bulk_move); gtk_box_append(GTK_BOX(pane), bulk_bar); From 7b66cce1240802a1ba76940d4cca521f448889f7 Mon Sep 17 00:00:00 2001 From: "ren.ji" Date: Wed, 21 Oct 2026 11:25:33 +0800 Subject: [PATCH 10/11] docs: align integrated cross-platform batch status --- docs/UX-BENCHMARKS.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/UX-BENCHMARKS.md b/docs/UX-BENCHMARKS.md index 5164c16e..1ace01c8 100644 --- a/docs/UX-BENCHMARKS.md +++ b/docs/UX-BENCHMARKS.md @@ -80,10 +80,16 @@ Scheduled(有日期的)。 - ✅ macOS 已落地:⌘/⇧ 多选 + 批量完成/排期/移动/删除,删除走单条撤销横幅 (PRODUCT-SPEC §5b 已收录批量语义)。 +- ✅ Windows 列表选择基础:WinUI Extended 模式支持 Ctrl/Shift + 多选、方向键与范围选择;Enter 打开当前选中任务的编辑对话框,Esc + 清除选择。按任务 ID 在列表刷新后恢复选择(普通列表与搜索结果)。 +- ✅ Windows 批量操作(分阶段):选择多条任务时显示共享操作栏; + 支持一次批量标记完成、批量移动到目标列表;使用稳定任务 ID,列表刷新 + 与搜索不会把操作错误应用到其他任务。打包验收覆盖两条任务批量完成。 - ✅ Linux:基于 GTK 原生多选(#33)增加可见的批量完成和批量移动操作栏, 同时适用于当前列表和全局搜索。刷新时按任务 ID 保留选择。 -- 🔜 待补:Windows/Linux 的批量排期、批量删除与可靠撤销; - Windows 的行内编辑及三平台手动快捷键回归验收。 +- 🔜 **尚未完成**:Windows 批量排期/删除及撤销、行内详情展开; + Linux 批量排期/删除及撤销;以及三平台按真实快捷键进行实机回归。 ### 2.5 Quick Find From 7a7897fbfb1ee75c75ae19062acd98813dcff43a Mon Sep 17 00:00:00 2001 From: "ren.ji" Date: Wed, 21 Oct 2026 11:30:08 +0800 Subject: [PATCH 11/11] docs: align integrated Windows scheduling progress --- docs/UX-BENCHMARKS.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/UX-BENCHMARKS.md b/docs/UX-BENCHMARKS.md index 1ace01c8..2cf2f6b0 100644 --- a/docs/UX-BENCHMARKS.md +++ b/docs/UX-BENCHMARKS.md @@ -86,9 +86,11 @@ Scheduled(有日期的)。 - ✅ Windows 批量操作(分阶段):选择多条任务时显示共享操作栏; 支持一次批量标记完成、批量移动到目标列表;使用稳定任务 ID,列表刷新 与搜索不会把操作错误应用到其他任务。打包验收覆盖两条任务批量完成。 +- ✅ Windows 批量排期:多选时支持今天、明天、清除三种日期操作; + 清除日期同时清除时间,避免任务残留不可见的 due_time。 - ✅ Linux:基于 GTK 原生多选(#33)增加可见的批量完成和批量移动操作栏, 同时适用于当前列表和全局搜索。刷新时按任务 ID 保留选择。 -- 🔜 **尚未完成**:Windows 批量排期/删除及撤销、行内详情展开; +- 🔜 **尚未完成**:Windows 批量删除及撤销、行内详情展开; Linux 批量排期/删除及撤销;以及三平台按真实快捷键进行实机回归。 ### 2.5 Quick Find