diff --git a/.coverage b/.coverage index dedd60e..5459580 100644 Binary files a/.coverage and b/.coverage differ diff --git a/src/maildir_gtd/app.py b/src/maildir_gtd/app.py index cd0eb8f..a606581 100644 --- a/src/maildir_gtd/app.py +++ b/src/maildir_gtd/app.py @@ -244,9 +244,42 @@ class EmailViewerApp(App): list_view = self.query_one("#envelopes_list", ListView) if list_view.index != metadata["index"]: list_view.index = metadata["index"] + + # Mark message as read + await self._mark_message_as_read(message_id, metadata["index"]) else: logging.warning(f"Message ID {message_id} not found in metadata.") + async def _mark_message_as_read(self, message_id: int, index: int) -> None: + """Mark a message as read and update the UI.""" + # Check if already read + envelope_data = self.message_store.envelopes[index] + if envelope_data and envelope_data.get("type") != "header": + flags = envelope_data.get("flags", []) + if "Seen" in flags: + return # Already read + + # Mark as read via himalaya + _, success = await himalaya_client.mark_as_read(message_id) + + if success: + # Update the envelope flags in the store + if envelope_data: + if "flags" not in envelope_data: + envelope_data["flags"] = [] + if "Seen" not in envelope_data["flags"]: + envelope_data["flags"].append("Seen") + + # Update the visual state of the list item + try: + list_view = self.query_one("#envelopes_list", ListView) + list_item = list_view.children[index] + envelope_widget = list_item.query_one(EnvelopeListItem) + envelope_widget.is_read = True + envelope_widget.remove_class("unread") + except Exception: + pass # Widget may not exist + def on_list_view_selected(self, event: ListView.Selected) -> None: """Called when an item in the list view is selected.""" if event.list_view.index is None: diff --git a/src/maildir_gtd/screens/LinkPanel.py b/src/maildir_gtd/screens/LinkPanel.py index 3cff5f7..7b39a11 100644 --- a/src/maildir_gtd/screens/LinkPanel.py +++ b/src/maildir_gtd/screens/LinkPanel.py @@ -411,6 +411,10 @@ class LinkPanel(ModalScreen): self._mnemonic_map: dict[str, LinkItem] = { link.mnemonic: link for link in links if link.mnemonic } + self._key_buffer: str = "" + self._key_timer = None + # Check if we have any multi-char mnemonics + self._has_multi_char = any(len(m) > 1 for m in self._mnemonic_map.keys()) def compose(self) -> ComposeResult: with Container(id="link-panel-container"): @@ -436,18 +440,68 @@ class LinkPanel(ModalScreen): self.query_one("#link-list").focus() def on_key(self, event) -> None: - """Handle mnemonic key presses.""" + """Handle mnemonic key presses with buffering for multi-char mnemonics.""" key = event.key.lower() - # Check for single-char mnemonic - if key in self._mnemonic_map: - self._open_link(self._mnemonic_map[key]) + # Only buffer alphabetic keys + if not key.isalpha() or len(key) != 1: + return + + # Cancel any pending timer + if self._key_timer: + self._key_timer.stop() + self._key_timer = None + + # Add key to buffer + self._key_buffer += key + + # Check for exact match with buffered keys + if self._key_buffer in self._mnemonic_map: + # If no multi-char mnemonics exist, open immediately + if not self._has_multi_char: + self._open_link(self._mnemonic_map[self._key_buffer]) + self._key_buffer = "" + event.prevent_default() + return + + # Check if any longer mnemonic starts with our buffer + has_longer_match = any( + m.startswith(self._key_buffer) and len(m) > len(self._key_buffer) + for m in self._mnemonic_map.keys() + ) + + if has_longer_match: + # Wait for possible additional keys + self._key_timer = self.set_timer(0.4, self._flush_key_buffer) + else: + # No longer matches possible, open immediately + self._open_link(self._mnemonic_map[self._key_buffer]) + self._key_buffer = "" + event.prevent_default() return - # Check for two-char mnemonics (accumulate?) - # For simplicity, we'll just support single-char for now - # A more sophisticated approach would use a timeout buffer + # Check if buffer could still match something + could_match = any( + m.startswith(self._key_buffer) for m in self._mnemonic_map.keys() + ) + + if could_match: + # Wait for more keys + self._key_timer = self.set_timer(0.4, self._flush_key_buffer) + event.prevent_default() + else: + # No possible match, clear buffer + self._key_buffer = "" + + def _flush_key_buffer(self) -> None: + """Called after timeout to process buffered keys.""" + self._key_timer = None + + if self._key_buffer and self._key_buffer in self._mnemonic_map: + self._open_link(self._mnemonic_map[self._key_buffer]) + + self._key_buffer = "" def action_open_selected(self) -> None: """Open the currently selected link.""" diff --git a/src/services/himalaya/client.py b/src/services/himalaya/client.py index 8195742..2673d02 100644 --- a/src/services/himalaya/client.py +++ b/src/services/himalaya/client.py @@ -216,6 +216,39 @@ 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]: + """ + Mark a message as read by adding the 'seen' flag. + + Args: + message_id: The ID of the message to mark as read + + Returns: + Tuple containing: + - Result message or error + - Success status (True if operation was successful) + """ + try: + cmd = f"himalaya flag add seen {message_id}" + + process = await asyncio.create_subprocess_shell( + cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + + if process.returncode == 0: + return stdout.decode().strip() or "Marked as read", True + else: + error_msg = stderr.decode().strip() + logging.error(f"Error marking message as read: {error_msg}") + return error_msg or "Unknown error", False + except Exception as e: + logging.error(f"Exception during marking message as read: {e}") + return str(e), False + + def sync_himalaya(): """This command does not exist. Halucinated by AI.""" try: