add new tasks
This commit is contained in:
144
src/tasks/app.py
144
src/tasks/app.py
@@ -10,8 +10,9 @@ from typing import Optional
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import ScrollableContainer
|
||||
from textual.logging import TextualHandler
|
||||
from textual.widgets import DataTable, Footer, Header, Static
|
||||
from textual.widgets import DataTable, Footer, Header, Static, Markdown
|
||||
|
||||
from .config import get_config, TasksAppConfig
|
||||
from .backend import Task, TaskBackend, TaskPriority, TaskStatus, Project
|
||||
@@ -90,6 +91,23 @@ class TasksApp(App):
|
||||
color: $text-muted;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
#notes-pane {
|
||||
dock: bottom;
|
||||
height: 50%;
|
||||
border-top: solid $primary;
|
||||
padding: 1;
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
#notes-pane.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#notes-content {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
@@ -103,6 +121,8 @@ class TasksApp(App):
|
||||
Binding("S", "stop_task", "Stop", show=False),
|
||||
Binding("a", "add_task", "Add", show=True),
|
||||
Binding("e", "edit_task", "Edit", show=True),
|
||||
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),
|
||||
@@ -122,6 +142,7 @@ class TasksApp(App):
|
||||
current_tag_filters: list[str]
|
||||
current_sort_column: str
|
||||
current_sort_ascending: bool
|
||||
notes_visible: bool
|
||||
backend: Optional[TaskBackend]
|
||||
config: Optional[TasksAppConfig]
|
||||
|
||||
@@ -135,6 +156,7 @@ class TasksApp(App):
|
||||
self.current_tag_filters = []
|
||||
self.current_sort_column = "priority"
|
||||
self.current_sort_ascending = True
|
||||
self.notes_visible = False
|
||||
self.config = get_config()
|
||||
|
||||
if backend:
|
||||
@@ -149,6 +171,11 @@ class TasksApp(App):
|
||||
"""Create the app layout."""
|
||||
yield Header()
|
||||
yield DataTable(id="task-table", cursor_type="row")
|
||||
yield ScrollableContainer(
|
||||
Markdown("*No task selected*", id="notes-content"),
|
||||
id="notes-pane",
|
||||
classes="hidden",
|
||||
)
|
||||
yield TasksStatusBar(id="status-bar")
|
||||
yield Footer()
|
||||
|
||||
@@ -171,6 +198,14 @@ class TasksApp(App):
|
||||
width = w
|
||||
table.add_column(col.capitalize(), width=width, key=col)
|
||||
|
||||
# Set notes pane height from config
|
||||
if self.config:
|
||||
notes_pane = self.query_one("#notes-pane")
|
||||
height = self.config.display.notes_pane_height
|
||||
# Clamp to valid range
|
||||
height = max(10, min(90, height))
|
||||
notes_pane.styles.height = f"{height}%"
|
||||
|
||||
# Load tasks
|
||||
self.load_tasks()
|
||||
|
||||
@@ -395,8 +430,36 @@ class TasksApp(App):
|
||||
|
||||
def action_add_task(self) -> None:
|
||||
"""Add a new task."""
|
||||
# TODO: Push AddTask screen
|
||||
self.notify("Add task not yet implemented", severity="warning")
|
||||
from .screens.AddTaskScreen import AddTaskScreen
|
||||
from .widgets.AddTaskForm import TaskFormData
|
||||
|
||||
# Get project names for dropdown
|
||||
project_names = [p.name for p in self.projects if p.name]
|
||||
|
||||
def handle_task_created(data: TaskFormData | None) -> None:
|
||||
if data is None or not self.backend:
|
||||
return
|
||||
|
||||
try:
|
||||
task = self.backend.add_task(
|
||||
summary=data.summary,
|
||||
project=data.project,
|
||||
tags=data.tags,
|
||||
priority=data.priority,
|
||||
due=data.due,
|
||||
notes=data.notes,
|
||||
)
|
||||
self.notify(
|
||||
f"Task created: {task.summary[:40]}...", severity="information"
|
||||
)
|
||||
self.load_tasks()
|
||||
except Exception as e:
|
||||
self.notify(f"Failed to create task: {e}", severity="error")
|
||||
|
||||
self.push_screen(
|
||||
AddTaskScreen(projects=project_names),
|
||||
handle_task_created,
|
||||
)
|
||||
|
||||
def action_view_task(self) -> None:
|
||||
"""View task details."""
|
||||
@@ -505,6 +568,8 @@ Keybindings:
|
||||
s/S - Start/Stop task
|
||||
a - Add new task
|
||||
e - Edit task in editor
|
||||
n - Toggle notes pane
|
||||
N - Edit notes
|
||||
x - Delete task
|
||||
p - Filter by project
|
||||
t - Filter by tag
|
||||
@@ -517,6 +582,79 @@ Keybindings:
|
||||
"""
|
||||
self.notify(help_text.strip(), timeout=10)
|
||||
|
||||
# Notes actions
|
||||
def action_toggle_notes(self) -> None:
|
||||
"""Toggle the notes pane visibility."""
|
||||
notes_pane = self.query_one("#notes-pane")
|
||||
self.notes_visible = not self.notes_visible
|
||||
|
||||
if self.notes_visible:
|
||||
notes_pane.remove_class("hidden")
|
||||
self._update_notes_display()
|
||||
else:
|
||||
notes_pane.add_class("hidden")
|
||||
|
||||
def action_edit_notes(self) -> None:
|
||||
"""Edit notes for selected task."""
|
||||
task = self._get_selected_task()
|
||||
if not task or not self.backend:
|
||||
return
|
||||
|
||||
# Check config for editor mode
|
||||
use_builtin = self.config and self.config.display.notes_editor == "builtin"
|
||||
|
||||
if use_builtin:
|
||||
self._edit_notes_builtin(task)
|
||||
else:
|
||||
self._edit_notes_external(task)
|
||||
|
||||
def _edit_notes_external(self, task: Task) -> None:
|
||||
"""Edit notes using external $EDITOR."""
|
||||
# Suspend the app, open editor, then resume
|
||||
with self.suspend():
|
||||
self.backend.edit_note_interactive(str(task.id))
|
||||
|
||||
# Reload task to get updated notes
|
||||
self.load_tasks()
|
||||
if self.notes_visible:
|
||||
self._update_notes_display()
|
||||
|
||||
def _edit_notes_builtin(self, task: Task) -> None:
|
||||
"""Edit notes using built-in TextArea widget."""
|
||||
from .screens.NotesEditor import NotesEditorScreen
|
||||
|
||||
def handle_notes_save(new_notes: str | None) -> None:
|
||||
if new_notes is not None and self.backend:
|
||||
# Save the notes via backend
|
||||
self.backend.modify_task(str(task.id), notes=new_notes)
|
||||
self.load_tasks()
|
||||
if self.notes_visible:
|
||||
self._update_notes_display()
|
||||
self.notify("Notes saved", severity="information")
|
||||
|
||||
self.push_screen(
|
||||
NotesEditorScreen(task.id, task.summary, task.notes or ""),
|
||||
handle_notes_save,
|
||||
)
|
||||
|
||||
def _update_notes_display(self) -> None:
|
||||
"""Update the notes pane with the selected task's notes."""
|
||||
task = self._get_selected_task()
|
||||
notes_widget = self.query_one("#notes-content", Markdown)
|
||||
|
||||
if task:
|
||||
if task.notes:
|
||||
notes_widget.update(task.notes)
|
||||
else:
|
||||
notes_widget.update("*No notes for this task*")
|
||||
else:
|
||||
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."""
|
||||
if self.notes_visible:
|
||||
self._update_notes_display()
|
||||
|
||||
|
||||
def run_app(backend: Optional[TaskBackend] = None) -> None:
|
||||
"""Run the Tasks TUI application."""
|
||||
|
||||
@@ -257,3 +257,15 @@ class TaskBackend(ABC):
|
||||
True if successful
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def edit_note_interactive(self, task_id: str) -> bool:
|
||||
"""Open task notes in editor for interactive editing.
|
||||
|
||||
Args:
|
||||
task_id: Task ID or UUID
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -60,6 +60,12 @@ class DisplayConfig(BaseModel):
|
||||
# Sort direction (asc or desc)
|
||||
sort_direction: Literal["asc", "desc"] = "asc"
|
||||
|
||||
# Notes pane height as percentage (10-90)
|
||||
notes_pane_height: int = 50
|
||||
|
||||
# Notes editor mode: "external" uses $EDITOR, "builtin" uses TextArea widget
|
||||
notes_editor: Literal["external", "builtin"] = "external"
|
||||
|
||||
|
||||
class IconsConfig(BaseModel):
|
||||
"""NerdFont icons for task display."""
|
||||
|
||||
151
src/tasks/screens/AddTaskScreen.py
Normal file
151
src/tasks/screens/AddTaskScreen.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Add Task modal screen for Tasks TUI."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from textual import on
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Button, Input, Label
|
||||
|
||||
from src.tasks.widgets.AddTaskForm import AddTaskForm, TaskFormData
|
||||
|
||||
|
||||
class AddTaskScreen(ModalScreen[Optional[TaskFormData]]):
|
||||
"""Modal screen for adding a new task."""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("escape", "cancel", "Cancel"),
|
||||
Binding("ctrl+s", "submit", "Save"),
|
||||
]
|
||||
|
||||
DEFAULT_CSS = """
|
||||
AddTaskScreen {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
AddTaskScreen #add-task-container {
|
||||
width: 80%;
|
||||
height: auto;
|
||||
max-height: 85%;
|
||||
background: $surface;
|
||||
border: thick $primary;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
AddTaskScreen #add-task-title {
|
||||
text-style: bold;
|
||||
width: 100%;
|
||||
height: 1;
|
||||
text-align: center;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
AddTaskScreen #add-task-content {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
AddTaskScreen #add-task-form {
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
AddTaskScreen #add-task-sidebar {
|
||||
width: 16;
|
||||
height: auto;
|
||||
padding: 1;
|
||||
align: center top;
|
||||
}
|
||||
|
||||
AddTaskScreen #add-task-sidebar Button {
|
||||
width: 100%;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
AddTaskScreen #help-text {
|
||||
width: 100%;
|
||||
height: 1;
|
||||
color: $text-muted;
|
||||
text-align: center;
|
||||
margin-top: 1;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
projects: list[str] | None = None,
|
||||
initial_data: TaskFormData | None = None,
|
||||
mail_link: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the add task screen.
|
||||
|
||||
Args:
|
||||
projects: List of available project names for the dropdown
|
||||
initial_data: Pre-populate form with this data
|
||||
mail_link: Optional mail link to prepend to notes
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._projects = projects or []
|
||||
self._initial_data = initial_data
|
||||
self._mail_link = mail_link
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="add-task-container"):
|
||||
yield Label("Add New Task", id="add-task-title")
|
||||
|
||||
with Horizontal(id="add-task-content"):
|
||||
yield AddTaskForm(
|
||||
projects=self._projects,
|
||||
initial_data=self._initial_data,
|
||||
show_notes=True,
|
||||
mail_link=self._mail_link,
|
||||
id="add-task-form",
|
||||
)
|
||||
|
||||
with Vertical(id="add-task-sidebar"):
|
||||
yield Button("Create", id="create", variant="primary")
|
||||
yield Button("Cancel", id="cancel", variant="default")
|
||||
|
||||
yield Label("Ctrl+S to save, Escape to cancel", id="help-text")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Focus the summary input."""
|
||||
try:
|
||||
form = self.query_one("#add-task-form", AddTaskForm)
|
||||
summary_input = form.query_one("#summary-input")
|
||||
summary_input.focus()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@on(Button.Pressed, "#create")
|
||||
def handle_create(self) -> None:
|
||||
"""Handle create button press."""
|
||||
self.action_submit()
|
||||
|
||||
@on(Button.Pressed, "#cancel")
|
||||
def handle_cancel(self) -> None:
|
||||
"""Handle cancel button press."""
|
||||
self.action_cancel()
|
||||
|
||||
@on(Input.Submitted, "#summary-input")
|
||||
def handle_summary_submit(self) -> None:
|
||||
"""Handle Enter key in summary input."""
|
||||
self.action_submit()
|
||||
|
||||
def action_submit(self) -> None:
|
||||
"""Validate and submit the form."""
|
||||
form = self.query_one("#add-task-form", AddTaskForm)
|
||||
is_valid, error = form.validate()
|
||||
|
||||
if not is_valid:
|
||||
self.notify(error, severity="error")
|
||||
return
|
||||
|
||||
data = form.get_form_data()
|
||||
self.dismiss(data)
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
"""Cancel and dismiss."""
|
||||
self.dismiss(None)
|
||||
131
src/tasks/screens/NotesEditor.py
Normal file
131
src/tasks/screens/NotesEditor.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""Notes editor screen using built-in TextArea widget."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Button, Label, TextArea
|
||||
|
||||
|
||||
class NotesEditorScreen(ModalScreen[Optional[str]]):
|
||||
"""Modal screen for editing task notes with built-in TextArea."""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("escape", "cancel", "Cancel"),
|
||||
Binding("ctrl+s", "save", "Save"),
|
||||
]
|
||||
|
||||
DEFAULT_CSS = """
|
||||
NotesEditorScreen {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
NotesEditorScreen #editor-container {
|
||||
width: 80%;
|
||||
height: 80%;
|
||||
background: $surface;
|
||||
border: thick $primary;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
NotesEditorScreen #editor-title {
|
||||
text-style: bold;
|
||||
width: 100%;
|
||||
height: 1;
|
||||
text-align: center;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
NotesEditorScreen #task-summary {
|
||||
width: 100%;
|
||||
height: 1;
|
||||
color: $text-muted;
|
||||
text-align: center;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
NotesEditorScreen TextArea {
|
||||
height: 1fr;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
NotesEditorScreen #editor-buttons {
|
||||
width: 100%;
|
||||
height: 3;
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
NotesEditorScreen Button {
|
||||
margin: 0 1;
|
||||
}
|
||||
|
||||
NotesEditorScreen #help-text {
|
||||
width: 100%;
|
||||
height: 1;
|
||||
color: $text-muted;
|
||||
text-align: center;
|
||||
margin-top: 1;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
task_id: int,
|
||||
task_summary: str,
|
||||
current_notes: str,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the notes editor screen.
|
||||
|
||||
Args:
|
||||
task_id: The task ID
|
||||
task_summary: Task summary for display
|
||||
current_notes: Current notes content
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._task_id = task_id
|
||||
self._task_summary = task_summary
|
||||
self._current_notes = current_notes
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="editor-container"):
|
||||
yield Label("Edit Notes", id="editor-title")
|
||||
yield Label(
|
||||
f"Task #{self._task_id}: {self._task_summary[:50]}{'...' if len(self._task_summary) > 50 else ''}",
|
||||
id="task-summary",
|
||||
)
|
||||
|
||||
yield TextArea(
|
||||
self._current_notes,
|
||||
id="notes-textarea",
|
||||
language="markdown",
|
||||
show_line_numbers=True,
|
||||
)
|
||||
|
||||
with Horizontal(id="editor-buttons"):
|
||||
yield Button("Cancel", id="cancel", variant="default")
|
||||
yield Button("Save", id="save", variant="primary")
|
||||
|
||||
yield Label("Ctrl+S to save, Escape to cancel", id="help-text")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Focus the text area."""
|
||||
self.query_one("#notes-textarea", TextArea).focus()
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle button presses."""
|
||||
if event.button.id == "save":
|
||||
self.action_save()
|
||||
elif event.button.id == "cancel":
|
||||
self.action_cancel()
|
||||
|
||||
def action_save(self) -> None:
|
||||
"""Save the notes and dismiss."""
|
||||
textarea = self.query_one("#notes-textarea", TextArea)
|
||||
self.dismiss(textarea.text)
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
"""Cancel editing and dismiss."""
|
||||
self.dismiss(None)
|
||||
@@ -1,5 +1,14 @@
|
||||
"""Screen components for Tasks TUI."""
|
||||
|
||||
from .FilterScreens import ProjectFilterScreen, TagFilterScreen
|
||||
from .AddTaskScreen import AddTaskScreen
|
||||
from .FilterScreens import ProjectFilterScreen, SortConfig, SortScreen, TagFilterScreen
|
||||
from .NotesEditor import NotesEditorScreen
|
||||
|
||||
__all__ = ["ProjectFilterScreen", "TagFilterScreen"]
|
||||
__all__ = [
|
||||
"AddTaskScreen",
|
||||
"NotesEditorScreen",
|
||||
"ProjectFilterScreen",
|
||||
"SortConfig",
|
||||
"SortScreen",
|
||||
"TagFilterScreen",
|
||||
]
|
||||
|
||||
306
src/tasks/widgets/AddTaskForm.py
Normal file
306
src/tasks/widgets/AddTaskForm.py
Normal file
@@ -0,0 +1,306 @@
|
||||
"""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())
|
||||
@@ -1 +1,5 @@
|
||||
"""Widget components for Tasks TUI."""
|
||||
|
||||
from .AddTaskForm import AddTaskForm, TaskFormData
|
||||
|
||||
__all__ = ["AddTaskForm", "TaskFormData"]
|
||||
|
||||
Reference in New Issue
Block a user