From 3629757e70c7c07f37a9ecf1779cb7e4f53f4663 Mon Sep 17 00:00:00 2001 From: Bendt Date: Fri, 19 Dec 2025 11:51:53 -0500 Subject: [PATCH] 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 --- src/calendar/widgets/InvitesPanel.py | 40 ++++++------- src/calendar/widgets/WeekGrid.py | 7 +-- src/services/dstask/client.py | 40 +++++++++++++ src/tasks/app.py | 34 +++++++++-- src/tasks/backend.py | 33 +++++++++++ src/tasks/widgets/FilterSidebar.py | 85 +++++++++++++++++++++++++++- 6 files changed, 205 insertions(+), 34 deletions(-) diff --git a/src/calendar/widgets/InvitesPanel.py b/src/calendar/widgets/InvitesPanel.py index 4149b1b..b64a044 100644 --- a/src/calendar/widgets/InvitesPanel.py +++ b/src/calendar/widgets/InvitesPanel.py @@ -39,6 +39,7 @@ class InvitesPanel(Widget): min-height: 3; padding: 0 1; border: round $primary; + border-title-color: $primary; } """ @@ -74,7 +75,12 @@ class InvitesPanel(Widget): def on_mount(self) -> None: """Set border title on mount.""" - self.border_title = "Invites" + self._update_border_title() + + def _update_border_title(self) -> None: + """Update border title with invite count.""" + count = len(self.invites) + self.border_title = f"Invites ({count})" if count else "Invites" def _get_theme_color(self, color_name: str) -> str: """Get a color from the current theme.""" @@ -97,25 +103,21 @@ class InvitesPanel(Widget): return fallbacks.get(color_name, "white") def get_content_height(self, container, viewport, width: int) -> int: - """Calculate height: header + invite rows.""" - # Header (1) + invites (2 lines each) + empty message if no invites + """Calculate height: invite rows only (no internal header).""" if not self.invites: - return 2 # Header + "No pending invites" - return 1 + len(self.invites) * 2 # Header + 2 lines per invite + return 1 # "No pending invites" + return len(self.invites) * 2 # 2 lines per invite def render_line(self, y: int) -> Strip: """Render a line of the panel.""" - if y == 0: - return self._render_header() - if not self.invites: - if y == 1: + if y == 0: return self._render_empty_message() return Strip.blank(self.size.width) # Each invite takes 2 lines - invite_idx = (y - 1) // 2 - line_in_invite = (y - 1) % 2 + invite_idx = y // 2 + line_in_invite = y % 2 if 0 <= invite_idx < len(self.invites): return self._render_invite_line( @@ -126,15 +128,6 @@ class InvitesPanel(Widget): return Strip.blank(self.size.width) - def _render_header(self) -> Strip: - """Render the panel header.""" - primary_color = self._get_theme_color("primary") - count = len(self.invites) - header = f"Invites ({count})" if count else "Invites" - header = header[: self.size.width].ljust(self.size.width) - style = Style(bold=True, color=primary_color) - return Strip([Segment(header, style)]) - def _render_empty_message(self) -> Strip: """Render empty state message.""" msg = "No pending invites" @@ -179,6 +172,7 @@ class InvitesPanel(Widget): self.invites = invites if self.selected_index >= len(invites): self.selected_index = max(0, len(invites) - 1) + self._update_border_title() self.refresh() def select_next(self) -> None: @@ -203,11 +197,11 @@ class InvitesPanel(Widget): """Handle mouse clicks.""" y = event.y - if y == 0 or not self.invites: + if not self.invites: return - # Calculate which invite was clicked - invite_idx = (y - 1) // 2 + # Calculate which invite was clicked (2 lines per invite) + invite_idx = y // 2 if 0 <= invite_idx < len(self.invites): self.selected_index = invite_idx self.post_message(self.InviteSelected(self.invites[invite_idx])) diff --git a/src/calendar/widgets/WeekGrid.py b/src/calendar/widgets/WeekGrid.py index 51422fc..68a9502 100644 --- a/src/calendar/widgets/WeekGrid.py +++ b/src/calendar/widgets/WeekGrid.py @@ -648,13 +648,10 @@ class WeekGrid(Vertical): current_row = (now.hour * rows_per_hour) + (now.minute // minutes_per_row) self.cursor_row = current_row - # Scroll to show work day start initially + # Always scroll to work day start initially (e.g., 7am) if self._body: work_start_row = config.work_day_start_hour() * rows_per_hour - # If current time is before work day start, scroll to work day start - # Otherwise scroll to show current time - scroll_target = min(work_start_row, current_row) - self._body.scroll_to(y=scroll_target, animate=False) + self._body.scroll_to(y=work_start_row, animate=False) def watch_week_start(self, old: date, new: date) -> None: """Handle week_start changes.""" diff --git a/src/services/dstask/client.py b/src/services/dstask/client.py index 6ee27c4..6cc95ef 100644 --- a/src/services/dstask/client.py +++ b/src/services/dstask/client.py @@ -349,3 +349,43 @@ class DstaskClient(TaskBackend): # This needs to run without capturing output result = self._run_command(["note", task_id], capture_output=False) return result.returncode == 0 + + def get_context(self) -> Optional[str]: + """Get the current context filter. + + Returns: + Current context string, or None if no context is set + """ + result = self._run_command(["context"]) + if result.returncode == 0: + context = result.stdout.strip() + return context if context else None + return None + + 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 + """ + if context is None or context.lower() == "none" or context == "": + result = self._run_command(["context", "none"]) + else: + result = self._run_command(["context", context]) + return result.returncode == 0 + + def get_contexts(self) -> list[str]: + """Get available contexts based on tags. + + For dstask, contexts are typically tag-based filters like "+work". + We derive available contexts from the existing tags. + + Returns: + List of context strings (tag-based) + """ + # Get all tags and convert to context format + tags = self.get_tags() + return [f"+{tag}" for tag in tags if tag] diff --git a/src/tasks/app.py b/src/tasks/app.py index fd85aeb..433f256 100644 --- a/src/tasks/app.py +++ b/src/tasks/app.py @@ -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: diff --git a/src/tasks/backend.py b/src/tasks/backend.py index 75a2d73..d05d6e8 100644 --- a/src/tasks/backend.py +++ b/src/tasks/backend.py @@ -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 diff --git a/src/tasks/widgets/FilterSidebar.py b/src/tasks/widgets/FilterSidebar.py index 679d132..062ec2f 100644 --- a/src/tasks/widgets/FilterSidebar.py +++ b/src/tasks/widgets/FilterSidebar.py @@ -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