Files
luk/src/tasks/widgets/AddTaskForm.py
2025-12-18 15:40:03 -05:00

307 lines
8.9 KiB
Python

"""Reusable Add Task form widget for Tasks TUI.
This widget can be used standalone in modals or embedded in other screens
(e.g., the mail app for creating tasks from emails).
"""
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.message import Message
from textual.widget import Widget
from textual.widgets import Input, Label, RadioButton, RadioSet, Select, TextArea
from textual.widgets._select import NoSelection
from src.tasks.backend import TaskPriority
@dataclass
class TaskFormData:
"""Data from the add task form."""
summary: str
project: Optional[str] = None
tags: list[str] | None = None
priority: TaskPriority = TaskPriority.P2
due: Optional[datetime] = None
notes: Optional[str] = None
class AddTaskForm(Widget):
"""A reusable form widget for creating/editing tasks.
This widget emits a TaskFormData when submitted and can be embedded
in various contexts (modal screens, sidebars, etc.)
"""
DEFAULT_CSS = """
AddTaskForm {
width: 100%;
height: auto;
padding: 1;
}
AddTaskForm .form-row {
width: 100%;
height: auto;
margin-bottom: 1;
}
AddTaskForm .form-label {
width: 12;
height: 1;
padding-right: 1;
}
AddTaskForm .form-input {
width: 1fr;
}
AddTaskForm #summary-input {
width: 1fr;
}
AddTaskForm #project-select {
width: 1fr;
}
AddTaskForm #tags-input {
width: 1fr;
}
AddTaskForm #due-input {
width: 20;
}
AddTaskForm #priority-set {
width: 1fr;
height: auto;
background: transparent;
border: none;
padding: 0;
layout: horizontal;
}
AddTaskForm #priority-set RadioButton {
width: auto;
padding: 0 2;
background: transparent;
height: 1;
}
AddTaskForm #notes-textarea {
width: 1fr;
height: 6;
}
AddTaskForm .required {
color: $error;
}
"""
class Submitted(Message):
"""Message emitted when the form is submitted."""
def __init__(self, data: TaskFormData) -> None:
super().__init__()
self.data = data
class Cancelled(Message):
"""Message emitted when the form is cancelled."""
pass
def __init__(
self,
projects: list[str] | None = None,
initial_data: TaskFormData | None = None,
show_notes: bool = True,
mail_link: str | None = None,
**kwargs,
):
"""Initialize the add task form.
Args:
projects: List of available project names for the dropdown
initial_data: Pre-populate form with this data
show_notes: Whether to show the notes field
mail_link: Optional mail link to prepend to notes (mail://message-id)
"""
super().__init__(**kwargs)
self._projects = projects or []
self._initial_data = initial_data
self._show_notes = show_notes
self._mail_link = mail_link
def compose(self) -> ComposeResult:
"""Compose the form layout."""
initial = self._initial_data or TaskFormData(summary="")
# Summary (required)
with Horizontal(classes="form-row"):
yield Label("Summary", classes="form-label")
yield Label("*", classes="required")
yield Input(
value=initial.summary,
placeholder="Task summary...",
id="summary-input",
classes="form-input",
)
# Project (optional dropdown)
with Horizontal(classes="form-row"):
yield Label("Project", classes="form-label")
# Build options list with empty option for "none"
options = [("(none)", "")] + [(p, p) for p in self._projects]
yield Select(
options=options,
value=initial.project or "",
id="project-select",
allow_blank=True,
)
# Tags (comma-separated input)
with Horizontal(classes="form-row"):
yield Label("Tags", classes="form-label")
tags_str = ", ".join(initial.tags) if initial.tags else ""
yield Input(
value=tags_str,
placeholder="tag1, tag2, tag3...",
id="tags-input",
classes="form-input",
)
# Priority (radio buttons)
with Horizontal(classes="form-row"):
yield Label("Priority", classes="form-label")
with RadioSet(id="priority-set"):
yield RadioButton(
"P0", value=initial.priority == TaskPriority.P0, id="priority-p0"
)
yield RadioButton(
"P1", value=initial.priority == TaskPriority.P1, id="priority-p1"
)
yield RadioButton(
"P2", value=initial.priority == TaskPriority.P2, id="priority-p2"
)
yield RadioButton(
"P3", value=initial.priority == TaskPriority.P3, id="priority-p3"
)
# Due date (input with date format)
with Horizontal(classes="form-row"):
yield Label("Due", classes="form-label")
due_str = initial.due.strftime("%Y-%m-%d") if initial.due else ""
yield Input(
value=due_str,
placeholder="YYYY-MM-DD",
id="due-input",
)
# Notes (optional textarea)
if self._show_notes:
with Vertical(classes="form-row"):
yield Label("Notes", classes="form-label")
# If mail_link is provided, prepend it to notes
notes_content = initial.notes or ""
if self._mail_link:
notes_content = f"<!-- {self._mail_link} -->\n\n{notes_content}"
yield TextArea(
notes_content,
id="notes-textarea",
language="markdown",
)
def get_form_data(self) -> TaskFormData:
"""Extract current form data.
Returns:
TaskFormData with current form values
"""
summary = self.query_one("#summary-input", Input).value.strip()
# Get project (handle NoSelection and empty string)
project_select = self.query_one("#project-select", Select)
project_value = project_select.value
project: str | None = None
if isinstance(project_value, str) and project_value:
project = project_value
# Get tags (parse comma-separated)
tags_str = self.query_one("#tags-input", Input).value.strip()
tags = (
[t.strip() for t in tags_str.split(",") if t.strip()] if tags_str else None
)
# Get priority from radio set
priority_set = self.query_one("#priority-set", RadioSet)
priority = TaskPriority.P2 # default
if priority_set.pressed_button and priority_set.pressed_button.id:
priority_map = {
"priority-p0": TaskPriority.P0,
"priority-p1": TaskPriority.P1,
"priority-p2": TaskPriority.P2,
"priority-p3": TaskPriority.P3,
}
priority = priority_map.get(priority_set.pressed_button.id, TaskPriority.P2)
# Get due date
due_str = self.query_one("#due-input", Input).value.strip()
due = None
if due_str:
try:
due = datetime.strptime(due_str, "%Y-%m-%d")
except ValueError:
pass # Invalid date format, ignore
# Get notes
notes = None
if self._show_notes:
try:
notes_area = self.query_one("#notes-textarea", TextArea)
notes = notes_area.text if notes_area.text.strip() else None
except Exception:
pass
return TaskFormData(
summary=summary,
project=project,
tags=tags,
priority=priority,
due=due,
notes=notes,
)
def validate(self) -> tuple[bool, str]:
"""Validate the form data.
Returns:
Tuple of (is_valid, error_message)
"""
data = self.get_form_data()
if not data.summary:
return False, "Summary is required"
return True, ""
def submit(self) -> bool:
"""Validate and submit the form.
Returns:
True if form was valid and submitted, False otherwise
"""
is_valid, error = self.validate()
if not is_valid:
return False
self.post_message(self.Submitted(self.get_form_data()))
return True
def cancel(self) -> None:
"""Cancel the form."""
self.post_message(self.Cancelled())