WIP
This commit is contained in:
398
src/tasks/app.py
398
src/tasks/app.py
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
374
src/tasks/widgets/FilterSidebar.py
Normal file
374
src/tasks/widgets/FilterSidebar.py
Normal 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([]))
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user