diff --git a/.coverage b/.coverage index 0aee14d..dedd60e 100644 Binary files a/.coverage and b/.coverage differ diff --git a/src/maildir_gtd/app.py b/src/maildir_gtd/app.py index abf6021..cd0eb8f 100644 --- a/src/maildir_gtd/app.py +++ b/src/maildir_gtd/app.py @@ -3,6 +3,7 @@ from .message_store import MessageStore from .widgets.ContentContainer import ContentContainer from .widgets.EnvelopeListItem import EnvelopeListItem, GroupHeader from .screens.LinkPanel import LinkPanel +from .screens.ConfirmDialog import ConfirmDialog from .actions.task import action_create_task from .actions.open import action_open from .actions.delete import delete_current @@ -116,10 +117,11 @@ class EmailViewerApp(App): BINDINGS.extend( [ - Binding("space", "scroll_page_down", "Scroll page down"), + Binding("pagedown", "scroll_page_down", "Scroll page down"), Binding("b", "scroll_page_up", "Scroll page up"), Binding("s", "toggle_sort_order", "Toggle Sort Order"), - Binding("x", "toggle_selection", "Toggle selection"), + Binding("x", "toggle_selection", "Toggle selection", show=False), + Binding("space", "toggle_selection", "Toggle selection"), Binding("escape", "clear_selection", "Clear selection"), ] ) @@ -298,6 +300,16 @@ class EmailViewerApp(App): # self.current_message_id = message_id self.highlighted_message_index = highlighted_index + def on_key(self, event) -> None: + """Handle key events to intercept space on the envelopes list.""" + # Intercept space key when envelopes list is focused to prevent default select behavior + if event.key == "space": + focused = self.focused + if focused and focused.id == "envelopes_list": + event.prevent_default() + event.stop() + self.action_toggle_selection() + @work(exclusive=False) async def fetch_envelopes(self) -> None: msglist = self.query_one("#envelopes_list", ListView) @@ -476,66 +488,165 @@ class EmailViewerApp(App): self.current_message_index = prev_idx async def action_delete(self) -> None: - """Delete the current message and update UI consistently.""" - # Call the delete_current function which uses our Himalaya client module - worker = delete_current(self) + """Delete the current or selected messages.""" + if self.selected_messages: + # --- Multi-message delete: show confirmation --- + count = len(self.selected_messages) + + def do_delete(confirmed: bool) -> None: + if confirmed: + self.run_worker(self._delete_selected_messages()) + + self.push_screen( + ConfirmDialog( + title="Delete Messages", + message=f"Delete {count} selected message{'s' if count > 1 else ''}?", + confirm_label="Delete", + cancel_label="Cancel", + ), + do_delete, + ) + else: + # --- Single message delete (no confirmation needed) --- + worker = delete_current(self) + await worker.wait() + + async def _delete_selected_messages(self) -> None: + """Delete all selected messages.""" + message_ids_to_delete = list(self.selected_messages) + next_id_to_select = None + + if message_ids_to_delete: + highest_deleted_id = max(message_ids_to_delete) + metadata = self.message_store.get_metadata(highest_deleted_id) + if metadata: + next_id, _ = self.message_store.find_next_valid_id(metadata["index"]) + if next_id is None: + next_id, _ = self.message_store.find_prev_valid_id( + metadata["index"] + ) + next_id_to_select = next_id + + # Delete each message + success_count = 0 + for mid in message_ids_to_delete: + message, success = await himalaya_client.delete_message(mid) + if success: + success_count += 1 + else: + self.show_status(f"Failed to delete message {mid}: {message}", "error") + + if success_count > 0: + self.show_status( + f"Deleted {success_count} message{'s' if success_count > 1 else ''}" + ) + self.selected_messages.clear() + self.refresh_list_view_items() + + # Refresh the envelope list + worker = self.fetch_envelopes() await worker.wait() + # After refresh, select the next message + if next_id_to_select: + new_metadata = self.message_store.get_metadata(next_id_to_select) + if new_metadata: + self.current_message_id = next_id_to_select + else: + self.action_oldest() + else: + self.action_oldest() + async def action_archive(self) -> None: """Archive the current or selected messages and update UI consistently.""" + if self.selected_messages: + # --- Multi-message archive: show confirmation --- + count = len(self.selected_messages) + + def do_archive(confirmed: bool) -> None: + if confirmed: + self.run_worker(self._archive_selected_messages()) + + self.push_screen( + ConfirmDialog( + title="Archive Messages", + message=f"Archive {count} selected message{'s' if count > 1 else ''}?", + confirm_label="Archive", + cancel_label="Cancel", + ), + do_archive, + ) + else: + # --- Single message archive (no confirmation needed) --- + await self._archive_single_message() + + async def _archive_selected_messages(self) -> None: + """Archive all selected messages.""" + message_ids_to_archive = list(self.selected_messages) next_id_to_select = None - if self.selected_messages: - # --- Multi-message archive --- - message_ids_to_archive = list(self.selected_messages) - - if message_ids_to_archive: - highest_archived_id = max(message_ids_to_archive) - metadata = self.message_store.get_metadata(highest_archived_id) - if metadata: - next_id, _ = self.message_store.find_next_valid_id( + if message_ids_to_archive: + highest_archived_id = max(message_ids_to_archive) + metadata = self.message_store.get_metadata(highest_archived_id) + if metadata: + next_id, _ = self.message_store.find_next_valid_id(metadata["index"]) + if next_id is None: + next_id, _ = self.message_store.find_prev_valid_id( metadata["index"] ) - if next_id is None: - next_id, _ = self.message_store.find_prev_valid_id( - metadata["index"] - ) - next_id_to_select = next_id + next_id_to_select = next_id - message, success = await himalaya_client.archive_messages( - [str(mid) for mid in message_ids_to_archive] + message, success = await himalaya_client.archive_messages( + [str(mid) for mid in message_ids_to_archive] + ) + + if success: + self.show_status( + message or f"Archived {len(message_ids_to_archive)} messages" ) - - if success: - self.show_status(message or "Success archived") - self.selected_messages.clear() - else: - self.show_status(f"Failed to archive messages: {message}", "error") - return - + self.selected_messages.clear() + self.refresh_list_view_items() else: - # --- Single message archive --- - if not self.current_message_id: - self.show_status("No message selected to archive.", "error") - return + self.show_status(f"Failed to archive messages: {message}", "error") + return - current_id = self.current_message_id - current_idx = self.current_message_index + # Refresh the envelope list + worker = self.fetch_envelopes() + await worker.wait() - next_id, _ = self.message_store.find_next_valid_id(current_idx) - if next_id is None: - 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)]) - - if success: - self.show_status(message or "Archived") + # After refresh, select the next message + if next_id_to_select: + new_metadata = self.message_store.get_metadata(next_id_to_select) + if new_metadata: + self.current_message_id = next_id_to_select else: - self.show_status( - f"Failed to archive message {current_id}: {message}", "error" - ) - return + self.action_oldest() + else: + self.action_oldest() + + async def _archive_single_message(self) -> None: + """Archive the current single message.""" + if not self.current_message_id: + self.show_status("No message selected to archive.", "error") + return + + current_id = self.current_message_id + current_idx = self.current_message_index + + next_id, _ = self.message_store.find_next_valid_id(current_idx) + if next_id is None: + 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)]) + + if success: + self.show_status(message or "Archived") + else: + self.show_status( + f"Failed to archive message {current_id}: {message}", "error" + ) + return # Refresh the envelope list worker = self.fetch_envelopes() diff --git a/src/maildir_gtd/config.py b/src/maildir_gtd/config.py index 3d949b7..c7ded44 100644 --- a/src/maildir_gtd/config.py +++ b/src/maildir_gtd/config.py @@ -66,8 +66,9 @@ class KeybindingsConfig(BaseModel): create_task: str = "t" reload: str = "%" toggle_sort: str = "s" - toggle_selection: str = "x" + toggle_selection: str = "space" clear_selection: str = "escape" + scroll_page_down: str = "pagedown" scroll_page_down: str = "space" scroll_page_up: str = "b" toggle_main_content: str = "w" diff --git a/src/maildir_gtd/screens/ConfirmDialog.py b/src/maildir_gtd/screens/ConfirmDialog.py new file mode 100644 index 0000000..5c24988 --- /dev/null +++ b/src/maildir_gtd/screens/ConfirmDialog.py @@ -0,0 +1,108 @@ +"""Confirmation dialog screen for destructive actions.""" + +from textual import on +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical, Container +from textual.screen import ModalScreen +from textual.widgets import Button, Label, Static + + +class ConfirmDialog(ModalScreen[bool]): + """A modal confirmation dialog that returns True if confirmed, False otherwise.""" + + BINDINGS = [ + Binding("escape", "cancel", "Cancel"), + Binding("enter", "confirm", "Confirm"), + Binding("y", "confirm", "Yes"), + Binding("n", "cancel", "No"), + ] + + DEFAULT_CSS = """ + ConfirmDialog { + align: center middle; + } + + ConfirmDialog #confirm-container { + width: 50; + height: auto; + min-height: 7; + background: $surface; + border: thick $primary; + padding: 1 2; + } + + ConfirmDialog #confirm-title { + text-style: bold; + width: 1fr; + height: 1; + text-align: center; + margin-bottom: 1; + } + + ConfirmDialog #confirm-message { + width: 1fr; + height: 1; + text-align: center; + margin-bottom: 1; + } + + ConfirmDialog #confirm-buttons { + width: 1fr; + height: 3; + align: center middle; + margin-top: 1; + } + + ConfirmDialog Button { + margin: 0 1; + } + """ + + def __init__( + self, + title: str = "Confirm", + message: str = "Are you sure?", + confirm_label: str = "Yes", + cancel_label: str = "No", + **kwargs, + ): + """Initialize the confirmation dialog. + + Args: + title: The dialog title + message: The confirmation message + confirm_label: Label for the confirm button + cancel_label: Label for the cancel button + """ + super().__init__(**kwargs) + self._title = title + self._message = message + self._confirm_label = confirm_label + self._cancel_label = cancel_label + + def compose(self) -> ComposeResult: + with Container(id="confirm-container"): + yield Label(self._title, id="confirm-title") + yield Label(self._message, id="confirm-message") + with Horizontal(id="confirm-buttons"): + yield Button(self._cancel_label, id="cancel", variant="default") + yield Button(self._confirm_label, id="confirm", variant="error") + + def on_mount(self) -> None: + """Focus the cancel button by default (safer option).""" + self.query_one("#cancel", Button).focus() + + @on(Button.Pressed, "#confirm") + def handle_confirm(self) -> None: + self.dismiss(True) + + @on(Button.Pressed, "#cancel") + def handle_cancel(self) -> None: + self.dismiss(False) + + def action_confirm(self) -> None: + self.dismiss(True) + + def action_cancel(self) -> None: + self.dismiss(False) diff --git a/src/maildir_gtd/screens/__init__.py b/src/maildir_gtd/screens/__init__.py index 9a4d624..3b9ddba 100644 --- a/src/maildir_gtd/screens/__init__.py +++ b/src/maildir_gtd/screens/__init__.py @@ -3,6 +3,7 @@ from .CreateTask import CreateTaskScreen from .OpenMessage import OpenMessageScreen from .DocumentViewer import DocumentViewerScreen from .LinkPanel import LinkPanel, LinkItem, extract_links_from_content +from .ConfirmDialog import ConfirmDialog __all__ = [ "CreateTaskScreen", @@ -11,4 +12,5 @@ __all__ = [ "LinkPanel", "LinkItem", "extract_links_from_content", + "ConfirmDialog", ] diff --git a/src/maildir_gtd/widgets/EnvelopeListItem.py b/src/maildir_gtd/widgets/EnvelopeListItem.py index 45118f4..eab1a02 100644 --- a/src/maildir_gtd/widgets/EnvelopeListItem.py +++ b/src/maildir_gtd/widgets/EnvelopeListItem.py @@ -54,18 +54,19 @@ class EnvelopeListItem(Static): EnvelopeListItem .sender-name { width: 1fr; + color: $text-muted; } EnvelopeListItem .message-datetime { width: auto; padding: 0 1; - color: $text-muted; + color: $text-disabled; } EnvelopeListItem .email-subject { width: 1fr; padding: 0 3; - text-style: bold; + color: $text-muted; } EnvelopeListItem .email-preview { @@ -76,10 +77,16 @@ class EnvelopeListItem(Static): EnvelopeListItem.unread .sender-name { text-style: bold; + color: $text; + } + + EnvelopeListItem.unread .message-datetime { + color: $text-muted; } EnvelopeListItem.unread .email-subject { text-style: bold; + color: $text; } """