From 4a21eef6f8f73cbc4ec886208d2e8ff842452476 Mon Sep 17 00:00:00 2001 From: Bendt Date: Thu, 18 Dec 2025 13:44:17 -0500 Subject: [PATCH] selection and archive --- .coverage | Bin 69632 -> 69632 bytes src/maildir_gtd/app.py | 207 +++++++++++++++----- src/maildir_gtd/config.py | 3 +- src/maildir_gtd/screens/ConfirmDialog.py | 108 ++++++++++ src/maildir_gtd/screens/__init__.py | 2 + src/maildir_gtd/widgets/EnvelopeListItem.py | 11 +- 6 files changed, 280 insertions(+), 51 deletions(-) create mode 100644 src/maildir_gtd/screens/ConfirmDialog.py diff --git a/.coverage b/.coverage index 0aee14dbce7f2ed678e0cf1f18dd55f0d7865fc9..dedd60e712eb086d4fdf314145f2bc447713b6e8 100644 GIT binary patch delta 3211 zcmeH~d2E!&6~Je{YrlKeH^$iBU0>_n_3qkUPGh`>ZLqNy8=ItQ0b}q2ZooDeCutS1 ztrjIV1jiZGt`t%eH6dx6G!XX>lqdm`s;!YyQ&E*3X+)3;Bv5cmB?yh6?;DO%+N!Fm zO8ujwUH#@=bG;qC*?|MXzyaY(K@MqzJLZZ>ETlqM$h)dW1Zc7z5brBRL!GnBVjz3PDB=oA@C6C z$IFZ8&J{8>qNY&JqyBq}cvKV1?z!xarsGlw^V1Vi9S7;Icqx@*$zlLJ42TKR6|p4j zr|q#gE}@5FOR+|(B;8FmD2-D<>Xj**T&B6TdOK_hPe;D$yh%RLS=sdS~=72%b5?t7twHQ z5YnY#54cz>veIRT)=T`u(9$AD|D~UYk$_RY8SOr+J22{J=$h%jpor5^;LD3+NZ8nSE(-LPs)eN z8_Fxns4}RuD4xkA`&+WDC?sH|DM%k0%ERUKt3xG$GEn5$&VG(q3#XQX>=FMcA{lXZ zG=LC?utN%h))Y8zfc|30m*WS??|*OnH3VJ`0fqY!iVA|S1oU4lp^jm<W70&6R<9l4H(~8V+h37ix`+Mef=7Q)aH#YTI1BZK%@D2>`sFrf9AQMa3 z7G+MFV=;4eVHPl+_v-{~@^(AG!+xwCv&A6}qx1oU&Dr3WqrnOpx7PuFz%A zxceujI^MkWF4j>PJv*@3E@EY{DfQ;9TSaHCufKImL}=#LbQNcvaK3Ox*tP&6-ddLf zK~^M$)&VgSK@+hL*as{te4t@)v6{6T^qj-x;vC?LWY=@mvPxh<=>@w=YS92MnIM?% zWSarB7k>*&x~0g+l4dalH>lRSW_hW(uMvmn>V02^-_4Kry$-jmZ=?lo-PA~Nzh|C` z!9AV3Fg^1Mr@~Aja}1d;kQ?NrdD#4}dBprSnMZW&peVSU3AQo@v6MjwGDraizn`I`gdyN#@Oc@EJPd`!4B2i5 zy@)|8WKatjjC=+qPZIiJ_E|0_W;hw_4hE9TVCFE$*$kO;81k|hT(cQwXEJ2XV#vv0 zaHKQjrZLP(WpJi2q$e|^B>@r&PPH?!fH1gChGK&uUuQ_s7`!S&NMR_K8F(8*sl-qw zG9(KONj!sR^0Nsg*uNV&(J@4ZaHHVW)@p^^1I2=0C2Zt1aT#~qDjNKOe^xgBonm4A zlJB(9{Z;>kx*?Vd*U@4+xYn=k=-lwA^=<3BuMXJ6?kydOUb^N~4ZU&aeS+ZbMO)vpb=MERaOXA@H^>X+&?LNvJL-ToxNRw|6#R#u$GQo@X=#6#$@iL zGd#&NAk*{FQAFM+Pm^Y9hAaO6IQ(CE9A5j5JV)1C5jjK>B+LAnIb>EC=ZrBUqJOII z*IVJC5z@KZ7(7}-bmUwz*AN}Qtm8&80&ma&eerzNc)x*D#4w0nOQd_w+ogCFh)xje zrIsbk@`v*)xQ0qAG&X+H#428V|1(&*b4!+0-1oA+b4ACq39cD`jbK$=2t#F9e-P|{ by`b?0Y${Na-p$>Qw{1=I_O@@buI^s|+{;%~ delta 3033 zcmeH}d2Ce29mi+hweOzi1D|-kyS{e4>s?Gu*Gp|H1vKiOaud9ozVRaA)>qL)xEZgYMkI_Y>AMXk%TB-E69^PsXLJ%iek&hv>a+W%OiyvKRypgI;Pbr8nx6 za0#^<+Hio*ME$gMKAAq+pkhBg-&6^$iqX?EFgP&O(-YjicU3WrRfBzO#LZD1d%??3 zJ7PMuVzSoPzq5a+AN*oPFjk{j2(zTKSzege>BPLkH5brbEtT|z`E)LpPbb$l;yl<~ z9nFa8q6fTvbmpBlslyG$0w^}n(=q7ef=)}b9B-6o?`%9JZ`;H@LT=}>@vA~ay)5~J zlOo1RC?Q@_r`4@$rrhe-;mCG8D?ft2D9=e}q+w;Xab3^X{-`~uoKwDSd|w|l?l5Hi zIav|!7n}HNd=~#hzSrDO6f|&{>0vSJe}HoqPh z(!s73cA#;=r##NDb>Mv1QP}#YyykTZ5c&-pE9uiQi|&kR*h8P{*?`?J2-`Gefxlx~ z#_k{W3NF4vX{V03t8u^4YpgST`fK|A`j|ed_vr1qS9j?dx}|-fCA3r81KJ_&n_9aT zRL`p4RUcOGQ@^e@s+lTQUQ&LiJfoaY?orxjldMa!qo_i_N=Jy6jpgBT`uVYvP#IJ> z9hW~s>d1xucvdIEu$OF-cfgz5z$ z5P6u!PR6B2;%lO0~Wy})C6i)j3S52E_` zfnJEHHtvEb2FA-%^S~k1xcuHROY%U`R5-Vr-Zkz~U3SlOPk2QxJvv^s!wF`Pe7Jqs z9yq)k2|t73T~R5=?qp(VxlNgqvTbH|6=VThJb&niO|mEKKo%72McKB%p#Z|1nF(%A z_l=JpfI16dR|c&;=vpSF+oNRvR7nG-I@j=V_u6H2&%qc@rOzI0#VJ%iltXI{c}LFm zm2bNHPe?*8v@X2a60tH`nR;V!F=h9@nZ-p8p$``4ChR!N3a*YUH_>T?Fk3?}%qSt0 z0RT4+Spt@^9JUw9p8QBODukg4S<(D4OC_+BwETNxD9%f!y}Y7ihmi{f%X<_{x?Pip zCCz3EZc*)(WE4~La4W8$U5CF6Z^Um7KMCK>@I)tkM`tE{BfcsXgL@ix<=n#eI2C59 zFpnT}ntVvko2Sef^B3mhq?V)*(Y#?^BRZ)fA>t)h$)CxO$s{>I?jvrJMWSRA=_UKj z1+twClRKcP#SDc$h5|1`P7#A%$eFaBHVOdxw|)CLay|&xAlM6`N3$j&=t@ zmyYYq>*2e?~o1D46pwGbQpzuvRDn{rL%ju`pKvEcU)T>82|tP 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; } """