From b26674ff4e7927ce4ecdc6037031d318924994f7 Mon Sep 17 00:00:00 2001 From: Tim Bendt Date: Sun, 4 May 2025 14:50:58 -0600 Subject: [PATCH] layout of panels tweaked --- fetch_outlook.py | 5 +- .../actions/__pycache__/open.cpython-311.pyc | Bin 1278 -> 1386 bytes maildir_gtd/actions/open.py | 4 +- maildir_gtd/app.py | 131 ++++++++++++++---- maildir_gtd/email_viewer.tcss | 72 ++++++++-- maildir_gtd/screens/CreateTask.py | 25 ++-- maildir_gtd/screens/OpenMessage.py | 18 +-- .../__pycache__/CreateTask.cpython-311.pyc | Bin 2095 -> 2443 bytes .../__pycache__/OpenMessage.cpython-311.pyc | Bin 2349 -> 2451 bytes 9 files changed, 201 insertions(+), 54 deletions(-) diff --git a/fetch_outlook.py b/fetch_outlook.py index a4bed74..86c9a04 100644 --- a/fetch_outlook.py +++ b/fetch_outlook.py @@ -97,6 +97,9 @@ def synchronize_maildir(maildir_path, headers): ) if response.status_code != 201: # 201 Created indicates success print(f"Failed to move message to 'Archive': {message_id}, {response.status_code}, {response.text}") + if response.status_code == 404: + os.remove(filepath) # Remove the file from local archive if not found on server + # Save the current sync timestamp save_sync_timestamp() @@ -311,7 +314,7 @@ new_files = set(glob.glob(os.path.join(new_dir, '*.eml'))) cur_files = set(glob.glob(os.path.join(cur_dir, '*.eml'))) for filename in Set.union(cur_files, new_files): - message_id = filename.split('.')[0] # Extract the Message-ID from the filename + message_id = filename.split('.')[0].split('/')[-1] # Extract the Message-ID from the filename if (message_id not in inbox_msg_ids): print(f"Deleting {filename} from inbox") os.remove(filename) diff --git a/maildir_gtd/actions/__pycache__/open.cpython-311.pyc b/maildir_gtd/actions/__pycache__/open.cpython-311.pyc index f16fa57d1bbd392733862184a679702d2d0f472c..fe73ff729f42c76898ce66978e4410ccdb81fc17 100644 GIT binary patch delta 334 zcmeyz`HG8oIWI340}!l{7R_jw$Xmo%HL*cls*0U~A%(ew2_ypnX)GNKX^bf>6IXjO zGEMyI!{|5Jkx`n7jd5}uV}M8vL!)#JLkeRy3rKa5XeZ+W#>pQUg(uHu%+hB{VP3)0enlV!oAsHP7`bKuSw9*WJ}_}iwq{mT zz9ArWK|pPV)egp0b}Q^|2nb&nP`D(Zut4gffX)>GoeKgwKPJaB`-=$ywKY33I~#VR^Cc!9~xs1FQ4=2{mpg_{tY?8-7jk(bf<0|O4C INDOE+0OG<>6951J delta 281 zcmaFG^^cQxIWI340}xEp5y=pk$Xmo%GO2I9|W7$&DO226g>7_P;b!nB5Y8PJ^7aI+X0A{kOxf*HygDwrb~ z${ABwgBdj0{5F>|F)?yg0QLW9VEDkqHo1{mk@*6T#^j^S{v!N9;bs*Lh83ocip&QU x7=fhUWGxmy(;%pN77kX?xxpV8fYj0uFol~Cn!J%^h9Wnk@dpMRM3ETKSOBksJShMG diff --git a/maildir_gtd/actions/open.py b/maildir_gtd/actions/open.py index b9b3632..917bb00 100644 --- a/maildir_gtd/actions/open.py +++ b/maildir_gtd/actions/open.py @@ -3,10 +3,12 @@ from maildir_gtd.screens.OpenMessage import OpenMessageScreen def action_open(app) -> None: """Show the input modal for opening a specific message by ID.""" - def check_id(message_id: str) -> bool: + def check_id(message_id: str | None) -> bool: try: int(message_id) app.show_message(message_id) + if (message_id is not None and message_id > 0): + app.show_message(message_id) except ValueError: app.bell() app.show_status("Invalid message ID. Please enter an integer.", severity="error") diff --git a/maildir_gtd/app.py b/maildir_gtd/app.py index 30f6a31..ad06b0c 100644 --- a/maildir_gtd/app.py +++ b/maildir_gtd/app.py @@ -19,7 +19,7 @@ from textual.widgets import Footer, Static, Label, Markdown, ListView, ListItem from textual.reactive import reactive, Reactive from textual.binding import Binding from textual.timer import Timer -from textual.containers import ScrollableContainer, Grid +from textual.containers import ScrollableContainer, Grid, Vertical, Horizontal from actions.archive import archive_current from actions.delete import delete_current @@ -48,7 +48,6 @@ class EmailViewerApp(App): """A simple email viewer app using the Himalaya CLI.""" CSS_PATH = "email_viewer.tcss" title = "Maildir GTD Reader" - current_message_id: Reactive[int] = reactive(0) current_message_index: Reactive[int] = reactive(0) folder = reactive("INBOX") @@ -61,6 +60,8 @@ class EmailViewerApp(App): newest_id: Reactive[int] = reactive(0) msg_worker: Worker | None = None message_body_cache: dict[int, str] = {} + total_messages: Reactive[int] = reactive(0) + status_title = reactive("Message View") def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]: yield from super().get_system_commands(screen) @@ -72,7 +73,7 @@ class EmailViewerApp(App): yield SystemCommand("Create Task", "Create a task using the task CLI", self.action_create_task) yield SystemCommand("Oldest Message", "Show the oldest message", self.action_oldest) yield SystemCommand("Newest Message", "Show the newest message", self.action_newest) - yield SystemCommand("Reload", "Reload the message list", self.action_fetch_list) + yield SystemCommand("Reload", "Reload the message list", self.fetch_envelopes) BINDINGS = [ Binding("j", "next", "Next message"), @@ -83,40 +84,60 @@ class EmailViewerApp(App): Binding("q", "quit", "Quit application"), Binding("h", "toggle_header", "Toggle Envelope Header"), Binding("t", "create_task", "Create Task"), - Binding("ctrl-r", "reload", "Reload message list") + Binding("%", "reload", "Reload message list"), + Binding("1", "focus_1", "Focus Accounts Panel"), + Binding("2", "focus_2", "Focus Folders Panel"), + Binding("3", "focus_3", "Focus Envelopes Panel") ] BINDINGS.extend([ - Binding("down", "scroll_down", "Scroll down"), - Binding("up", "scroll_up", "Scroll up"), Binding("space", "scroll_page_down", "Scroll page down"), Binding("b", "scroll_page_up", "Scroll page up") ]) def compose(self) -> ComposeResult: - """Create child widgets for the app.""" - yield Grid( - ListView(ListItem(Label("All emails...")), id="list_view", initial_index=0), - ScrollableContainer( - StatusTitle().data_bind(EmailViewerApp.current_message_id), + yield Horizontal( + Vertical( + ListView(ListItem(Label("All emails...")), id="envelopes_list", classes="list_view", initial_index=0), + ListView(id="accounts_list", classes="list_view"), + ListView(id="folders_list", classes="list_view"), + id="sidebar" + ), + ScrollableContainer( EnvelopeHeader(), Markdown(), - id="main_content", - ) - ) + id="main_content" + ), + id="outer-wrapper" + ) yield Footer() async def on_mount(self) -> None: self.alert_timer: Timer | None = None # Timer to throttle alerts self.theme = "monokai" self.title = "MaildirGTD" + self.query_one("#main_content").border_title = self.status_title + self.query_one("#envelopes_list").border_title = "\[1] Emails" + self.query_one("#accounts_list").border_title = "\[2] Accounts" + + self.query_one("#folders_list").border_title = "\[3] Folders" + # self.query_one(ListView).data_bind(index=EmailViewerApp.current_message_index) # self.watch(self.query_one(StatusTitle), "current_message_id", update_progress) # Fetch the ID of the most recent message using the Himalaya CLI - worker = self.action_fetch_list() + self.fetch_accounts() + self.fetch_folders() + worker = self.fetch_envelopes() await worker.wait() + self.query_one("#envelopes_list").focus() self.action_oldest() + def compute_status_title(self) -> None: + return f"Message ID: {self.current_message_id} | [b]{self.current_message_index}[/b]/{self.total_messages}" + + def watch_status_title(self, old_status_title: str, new_status_title: str) -> None: + self.query_one("#main_content").border_title = new_status_title + def compute_newest_id(self) -> None: if not self.all_envelopes: return 0 @@ -146,7 +167,7 @@ class EmailViewerApp(App): def watch_reload_needed(self, old_reload_needed: bool, new_reload_needed: bool) -> None: logging.info(f"Reload needed: {new_reload_needed}") if (old_reload_needed == False and new_reload_needed == True): - self.action_fetch_list() + self.fetch_envelopes() def watch_current_message_id(self, old_message_id: int, new_message_id: int) -> None: @@ -165,7 +186,7 @@ class EmailViewerApp(App): headers.to = envelope['to']['addr'] headers.date = datetime.strptime(envelope['date'].replace("+00:00", ""), "%Y-%m-%d %H:%M").strftime("%a %b %d %H:%M") headers.cc = envelope['cc']['addr'] if 'cc' in envelope else "" - self.query_one(StatusTitle).current_message_index = index + # self.query_one(StatusTitle).current_message_index = index self.query_one(ListView).index = index break @@ -211,8 +232,8 @@ class EmailViewerApp(App): logging.error(f"Error fetching message content: {e}") @work(exclusive=False) - async def action_fetch_list(self) -> None: - msglist = self.query_one(ListView) + async def fetch_envelopes(self) -> None: + msglist = self.query_one("#envelopes_list") try: msglist.loading = True process = await asyncio.create_subprocess_shell( @@ -227,8 +248,7 @@ class EmailViewerApp(App): envelopes = json.loads(stdout.decode()) if envelopes: self.reload_needed = False - status = self.query_one(StatusTitle) - status.total_messages = len(envelopes) + self.total_messages = len(envelopes) msglist.clear() envelopes = sorted(envelopes, key=lambda x: int(x['id'])) self.all_envelopes = envelopes @@ -243,7 +263,55 @@ class EmailViewerApp(App): finally: msglist.loading = False + @work(exclusive=False) + async def fetch_accounts(self) -> None: + accounts_list = self.query_one("#accounts_list") + try: + accounts_list.loading = True + process = await asyncio.create_subprocess_shell( + "himalaya account list -o json", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await process.communicate() + logging.info(f"stdout: {stdout.decode()[0:50]}") + if process.returncode == 0: + import json + accounts = json.loads(stdout.decode()) + if accounts: + for account in accounts: + item = ListItem(Label(str(account['name']).strip(), classes="account_name", markup=False)) + accounts_list.append(item) + except Exception as e: + self.show_status(f"Error fetching account list: {e}", "error") + finally: + accounts_list.loading = False + @work(exclusive=False) + async def fetch_folders(self) -> None: + folders_list = self.query_one("#folders_list") + folders_list.clear() + folders_list.append(ListItem(Label("INBOX", classes="folder_name", markup=False))) + try: + folders_list.loading = True + process = await asyncio.create_subprocess_shell( + "himalaya folder list -o json", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await process.communicate() + logging.info(f"stdout: {stdout.decode()[0:50]}") + if process.returncode == 0: + import json + folders = json.loads(stdout.decode()) + if folders: + for folder in folders: + item = ListItem(Label(str(folder['name']).strip(), classes="folder_name", markup=False)) + folders_list.append(item) + except Exception as e: + self.show_status(f"Error fetching folder list: {e}", "error") + finally: + folders_list.loading = False def show_message(self, message_id: int) -> None: self.current_message_id = message_id @@ -260,22 +328,22 @@ class EmailViewerApp(App): def action_next(self) -> None: self.show_message(self.next_id) - self.action_fetch_list() if self.reload_needed else None + self.fetch_envelopes() if self.reload_needed else None def action_previous(self) -> None: - self.action_fetch_list() if self.reload_needed else None + self.fetch_envelopes() if self.reload_needed else None self.show_message(self.previous_id) def action_delete(self) -> None: self.all_envelopes.remove(self.all_envelopes[self.current_message_index]) self.message_body_cache.pop(self.current_message_id, None) - self.query_one(StatusTitle).total_messages = len(self.all_envelopes) + self.total_messages = len(self.all_envelopes) delete_current(self) def action_archive(self) -> None: self.all_envelopes.remove(self.all_envelopes[self.current_message_index]) self.message_body_cache.pop(self.current_message_id, None) - self.query_one(StatusTitle).total_messages = len(self.all_envelopes) + self.total_messages = len(self.all_envelopes) archive_current(self) def action_open(self) -> None: @@ -306,13 +374,22 @@ class EmailViewerApp(App): self.exit() def action_oldest(self) -> None: - self.action_fetch_list() if self.reload_needed else None + self.fetch_envelopes() if self.reload_needed else None self.show_message(self.oldest_id) def action_newest(self) -> None: - self.action_fetch_list() if self.reload_needed else None + self.fetch_envelopes() if self.reload_needed else None self.show_message(self.newest_id) + def action_focus_1(self) -> None: + self.query_one("#envelopes_list").focus() + + def action_focus_2(self) -> None: + self.query_one("#accounts_list").focus() + + def action_focus_3(self) -> None: + self.query_one("#folders_list").focus() + if __name__ == "__main__": app = EmailViewerApp() app.run() diff --git a/maildir_gtd/email_viewer.tcss b/maildir_gtd/email_viewer.tcss index a10954e..954345f 100644 --- a/maildir_gtd/email_viewer.tcss +++ b/maildir_gtd/email_viewer.tcss @@ -1,5 +1,37 @@ /* Basic stylesheet for the Textual Email Viewer App */ + +#main_content, .list_view { + scrollbar-size: 1 1; + border: round rgb(117, 106, 129); + height: 1fr; +} + +#sidebar { + width: 1fr +} + +#main_content { + width: 2fr; + +} + +#sidebar:focus-within { + background: $panel; + .list_view:blur { + height: 3; + } + .list_view:focus { + height: 2fr; + } +} + +#main_content:focus, .list_view:focus { + border: round $secondary; + background: rgb(55, 53, 57); + border-title-style: bold; +} + Label#task_prompt { padding: 1; color: rgb(128,128,128); @@ -25,7 +57,6 @@ StatusTitle { EnvelopeHeader { dock: top; - margin-top: 1; width: 100%; max-height: 2; tint: $primary 10%; @@ -35,21 +66,15 @@ Markdown { padding: 1 2; } -ListView { - dock: left; - width: 30%; - height: 100%; - padding: 0; -} - .email_subject { - width: 100%; + width: 1fr; padding: 0 } .header_key { tint: gray 20%; min-width: 10; + text-style:bold; } .header_value { @@ -57,3 +82,32 @@ ListView { height: auto; width: auto; } + +.modal_screen { + align: center middle; + margin: 1; + padding: 2; + border: round $border; + background: $panel; + width: auto; + height: auto; +} + + +#create_task_container { + width: 50%; + height: 50%; + border: heavy $secondary; + layout: horizontal; + align: center middle; + Label { + width: auto; + } + Input { + width: 1fr; + } +} + +#envelopes-list ListItem:odd { + background: rgb(25, 24, 26); +} diff --git a/maildir_gtd/screens/CreateTask.py b/maildir_gtd/screens/CreateTask.py index ddd433f..7de2c95 100644 --- a/maildir_gtd/screens/CreateTask.py +++ b/maildir_gtd/screens/CreateTask.py @@ -1,15 +1,24 @@ from textual import on -from textual.app import ComposeResult, Screen -from textual.widgets import Input, Label -from textual.containers import Horizontal +from textual.app import ComposeResult +from textual.screen import ModalScreen +from textual.widgets import Input, Label, Button +from textual.containers import Horizontal, Vertical -class CreateTaskScreen(Screen[str]): +class CreateTaskScreen(ModalScreen[str]): def compose(self) -> ComposeResult: - yield Horizontal( - Label("$>", id="task_prompt"), - Label("task add ", id="task_prompt_label"), - Input(placeholder="arguments", id="task_input") + yield Vertical( + Horizontal( + Label("$>", id="task_prompt"), + Label("task add ", id="task_prompt_label"), + Input(placeholder="arguments", id="task_input"), + ), + Horizontal( + Button("Cancel"), + Button("Submit") + ), + id="create_task_container", + classes="modal_screen" ) @on(Input.Submitted) diff --git a/maildir_gtd/screens/OpenMessage.py b/maildir_gtd/screens/OpenMessage.py index a9234ee..2cf0003 100644 --- a/maildir_gtd/screens/OpenMessage.py +++ b/maildir_gtd/screens/OpenMessage.py @@ -1,22 +1,24 @@ from textual import on -from textual.app import ComposeResult, Screen +from textual.app import ComposeResult +from textual.screen import ModalScreen from textual.widgets import Input, Label, Button -from textual.containers import Horizontal +from textual.containers import Container + +class OpenMessageScreen(ModalScreen[int | None]): -class OpenMessageScreen(Screen[int]): def compose(self) -> ComposeResult: - yield Horizontal( + yield Container( Label("📨", id="message_label"), Input(placeholder="Enter message ID (integer only)", type="integer", id="open_message_input"), - Button("Open", variant="primary", id="open_message_button") + Button("Open", variant="primary", id="open_message_button"), + id="open_message_container", + classes="modal_screen" ) @on(Input.Submitted) def handle_message_id(self) -> None: input_widget = self.query_one("#open_message_input", Input) - self.disabled = True - self.loading = True - message_id = int(input_widget.value) + message_id = int(input_widget.value if input_widget.value else 0) self.dismiss(message_id) @on(Input._on_key) diff --git a/maildir_gtd/screens/__pycache__/CreateTask.cpython-311.pyc b/maildir_gtd/screens/__pycache__/CreateTask.cpython-311.pyc index 364ca27faf286f68034c29f13399d286be7a6bb5..c719a930ca58e7b9a039fd7282a168b749ca039c 100644 GIT binary patch delta 1094 zcmZuv&1=*^6rV{pAG@1u2W+<=Yc*;=SlVi#2eniS_TUFq(PF_sDd{HQ+GMvR*;*B8 z54{P}GGGe|_Ehl$^d$O6C|TLV64|?ghb-2MdT=JurK^4O-fw>M-c07bd3lBI?@K+E z+Hkwb**9V|kIRHh>OvXaWnW5K_*VjK!*K$XPRHaVi(`n8{m$Dp;Z_T5&ZV z`ZzOTCDo)QsS*SXI16-s73hM&8FDARDwJ&&fEUCfQB%4&1+wuk089grg+5a(5R@m1 z)vNOZoMHO>nUd?0bBv!jSG4nYiUn6Q zeR0~b-F$BSPaB$3R^!PR; z854`7DVB(0_z9a<^rSAml(%DKGqFgf!AAIuq{I~FnHdcczt2x-g&fU$sx)WiUB8W# zrMbqet2y(tIhuXGVA#H7(VAzSsA$Fx&k8wHa~#9TayG5XrkUCT1)hS4OU&vrbvlm; zd?sTB)Kcl1l&+z+HGT7*hla{%sDg&*dfw|h>7mgw8m*vF;@BE^J?NqFG8(U-@wyO` zQ#*jb_XLpJYbd?8R7Oe#DZ6N$W9cRWfe284we@h&-Xpan>VUuwg#bwLJpj+JKVcWX z9_xh}yc|0LJMi1sB<#i<_b@}7m1XQMGWndbXcSzBAzV@H41UA)Km~u|PKn*oE*UHQ zFx-!?@ST_Yh|3-z@C?{AY-cD)e?cU~Ss<{|7y%=5iOuc+km+AIfZy>Kr0_fR6PL0r zxJ6KqpK^^Qx1^azqu$_Sg2IrepAhvyN*=HxJY68`=C2$4FyKrg7RnP6G7D za-I_iSO{Siq`hXXf?ls#tDw(o)+!kGBE8vQyGRv`czbEUcfrm8{10n)w?-=M{dF4J J*&`kn^BXuw4d?&> delta 834 zcmZuv&ubGw6n?Y2v%5*BsV1?e*kY;?T%^^5hCrng9O^<5Z+c6y2eB7tHc(LW=DlzBoA>7Z*xl!u&&N{l2M3cN zv$K!F)> z@H#j;)o5}=rjBJ>1^6heC-#72EP!SHkkmqvQma}@GM6ZY!dmu$}}*l>r>h z!Mu&ecuE;3w_qzCP&AX^@07)KN?fp1CUY`Fg3fQN>yG69qJZyWHDNE6+at#}3a#mo zSZ!i;h$Z=j@XVEv6x*cOAw_Y}y1V0sWU5W3I%I0!(1wx+AfO9cq7U#V+9AsDNBA7d z@*P}4L;M?_L&JPhe|2h9)V36s5nF`mDy;^-oGzbdS^iZopp#rPrp@7A)Bv9~F0Gvq zgvr5~E+9%QG?u(d5KxDmmWKraAArd#EGt3&2QkRG{knkW(dsG~CtG7(5Zk|y0^MxR zKgBPG+Bnz2IT>5%0{>-PCUJI|_@+g8$;|T=Gd~lh19~rLl-_s za5xBDcE*}6C@R1wVbnxlQ+{0K%0n>0lq|5Nj3fzNwq#q$C>33?R2yYbMOQ7&)-$?o zWDGly2~>Q@3fiGeNCFv#z|^jTsbhreOx>8`O`^bBvtzX~ew{69XXzNh*h$GG!VCENn3baxQ}$hM?>v=0 zLouOgFK?g6j_IY3;49eTd7nu}!E$ zinCtScc6ji(GwhI;bUyuAO_;%rLmmYUg1?I6$ns`bt!cz}o@ zs}uvL3+L^;=V6m{h|hJ-K6?pIl0Dpd4x~k7#s!3GAnq`|$zgW=DXTgP&LzsgUw=ab zXvXa54RkBAjG9YH)LfPo)Lw=ftSX>IOEq_pE$Kb|m9C=U$yB|sd-$qX$XONEerV0w zo12&*Do^q2Yo6*IV{OKvu2vqXGVT`I`V_y6etsVd79JqIJRp81U0}D3cw2iBHmnus qC~jPA$w-dwD#C_URvV>g8UBU2(EQ-Sjwi=U&FNJU{`sGr3a{TiKoB4R delta 952 zcmZuv%}*0S6rb7MncaS3AR%1}x)>nVNLmkw34)1|K(u%Oet zVh5a{8H7LrW57aBfrW`eLVB~p0C>ZT?(*FrGi<3-AfX)yH32JFh zyu9sK@b=g4P;lI(*(Yw!pS8CSzrh#m3ta9vo@@r})H~$~!|blK%SPnhb2pQ>Jwm;t zpLOO)&hnF^ysO3vtwJVNm4DOeBin7*~}xs8_-NP{0N6Rj4LBhbOq}-TeWHIpH1v