Add context filter to Tasks TUI and fix calendar UI bugs
Tasks TUI: - Add context support to TaskBackend interface (get_context, set_context, get_contexts methods) - Implement context methods in DstaskClient - Add Context section to FilterSidebar (above projects/tags) - Context changes persist via backend CLI Calendar TUI: - Remove duplicate header from InvitesPanel (use border_title instead) - Fix border_title color to use $primary - Fix WeekGrid to always scroll to work day start (7am) on mount
This commit is contained in:
@@ -199,6 +199,8 @@ class TasksApp(App):
|
||||
self.tags = []
|
||||
self.all_projects = [] # Stable list of all projects (not filtered)
|
||||
self.all_tags = [] # Stable list of all tags (not filtered)
|
||||
self.all_contexts = [] # Available contexts from backend
|
||||
self.current_context = None # Current active context
|
||||
self.current_project_filter = None
|
||||
self.current_tag_filters = []
|
||||
self.current_sort_column = "priority"
|
||||
@@ -393,12 +395,14 @@ class TasksApp(App):
|
||||
self._update_table()
|
||||
|
||||
def _load_all_filters(self) -> None:
|
||||
"""Load all projects and tags once for stable sidebar."""
|
||||
"""Load all projects, tags, and contexts once for stable sidebar."""
|
||||
if not self.backend:
|
||||
return
|
||||
|
||||
self.all_projects = self.backend.get_projects()
|
||||
self.all_tags = self.backend.get_tags()
|
||||
self.all_contexts = self.backend.get_contexts()
|
||||
self.current_context = self.backend.get_context()
|
||||
|
||||
# Update sidebar with stable filter options
|
||||
self._update_sidebar()
|
||||
@@ -426,12 +430,19 @@ class TasksApp(App):
|
||||
return filtered
|
||||
|
||||
def _update_sidebar(self) -> None:
|
||||
"""Update the filter sidebar with all available projects and tags."""
|
||||
"""Update the filter sidebar with all available projects, tags, and contexts."""
|
||||
try:
|
||||
sidebar = self.query_one("#sidebar", FilterSidebar)
|
||||
# Use stable all_projects/all_tags, not filtered ones
|
||||
# Use stable all_projects/all_tags/all_contexts, not filtered ones
|
||||
project_data = [(p.name, p.task_count) for p in self.all_projects if p.name]
|
||||
sidebar.update_filters(projects=project_data, tags=self.all_tags)
|
||||
sidebar.update_filters(
|
||||
contexts=self.all_contexts,
|
||||
projects=project_data,
|
||||
tags=self.all_tags,
|
||||
)
|
||||
# Set current context in sidebar if loaded from backend
|
||||
if self.current_context:
|
||||
sidebar.set_current_context(self.current_context)
|
||||
except Exception:
|
||||
pass # Sidebar may not be mounted yet
|
||||
|
||||
@@ -694,6 +705,21 @@ class TasksApp(App):
|
||||
else:
|
||||
sidebar.add_class("hidden")
|
||||
|
||||
def on_filter_sidebar_context_changed(
|
||||
self, event: FilterSidebar.ContextChanged
|
||||
) -> None:
|
||||
"""Handle context changes from sidebar."""
|
||||
if event.context != self.current_context:
|
||||
# Set context via backend (this persists it)
|
||||
if self.backend:
|
||||
self.backend.set_context(event.context)
|
||||
self.current_context = event.context
|
||||
self.load_tasks()
|
||||
if event.context:
|
||||
self.notify(f"Context set: {event.context}")
|
||||
else:
|
||||
self.notify("Context cleared")
|
||||
|
||||
def on_filter_sidebar_project_filter_changed(
|
||||
self, event: FilterSidebar.ProjectFilterChanged
|
||||
) -> None:
|
||||
|
||||
@@ -269,3 +269,36 @@ class TaskBackend(ABC):
|
||||
True if successful
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_context(self) -> Optional[str]:
|
||||
"""Get the current context filter.
|
||||
|
||||
Returns:
|
||||
Current context string, or None if no context is set
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_context(self, context: Optional[str]) -> bool:
|
||||
"""Set the context filter.
|
||||
|
||||
Args:
|
||||
context: Context string (e.g., "+work", "project:foo") or None to clear
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_contexts(self) -> list[str]:
|
||||
"""Get available predefined contexts.
|
||||
|
||||
For taskwarrior, returns named contexts from config.
|
||||
For dstask, may return common tag-based contexts.
|
||||
|
||||
Returns:
|
||||
List of context names/filters
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -18,7 +18,7 @@ from textual.widgets.selection_list import Selection
|
||||
|
||||
|
||||
class FilterSidebar(Widget):
|
||||
"""Collapsible sidebar with project filter, tag filter, and sort options."""
|
||||
"""Collapsible sidebar with context, project filter, tag filter, and sort options."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
FilterSidebar {
|
||||
@@ -53,6 +53,13 @@ class FilterSidebar(Widget):
|
||||
border-title-style: bold;
|
||||
}
|
||||
|
||||
/* Context section - single selection list */
|
||||
FilterSidebar #context-list {
|
||||
height: auto;
|
||||
max-height: 6;
|
||||
min-height: 3;
|
||||
}
|
||||
|
||||
FilterSidebar .sort-section {
|
||||
height: auto;
|
||||
border: round rgb(117, 106, 129);
|
||||
@@ -110,6 +117,13 @@ class FilterSidebar(Widget):
|
||||
self.tags = tags
|
||||
super().__init__()
|
||||
|
||||
class ContextChanged(Message):
|
||||
"""Sent when context selection changes."""
|
||||
|
||||
def __init__(self, context: Optional[str]) -> None:
|
||||
self.context = context
|
||||
super().__init__()
|
||||
|
||||
class SortChanged(Message):
|
||||
"""Sent when sort settings change."""
|
||||
|
||||
@@ -128,8 +142,10 @@ class FilterSidebar(Widget):
|
||||
]
|
||||
|
||||
# Reactive properties - use factory functions for mutable defaults
|
||||
contexts: reactive[list[str]] = reactive(list)
|
||||
projects: reactive[list[tuple[str, int]]] = reactive(list)
|
||||
tags: reactive[list[str]] = reactive(list)
|
||||
current_context: reactive[Optional[str]] = reactive(None)
|
||||
current_project: reactive[Optional[str]] = reactive(None)
|
||||
current_tags: reactive[list[str]] = reactive(list)
|
||||
current_sort_column: reactive[str] = reactive("priority")
|
||||
@@ -137,8 +153,10 @@ class FilterSidebar(Widget):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
contexts: Optional[list[str]] = None,
|
||||
projects: Optional[list[tuple[str, int]]] = None,
|
||||
tags: Optional[list[str]] = None,
|
||||
current_context: Optional[str] = None,
|
||||
current_project: Optional[str] = None,
|
||||
current_tags: Optional[list[str]] = None,
|
||||
current_sort_column: str = "priority",
|
||||
@@ -146,8 +164,10 @@ class FilterSidebar(Widget):
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(**kwargs)
|
||||
self.contexts = contexts or []
|
||||
self.projects = projects or []
|
||||
self.tags = tags or []
|
||||
self.current_context = current_context
|
||||
self.current_project = current_project
|
||||
self.current_tags = current_tags or []
|
||||
self.current_sort_column = current_sort_column
|
||||
@@ -155,6 +175,9 @@ class FilterSidebar(Widget):
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with ScrollableContainer(id="sidebar-scroll"):
|
||||
# Context filter section - bordered list (at top since it's global)
|
||||
yield SelectionList[str](id="context-list", classes="filter-list")
|
||||
|
||||
# Project filter section - bordered list
|
||||
yield SelectionList[str](id="project-list", classes="filter-list")
|
||||
|
||||
@@ -187,6 +210,9 @@ class FilterSidebar(Widget):
|
||||
def on_mount(self) -> None:
|
||||
"""Initialize the sidebar with current filter state and set border titles."""
|
||||
# Set border titles like mail app
|
||||
context_list = self.query_one("#context-list", SelectionList)
|
||||
context_list.border_title = "Context"
|
||||
|
||||
project_list = self.query_one("#project-list", SelectionList)
|
||||
project_list.border_title = "Projects"
|
||||
|
||||
@@ -197,12 +223,19 @@ class FilterSidebar(Widget):
|
||||
sort_section.border_title = "Sort"
|
||||
|
||||
# Update the lists
|
||||
self._update_context_list()
|
||||
self._update_project_list()
|
||||
self._update_tag_list()
|
||||
self._update_subtitles()
|
||||
|
||||
def _update_subtitles(self) -> None:
|
||||
"""Update border subtitles to show selection counts."""
|
||||
context_list = self.query_one("#context-list", SelectionList)
|
||||
if self.current_context:
|
||||
context_list.border_subtitle = f"[b]{self.current_context}[/b]"
|
||||
else:
|
||||
context_list.border_subtitle = "none"
|
||||
|
||||
project_list = self.query_one("#project-list", SelectionList)
|
||||
if self.current_project:
|
||||
project_list.border_subtitle = f"[b]{self.current_project}[/b]"
|
||||
@@ -224,6 +257,20 @@ class FilterSidebar(Widget):
|
||||
)
|
||||
sort_section.border_subtitle = f"{col_display} {direction}"
|
||||
|
||||
def _update_context_list(self) -> None:
|
||||
"""Update the context selection list."""
|
||||
context_list = self.query_one("#context-list", SelectionList)
|
||||
context_list.clear_options()
|
||||
|
||||
for ctx in self.contexts:
|
||||
context_list.add_option(
|
||||
Selection(
|
||||
ctx,
|
||||
ctx,
|
||||
initial_state=ctx == self.current_context,
|
||||
)
|
||||
)
|
||||
|
||||
def _update_project_list(self) -> None:
|
||||
"""Update the project selection list."""
|
||||
project_list = self.query_one("#project-list", SelectionList)
|
||||
@@ -254,10 +301,14 @@ class FilterSidebar(Widget):
|
||||
|
||||
def update_filters(
|
||||
self,
|
||||
contexts: Optional[list[str]] = None,
|
||||
projects: Optional[list[tuple[str, int]]] = None,
|
||||
tags: Optional[list[str]] = None,
|
||||
) -> None:
|
||||
"""Update available projects and tags."""
|
||||
"""Update available contexts, projects and tags."""
|
||||
if contexts is not None:
|
||||
self.contexts = contexts
|
||||
self._update_context_list()
|
||||
if projects is not None:
|
||||
self.projects = projects
|
||||
self._update_project_list()
|
||||
@@ -272,6 +323,12 @@ class FilterSidebar(Widget):
|
||||
self._update_project_list()
|
||||
self._update_subtitles()
|
||||
|
||||
def set_current_context(self, context: Optional[str]) -> None:
|
||||
"""Set the current context (updates UI to match)."""
|
||||
self.current_context = context
|
||||
self._update_context_list()
|
||||
self._update_subtitles()
|
||||
|
||||
def set_current_tags(self, tags: list[str]) -> None:
|
||||
"""Set the current tag filters (updates UI to match)."""
|
||||
self.current_tags = tags
|
||||
@@ -297,6 +354,30 @@ class FilterSidebar(Widget):
|
||||
|
||||
self._update_subtitles()
|
||||
|
||||
@on(SelectionList.SelectedChanged, "#context-list")
|
||||
def _on_context_selection_changed(
|
||||
self, event: SelectionList.SelectedChanged
|
||||
) -> None:
|
||||
"""Handle context selection changes."""
|
||||
selected = list(event.selection_list.selected)
|
||||
# For context, we only allow single selection
|
||||
if selected:
|
||||
new_context = selected[0]
|
||||
# If same context clicked again, deselect it (clear context)
|
||||
if new_context == self.current_context:
|
||||
self.current_context = None
|
||||
event.selection_list.deselect(new_context)
|
||||
else:
|
||||
# Deselect previous if any
|
||||
if self.current_context:
|
||||
event.selection_list.deselect(self.current_context)
|
||||
self.current_context = new_context
|
||||
else:
|
||||
self.current_context = None
|
||||
|
||||
self._update_subtitles()
|
||||
self.post_message(self.ContextChanged(self.current_context))
|
||||
|
||||
@on(SelectionList.SelectedChanged, "#project-list")
|
||||
def _on_project_selection_changed(
|
||||
self, event: SelectionList.SelectedChanged
|
||||
|
||||
Reference in New Issue
Block a user