add new tasks

This commit is contained in:
Bendt
2025-12-18 15:40:03 -05:00
parent a63aadffcb
commit 0ed7800575
16 changed files with 1095 additions and 39 deletions

View File

@@ -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."""

View File

@@ -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

View File

@@ -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."""

View 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)

View 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)

View File

@@ -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",
]

View 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())

View File

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