Skip to content
Merged
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
67 changes: 61 additions & 6 deletions src/ui/pyqt/board_view.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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):
Expand All @@ -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)
Expand Down Expand Up @@ -103,18 +104,43 @@ 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."""

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)
Expand Down Expand Up @@ -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."""
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
19 changes: 18 additions & 1 deletion src/ui/pyqt/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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":
Expand Down
114 changes: 111 additions & 3 deletions tests/test_pyqt_task_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand All @@ -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()

Expand Down