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:
Bendt
2025-12-19 11:51:53 -05:00
parent be2f67bb7b
commit 3629757e70
6 changed files with 205 additions and 34 deletions

View File

@@ -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]))

View File

@@ -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."""

View File

@@ -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]

View File

@@ -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:

View File

@@ -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

View File

@@ -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