diff --git a/src/ui/pyqt/board_view.py b/src/ui/pyqt/board_view.py index 18f3bc3..bb2f469 100644 --- a/src/ui/pyqt/board_view.py +++ b/src/ui/pyqt/board_view.py @@ -1,6 +1,7 @@ """Kanban board view for planning tasks.""" -from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtCore import QMimeData, Qt, pyqtSignal +from PyQt6.QtGui import QDrag from PyQt6.QtWidgets import ( QFrame, QHBoxLayout, @@ -13,13 +14,13 @@ from logic.board import BOARD_STAGES, group_by_stage -STAGE_LABELS = { +BOARD_STAGE_LABELS = { "backlog": "Backlog", "in_progress": "In progress", "done": "Done", } -CARD_ACTIONS = "Complete", "Edit", "Delete" +BOARD_MIME_TYPE = "application/x-schedplus-task" class BoardCard(QWidget): @@ -33,7 +34,7 @@ def __init__(self, task, parent=None): super().__init__(parent) self.task = task self.setObjectName("BoardCard") - self.setToolTip("Double-click to edit") + self.setToolTip("Drag to a column to move it; double-click to edit") self.setCursor(Qt.CursorShape.PointingHandCursor) layout = QVBoxLayout(self) @@ -103,6 +104,26 @@ def __init__(self, task, parent=None): self.edit_button.clicked.connect(lambda: self.edit_requested.emit(task)) self.delete_button.clicked.connect(lambda: self.delete_requested.emit(task)) + @staticmethod + def drag_mime(task) -> QMimeData: + mime = QMimeData() + mime.setData(BOARD_MIME_TYPE, task.id.encode("utf-8")) + return mime + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + drag = QDrag(self) + drag.setMimeData(self.drag_mime(self.task)) + drag.setPixmap(self.grab()) + drag.setHotSpot(drag.pixmap().rect().center()) + drag.exec(Qt.DropAction.MoveAction) + super().mousePressEvent(event) + + def mouseDoubleClickEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + self.edit_requested.emit(self.task) + super().mouseDoubleClickEvent(event) + class BoardColumn(QWidget): """A single stage column holding task cards.""" @@ -110,11 +131,16 @@ class BoardColumn(QWidget): edit_requested = pyqtSignal(object) delete_requested = pyqtSignal(object) complete_requested = pyqtSignal(object) + move_requested = pyqtSignal(str, str) - def __init__(self, title: str, parent=None): + def __init__(self, stage: str, parent=None): super().__init__(parent) + self.stage = stage self.setObjectName("BoardColumn") + self.setAcceptDrops(True) + self.setToolTip("Drop a card here to move it") + title = BOARD_STAGE_LABELS[stage] layout = QVBoxLayout(self) layout.setContentsMargins(10, 12, 10, 12) layout.setSpacing(8) @@ -155,6 +181,27 @@ def set_tasks(self, tasks): self.card_layout.insertWidget(self.card_layout.count() - 1, card) self.empty_label.setVisible(len(tasks) == 0) + def dragEnterEvent(self, event): + if event.mimeData().hasFormat(BOARD_MIME_TYPE): + event.acceptProposedAction() + else: + event.ignore() + + def dragMoveEvent(self, event): + if event.mimeData().hasFormat(BOARD_MIME_TYPE): + event.acceptProposedAction() + else: + event.ignore() + + def dropEvent(self, event): + mime = event.mimeData() + if not mime.hasFormat(BOARD_MIME_TYPE): + event.ignore() + return + task_id = bytes(mime.data(BOARD_MIME_TYPE)).decode("utf-8") + self.move_requested.emit(task_id, self.stage) + event.acceptProposedAction() + class BoardView(QWidget): """Kanban planning board: one column per stage.""" @@ -163,6 +210,7 @@ class BoardView(QWidget): edit_requested = pyqtSignal(object) delete_requested = pyqtSignal(object) complete_requested = pyqtSignal(object) + move_requested = pyqtSignal(object, str) def __init__(self, scheduler, parent=None): super().__init__(parent) @@ -192,10 +240,11 @@ def __init__(self, scheduler, parent=None): self.columns_row.setSpacing(14) self.columns: dict[str, BoardColumn] = {} for stage in BOARD_STAGES: - column = BoardColumn(STAGE_LABELS[stage]) + column = BoardColumn(stage) column.edit_requested.connect(self.edit_requested) column.delete_requested.connect(self.delete_requested) column.complete_requested.connect(self.complete_requested) + column.move_requested.connect(self._resolve_move) self.columns[stage] = column self.columns_row.addWidget(column, 1) layout.addWidget(self.columns_widget, 1) @@ -224,5 +273,11 @@ def refresh(self): f"{total_on_board} of {len(self.scheduler.get_tasks())} tasks on the board" ) + def _resolve_move(self, task_id: str, stage: str): + for task in self.scheduler.get_tasks(): + if task.id == task_id: + self.move_requested.emit(task, stage) + return + def focus_add(self): self.add_button.setFocus() diff --git a/src/ui/pyqt/window.py b/src/ui/pyqt/window.py index c5acd20..2561682 100644 --- a/src/ui/pyqt/window.py +++ b/src/ui/pyqt/window.py @@ -34,7 +34,7 @@ from logic.validation import ValidationError from schedplus.identity import get_application_identity from ui.pyqt.add_dialog import AddTaskDialog, EditTaskDialog -from ui.pyqt.board_view import BoardView +from ui.pyqt.board_view import BOARD_STAGE_LABELS, BoardView from ui.pyqt.calendar_view import CalendarWorkspace from ui.pyqt.ics_import_dialog import IcsImportDialog from ui.pyqt.settings_dialog import SettingsDialog, SettingsStore, UiPreferences @@ -123,6 +123,7 @@ def __init__(self, scheduler): self.board_page.edit_requested.connect(self.open_edit_dialog) self.board_page.delete_requested.connect(self.delete_task) self.board_page.complete_requested.connect(self.complete_task) + self.board_page.move_requested.connect(self.move_board_task) self._create_shortcuts() self.show_page(self.preferences.startup_view) @@ -307,6 +308,22 @@ def delete_task(self, task): except StorageError as exc: self._show_storage_error("Unable to delete task", exc) + def move_board_task(self, task, stage): + if task.board_stage == stage: + return + self.scheduler.undo_manager.record_edit(task) + draft = replace(task, board_stage=stage) + try: + self.scheduler.update_task(draft) + self.refresh_views() + self.show_status_message(f"Task moved to {BOARD_STAGE_LABELS[stage]}") + except ValidationError as exc: + self.refresh_views() + self._show_validation_error(exc) + except StorageError as exc: + self.refresh_views() + self._show_storage_error("Unable to move task", exc) + def complete_task(self, task): try: if task.completed == "true": diff --git a/tests/test_pyqt_task_workspace.py b/tests/test_pyqt_task_workspace.py index 86b2fad..0e059b7 100644 --- a/tests/test_pyqt_task_workspace.py +++ b/tests/test_pyqt_task_workspace.py @@ -5,13 +5,15 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +from PyQt6.QtCore import QMimeData, Qt from PyQt6.QtWidgets import QApplication from logic import local_time from logic.ical_import import ICSImportPlan, SkippedEvent -from logic.scheduler import Task +from logic.scheduler import Scheduler, Task +from logic.storage import sqlite_storage as storage from ui.pyqt.add_dialog import AddTaskDialog, EditTaskDialog -from ui.pyqt.board_view import BoardColumn, BoardView +from ui.pyqt.board_view import BOARD_MIME_TYPE, BoardCard, BoardColumn, BoardView from ui.pyqt.calendar_view import CalendarWorkspace from ui.pyqt.ics_import_dialog import IcsImportDialog from ui.pyqt.settings_dialog import SettingsDialog, UiPreferences @@ -38,6 +40,15 @@ def app(): yield application +@pytest.fixture +def task_database(monkeypatch, tmp_path): + path = tmp_path / "data" / "tasks.db" + path.parent.mkdir() + monkeypatch.setattr(storage, "prepare_database", lambda: path) + monkeypatch.setattr(storage, "_configure_logging", lambda _directory: None) + return path + + def test_theme_installs_brand_palette_and_covers_core_widgets(app): from PyQt6.QtGui import QPalette @@ -272,7 +283,7 @@ def test_board_empty_state(app): def test_board_column_cards_forward_signals(app): task = Task(date="2026-09-11", time="09:00", text="Card", board_stage="backlog") - column = BoardColumn("Backlog") + column = BoardColumn("backlog") column.set_tasks([task]) emitted = [] column.complete_requested.connect(emitted.append) @@ -299,6 +310,103 @@ def test_board_reflects_refreshed_scheduler(app): assert "1 of 1 tasks" in board.count_label.text() +def test_board_card_mime_carries_task_id(app): + task = Task(date="2026-09-11", time="09:00", text="Card") + card = BoardCard(task) + + assert card.drag_mime(task).hasFormat(BOARD_MIME_TYPE) + assert bytes(card.drag_mime(task).data(BOARD_MIME_TYPE)).decode("utf-8") == task.id + + +def test_board_column_accepts_task_drops(app): + from PyQt6.QtCore import QPoint, QPointF + from PyQt6.QtGui import QDragEnterEvent, QDropEvent + + column = BoardColumn("backlog") + moved = [] + column.move_requested.connect(lambda task_id, stage: moved.append((task_id, stage))) + + mime = QMimeData() + mime.setData(BOARD_MIME_TYPE, b"task-1") + enter = QDragEnterEvent( + QPoint(4, 4), + Qt.DropAction.MoveAction, + mime, + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier, + ) + column.dragEnterEvent(enter) + assert enter.isAccepted() + + drop = QDropEvent( + QPointF(4, 4), + Qt.DropAction.MoveAction, + mime, + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier, + ) + column.dropEvent(drop) + + assert drop.isAccepted() + assert moved == [("task-1", "backlog")] + + +def test_board_view_resolves_column_move_to_task(app): + task = Task(date="2026-09-11", time="09:00", text="Card", board_stage="backlog") + board = BoardView(MemoryScheduler([task])) + emitted = [] + board.move_requested.connect(lambda moved, stage: emitted.append((moved, stage))) + + board.columns["backlog"].move_requested.emit(task.id, "done") + + assert emitted == [(task, "done")] + + +def test_window_board_move_persists_without_touching_completion(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.move_board_task(task, "done") + + persisted = scheduler.load_tasks()[0] + assert persisted.board_stage == "done" + assert persisted.completed == task.completed + assert window.board_page.columns["done"].card_layout.count() == 2 + + +def test_window_board_move_to_same_column_is_noop(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.move_board_task(task, "backlog") + + assert scheduler.load_tasks()[0].board_stage == "backlog" + + +def test_window_undo_reverts_board_move(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.move_board_task(task, "done") + assert scheduler.load_tasks()[0].board_stage == "done" + + scheduler.undo_manager.undo() + + assert scheduler.load_tasks()[0].board_stage == "backlog" + + def test_add_dialog_boards_task_when_checked(app): dialog = AddTaskDialog()