diff --git a/src/logic/data_transfer.py b/src/logic/data_transfer.py index baf49db..0d68372 100644 --- a/src/logic/data_transfer.py +++ b/src/logic/data_transfer.py @@ -201,8 +201,8 @@ def _validate_ui_preferences(value: object) -> dict[str, Any]: choices = { "sort_field": {"date", "time", "text", "status", "created"}, "sort_order": {"ascending", "descending"}, - "task_filter": {"all", "active", "completed", "today", "upcoming"}, - "startup_view": {"tasks", "calendar"}, + "task_filter": {"all", "active", "completed", "today", "upcoming", "board"}, + "startup_view": {"tasks", "calendar", "board"}, "calendar_view": {"month", "week", "day"}, "first_day_of_week": {"monday", "sunday"}, } diff --git a/src/ui/pyqt/board_view.py b/src/ui/pyqt/board_view.py index bb2f469..dfff75e 100644 --- a/src/ui/pyqt/board_view.py +++ b/src/ui/pyqt/board_view.py @@ -6,6 +6,7 @@ QFrame, QHBoxLayout, QLabel, + QLineEdit, QPushButton, QScrollArea, QVBoxLayout, @@ -33,9 +34,18 @@ class BoardCard(QWidget): def __init__(self, task, parent=None): super().__init__(parent) self.task = task + self._column = None self.setObjectName("BoardCard") - self.setToolTip("Drag to a column to move it; double-click to edit") + self.setToolTip( + "Drag to a column to move it; double-click to edit. " + "Arrow keys move between cards." + ) self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + self.setAccessibleName(f"Task: {task.text}") + self.setAccessibleDescription( + BOARD_STAGE_LABELS.get(task.board_stage, "Not on board") + ) layout = QVBoxLayout(self) layout.setContentsMargins(12, 10, 12, 10) @@ -124,6 +134,34 @@ def mouseDoubleClickEvent(self, event): self.edit_requested.emit(self.task) super().mouseDoubleClickEvent(event) + def keyPressEvent(self, event): + key = event.key() + board = self._column.board if self._column is not None else None + if key == Qt.Key.Key_Left: + if board is not None: + board.move_focus(self, column_delta=-1) + event.accept() + elif key == Qt.Key.Key_Right: + if board is not None: + board.move_focus(self, column_delta=1) + event.accept() + elif key == Qt.Key.Key_Up: + if board is not None: + board.move_focus(self, row_delta=-1) + event.accept() + elif key == Qt.Key.Key_Down: + if board is not None: + board.move_focus(self, row_delta=1) + event.accept() + elif key in (Qt.Key.Key_Return, Qt.Key.Key_Enter): + self.edit_requested.emit(self.task) + event.accept() + elif key == Qt.Key.Key_Delete: + self.delete_requested.emit(self.task) + event.accept() + else: + super().keyPressEvent(event) + class BoardColumn(QWidget): """A single stage column holding task cards.""" @@ -136,11 +174,13 @@ class BoardColumn(QWidget): def __init__(self, stage: str, parent=None): super().__init__(parent) self.stage = stage + self.board = None self.setObjectName("BoardColumn") self.setAcceptDrops(True) self.setToolTip("Drop a card here to move it") title = BOARD_STAGE_LABELS[stage] + self.setAccessibleName(title) layout = QVBoxLayout(self) layout.setContentsMargins(10, 12, 10, 12) layout.setSpacing(8) @@ -175,6 +215,7 @@ def set_tasks(self, tasks): widget.deleteLater() for task in tasks: card = BoardCard(task) + card._column = self card.edit_requested.connect(self.edit_requested) card.delete_requested.connect(self.delete_requested) card.complete_requested.connect(self.complete_requested) @@ -223,6 +264,11 @@ def __init__(self, scheduler, parent=None): heading_row = QHBoxLayout() heading = QLabel("Kanban") heading.setObjectName("PageHeading") + self.search_input = QLineEdit() + self.search_input.setPlaceholderText("Search cards…") + self.search_input.setClearButtonEnabled(True) + self.search_input.setAccessibleName("Search Kanban cards") + self.search_input.setMaximumWidth(230) self.count_label = QLabel() self.count_label.setObjectName("MutedLabel") self.add_button = QPushButton("+ Add task") @@ -230,6 +276,7 @@ def __init__(self, scheduler, parent=None): self.add_button.setAccessibleName("Add new task") heading_row.addWidget(heading) heading_row.addStretch() + heading_row.addWidget(self.search_input) heading_row.addWidget(self.count_label) heading_row.addWidget(self.add_button) layout.addLayout(heading_row) @@ -241,6 +288,7 @@ def __init__(self, scheduler, parent=None): self.columns: dict[str, BoardColumn] = {} for stage in BOARD_STAGES: column = BoardColumn(stage) + column.board = self column.edit_requested.connect(self.edit_requested) column.delete_requested.connect(self.delete_requested) column.complete_requested.connect(self.complete_requested) @@ -256,22 +304,73 @@ def __init__(self, scheduler, parent=None): layout.addWidget(self.empty_label, 1) self.add_button.clicked.connect(self.add_requested) + self.search_input.textChanged.connect(self._apply_search) + self.search_text = "" self.refresh() def refresh(self): - grouped = group_by_stage(self.scheduler.get_tasks()) - total_on_board = sum(len(tasks) for tasks in grouped.values()) + all_tasks = self.scheduler.get_tasks() + query = self.search_text + visible = [ + task for task in all_tasks if not query or query in task.text.casefold() + ] + grouped = group_by_stage(visible) + on_board = sum(len(tasks) for tasks in grouped.values()) for stage, column in self.columns.items(): column.set_tasks(grouped[stage]) - self.columns_widget.setVisible(total_on_board > 0) - self.empty_label.setVisible(total_on_board == 0) - self.empty_label.setText( - "No tasks on the board.\n" - "Add a task and tick \u201cAdd to Kanban\u201d to start planning." - ) - self.count_label.setText( - f"{total_on_board} of {len(self.scheduler.get_tasks())} tasks on the board" + self.columns_widget.setVisible(on_board > 0) + self.empty_label.setVisible(on_board == 0) + if query and on_board == 0 and any(bool(t.board_stage) for t in all_tasks): + self.empty_label.setText("No cards match the search.") + else: + self.empty_label.setText( + "No tasks on the board.\n" + "Add a task and tick \u201cAdd to Kanban\u201d to start planning." + ) + self.count_label.setText(f"{on_board} of {len(all_tasks)} tasks on the board") + + def _apply_search(self, text: str): + self.search_text = text.strip().casefold() + self.refresh() + + def _cards(self, stage: str) -> list[BoardCard]: + layout = self.columns[stage].card_layout + cards: list[BoardCard] = [] + for index in range(layout.count()): + item = layout.itemAt(index) + if item is None: + continue + widget = item.widget() + if isinstance(widget, BoardCard): + cards.append(widget) + return cards + + def _nav_target( + self, card: BoardCard, column_delta=0, row_delta=0 + ) -> BoardCard | None: + stages = list(BOARD_STAGES) + columns = {stage: self._cards(stage) for stage in stages} + current_col = next( + (index for index, stage in enumerate(stages) if card in columns[stage]), + None, ) + if current_col is None or not columns[stages[current_col]]: + return None + current_row = columns[stages[current_col]].index(card) + target_col = max(0, min(len(stages) - 1, current_col + column_delta)) + target_cards = columns[stages[target_col]] + if not target_cards: + return None + if column_delta: + target_row = min(current_row, len(target_cards) - 1) + else: + target_row = max(0, min(len(target_cards) - 1, current_row + row_delta)) + return target_cards[target_row] + + def move_focus(self, card: BoardCard, column_delta=0, row_delta=0): + target = self._nav_target(card, column_delta, row_delta) + if target is not None: + target.setFocus() def _resolve_move(self, task_id: str, stage: str): for task in self.scheduler.get_tasks(): diff --git a/src/ui/pyqt/settings_dialog.py b/src/ui/pyqt/settings_dialog.py index 155b333..35fa0e9 100644 --- a/src/ui/pyqt/settings_dialog.py +++ b/src/ui/pyqt/settings_dialog.py @@ -43,6 +43,7 @@ "completed": "Completed", "today": "Today", "upcoming": "Upcoming", + "board": "On board", } @@ -204,6 +205,7 @@ def _build_general_tab(self, preferences: UiPreferences) -> QWidget: self.startup_view = QComboBox() self.startup_view.addItem("Tasks", "tasks") self.startup_view.addItem("Calendar", "calendar") + self.startup_view.addItem("Kanban", "board") self.startup_view.setCurrentIndex( self.startup_view.findData(preferences.startup_view) ) diff --git a/src/ui/pyqt/task_list.py b/src/ui/pyqt/task_list.py index 2680411..927c9a9 100644 --- a/src/ui/pyqt/task_list.py +++ b/src/ui/pyqt/task_list.py @@ -104,6 +104,8 @@ def filterAcceptsRow(self, source_row, source_parent): return task.date == today and not is_completed if self.task_filter == "upcoming": return task.date >= today and not is_completed + if self.task_filter == "board": + return bool(task.board_stage) return True diff --git a/tests/test_data_transfer.py b/tests/test_data_transfer.py index 5e93bfd..122df95 100644 --- a/tests/test_data_transfer.py +++ b/tests/test_data_transfer.py @@ -70,6 +70,18 @@ def test_backup_and_restore_round_trip_tasks_and_preferences(data_environment): assert safety_document["tasks"][0]["id"] == "replacement" +def test_ui_preferences_accept_board_filter_and_startup_value(data_environment): + prefs = _ui_preferences() + prefs["task_filter"] = "board" + prefs["startup_view"] = "board" + backup = data_environment / "backup.json" + + data_transfer.create_backup(backup, ui_preferences=prefs) + result = data_transfer.restore_backup(backup) + + assert result.ui_preferences == prefs + + def test_malformed_restore_does_not_change_data_or_create_safety_backup( data_environment, ): diff --git a/tests/test_pyqt_task_workspace.py b/tests/test_pyqt_task_workspace.py index 0e059b7..2326231 100644 --- a/tests/test_pyqt_task_workspace.py +++ b/tests/test_pyqt_task_workspace.py @@ -17,7 +17,7 @@ from ui.pyqt.calendar_view import CalendarWorkspace from ui.pyqt.ics_import_dialog import IcsImportDialog from ui.pyqt.settings_dialog import SettingsDialog, UiPreferences -from ui.pyqt.task_list import TaskListWidget, TaskTableModel +from ui.pyqt.task_list import TaskFilterProxyModel, TaskListWidget, TaskTableModel from ui.pyqt.window import SchedPlusWindow from updater.config import BuildInfo from updater.preferences import UpdatePreferences @@ -407,6 +407,142 @@ def test_window_undo_reverts_board_move(app, task_database): assert scheduler.load_tasks()[0].board_stage == "backlog" +def test_task_proxy_filters_on_board_only(app): + model = TaskTableModel( + [ + Task(date="2026-09-11", time="09:00", text="On cards", board_stage="done"), + Task(date="2026-09-12", time="10:00", text="Plain row"), + ] + ) + proxy = TaskFilterProxyModel() + proxy.setSourceModel(model) + proxy.set_task_filter("board") + + assert proxy.rowCount() == 1 + assert proxy.index(0, 0).data(Qt.ItemDataRole.UserRole).text == "On cards" + + +def test_settings_offer_board_filter_and_kanban_startup(app): + dialog = SettingsDialog(UiPreferences()) + + assert dialog.task_filter.findData("board") != -1 + assert dialog.startup_view.findData("board") != -1 + + +def test_board_search_filters_cards(app): + scheduler = MemoryScheduler( + [ + Task( + date="2026-09-11", + time="09:00", + text="Plan release", + board_stage="backlog", + ), + Task( + date="2026-09-12", + time="10:00", + text="Write tests", + board_stage="in_progress", + ), + ] + ) + board = BoardView(scheduler) + + board.search_input.setText("release") + + assert board.columns["backlog"].card_layout.count() == 2 + assert board.columns["in_progress"].card_layout.count() == 1 + assert "1 of 2 tasks on the board" in board.count_label.text() + + board.search_input.setText("zzz") + + assert board.columns_widget.isHidden() + assert not board.empty_label.isHidden() + assert "No cards match the search." in board.empty_label.text() + + board.search_input.clear() + + assert not board.columns_widget.isHidden() + assert "2 of 2 tasks on the board" in board.count_label.text() + + +def test_board_card_keyboard_enter_edits_and_delete_deletes(app): + from PyQt6.QtTest import QTest + + task = Task(date="2026-09-11", time="09:00", text="Card", board_stage="backlog") + column = BoardColumn("backlog") + column.set_tasks([task]) + card = column.card_layout.itemAt(0).widget() + edited = [] + deleted = [] + card.edit_requested.connect(edited.append) + card.delete_requested.connect(deleted.append) + + QTest.keyClick(card, Qt.Key.Key_Return) + QTest.keyClick(card, Qt.Key.Key_Delete) + + assert edited == [task] + assert deleted == [task] + + +def test_board_arrow_keys_navigate_between_columns(app): + scheduler = MemoryScheduler( + [ + Task( + date="2026-09-11", + time="09:00", + text="Backlog card", + board_stage="backlog", + ), + Task( + date="2026-09-11", + time="09:30", + text="Second backlog", + board_stage="backlog", + ), + Task( + date="2026-09-12", + time="10:00", + text="Doing card", + board_stage="in_progress", + ), + Task( + date="2026-09-13", + time="11:00", + text="Done card", + board_stage="done", + ), + ] + ) + board = BoardView(scheduler) + backlog, backlog_two = board._cards("backlog") + doing = board._cards("in_progress")[0] + done = board._cards("done")[0] + + assert board._nav_target(backlog, column_delta=1) is doing + assert board._nav_target(backlog, column_delta=2) is done + assert board._nav_target(done, column_delta=-2) is backlog + assert board._nav_target(backlog_two, row_delta=-1) is backlog + assert board._nav_target(backlog, row_delta=1) is backlog_two + assert board._nav_target(backlog, row_delta=-1) is backlog + assert board._nav_target(backlog_two, row_delta=1) is backlog_two + + +def test_window_complete_reflects_on_board_and_task_list(app, task_database): + storage.initialize_database() + scheduler = Scheduler() + task = scheduler.add_task( + "2026-09-11", "09:00", "Plan release", board_stage="backlog" + ) + window = SchedPlusWindow(scheduler) + + window.complete_task(task) + + assert scheduler.load_tasks()[0].completed == "true" + assert window.board_page.columns["backlog"].card_layout.count() == 2 + assert window.task_list.model.tasks[0].completed == "true" + + def test_add_dialog_boards_task_when_checked(app): dialog = AddTaskDialog()