diff --git a/docs/UX-BENCHMARKS.md b/docs/UX-BENCHMARKS.md index 2af03f0..2cf2f6b 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) @@ -78,7 +80,18 @@ Scheduled(有日期的)。 - ✅ macOS 已落地:⌘/⇧ 多选 + 批量完成/排期/移动/删除,删除走单条撤销横幅 (PRODUCT-SPEC §5b 已收录批量语义)。 -- 🔜 短期:Windows/Linux 跟进同一套多选与批量语义(见 README 平台状态)。 +- ✅ Windows 列表选择基础:WinUI Extended 模式支持 Ctrl/Shift + 多选、方向键与范围选择;Enter 打开当前选中任务的编辑对话框,Esc + 清除选择。按任务 ID 在列表刷新后恢复选择(普通列表与搜索结果)。 +- ✅ Windows 批量操作(分阶段):选择多条任务时显示共享操作栏; + 支持一次批量标记完成、批量移动到目标列表;使用稳定任务 ID,列表刷新 + 与搜索不会把操作错误应用到其他任务。打包验收覆盖两条任务批量完成。 +- ✅ Windows 批量排期:多选时支持今天、明天、清除三种日期操作; + 清除日期同时清除时间,避免任务残留不可见的 due_time。 +- ✅ Linux:基于 GTK 原生多选(#33)增加可见的批量完成和批量移动操作栏, + 同时适用于当前列表和全局搜索。刷新时按任务 ID 保留选择。 +- 🔜 **尚未完成**:Windows 批量删除及撤销、行内详情展开; + Linux 批量排期/删除及撤销;以及三平台按真实快捷键进行实机回归。 ### 2.5 Quick Find diff --git a/linux/src/main.cpp b/linux/src/main.cpp index ea90512..9853436 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(); @@ -752,6 +759,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"; @@ -919,6 +927,66 @@ 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); + } + } +} + +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. + 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( @@ -929,11 +997,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(); }); }); } @@ -1816,7 +1891,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)); @@ -1862,7 +1937,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()); @@ -2066,19 +2141,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); } } @@ -2141,7 +2211,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); @@ -2419,6 +2492,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 +2537,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); @@ -2619,7 +2708,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); @@ -2629,7 +2718,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); @@ -2638,7 +2727,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; @@ -2657,7 +2746,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")); @@ -2857,6 +2946,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") @@ -2999,6 +3092,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; } @@ -3027,6 +3121,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; } @@ -3416,13 +3518,87 @@ 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); + 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* 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)); + }), nullptr); + 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); 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, "selected-rows-changed", + G_CALLBACK(+[](GtkListBox*, gpointer) { refresh_bulk_bar(); }), + nullptr); + 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); @@ -3492,6 +3668,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);