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

@@ -22,7 +22,11 @@ async def delete_current(app):
next_id, next_idx = app.message_store.find_prev_valid_id(current_index)
# Delete the message using our Himalaya client module
message, success = await himalaya_client.delete_message(current_message_id)
folder = app.folder if app.folder else None
account = app.current_account if app.current_account else None
message, success = await himalaya_client.delete_message(
current_message_id, folder=folder, account=account
)
if success:
app.show_status(f"Message {current_message_id} deleted.", "success")

View File

@@ -59,6 +59,7 @@ class EmailViewerApp(App):
current_message_index: Reactive[int] = reactive(0)
highlighted_message_index: Reactive[int] = reactive(0)
folder = reactive("INBOX")
current_account: Reactive[str] = reactive("") # Empty string = default account
header_expanded = reactive(False)
reload_needed = reactive(True)
message_store = MessageStore()
@@ -233,7 +234,9 @@ class EmailViewerApp(App):
async def load_message_content(self, message_id: int) -> None:
"""Worker to load message content asynchronously."""
content_container = self.query_one(ContentContainer)
content_container.display_content(message_id)
folder = self.folder if self.folder else None
account = self.current_account if self.current_account else None
content_container.display_content(message_id, folder=folder, account=account)
metadata = self.message_store.get_metadata(message_id)
if metadata:
@@ -259,8 +262,12 @@ class EmailViewerApp(App):
if "Seen" in flags:
return # Already read
# Mark as read via himalaya
_, success = await himalaya_client.mark_as_read(message_id)
# Mark as read via himalaya with current folder/account
folder = self.folder if self.folder else None
account = self.current_account if self.current_account else None
_, success = await himalaya_client.mark_as_read(
message_id, folder=folder, account=account
)
if success:
# Update the envelope flags in the store
@@ -285,6 +292,16 @@ class EmailViewerApp(App):
if event.list_view.index is None:
return
# Handle folder selection
if event.list_view.id == "folders_list":
self._handle_folder_selected(event)
return
# Handle account selection
if event.list_view.id == "accounts_list":
self._handle_account_selected(event)
return
# Only handle selection from the envelopes list
if event.list_view.id != "envelopes_list":
return
@@ -307,6 +324,45 @@ class EmailViewerApp(App):
# Focus the main content panel after selecting a message
self.action_focus_4()
def _handle_folder_selected(self, event: ListView.Selected) -> None:
"""Handle folder selection from the folders list."""
try:
list_item = event.item
label = list_item.query_one(Label)
folder_name = str(label.renderable).strip()
if folder_name and folder_name != self.folder:
self.folder = folder_name
self.show_status(f"Switching to folder: {folder_name}")
# Clear current state and reload
self.current_message_id = 0
self.current_message_index = 0
self.selected_messages.clear()
self.reload_needed = True
except Exception as e:
logging.error(f"Error selecting folder: {e}")
def _handle_account_selected(self, event: ListView.Selected) -> None:
"""Handle account selection from the accounts list."""
try:
list_item = event.item
label = list_item.query_one(Label)
account_name = str(label.renderable).strip()
if account_name and account_name != self.current_account:
self.current_account = account_name
self.folder = "INBOX" # Reset to INBOX when switching accounts
self.show_status(f"Switching to account: {account_name}")
# Clear current state and reload
self.current_message_id = 0
self.current_message_index = 0
self.selected_messages.clear()
# Refresh folders for new account
self.fetch_folders()
self.reload_needed = True
except Exception as e:
logging.error(f"Error selecting account: {e}")
def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
"""Called when an item in the list view is highlighted (e.g., via arrow keys)."""
if event.list_view.index is None:
@@ -349,8 +405,12 @@ class EmailViewerApp(App):
try:
msglist.loading = True
# Use the Himalaya client to fetch envelopes
envelopes, success = await himalaya_client.list_envelopes()
# Use the Himalaya client to fetch envelopes with current folder/account
folder = self.folder if self.folder else None
account = self.current_account if self.current_account else None
envelopes, success = await himalaya_client.list_envelopes(
folder=folder, account=account
)
if success:
self.reload_needed = False
@@ -407,14 +467,19 @@ class EmailViewerApp(App):
try:
folders_list.loading = True
# Use the Himalaya client to fetch folders
folders, success = await himalaya_client.list_folders()
# Use the Himalaya client to fetch folders for current account
account = self.current_account if self.current_account else None
folders, success = await himalaya_client.list_folders(account=account)
if success and folders:
for folder in folders:
folder_name = str(folder["name"]).strip()
# Skip INBOX since we already added it
if folder_name.upper() == "INBOX":
continue
item = ListItem(
Label(
str(folder["name"]).strip(),
folder_name,
classes="folder_name",
markup=False,
)
@@ -560,10 +625,14 @@ class EmailViewerApp(App):
)
next_id_to_select = next_id
# Delete each message
# Delete each message with current folder/account
folder = self.folder if self.folder else None
account = self.current_account if self.current_account else None
success_count = 0
for mid in message_ids_to_delete:
message, success = await himalaya_client.delete_message(mid)
message, success = await himalaya_client.delete_message(
mid, folder=folder, account=account
)
if success:
success_count += 1
else:
@@ -629,8 +698,13 @@ class EmailViewerApp(App):
)
next_id_to_select = next_id
# Archive messages with current folder/account
folder = self.folder if self.folder else None
account = self.current_account if self.current_account else None
message, success = await himalaya_client.archive_messages(
[str(mid) for mid in message_ids_to_archive]
[str(mid) for mid in message_ids_to_archive],
folder=folder,
account=account,
)
if success:
@@ -671,7 +745,12 @@ class EmailViewerApp(App):
next_id, _ = self.message_store.find_prev_valid_id(current_idx)
next_id_to_select = next_id
message, success = await himalaya_client.archive_messages([str(current_id)])
# Archive with current folder/account
folder = self.folder if self.folder else None
account = self.current_account if self.current_account else None
message, success = await himalaya_client.archive_messages(
[str(current_id)], folder=folder, account=account
)
if success:
self.show_status(message or "Archived")

View File

@@ -80,6 +80,8 @@ class ContentContainer(ScrollableContainer):
self.html_content = Static("", id="html_content", markup=False)
self.current_content = None
self.current_message_id = None
self.current_folder: str | None = None
self.current_account: str | None = None
self.content_worker = None
# Load default view mode from config
@@ -138,18 +140,27 @@ class ContentContainer(ScrollableContainer):
self.notify("No message ID provided.")
return
content, success = await himalaya_client.get_message_content(message_id)
content, success = await himalaya_client.get_message_content(
message_id, folder=self.current_folder, account=self.current_account
)
if success:
self._update_content(content)
else:
self.notify(f"Failed to fetch content for message ID {message_id}.")
def display_content(self, message_id: int) -> None:
def display_content(
self,
message_id: int,
folder: str | None = None,
account: str | None = None,
) -> None:
"""Display the content of a message."""
if not message_id:
return
self.current_message_id = message_id
self.current_folder = folder
self.current_account = account
# Immediately show a loading message
if self.current_mode == "markdown":

View File

@@ -343,3 +343,9 @@ class DstaskClient(TaskBackend):
# This needs to run without capturing output
result = self._run_command(["edit", task_id], capture_output=False)
return result.returncode == 0
def edit_note_interactive(self, task_id: str) -> bool:
"""Open task notes in editor for interactive editing."""
# This needs to run without capturing output
result = self._run_command(["note", task_id], capture_output=False)
return result.returncode == 0

View File

@@ -7,11 +7,17 @@ import subprocess
from src.mail.config import get_config
async def list_envelopes(limit: int = 9999) -> Tuple[List[Dict[str, Any]], bool]:
async def list_envelopes(
folder: Optional[str] = None,
account: Optional[str] = None,
limit: int = 9999,
) -> Tuple[List[Dict[str, Any]], bool]:
"""
Retrieve a list of email envelopes using the Himalaya CLI.
Args:
folder: The folder to list envelopes from (defaults to INBOX)
account: The account to use (defaults to default account)
limit: Maximum number of envelopes to retrieve
Returns:
@@ -20,8 +26,14 @@ async def list_envelopes(limit: int = 9999) -> Tuple[List[Dict[str, Any]], bool]
- Success status (True if operation was successful)
"""
try:
cmd = f"himalaya envelope list -o json -s {limit}"
if folder:
cmd += f" -f '{folder}'"
if account:
cmd += f" -a '{account}'"
process = await asyncio.create_subprocess_shell(
f"himalaya envelope list -o json -s {limit}",
cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
@@ -66,18 +78,27 @@ async def list_accounts() -> Tuple[List[Dict[str, Any]], bool]:
return [], False
async def list_folders() -> Tuple[List[Dict[str, Any]], bool]:
async def list_folders(
account: Optional[str] = None,
) -> Tuple[List[Dict[str, Any]], bool]:
"""
Retrieve a list of folders available in Himalaya.
Args:
account: The account to list folders for (defaults to default account)
Returns:
Tuple containing:
- List of folder dictionaries
- Success status (True if operation was successful)
"""
try:
cmd = "himalaya folder list -o json"
if account:
cmd += f" -a '{account}'"
process = await asyncio.create_subprocess_shell(
"himalaya folder list -o json",
cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
@@ -94,12 +115,18 @@ async def list_folders() -> Tuple[List[Dict[str, Any]], bool]:
return [], False
async def delete_message(message_id: int) -> Tuple[Optional[str], bool]:
async def delete_message(
message_id: int,
folder: Optional[str] = None,
account: Optional[str] = None,
) -> Tuple[Optional[str], bool]:
"""
Delete a message by its ID.
Args:
message_id: The ID of the message to delete
folder: The folder containing the message
account: The account to use
Returns:
Tuple containing:
@@ -107,8 +134,14 @@ async def delete_message(message_id: int) -> Tuple[Optional[str], bool]:
- Success status (True if deletion was successful)
"""
try:
cmd = f"himalaya message delete {message_id}"
if folder:
cmd += f" -f '{folder}'"
if account:
cmd += f" -a '{account}'"
process = await asyncio.create_subprocess_shell(
f"himalaya message delete {message_id}",
cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
@@ -149,12 +182,18 @@ async def delete_message(message_id: int) -> Tuple[Optional[str], bool]:
# return False
async def archive_messages(message_ids: List[str]) -> Tuple[Optional[str], bool]:
async def archive_messages(
message_ids: List[str],
folder: Optional[str] = None,
account: Optional[str] = None,
) -> Tuple[Optional[str], bool]:
"""
Archive multiple messages by their IDs.
Args:
message_ids: A list of message IDs to archive.
folder: The source folder containing the messages
account: The account to use
Returns:
A tuple containing an optional output string and a boolean indicating success.
@@ -164,6 +203,10 @@ async def archive_messages(message_ids: List[str]) -> Tuple[Optional[str], bool]
archive_folder = config.mail.archive_folder
ids_str = " ".join(message_ids)
cmd = f"himalaya message move {archive_folder} {ids_str}"
if folder:
cmd += f" -f '{folder}'"
if account:
cmd += f" -a '{account}'"
process = await asyncio.create_subprocess_shell(
cmd,
@@ -183,12 +226,18 @@ async def archive_messages(message_ids: List[str]) -> Tuple[Optional[str], bool]
return str(e), False
async def get_message_content(message_id: int) -> Tuple[Optional[str], bool]:
async def get_message_content(
message_id: int,
folder: Optional[str] = None,
account: Optional[str] = None,
) -> Tuple[Optional[str], bool]:
"""
Retrieve the content of a message by its ID.
Args:
message_id: The ID of the message to retrieve
folder: The folder containing the message
account: The account to use
Returns:
Tuple containing:
@@ -197,6 +246,10 @@ async def get_message_content(message_id: int) -> Tuple[Optional[str], bool]:
"""
try:
cmd = f"himalaya message read {message_id}"
if folder:
cmd += f" -f '{folder}'"
if account:
cmd += f" -a '{account}'"
process = await asyncio.create_subprocess_shell(
cmd,
@@ -216,12 +269,18 @@ async def get_message_content(message_id: int) -> Tuple[Optional[str], bool]:
return None, False
async def mark_as_read(message_id: int) -> Tuple[Optional[str], bool]:
async def mark_as_read(
message_id: int,
folder: Optional[str] = None,
account: Optional[str] = None,
) -> Tuple[Optional[str], bool]:
"""
Mark a message as read by adding the 'seen' flag.
Args:
message_id: The ID of the message to mark as read
folder: The folder containing the message
account: The account to use
Returns:
Tuple containing:
@@ -230,6 +289,10 @@ async def mark_as_read(message_id: int) -> Tuple[Optional[str], bool]:
"""
try:
cmd = f"himalaya flag add seen {message_id}"
if folder:
cmd += f" -f '{folder}'"
if account:
cmd += f" -a '{account}'"
process = await asyncio.create_subprocess_shell(
cmd,

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

View File

@@ -1,3 +1,35 @@
"""
Mail utilities module for email operations.
"""
from .helpers import (
ensure_directory_exists,
format_datetime,
format_mail_link,
format_mail_link_comment,
format_mime_date,
has_mail_link,
load_last_sync_timestamp,
parse_mail_link,
parse_maildir_name,
remove_mail_link_comment,
safe_filename,
save_sync_timestamp,
truncate_id,
)
__all__ = [
"ensure_directory_exists",
"format_datetime",
"format_mail_link",
"format_mail_link_comment",
"format_mime_date",
"has_mail_link",
"load_last_sync_timestamp",
"parse_mail_link",
"parse_maildir_name",
"remove_mail_link_comment",
"safe_filename",
"save_sync_timestamp",
"truncate_id",
]

View File

@@ -1,12 +1,14 @@
"""
Mail utility helper functions.
"""
import os
import json
import time
from datetime import datetime
import email.utils
def truncate_id(message_id, length=8):
"""
Truncate a message ID to a reasonable length for display.
@@ -24,6 +26,7 @@ def truncate_id(message_id, length=8):
return message_id
return f"{message_id[:length]}..."
def load_last_sync_timestamp():
"""
Load the last synchronization timestamp from a file.
@@ -32,12 +35,13 @@ def load_last_sync_timestamp():
float: The timestamp of the last synchronization, or 0 if not available.
"""
try:
with open('sync_timestamp.json', 'r') as f:
with open("sync_timestamp.json", "r") as f:
data = json.load(f)
return data.get('timestamp', 0)
return data.get("timestamp", 0)
except (FileNotFoundError, json.JSONDecodeError):
return 0
def save_sync_timestamp():
"""
Save the current timestamp as the last synchronization timestamp.
@@ -46,8 +50,9 @@ def save_sync_timestamp():
None
"""
current_time = time.time()
with open('sync_timestamp.json', 'w') as f:
json.dump({'timestamp': current_time}, f)
with open("sync_timestamp.json", "w") as f:
json.dump({"timestamp": current_time}, f)
def format_datetime(dt_str, format_string="%m/%d %I:%M %p"):
"""
@@ -63,11 +68,12 @@ def format_datetime(dt_str, format_string="%m/%d %I:%M %p"):
if not dt_str:
return ""
try:
dt = datetime.fromisoformat(dt_str.replace('Z', '+00:00'))
dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
return dt.strftime(format_string)
except (ValueError, AttributeError):
return dt_str
def format_mime_date(dt_str):
"""
Format a datetime string from ISO format to RFC 5322 format for MIME Date headers.
@@ -81,11 +87,12 @@ def format_mime_date(dt_str):
if not dt_str:
return ""
try:
dt = datetime.fromisoformat(dt_str.replace('Z', '+00:00'))
dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
return email.utils.format_datetime(dt)
except (ValueError, AttributeError):
return dt_str
def safe_filename(filename):
"""
Convert a string to a safe filename.
@@ -98,9 +105,10 @@ def safe_filename(filename):
"""
invalid_chars = '<>:"/\\|?*'
for char in invalid_chars:
filename = filename.replace(char, '_')
filename = filename.replace(char, "_")
return filename
def ensure_directory_exists(directory):
"""
Ensure that a directory exists, creating it if necessary.
@@ -114,6 +122,7 @@ def ensure_directory_exists(directory):
if not os.path.exists(directory):
os.makedirs(directory)
def parse_maildir_name(filename):
"""
Parse a Maildir filename to extract components.
@@ -125,9 +134,104 @@ def parse_maildir_name(filename):
tuple: (message_id, flags) components of the filename.
"""
# Maildir filename format: unique-id:flags
if ':' in filename:
message_id, flags = filename.split(':', 1)
if ":" in filename:
message_id, flags = filename.split(":", 1)
else:
message_id = filename
flags = ''
flags = ""
return message_id, flags
# Mail-Task Link Utilities
# These functions handle the mail://message-id links that connect tasks to emails
MAIL_LINK_PREFIX = "mail://"
MAIL_LINK_COMMENT_PATTERN = r"<!--\s*mail://([^>\s]+)\s*-->"
def format_mail_link(message_id: str) -> str:
"""
Format a message ID as a mail link URI.
Args:
message_id: The email message ID (e.g., "abc123@example.com")
Returns:
Mail link URI (e.g., "mail://abc123@example.com")
"""
# Clean up message ID - remove angle brackets if present
message_id = message_id.strip()
if message_id.startswith("<"):
message_id = message_id[1:]
if message_id.endswith(">"):
message_id = message_id[:-1]
return f"{MAIL_LINK_PREFIX}{message_id}"
def format_mail_link_comment(message_id: str) -> str:
"""
Format a message ID as an HTML comment for embedding in task notes.
Args:
message_id: The email message ID
Returns:
HTML comment containing the mail link (e.g., "<!-- mail://abc123@example.com -->")
"""
return f"<!-- {format_mail_link(message_id)} -->"
def parse_mail_link(notes: str) -> str | None:
"""
Extract a mail link message ID from task notes.
Looks for an HTML comment in the format: <!-- mail://message-id -->
Args:
notes: The task notes content
Returns:
The message ID if found, None otherwise
"""
import re
if not notes:
return None
match = re.search(MAIL_LINK_COMMENT_PATTERN, notes)
if match:
return match.group(1)
return None
def has_mail_link(notes: str) -> bool:
"""
Check if task notes contain a mail link.
Args:
notes: The task notes content
Returns:
True if a mail link is found, False otherwise
"""
return parse_mail_link(notes) is not None
def remove_mail_link_comment(notes: str) -> str:
"""
Remove the mail link comment from task notes.
Args:
notes: The task notes content
Returns:
Notes with the mail link comment removed
"""
import re
if not notes:
return ""
# Remove the mail link comment and any trailing newlines
cleaned = re.sub(MAIL_LINK_COMMENT_PATTERN + r"\n*", "", notes)
return cleaned.strip()