diff --git a/.coverage b/.coverage index c5909dd..c5b4ecb 100644 Binary files a/.coverage and b/.coverage differ diff --git a/src/mail/actions/delete.py b/src/mail/actions/delete.py index daa0278..e019fd4 100644 --- a/src/mail/actions/delete.py +++ b/src/mail/actions/delete.py @@ -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") diff --git a/src/mail/app.py b/src/mail/app.py index 6a71fe3..bb08319 100644 --- a/src/mail/app.py +++ b/src/mail/app.py @@ -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") diff --git a/src/mail/widgets/ContentContainer.py b/src/mail/widgets/ContentContainer.py index a7f9759..fdab7d2 100644 --- a/src/mail/widgets/ContentContainer.py +++ b/src/mail/widgets/ContentContainer.py @@ -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": diff --git a/src/services/dstask/client.py b/src/services/dstask/client.py index bcddb40..6ee27c4 100644 --- a/src/services/dstask/client.py +++ b/src/services/dstask/client.py @@ -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 diff --git a/src/services/himalaya/client.py b/src/services/himalaya/client.py index b533eb1..557b79d 100644 --- a/src/services/himalaya/client.py +++ b/src/services/himalaya/client.py @@ -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, diff --git a/src/tasks/app.py b/src/tasks/app.py index d83da26..208c935 100644 --- a/src/tasks/app.py +++ b/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.""" diff --git a/src/tasks/backend.py b/src/tasks/backend.py index 5803abf..75a2d73 100644 --- a/src/tasks/backend.py +++ b/src/tasks/backend.py @@ -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 diff --git a/src/tasks/config.py b/src/tasks/config.py index b10de27..d15dcff 100644 --- a/src/tasks/config.py +++ b/src/tasks/config.py @@ -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.""" diff --git a/src/tasks/screens/AddTaskScreen.py b/src/tasks/screens/AddTaskScreen.py new file mode 100644 index 0000000..d6ddeab --- /dev/null +++ b/src/tasks/screens/AddTaskScreen.py @@ -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) diff --git a/src/tasks/screens/NotesEditor.py b/src/tasks/screens/NotesEditor.py new file mode 100644 index 0000000..5debac1 --- /dev/null +++ b/src/tasks/screens/NotesEditor.py @@ -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) diff --git a/src/tasks/screens/__init__.py b/src/tasks/screens/__init__.py index 974cd63..3bbc918 100644 --- a/src/tasks/screens/__init__.py +++ b/src/tasks/screens/__init__.py @@ -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", +] diff --git a/src/tasks/widgets/AddTaskForm.py b/src/tasks/widgets/AddTaskForm.py new file mode 100644 index 0000000..b561b1b --- /dev/null +++ b/src/tasks/widgets/AddTaskForm.py @@ -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"\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()) diff --git a/src/tasks/widgets/__init__.py b/src/tasks/widgets/__init__.py index 7ce3c23..d211990 100644 --- a/src/tasks/widgets/__init__.py +++ b/src/tasks/widgets/__init__.py @@ -1 +1,5 @@ """Widget components for Tasks TUI.""" + +from .AddTaskForm import AddTaskForm, TaskFormData + +__all__ = ["AddTaskForm", "TaskFormData"] diff --git a/src/utils/mail_utils/__init__.py b/src/utils/mail_utils/__init__.py index 8558c42..467d8fa 100644 --- a/src/utils/mail_utils/__init__.py +++ b/src/utils/mail_utils/__init__.py @@ -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", +] diff --git a/src/utils/mail_utils/helpers.py b/src/utils/mail_utils/helpers.py index 6bddbcd..ed70956 100644 --- a/src/utils/mail_utils/helpers.py +++ b/src/utils/mail_utils/helpers.py @@ -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"" + + +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., "") + """ + return f"" + + +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: + + 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()