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
4 changes: 2 additions & 2 deletions src/logic/data_transfer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
}
Expand Down
121 changes: 110 additions & 11 deletions src/ui/pyqt/board_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
QFrame,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QScrollArea,
QVBoxLayout,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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."""
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -223,13 +264,19 @@ 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")
self.add_button.setObjectName("PrimaryButton")
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)
Expand All @@ -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)
Expand All @@ -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():
Expand Down
2 changes: 2 additions & 0 deletions src/ui/pyqt/settings_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"completed": "Completed",
"today": "Today",
"upcoming": "Upcoming",
"board": "On board",
}


Expand Down Expand Up @@ -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)
)
Expand Down
2 changes: 2 additions & 0 deletions src/ui/pyqt/task_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
12 changes: 12 additions & 0 deletions tests/test_data_transfer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
):
Expand Down
Loading