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:
@@ -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]))
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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