307 lines
8.9 KiB
Python
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())
|