This commit is contained in:
Bendt
2025-12-18 22:11:47 -05:00
parent 0ed7800575
commit a41d59e529
26 changed files with 4187 additions and 373 deletions

View File

@@ -10,12 +10,14 @@ from typing import Optional
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import ScrollableContainer
from textual.containers import ScrollableContainer, Vertical, Horizontal
from textual.logging import TextualHandler
from textual.widgets import DataTable, Footer, Header, Static, Markdown
from .config import get_config, TasksAppConfig
from .backend import Task, TaskBackend, TaskPriority, TaskStatus, Project
from .widgets.FilterSidebar import FilterSidebar
from src.utils.shared_config import get_theme_name
# Add the parent directory to the system path to resolve relative imports
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -45,69 +47,119 @@ class TasksApp(App):
CSS = """
Screen {
layout: grid;
grid-size: 1;
grid-size: 2;
grid-columns: auto 1fr;
grid-rows: auto 1fr auto auto;
}
Header {
column-span: 2;
}
Footer {
column-span: 2;
}
#sidebar {
width: 28;
height: 100%;
row-span: 1;
}
#sidebar.hidden {
display: none;
}
#main-area {
height: 100%;
width: 1fr;
}
#task-table {
height: 100%;
}
DataTable > .datatable--cursor {
background: $accent;
color: $text;
}
.priority-p0 {
color: red;
}
.priority-p1 {
color: orange;
}
.priority-p2 {
color: yellow;
}
.priority-p3 {
color: gray;
}
.overdue {
color: red;
text-style: bold;
}
.status-active {
color: cyan;
text-style: bold;
}
#status-bar {
dock: bottom;
height: 1;
background: $surface;
color: $text-muted;
padding: 0 1;
column-span: 2;
}
#detail-pane {
dock: bottom;
height: 50%;
border-top: solid $primary;
background: $surface;
column-span: 2;
}
#detail-pane.hidden {
display: none;
}
#task-details {
height: auto;
max-height: 8;
padding: 1;
border-bottom: solid $primary-darken-2;
}
#notes-container {
height: 1fr;
padding: 1;
}
#notes-content {
height: 100%;
width: 100%;
}
#notes-pane {
dock: bottom;
height: 50%;
border-top: solid $primary;
padding: 1;
background: $surface;
column-span: 2;
}
#notes-pane.hidden {
display: none;
}
#notes-content {
height: 100%;
width: 100%;
}
"""
BINDINGS = [
@@ -124,9 +176,7 @@ class TasksApp(App):
Binding("n", "toggle_notes", "Notes", show=True),
Binding("N", "edit_notes", "Edit Notes", show=False),
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("w", "toggle_sidebar", "Filters", show=True),
Binding("c", "clear_filters", "Clear", show=True),
Binding("r", "refresh", "Refresh", show=True),
Binding("y", "sync", "Sync", show=True),
@@ -143,6 +193,8 @@ class TasksApp(App):
current_sort_column: str
current_sort_ascending: bool
notes_visible: bool
detail_visible: bool
sidebar_visible: bool
backend: Optional[TaskBackend]
config: Optional[TasksAppConfig]
@@ -157,6 +209,8 @@ class TasksApp(App):
self.current_sort_column = "priority"
self.current_sort_ascending = True
self.notes_visible = False
self.detail_visible = False
self.sidebar_visible = True # Start with sidebar visible
self.config = get_config()
if backend:
@@ -170,9 +224,22 @@ class TasksApp(App):
def compose(self) -> ComposeResult:
"""Create the app layout."""
yield Header()
yield DataTable(id="task-table", cursor_type="row")
yield FilterSidebar(id="sidebar")
yield Vertical(
DataTable(id="task-table", cursor_type="row"),
id="main-area",
)
yield Vertical(
Static("", id="task-details"),
ScrollableContainer(
Markdown("*No notes*", id="notes-content"),
id="notes-container",
),
id="detail-pane",
classes="hidden",
)
yield ScrollableContainer(
Markdown("*No task selected*", id="notes-content"),
Markdown("*No task selected*", id="notes-only-content"),
id="notes-pane",
classes="hidden",
)
@@ -181,22 +248,17 @@ class TasksApp(App):
def on_mount(self) -> None:
"""Initialize the app on mount."""
self.theme = get_theme_name()
table = self.query_one("#task-table", DataTable)
# Setup columns based on config
# Setup columns based on config with dynamic widths
columns = (
self.config.display.columns
if self.config
else ["id", "priority", "project", "tags", "summary", "due"]
else ["id", "priority", "summary", "due", "project", "tags"]
)
for col in columns:
width = None
if self.config and col in self.config.display.column_widths:
w = self.config.display.column_widths[col]
if w > 0:
width = w
table.add_column(col.capitalize(), width=width, key=col)
self._setup_columns(table, columns)
# Set notes pane height from config
if self.config:
@@ -206,9 +268,54 @@ class TasksApp(App):
height = max(10, min(90, height))
notes_pane.styles.height = f"{height}%"
# Load tasks
# Load tasks (this will also update the sidebar)
self.load_tasks()
def _setup_columns(self, table: DataTable, columns: list[str]) -> None:
"""Setup table columns with dynamic widths based on available space."""
# Minimum widths for each column type
min_widths = {
"id": 3,
"priority": 5,
"project": 8,
"tags": 8,
"summary": 20,
"due": 10,
"status": 8,
}
# Preferred widths (used when space allows)
preferred_widths = {
"id": 3,
"priority": 5,
"project": 12,
"tags": 12,
"summary": 0, # 0 means take remaining space
"due": 10,
"status": 10,
}
# Calculate available width (approximate, will be refined on resize)
# Use config widths if available, otherwise use preferred
for col in columns:
if self.config and col in self.config.display.column_widths:
config_width = self.config.display.column_widths[col]
if config_width > 0:
# Use config width but enforce minimum
width = max(config_width, min_widths.get(col, 4))
else:
# 0 means auto/flexible - let DataTable handle it
width = None
else:
# Use preferred width
pref = preferred_widths.get(col, 10)
if pref == 0:
width = None
else:
width = max(pref, min_widths.get(col, 4))
table.add_column(col.capitalize(), width=width, key=col)
def _format_priority(self, priority: TaskPriority) -> str:
"""Format priority with icon."""
if not self.config:
@@ -288,9 +395,22 @@ class TasksApp(App):
self.projects = self.backend.get_projects()
self.tags = self.backend.get_tags()
# Update sidebar with available filters
self._update_sidebar()
# Update table
self._update_table()
def _update_sidebar(self) -> None:
"""Update the filter sidebar with current projects and tags."""
try:
sidebar = self.query_one("#sidebar", FilterSidebar)
# Convert projects to (name, count) tuples
project_data = [(p.name, p.task_count) for p in self.projects if p.name]
sidebar.update_filters(projects=project_data, tags=self.tags)
except Exception:
pass # Sidebar may not be mounted yet
def _sort_tasks(self) -> None:
"""Sort tasks based on current sort settings."""
@@ -462,82 +582,139 @@ class TasksApp(App):
)
def action_view_task(self) -> None:
"""View task details."""
"""Toggle task detail pane showing full details and notes."""
task = self._get_selected_task()
if not task:
return
# TODO: Push TaskDetail screen
self.notify(f"Task: {task.summary}\nNotes: {task.notes or 'None'}")
# Filter actions
def action_filter_project(self) -> None:
"""Open project filter dialog."""
from .screens.FilterScreens import ProjectFilterScreen
if not self.projects:
self.notify("No projects found", severity="warning")
self.notify("No task selected", severity="warning")
return
project_data = [(p.name, p.task_count) for p in self.projects if p.name]
detail_pane = self.query_one("#detail-pane")
def handle_project_selection(project: str | None) -> None:
if project != self.current_project_filter:
self.current_project_filter = project
self.load_tasks()
if project:
self.notify(f"Filtering by project: {project}")
else:
self.notify("Project filter cleared")
# Toggle visibility
self.detail_visible = not self.detail_visible
self.push_screen(
ProjectFilterScreen(project_data, self.current_project_filter),
handle_project_selection,
if self.detail_visible:
# Hide notes-only pane if visible
if self.notes_visible:
self.query_one("#notes-pane").add_class("hidden")
self.notes_visible = False
detail_pane.remove_class("hidden")
self._update_detail_display(task)
else:
detail_pane.add_class("hidden")
def _update_detail_display(self, task: Task) -> None:
"""Update the detail pane with task information."""
details_widget = self.query_one("#task-details", Static)
notes_widget = self.query_one("#notes-content", Markdown)
# Format task details
lines = []
# Title/Summary
lines.append(f"[bold]{task.summary}[/bold]")
lines.append("")
# Priority and Status
priority_colors = {"P0": "red", "P1": "orange", "P2": "yellow", "P3": "dim"}
p_color = priority_colors.get(task.priority.value, "white")
lines.append(
f"[dim]Priority:[/dim] [{p_color}]{task.priority.value}[/{p_color}] [dim]Status:[/dim] {task.status.value}"
)
def action_filter_tag(self) -> None:
"""Open tag filter dialog."""
from .screens.FilterScreens import TagFilterScreen
# Due date
if task.due:
date_format = self.config.display.date_format if self.config else "%Y-%m-%d"
due_str = task.due.strftime(date_format)
if task.is_overdue:
lines.append(
f"[dim]Due:[/dim] [red bold]{due_str} (OVERDUE)[/red bold]"
)
else:
lines.append(f"[dim]Due:[/dim] {due_str}")
if not self.tags:
self.notify("No tags found", severity="warning")
return
# Project
if task.project:
lines.append(f"[dim]Project:[/dim] {task.project}")
def handle_tag_selection(tags: list[str]) -> None:
if tags != self.current_tag_filters:
self.current_tag_filters = tags
self.load_tasks()
if tags:
self.notify(f"Filtering by tags: {', '.join(tags)}")
else:
self.notify("Tag filters cleared")
# Tags
if task.tags:
tags_str = " ".join(f"+{t}" for t in task.tags)
lines.append(f"[dim]Tags:[/dim] {tags_str}")
self.push_screen(
TagFilterScreen(self.tags, self.current_tag_filters),
handle_tag_selection,
)
# Created date
if task.created:
created_str = task.created.strftime("%Y-%m-%d %H:%M")
lines.append(f"[dim]Created:[/dim] {created_str}")
def action_sort_tasks(self) -> None:
"""Open sort dialog."""
from .screens.FilterScreens import SortScreen, SortConfig
# UUID (for reference)
lines.append(f"[dim]UUID:[/dim] {task.uuid[:8]}...")
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})")
details_widget.update("\n".join(lines))
self.push_screen(
SortScreen(self.current_sort_column, self.current_sort_ascending),
handle_sort_selection,
)
# Update notes
if task.notes:
notes_widget.update(task.notes)
else:
notes_widget.update("*No notes for this task*")
# Sidebar actions and handlers
def action_toggle_sidebar(self) -> None:
"""Toggle the filter sidebar visibility."""
sidebar = self.query_one("#sidebar", FilterSidebar)
self.sidebar_visible = not self.sidebar_visible
if self.sidebar_visible:
sidebar.remove_class("hidden")
else:
sidebar.add_class("hidden")
def on_filter_sidebar_project_filter_changed(
self, event: FilterSidebar.ProjectFilterChanged
) -> None:
"""Handle project filter changes from sidebar."""
if event.project != self.current_project_filter:
self.current_project_filter = event.project
self.load_tasks()
if event.project:
self.notify(f"Filtering by project: {event.project}")
else:
self.notify("Project filter cleared")
def on_filter_sidebar_tag_filter_changed(
self, event: FilterSidebar.TagFilterChanged
) -> None:
"""Handle tag filter changes from sidebar."""
if event.tags != self.current_tag_filters:
self.current_tag_filters = event.tags
self.load_tasks()
if event.tags:
self.notify(f"Filtering by tags: {', '.join(event.tags)}")
else:
self.notify("Tag filters cleared")
def on_filter_sidebar_sort_changed(self, event: FilterSidebar.SortChanged) -> None:
"""Handle sort changes from sidebar."""
self.current_sort_column = event.column
self.current_sort_ascending = event.ascending
self._sort_tasks()
self._update_table()
direction = "asc" if event.ascending else "desc"
self.notify(f"Sorted by {event.column} ({direction})")
def action_clear_filters(self) -> None:
"""Clear all filters."""
self.current_project_filter = None
self.current_tag_filters = []
# Also clear sidebar selections
try:
sidebar = self.query_one("#sidebar", FilterSidebar)
sidebar.clear_all_filters()
except Exception:
pass
self.load_tasks()
self.notify("Filters cleared", severity="information")
@@ -571,10 +748,8 @@ Keybindings:
n - Toggle notes pane
N - Edit notes
x - Delete task
p - Filter by project
t - Filter by tag
o - Sort tasks
c - Clear filters
w - Toggle filter sidebar
c - Clear all filters
r - Refresh
y - Sync with remote
Enter - View task details
@@ -584,13 +759,18 @@ Keybindings:
# Notes actions
def action_toggle_notes(self) -> None:
"""Toggle the notes pane visibility."""
"""Toggle the notes-only pane visibility."""
notes_pane = self.query_one("#notes-pane")
self.notes_visible = not self.notes_visible
if self.notes_visible:
# Hide detail pane if visible
if self.detail_visible:
self.query_one("#detail-pane").add_class("hidden")
self.detail_visible = False
notes_pane.remove_class("hidden")
self._update_notes_display()
self._update_notes_only_display()
else:
notes_pane.add_class("hidden")
@@ -617,7 +797,11 @@ Keybindings:
# Reload task to get updated notes
self.load_tasks()
if self.notes_visible:
self._update_notes_display()
self._update_notes_only_display()
if self.detail_visible:
task = self._get_selected_task()
if task:
self._update_detail_display(task)
def _edit_notes_builtin(self, task: Task) -> None:
"""Edit notes using built-in TextArea widget."""
@@ -629,7 +813,11 @@ Keybindings:
self.backend.modify_task(str(task.id), notes=new_notes)
self.load_tasks()
if self.notes_visible:
self._update_notes_display()
self._update_notes_only_display()
if self.detail_visible:
updated_task = self._get_selected_task()
if updated_task:
self._update_detail_display(updated_task)
self.notify("Notes saved", severity="information")
self.push_screen(
@@ -637,10 +825,10 @@ Keybindings:
handle_notes_save,
)
def _update_notes_display(self) -> None:
"""Update the notes pane with the selected task's notes."""
def _update_notes_only_display(self) -> None:
"""Update the notes-only pane with the selected task's notes."""
task = self._get_selected_task()
notes_widget = self.query_one("#notes-content", Markdown)
notes_widget = self.query_one("#notes-only-content", Markdown)
if task:
if task.notes:
@@ -651,9 +839,13 @@ Keybindings:
notes_widget.update("*No task selected*")
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
"""Handle row highlight changes to update notes display."""
"""Handle row highlight changes to update pane displays."""
if self.notes_visible:
self._update_notes_display()
self._update_notes_only_display()
if self.detail_visible:
task = self._get_selected_task()
if task:
self._update_detail_display(task)
def run_app(backend: Optional[TaskBackend] = None) -> None:

View File

@@ -32,17 +32,17 @@ class DisplayConfig(BaseModel):
# Columns to show in the task table
# Available: id, priority, project, tags, summary, due, status
columns: list[str] = Field(
default_factory=lambda: ["id", "priority", "project", "tags", "summary", "due"]
default_factory=lambda: ["id", "priority", "summary", "due", "project", "tags"]
)
# Column widths (0 = auto)
# Column widths (0 = auto/flexible, takes remaining space)
column_widths: dict[str, int] = Field(
default_factory=lambda: {
"id": 4,
"priority": 3,
"project": 15,
"tags": 15,
"summary": 0, # auto-expand
"id": 3,
"priority": 5,
"project": 12,
"tags": 12,
"summary": 0, # auto-expand to fill remaining space
"due": 10,
"status": 8,
}

View File

@@ -0,0 +1,374 @@
"""Filter sidebar widget for Tasks TUI.
A collapsible sidebar containing project filter, tag filter, and sort options.
Changes are applied immediately when selections change.
Uses bordered list containers similar to the mail app sidebar.
"""
from typing import Optional
from textual import on
from textual.app import ComposeResult
from textual.containers import Vertical, ScrollableContainer
from textual.message import Message
from textual.reactive import reactive
from textual.widget import Widget
from textual.widgets import Label, SelectionList, RadioButton, RadioSet, Static
from textual.widgets.selection_list import Selection
class FilterSidebar(Widget):
"""Collapsible sidebar with project filter, tag filter, and sort options."""
DEFAULT_CSS = """
FilterSidebar {
width: 30;
height: 100%;
background: $surface;
}
FilterSidebar.hidden {
display: none;
}
FilterSidebar #sidebar-scroll {
height: 100%;
width: 100%;
scrollbar-size: 1 1;
}
/* Bordered list containers like mail app */
FilterSidebar .filter-list {
height: auto;
max-height: 8;
min-height: 3;
border: round rgb(117, 106, 129);
margin: 0 0 1 0;
scrollbar-size: 1 1;
}
FilterSidebar .filter-list:focus {
border: round $secondary;
background: rgb(55, 53, 57);
border-title-style: bold;
}
FilterSidebar .sort-section {
height: auto;
border: round rgb(117, 106, 129);
margin: 0 0 1 0;
padding: 0 1;
}
FilterSidebar .sort-section:focus-within {
border: round $secondary;
background: rgb(55, 53, 57);
border-title-style: bold;
}
FilterSidebar SelectionList {
height: auto;
max-height: 8;
background: transparent;
border: none;
padding: 0;
}
FilterSidebar RadioSet {
height: auto;
background: transparent;
border: none;
padding: 0;
width: 100%;
}
FilterSidebar RadioButton {
height: 1;
background: transparent;
padding: 0;
}
FilterSidebar .direction-label {
margin-top: 1;
color: $text-muted;
height: 1;
}
"""
# Messages for filter/sort changes
class ProjectFilterChanged(Message):
"""Sent when project filter selection changes."""
def __init__(self, project: Optional[str]) -> None:
self.project = project
super().__init__()
class TagFilterChanged(Message):
"""Sent when tag filter selection changes."""
def __init__(self, tags: list[str]) -> None:
self.tags = tags
super().__init__()
class SortChanged(Message):
"""Sent when sort settings change."""
def __init__(self, column: str, ascending: bool) -> None:
self.column = column
self.ascending = ascending
super().__init__()
# Available sort columns
SORT_COLUMNS = [
("priority", "Priority"),
("project", "Project"),
("summary", "Summary"),
("due", "Due Date"),
("status", "Status"),
]
# Reactive properties - use factory functions for mutable defaults
projects: reactive[list[tuple[str, int]]] = reactive(list)
tags: reactive[list[str]] = reactive(list)
current_project: reactive[Optional[str]] = reactive(None)
current_tags: reactive[list[str]] = reactive(list)
current_sort_column: reactive[str] = reactive("priority")
current_sort_ascending: reactive[bool] = reactive(True)
def __init__(
self,
projects: Optional[list[tuple[str, int]]] = None,
tags: Optional[list[str]] = None,
current_project: Optional[str] = None,
current_tags: Optional[list[str]] = None,
current_sort_column: str = "priority",
current_sort_ascending: bool = True,
**kwargs,
):
super().__init__(**kwargs)
self.projects = projects or []
self.tags = tags or []
self.current_project = current_project
self.current_tags = current_tags or []
self.current_sort_column = current_sort_column
self.current_sort_ascending = current_sort_ascending
def compose(self) -> ComposeResult:
with ScrollableContainer(id="sidebar-scroll"):
# Project filter section - bordered list
yield SelectionList[str](id="project-list", classes="filter-list")
# Tag filter section - bordered list
yield SelectionList[str](id="tag-list", classes="filter-list")
# Sort section - bordered container
with Vertical(id="sort-section", classes="sort-section"):
with RadioSet(id="sort-column-set"):
for key, display in self.SORT_COLUMNS:
yield RadioButton(
display,
value=key == self.current_sort_column,
id=f"sort-{key}",
)
yield Label("Direction", classes="direction-label")
with RadioSet(id="sort-direction-set"):
yield RadioButton(
"Ascending",
value=self.current_sort_ascending,
id="sort-asc",
)
yield RadioButton(
"Descending",
value=not self.current_sort_ascending,
id="sort-desc",
)
def on_mount(self) -> None:
"""Initialize the sidebar with current filter state and set border titles."""
# Set border titles like mail app
project_list = self.query_one("#project-list", SelectionList)
project_list.border_title = "Projects"
tag_list = self.query_one("#tag-list", SelectionList)
tag_list.border_title = "Tags"
sort_section = self.query_one("#sort-section")
sort_section.border_title = "Sort"
# Update the lists
self._update_project_list()
self._update_tag_list()
self._update_subtitles()
def _update_subtitles(self) -> None:
"""Update border subtitles to show selection counts."""
project_list = self.query_one("#project-list", SelectionList)
if self.current_project:
project_list.border_subtitle = f"[b]{self.current_project}[/b]"
else:
project_list.border_subtitle = f"{len(self.projects)} available"
tag_list = self.query_one("#tag-list", SelectionList)
if self.current_tags:
tag_list.border_subtitle = f"[b]{len(self.current_tags)} selected[/b]"
else:
tag_list.border_subtitle = f"{len(self.tags)} available"
sort_section = self.query_one("#sort-section")
direction = "" if self.current_sort_ascending else ""
# Get display name for current column
col_display = next(
(d for k, d in self.SORT_COLUMNS if k == self.current_sort_column),
self.current_sort_column,
)
sort_section.border_subtitle = f"{col_display} {direction}"
def _update_project_list(self) -> None:
"""Update the project selection list."""
project_list = self.query_one("#project-list", SelectionList)
project_list.clear_options()
for name, count in self.projects:
project_list.add_option(
Selection(
f"{name} ({count})",
name,
initial_state=name == self.current_project,
)
)
def _update_tag_list(self) -> None:
"""Update the tag selection list."""
tag_list = self.query_one("#tag-list", SelectionList)
tag_list.clear_options()
for tag in self.tags:
tag_list.add_option(
Selection(
f"+{tag}",
tag,
initial_state=tag in self.current_tags,
)
)
def update_filters(
self,
projects: Optional[list[tuple[str, int]]] = None,
tags: Optional[list[str]] = None,
) -> None:
"""Update available projects and tags."""
if projects is not None:
self.projects = projects
self._update_project_list()
if tags is not None:
self.tags = tags
self._update_tag_list()
self._update_subtitles()
def set_current_project(self, project: Optional[str]) -> None:
"""Set the current project filter (updates UI to match)."""
self.current_project = project
self._update_project_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
self._update_tag_list()
self._update_subtitles()
def set_sort_settings(self, column: str, ascending: bool) -> None:
"""Set the current sort settings (updates UI to match)."""
self.current_sort_column = column
self.current_sort_ascending = ascending
# Update radio buttons
column_set = self.query_one("#sort-column-set", RadioSet)
for button in column_set.query(RadioButton):
if button.id == f"sort-{column}":
button.value = True
direction_set = self.query_one("#sort-direction-set", RadioSet)
asc_btn = direction_set.query_one("#sort-asc", RadioButton)
desc_btn = direction_set.query_one("#sort-desc", RadioButton)
asc_btn.value = ascending
desc_btn.value = not ascending
self._update_subtitles()
@on(SelectionList.SelectedChanged, "#project-list")
def _on_project_selection_changed(
self, event: SelectionList.SelectedChanged
) -> None:
"""Handle project selection changes."""
selected = list(event.selection_list.selected)
# For project, we only allow single selection
if selected:
new_project = selected[0]
# If same project clicked again, deselect it
if new_project == self.current_project:
self.current_project = None
event.selection_list.deselect(new_project)
else:
# Deselect previous if any
if self.current_project:
event.selection_list.deselect(self.current_project)
self.current_project = new_project
else:
self.current_project = None
self._update_subtitles()
self.post_message(self.ProjectFilterChanged(self.current_project))
@on(SelectionList.SelectedChanged, "#tag-list")
def _on_tag_selection_changed(self, event: SelectionList.SelectedChanged) -> None:
"""Handle tag selection changes."""
selected = list(event.selection_list.selected)
self.current_tags = selected
self._update_subtitles()
self.post_message(self.TagFilterChanged(self.current_tags))
@on(RadioSet.Changed, "#sort-column-set")
def _on_sort_column_changed(self, event: RadioSet.Changed) -> None:
"""Handle sort column changes."""
if event.pressed and event.pressed.id:
column = event.pressed.id.replace("sort-", "")
if column in [c[0] for c in self.SORT_COLUMNS]:
self.current_sort_column = column
self._update_subtitles()
self.post_message(
self.SortChanged(
self.current_sort_column, self.current_sort_ascending
)
)
@on(RadioSet.Changed, "#sort-direction-set")
def _on_sort_direction_changed(self, event: RadioSet.Changed) -> None:
"""Handle sort direction changes."""
if event.pressed and event.pressed.id:
self.current_sort_ascending = event.pressed.id == "sort-asc"
self._update_subtitles()
self.post_message(
self.SortChanged(self.current_sort_column, self.current_sort_ascending)
)
def clear_all_filters(self) -> None:
"""Clear all project and tag filters."""
# Clear project
project_list = self.query_one("#project-list", SelectionList)
if self.current_project:
project_list.deselect(self.current_project)
self.current_project = None
# Clear tags
tag_list = self.query_one("#tag-list", SelectionList)
for tag in self.current_tags:
tag_list.deselect(tag)
self.current_tags = []
self._update_subtitles()
# Notify app
self.post_message(self.ProjectFilterChanged(None))
self.post_message(self.TagFilterChanged([]))

View File

@@ -1,5 +1,6 @@
"""Widget components for Tasks TUI."""
from .AddTaskForm import AddTaskForm, TaskFormData
from .FilterSidebar import FilterSidebar
__all__ = ["AddTaskForm", "TaskFormData"]
__all__ = ["AddTaskForm", "TaskFormData", "FilterSidebar"]