diff --git a/src/tasks/app.py b/src/tasks/app.py index 8544dd1..d83da26 100644 --- a/src/tasks/app.py +++ b/src/tasks/app.py @@ -106,6 +106,7 @@ class TasksApp(App): Binding("x", "delete_task", "Delete", show=False), Binding("p", "filter_project", "Project", show=True), Binding("t", "filter_tag", "Tag", show=True), + Binding("o", "sort_tasks", "Sort", show=True), Binding("c", "clear_filters", "Clear", show=True), Binding("r", "refresh", "Refresh", show=True), Binding("y", "sync", "Sync", show=True), @@ -119,6 +120,8 @@ class TasksApp(App): tags: list[str] current_project_filter: Optional[str] current_tag_filters: list[str] + current_sort_column: str + current_sort_ascending: bool backend: Optional[TaskBackend] config: Optional[TasksAppConfig] @@ -130,6 +133,8 @@ class TasksApp(App): self.tags = [] self.current_project_filter = None self.current_tag_filters = [] + self.current_sort_column = "priority" + self.current_sort_ascending = True self.config = get_config() if backend: @@ -241,6 +246,9 @@ class TasksApp(App): tags=self.current_tag_filters if self.current_tag_filters else None, ) + # Sort tasks + self._sort_tasks() + # Also load projects and tags for filtering self.projects = self.backend.get_projects() self.tags = self.backend.get_tags() @@ -248,6 +256,31 @@ class TasksApp(App): # Update table self._update_table() + def _sort_tasks(self) -> None: + """Sort tasks based on current sort settings.""" + + def get_sort_key(task: Task): + col = self.current_sort_column + if col == "priority": + # P0 should come first (lowest value), so use enum value index + priority_order = {"P0": 0, "P1": 1, "P2": 2, "P3": 3} + return priority_order.get(task.priority.value, 99) + elif col == "project": + return task.project or "" + elif col == "summary": + return task.summary.lower() + elif col == "due": + # Tasks without due date go to the end + if task.due is None: + return "9999-99-99" + return task.due.isoformat() + elif col == "status": + return task.status.value + else: + return "" + + self.tasks.sort(key=get_sort_key, reverse=not self.current_sort_ascending) + def _update_table(self) -> None: """Update the task table with current tasks.""" table = self.query_one("#task-table", DataTable) @@ -420,6 +453,24 @@ class TasksApp(App): handle_tag_selection, ) + def action_sort_tasks(self) -> None: + """Open sort dialog.""" + from .screens.FilterScreens import SortScreen, SortConfig + + def handle_sort_selection(config: SortConfig | None) -> None: + if config is not None: + self.current_sort_column = config.column + self.current_sort_ascending = config.ascending + self._sort_tasks() + self._update_table() + direction = "asc" if config.ascending else "desc" + self.notify(f"Sorted by {config.column} ({direction})") + + self.push_screen( + SortScreen(self.current_sort_column, self.current_sort_ascending), + handle_sort_selection, + ) + def action_clear_filters(self) -> None: """Clear all filters.""" self.current_project_filter = None @@ -457,6 +508,7 @@ Keybindings: x - Delete task p - Filter by project t - Filter by tag + o - Sort tasks c - Clear filters r - Refresh y - Sync with remote diff --git a/src/tasks/screens/FilterScreens.py b/src/tasks/screens/FilterScreens.py index 848fbd2..72a5c76 100644 --- a/src/tasks/screens/FilterScreens.py +++ b/src/tasks/screens/FilterScreens.py @@ -5,9 +5,9 @@ from typing import Optional from textual import on from textual.app import ComposeResult from textual.binding import Binding -from textual.containers import Container, Vertical +from textual.containers import Horizontal, Vertical from textual.screen import ModalScreen -from textual.widgets import Label, SelectionList, Button +from textual.widgets import Label, SelectionList, Button, RadioButton, RadioSet from textual.widgets.selection_list import Selection @@ -21,37 +21,39 @@ class ProjectFilterScreen(ModalScreen[Optional[str]]): DEFAULT_CSS = """ ProjectFilterScreen { - align: center middle; + align: center bottom; } ProjectFilterScreen #filter-container { - width: 50; + width: 100%; height: auto; - max-height: 80%; + max-height: 12; background: $surface; - border: thick $primary; + border-top: thick $primary; padding: 1 2; + layout: horizontal; } ProjectFilterScreen #filter-title { text-style: bold; - width: 1fr; + width: auto; height: 1; - text-align: center; - margin-bottom: 1; + margin-right: 2; + padding-top: 1; } ProjectFilterScreen SelectionList { + width: 1fr; height: auto; - max-height: 15; - margin-bottom: 1; + max-height: 8; } ProjectFilterScreen #filter-buttons { - width: 1fr; + width: auto; height: auto; + layout: horizontal; align: center middle; - margin-top: 1; + margin-left: 2; } ProjectFilterScreen Button { @@ -76,8 +78,8 @@ class ProjectFilterScreen(ModalScreen[Optional[str]]): self._current_filter = current_filter def compose(self) -> ComposeResult: - with Container(id="filter-container"): - yield Label("Select Project", id="filter-title") + with Horizontal(id="filter-container"): + yield Label("Project:", id="filter-title") selections = [ Selection( @@ -90,7 +92,7 @@ class ProjectFilterScreen(ModalScreen[Optional[str]]): yield SelectionList[str](*selections, id="project-list") - with Container(id="filter-buttons"): + with Horizontal(id="filter-buttons"): yield Button("Cancel", id="cancel", variant="default") yield Button("Clear", id="clear", variant="warning") yield Button("Apply", id="apply", variant="primary") @@ -133,37 +135,39 @@ class TagFilterScreen(ModalScreen[list[str]]): DEFAULT_CSS = """ TagFilterScreen { - align: center middle; + align: center bottom; } TagFilterScreen #filter-container { - width: 50; + width: 100%; height: auto; - max-height: 80%; + max-height: 12; background: $surface; - border: thick $primary; + border-top: thick $primary; padding: 1 2; + layout: horizontal; } TagFilterScreen #filter-title { text-style: bold; - width: 1fr; + width: auto; height: 1; - text-align: center; - margin-bottom: 1; + margin-right: 2; + padding-top: 1; } TagFilterScreen SelectionList { + width: 1fr; height: auto; - max-height: 15; - margin-bottom: 1; + max-height: 8; } TagFilterScreen #filter-buttons { - width: 1fr; + width: auto; height: auto; + layout: horizontal; align: center middle; - margin-top: 1; + margin-left: 2; } TagFilterScreen Button { @@ -188,8 +192,8 @@ class TagFilterScreen(ModalScreen[list[str]]): self._current_filters = current_filters def compose(self) -> ComposeResult: - with Container(id="filter-container"): - yield Label("Select Tags (multi-select)", id="filter-title") + with Horizontal(id="filter-container"): + yield Label("Tags:", id="filter-title") selections = [ Selection( @@ -202,7 +206,7 @@ class TagFilterScreen(ModalScreen[list[str]]): yield SelectionList[str](*selections, id="tag-list") - with Container(id="filter-buttons"): + with Horizontal(id="filter-buttons"): yield Button("Cancel", id="cancel", variant="default") yield Button("Clear", id="clear", variant="warning") yield Button("Apply", id="apply", variant="primary") @@ -230,3 +234,157 @@ class TagFilterScreen(ModalScreen[list[str]]): def action_select(self) -> None: self.handle_apply() + + +class SortConfig: + """Configuration for sort settings.""" + + def __init__(self, column: str = "priority", ascending: bool = True): + self.column = column + self.ascending = ascending + + +class SortScreen(ModalScreen[Optional[SortConfig]]): + """Modal screen for selecting sort column and direction.""" + + BINDINGS = [ + Binding("escape", "cancel", "Cancel"), + Binding("enter", "select", "Select"), + ] + + DEFAULT_CSS = """ + SortScreen { + align: center middle; + } + + SortScreen #sort-container { + width: 40; + height: auto; + background: $surface; + border: thick $primary; + padding: 1 2; + } + + SortScreen #sort-title { + text-style: bold; + width: 100%; + height: 1; + text-align: center; + margin-bottom: 1; + } + + SortScreen .section-label { + margin-top: 1; + margin-bottom: 0; + color: $text-muted; + } + + SortScreen RadioSet { + width: 100%; + height: auto; + background: transparent; + border: none; + padding: 0; + } + + SortScreen RadioButton { + width: 100%; + background: transparent; + padding: 0; + height: 1; + } + + SortScreen #sort-buttons { + width: 100%; + height: 3; + align: center middle; + margin-top: 1; + } + + SortScreen Button { + margin: 0 1; + } + """ + + # Available columns for sorting + SORT_COLUMNS = [ + ("priority", "Priority"), + ("project", "Project"), + ("summary", "Summary"), + ("due", "Due"), + ("status", "Status"), + ] + + def __init__( + self, + current_column: str = "priority", + current_ascending: bool = True, + **kwargs, + ): + """Initialize the sort screen. + + Args: + current_column: Currently selected sort column + current_ascending: Current sort direction (True=ascending) + """ + super().__init__(**kwargs) + self._current_column = current_column + self._current_ascending = current_ascending + + def compose(self) -> ComposeResult: + with Vertical(id="sort-container"): + yield Label("Sort Tasks", id="sort-title") + + # Column selection + yield Label("Sort by:", classes="section-label") + with RadioSet(id="column-set"): + for key, display in self.SORT_COLUMNS: + yield RadioButton( + display, value=key == self._current_column, id=f"col-{key}" + ) + + # Direction selection + yield Label("Direction:", classes="section-label") + with RadioSet(id="direction-set"): + yield RadioButton( + "Ascending", value=self._current_ascending, id="dir-asc" + ) + yield RadioButton( + "Descending", value=not self._current_ascending, id="dir-desc" + ) + + with Horizontal(id="sort-buttons"): + yield Button("Cancel", id="cancel", variant="default") + yield Button("Apply", id="apply", variant="primary") + + def on_mount(self) -> None: + """Focus the column radio set.""" + self.query_one("#column-set", RadioSet).focus() + + @on(Button.Pressed, "#apply") + def handle_apply(self) -> None: + column_set = self.query_one("#column-set", RadioSet) + direction_set = self.query_one("#direction-set", RadioSet) + + # Get selected column from pressed button id + column = self._current_column + if column_set.pressed_button and column_set.pressed_button.id: + # Extract column key from id like "col-priority" + column = column_set.pressed_button.id.replace("col-", "") + + # Get direction + ascending = self._current_ascending + if direction_set.pressed_button and direction_set.pressed_button.id: + ascending = direction_set.pressed_button.id == "dir-asc" + + self.dismiss(SortConfig(column=column, ascending=ascending)) + + @on(Button.Pressed, "#cancel") + def handle_cancel(self) -> None: + self.dismiss(None) + + def action_cancel(self) -> None: + self.dismiss(None) + + def action_select(self) -> None: + self.handle_apply()