From e08f55238624ed544bca8768e98aa4be2d8afe78 Mon Sep 17 00:00:00 2001 From: Bendt Date: Thu, 18 Dec 2025 11:43:37 -0500 Subject: [PATCH] link panel --- .coverage | Bin 69632 -> 69632 bytes pyproject.toml | 3 + src/maildir_gtd/app.py | 146 +++---- src/maildir_gtd/config.py | 162 +++++++ src/maildir_gtd/email_viewer.tcss | 100 +++++ src/maildir_gtd/screens/LinkPanel.py | 457 ++++++++++++++++++++ src/maildir_gtd/screens/__init__.py | 10 +- src/maildir_gtd/widgets/ContentContainer.py | 84 +++- src/maildir_gtd/widgets/EnvelopeListItem.py | 248 +++++++++++ src/maildir_gtd/widgets/__init__.py | 7 +- uv.lock | 29 ++ 11 files changed, 1126 insertions(+), 120 deletions(-) create mode 100644 src/maildir_gtd/config.py create mode 100644 src/maildir_gtd/screens/LinkPanel.py create mode 100644 src/maildir_gtd/widgets/EnvelopeListItem.py diff --git a/.coverage b/.coverage index c5b67e6ba3d5b6e26d59662c595370872d396cec..fd69aec57ec97954e7c296a9681a0a06bf6d8582 100644 GIT binary patch delta 5777 zcmeHLd30698K1e^yZgMCeb38FLiW5Q>&B^`E^3DC` z`(}RgzHhpA2wgjbJ*Ksc$mT)wI&-S=nem`8-*D*r^c8xd_KtSDwopq}kE{2o4Qhn) zs`6tcUw%*ine1~6J05rV9l_FzQitRg4~yMmi7+S}5Uv;U`H%Sf_=S8Vca-}%w}^|u z$MFtaiv{#_PfsydF_~r+Rf>}Yv>}1!78TIFMNT}Co-Lb9UoVthZL9oinpduF&u&{g za{|clATyrME>?u`JX#Bt6xvx#I4h4jOT6@##SXoF*_A6BRyDzZX}R>4;^{aCx(Wqv zK>WHAC(fqs*%|a`zeIQRySS1p`bNntF%v!?PYX}y-g+v6E6t#D3p41!DuoW8RJ6vH zW`Ao3e6u{AR##52+vw?8x{x;V)esQyb~HCGxBo$GYHC2;!cr$rfiW3$X0dLYM0b~} z)ITe8q^`(q{#F=KnhXkODjf~&_V~)AfCuPh6}p%RQ%j@0^#ycBp=|h?+dC>c{3}4N z#&at;YFmX29WH5h0?0+%9&)ueuJZd^+r7T#*5x$~t^O8zzI3Nl5eEX{VB<9U!*W%O zW#WaW%Q#;Qy|=6iM}yW%>5`}sb#u7NNHz~!!C-}sBbZaH_oKP;aImRD3KINPf>t!iH5 zhyD^Ltj2_`)h1Sy_O-2z&{Rs8-o(sF9Z{#+kLudFk6nlmqY*TixBlOH79-b zv_q=WpedeS5OC7y0Y$1-p($~+>8b&Z(yCILK1Ua2=;2wWPac}%!qVvG z8JH7@6aA5sK^1w=xUIQ&O8_^HRIW%RGAgvJASfI0)xLZy^wLZf!pH()i0W1Dphg zHk_J;v*_FfnIj~O#F?~aZlI>9qm;~`4OdJYVWfuB*$l=|F^x4eHnjL#n;KSu(^EnI zqEWj`71~sn6=0*|0vpGHDoY+AV{*V|;G!lb%@cr&SI?g?LOWR>!^cEAJby}nkD|vX z03AW}%jh`&esFiC7{_FK>+)z@xklHW)^IG{v7idafYRAw^cZE)IEqc=0vR$(UZ&`WC2OJe zmycHhM0CLtOk@O;M|kLD8i9rvolih@g=mf#kqhJt7mYDBMn0^MtYZ|78IVP-MgZFN z*4ffdYSS)hz^0vs71&6YC>IGpx5AUzKsP6LjP8qpIWBsxzZ@tx{bV^7=$2!XFb}I6 zF9TJ~FM8^+#e#cTo^l%^=g0v0D>*_Akrn2L@Vi!PO2&}!iSfSiuJM-9W%!LMBVQlT zAJOmC@6xZ)m+FQ3R6SQu(_Pxz+8f#)ZI^bBc9mA4_N&L#m(&Alx4K9zRU=iGa$Y&D z>{o94h0B?$I8yQitkh)F?KgQEGjjz+xnl5S{42hHI3}XQsD?lHm$oS}&0B_&5a;Yd zhmWPKOUinD<3|3s|0pwaU}T)5?{k~;2w5RssHck`iAV-v)P)d-uv^N8R#V_IvH*V| z(NAtliOYmycIKh2k020r3&`q3s7MepGoT$fRd464C174TS4@*N*G{B z{0zP;VMZtv*C!0FKeLx0q@#njAUsy!E!f)+o=mrJa0dGfLK_gM0F|5&;R~U&Zm#MK zhOZ>WtcM>=cn%@7I3_FznzF;T?dR<#7!ZSEkig+c7qm|Zz4kY8H0Ake=Z9Zwdh1U} zLT*%UEpXZklN~ZNJRID1UGMNPhtOxkC$`udL{{*W!Wh$@ABI2pXbxhN1;;p1ZI@fN zWDEU>>4KMA@pwGQUd=AIr{a2*fYPw!^`)qbmrN*|NV#U7caecOldd(1EOH9u?hCIye028ewoa5sC<3 z>#(QL!4FYxOJl?b`#)WWkp-?{k33JZd&ha{nl1A>bB1(SMQ}g-^XMQ(5vWTNY_o!v zNW6`%S0&EI)JZA^4CDG=``|%N1^GO4Co&%JvYXsVwvz|Q$K)Ba&pd3t zV!mVUB!3`B$;;#roF`MvP6@Q*>^u=p5}eB_jvNM}mw}MYK+0l}naLm{gF#k0gY+~8 zaj6VqQyA#u7--21)FcK*A_K+4AR<8$IzfB5n^m3h3`iUUGnRoI!yq!6L1GjGPb7n= z2nNyN4C2EWB!n_>hcJiTqr?^iz_*Ib>L`x6xAAWVb_01wJ zP&xYa+@1gb47p$*C7>wX@)>_K9yS_{ApHfsLr>QJsBO|L^%HfEx=f8!-c-7k5(UdI$Q$5% zl4)t0* z4Te?Wg3-tlw!e^Fz^|jXlw^}JQ~sAg)J{JxWO|{aL1-Xpf#4J}!>^aFu*~L*Nz0d} z0%Ghpjig0&%m|3YzYu9(h_G!V{w-<1;a@x0$EF>mV|i>YLj9K(+E)`_nF@7!3|Rfi z=xIcLMQ$W>zpERjz){OUF5ZX+;VENkvN9S{{1b)V|Q)G*TzTi5jzJPUvco-I2S}AWMdKo7RE6+2}CWi zjVKZcGIgfKp|l}Lv_a)aN<}LWpd^%3L8l32DpOV)vU_pH)T0KcGtN zYTs|(_sx6rec!y_yx-2?0cG%ja+pM~C!dq2$PQBF{MBJjqy4e{Q+t!0X1!|tz`DlD zHs3Hu%{Egto-(?Obp4cmhhFPB=Xt=>p^a<5(0a5A^&|CBb&Hy!yrS$;Ius)RMjn<| z%RcG2v_mSvXYeS#9DR-sjf~Vvu^QIiQo@eBoi-Yv*w8wv6*QF3eimQE&R_6K%~hip zZCt{l%k9w%6q}w~x0UwoY^h`?S41#rh4cBZwQ^)(vo%6DDX z*fgJ=Z17o|d%C-NMQZtwMkV-DaGn@!TleN0_>T{t3zj+@o}H-97B!hTG^a|svbC?P zm$}Oyl499V-ZnLBDoZxetg}D1SlWtAFr|uEj=X2jjNckyxTRHU&X{{g_EM9srcd2P zRMZ=fclUO06J=PH2CgEgRl9as>SV7)to?!|HT(IED%l(7jF~}NQXqvDYBKap;sy5I z*wx?Pd0kh$f7{lZyLefXpsdAw*7eT5J}}h#pp;_9Sw^ePHnx_TT`&nf;Hq|6ytM{X zs4&#+ykYe3?A;9ZX2M5a$^tP1JBgy6H(>6jgNFNHc#=Mdxkl&qZP&vkt=6O^O*J95 z#8iQwweja2EV-g!-15UVTh-@S&Vj`P7FqNuZMRho&OB`7?0nYK?!_u((14XhUHi@k zwB<7Rlfd7gR!K6t39c((hZ~it1ZpvV00t5fBZS-<367XP3`a!i!`A)QW`!y%={@p2 z@=NN+R*sP&MYJ37o45n-(|#%aTDnDl)jVVF^BmA7JS#otCNzJm3RQNdJP z!ydn|5?8aM8za;7)C4`N*u~Q;V9qa|j)LQOPhpB;I1AFg3}^0L7sG)$7Xg_(IAc0p;7N?VA5X$*U~0vwqVLHDgL7skO#ft< z+?=eYv?%=1SzGD-PwFH?WduIF{n8k^5O_Rm7NLkC$z{ss_7ItSU|( zeUdy>1kn-m%>+P103ER`?Cf1v#c88Y(#B4lr>fR;wG*Tj z3)bcbSMmukSZTY3Jq(|%#~MVvfE^OFIl6JVs)&4tHkPvI&iWO(TD}Rg@GC$B!a`R> z6XknilM>tzG4~<*Aw5UmqJN^#(_Zo^xtFw%C|O9#NC62r&o~b`BTj>}&?$FP9qM@O z346>wY#+4mw0GJ4cGS+Xp0l2`_FLbF*IlKRXIZJZ=1PO@WASmE2`0s=;tkEtz-7+F&FBCg&`b2SYI*JAZfEfENsT z*}KFvqhknd%JjQnDox)1tPEknwb4wJi4+N6O?ldNN#hd((s0>F6LD}(42(lz(cpL8 z!K7cELC8ife9OTD7ux`kuK4g(6`|XMD1@+{IDwq=!k31nAVzw4Z=-G!7}uY%s~HW- zngeYT@_B4PH@qKf7B{40&E)9AF@pz_mdD6&v)-xmlllnTFr3r4f%4+Q2P0R%oi~Cg zij*MKZBP%nZZOJiD6H$yLr3et;m18|*S?VVqTKJ{H@a40pAK*Fx}qNxV711r^NQK_ zdsYvWjN6nuHo;FuM=(l5gHUY<%6qLQb97(UB#yb&238Ei|IRBP9F$Dx9w9Fy`cHa_ z{Dr(m-X*`IowSA)(J&3ra=MWFAKgov=u&z;-AbRN2WT>N=nlGv-bU|8 zqX+5Z^dCSvjeJ5bk_qxM{R90oJpr`oB?B4^jEf=yv=RYIu>iS9fLbV^pg_R9c>)Ua z1?1-m$jlWG2n(=60?eQQBS(OpEkMr_;LjA063_(i{Kn4*Ias-5J0qK?ipD7^E(3DCTKB9|L$s?dx6Ho#=0eoIX zfGZ2gl@ypCfd_cJyW#JA)kIQSc1z*ieABLR%jND)TfAscOi}gBu1LJRsSc7*; z1?Y9?%-5p=)GUx(`^4CwvuEC>|63Ngv!p60HIPj3x@S2n0$-m_P+{GXpGg$*OKJss zN-MzPcWhD+eC2|n+yf$Cs+D)Lq4q*9wO7unF<%CAwAT=0UBS&Pk!=0UU5OgBy#cN$Cd5A`SXURdky)mZH9QIM|05vDe2M*==Jkt^-MYIj)@)uONchk&P{Lg%6geWRa>) zORX;uwxEFFpXXe8JEKXzglfaaq~x2P#{Z(?xGw%;*@{!XoX&*WtWg)vsUzPcfWIDd z6=Om+!{3pCY6oVq1|3551v*TdzN(#!sl{^SzKU;lG``u<_-04ri*__}W-Yp$t{O>4 z^bvX$$i_pYlcd8EsuHPr3EwGMju%hv4~De0i(tQ`40cH>*w!(^m(L_zH~PAR*QmAN U2~Bxs@A0f&$U8KQ9w*rSH;_pWS^xk5 diff --git a/pyproject.toml b/pyproject.toml index f504ce8..e16b2a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,8 @@ dependencies = [ "openai>=1.78.1", "orjson>=3.10.18", "pillow>=11.2.1", + "pydantic>=2.0.0", + "pydantic-settings>=2.0.0", "python-dateutil>=2.9.0.post0", "python-docx>=1.1.2", "requests>=2.31.0", @@ -40,6 +42,7 @@ dependencies = [ "textual>=3.2.0", "textual-image>=0.8.2", "ticktick-py>=2.0.0", + "toml>=0.10.0", ] [project.scripts] diff --git a/src/maildir_gtd/app.py b/src/maildir_gtd/app.py index accce56..7b051c3 100644 --- a/src/maildir_gtd/app.py +++ b/src/maildir_gtd/app.py @@ -1,5 +1,8 @@ +from .config import get_config, MaildirGTDConfig from .message_store import MessageStore from .widgets.ContentContainer import ContentContainer +from .widgets.EnvelopeListItem import EnvelopeListItem, GroupHeader +from .screens.LinkPanel import LinkPanel from .actions.task import action_create_task from .actions.open import action_open from .actions.delete import delete_current @@ -102,6 +105,7 @@ class EmailViewerApp(App): Binding("q", "quit", "Quit application"), Binding("h", "toggle_header", "Toggle Envelope Header"), Binding("t", "create_task", "Create Task"), + Binding("l", "open_links", "Show Links"), Binding("%", "reload", "Reload message list"), Binding("1", "focus_1", "Focus Accounts Panel"), Binding("2", "focus_2", "Focus Folders Panel"), @@ -358,68 +362,27 @@ class EmailViewerApp(App): folders_list.loading = False def _populate_list_view(self) -> None: - """Populate the ListView with new items. This clears existing items.""" + """Populate the ListView with new items using the new EnvelopeListItem widget.""" envelopes_list = self.query_one("#envelopes_list", ListView) envelopes_list.clear() + config = get_config() + for item in self.message_store.envelopes: if item and item.get("type") == "header": - envelopes_list.append( - ListItem( - Container( - Label("", classes="checkbox"), # Hidden checkbox for header - Label( - item["label"], - classes="group_header", - markup=False, - ), - classes="envelope_item_row", - ) - ) + # Use the new GroupHeader widget for date groupings + envelopes_list.append(ListItem(GroupHeader(label=item["label"]))) + elif item: + # Use the new EnvelopeListItem widget + message_id = int(item.get("id", 0)) + is_selected = message_id in self.selected_messages + envelope_widget = EnvelopeListItem( + envelope=item, + config=config.envelope_display, + is_selected=is_selected, ) - elif item: # Check if not None - # Extract sender and date - sender_name = item.get("from", {}).get( - "name", item.get("from", {}).get("addr", "Unknown") - ) - if not sender_name: - sender_name = item.get("from", {}).get("addr", "Unknown") + envelopes_list.append(ListItem(envelope_widget)) - # Truncate sender name - max_sender_len = 25 # Adjust as needed - if len(sender_name) > max_sender_len: - sender_name = sender_name[: max_sender_len - 3] + "..." - - message_date_str = item.get("date", "") - formatted_date = "" - if message_date_str: - try: - # Parse the date string, handling potential timezone info - dt_object = datetime.fromisoformat(message_date_str) - formatted_date = dt_object.strftime("%m/%d %H:%M") - except ValueError: - formatted_date = "Invalid Date" - - list_item = ListItem( - Container( - Container( - Label("☐", classes="checkbox"), # Placeholder for checkbox - Label(sender_name, classes="sender_name"), - Label(formatted_date, classes="message_date"), - classes="envelope_header_row", - ), - Container( - Label( - str(item.get("subject", "")).strip(), - classes="email_subject", - markup=False, - ), - classes="envelope_subject_row", - ), - classes="envelope_item_row", - ) - ) - envelopes_list.append(list_item) self.refresh_list_view_items() # Initial refresh of item states def refresh_list_view_items(self) -> None: @@ -429,46 +392,17 @@ class EmailViewerApp(App): if isinstance(list_item, ListItem): item_data = self.message_store.envelopes[i] - # Find the checkbox label within the ListItem's children - # checkbox_label = list_item.query_one(".checkbox", Label) if item_data and item_data.get("type") != "header": message_id = int(item_data["id"]) is_selected = message_id in self.selected_messages or False list_item.set_class(is_selected, "selection") - # if checkbox_label: - # checkbox_label.update("\uf4a7" if is_selected else "\ue640") - # checkbox_label.display = True # Always display checkbox - - # list_item.highlighted = is_selected - - # # Update sender and date labels - # sender_name = item_data.get("from", {}).get("name", item_data.get("from", {}).get("addr", "Unknown")) - # if not sender_name: - # sender_name = item_data.get("from", {}).get("addr", "Unknown") - # max_sender_len = 25 - # if len(sender_name) > max_sender_len: - # sender_name = sender_name[:max_sender_len-3] + "..." - # list_item.query_one(".sender_name", Label).update(sender_name) - - # message_date_str = item_data.get("date", "") - # formatted_date = "" - # if message_date_str: - # try: - # dt_object = datetime.fromisoformat(message_date_str) - # formatted_date = dt_object.strftime("%m/%d %H:%M") - # except ValueError: - # formatted_date = "Invalid Date" - # list_item.query_one(".message_date", Label).update(formatted_date) - - # else: - # # For header items, checkbox should be unchecked and visible - # checkbox_label.update("\ue640") # Always unchecked for headers - # checkbox_label.display = True # Always display checkbox - # list_item.highlighted = False # Headers are never highlighted for selection - - # Update total messages count (this is still fine here) - # self.total_messages = self.message_store.total_messages + # Try to update the EnvelopeListItem's selection state + try: + envelope_widget = list_item.query_one(EnvelopeListItem) + envelope_widget.set_selected(is_selected) + except Exception: + pass # Widget may not exist or be of old type def show_message(self, message_id: int, new_index=None) -> None: if new_index: @@ -602,6 +536,12 @@ class EmailViewerApp(App): def action_create_task(self) -> None: action_create_task(self) + def action_open_links(self) -> None: + """Open the link panel showing links from the current message.""" + content_container = self.query_one(ContentContainer) + links = content_container.get_links() + self.push_screen(LinkPanel(links)) + def action_scroll_down(self) -> None: """Scroll the main content down.""" self.query_one("#main_content").scroll_down() @@ -644,15 +584,31 @@ class EmailViewerApp(App): message_id = int(current_item_data["id"]) envelopes_list = self.query_one("#envelopes_list", ListView) current_list_item = envelopes_list.children[self.highlighted_message_index] - checkbox_label = current_list_item.query_one(".checkbox", Label) + + # Toggle selection state if message_id in self.selected_messages: self.selected_messages.remove(message_id) - checkbox_label.remove_class("x-list") - checkbox_label.update("\ue640") + is_selected = False else: self.selected_messages.add(message_id) - checkbox_label.add_class("x-list") - checkbox_label.update("\uf4a7") + is_selected = True + + # Update the EnvelopeListItem widget + try: + envelope_widget = current_list_item.query_one(EnvelopeListItem) + envelope_widget.set_selected(is_selected) + except Exception: + # Fallback for old-style widgets + try: + checkbox_label = current_list_item.query_one(".checkbox", Label) + if is_selected: + checkbox_label.add_class("x-list") + checkbox_label.update("\uf4a7") + else: + checkbox_label.remove_class("x-list") + checkbox_label.update("\ue640") + except Exception: + pass self._update_list_view_subtitle() diff --git a/src/maildir_gtd/config.py b/src/maildir_gtd/config.py new file mode 100644 index 0000000..cc47a53 --- /dev/null +++ b/src/maildir_gtd/config.py @@ -0,0 +1,162 @@ +"""Configuration system for MaildirGTD email reader using Pydantic.""" + +import logging +import os +from pathlib import Path +from typing import Literal, Optional + +import toml +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + + +class TaskBackendConfig(BaseModel): + """Configuration for task management backend.""" + + backend: Literal["taskwarrior", "dstask"] = "taskwarrior" + taskwarrior_path: str = "task" + dstask_path: str = Field( + default_factory=lambda: str(Path.home() / ".local" / "bin" / "dstask") + ) + + +class EnvelopeDisplayConfig(BaseModel): + """Configuration for envelope list item rendering.""" + + # Sender display + max_sender_length: int = 25 + + # Date/time display + date_format: str = "%m/%d" + time_format: str = "%H:%M" + show_date: bool = True + show_time: bool = True + + # Grouping + group_by: Literal["relative", "absolute"] = "relative" + # relative: "Today", "Yesterday", "This Week", etc. + # absolute: "December 2025", "November 2025", etc. + + # Layout + lines: Literal[2, 3] = 2 + # 2: sender/date on line 1, subject on line 2 + # 3: sender/date on line 1, subject on line 2, preview on line 3 + + show_checkbox: bool = True + show_preview: bool = False # Only used when lines=3 + + # NerdFont icons for status + icon_unread: str = "\uf0e0" # nf-fa-envelope (filled) + icon_read: str = "\uf2b6" # nf-fa-envelope_open (open) + icon_flagged: str = "\uf024" # nf-fa-flag + icon_attachment: str = "\uf0c6" # nf-fa-paperclip + + +class KeybindingsConfig(BaseModel): + """Keybinding customization.""" + + next_message: str = "j" + prev_message: str = "k" + delete: str = "#" + archive: str = "e" + open_by_id: str = "o" + quit: str = "q" + toggle_header: str = "h" + create_task: str = "t" + reload: str = "%" + toggle_sort: str = "s" + toggle_selection: str = "x" + clear_selection: str = "escape" + scroll_page_down: str = "space" + scroll_page_up: str = "b" + toggle_main_content: str = "w" + open_links: str = "l" + toggle_view_mode: str = "m" + + +class ContentDisplayConfig(BaseModel): + """Configuration for message content display.""" + + # View mode: "markdown" for pretty rendering, "html" for raw/plain display + default_view_mode: Literal["markdown", "html"] = "markdown" + + +class ThemeConfig(BaseModel): + """Theme/appearance settings.""" + + theme_name: str = "monokai" + + +class MaildirGTDConfig(BaseModel): + """Main configuration for MaildirGTD email reader.""" + + task: TaskBackendConfig = Field(default_factory=TaskBackendConfig) + envelope_display: EnvelopeDisplayConfig = Field( + default_factory=EnvelopeDisplayConfig + ) + content_display: ContentDisplayConfig = Field(default_factory=ContentDisplayConfig) + keybindings: KeybindingsConfig = Field(default_factory=KeybindingsConfig) + theme: ThemeConfig = Field(default_factory=ThemeConfig) + + @classmethod + def get_config_path(cls) -> Path: + """Get the path to the config file.""" + # Check environment variable first + env_path = os.getenv("MAILDIR_GTD_CONFIG") + if env_path: + return Path(env_path) + + # Default to ~/.config/luk/maildir_gtd.toml + return Path.home() / ".config" / "luk" / "maildir_gtd.toml" + + @classmethod + def load(cls, config_path: Optional[Path] = None) -> "MaildirGTDConfig": + """Load config from TOML file with defaults for missing values.""" + if config_path is None: + config_path = cls.get_config_path() + + if config_path.exists(): + try: + with open(config_path, "r") as f: + data = toml.load(f) + logger.info(f"Loaded config from {config_path}") + return cls.model_validate(data) + except Exception as e: + logger.warning(f"Error loading config from {config_path}: {e}") + logger.warning("Using default configuration") + return cls() + else: + logger.info(f"No config file at {config_path}, using defaults") + return cls() + + def save(self, config_path: Optional[Path] = None) -> None: + """Save current config to TOML file.""" + if config_path is None: + config_path = self.get_config_path() + + # Ensure parent directory exists + config_path.parent.mkdir(parents=True, exist_ok=True) + + with open(config_path, "w") as f: + toml.dump(self.model_dump(), f) + logger.info(f"Saved config to {config_path}") + + +# Global config instance (lazy-loaded) +_config: Optional[MaildirGTDConfig] = None + + +def get_config() -> MaildirGTDConfig: + """Get the global config instance, loading it if necessary.""" + global _config + if _config is None: + _config = MaildirGTDConfig.load() + return _config + + +def reload_config() -> MaildirGTDConfig: + """Force reload of the config from disk.""" + global _config + _config = MaildirGTDConfig.load() + return _config diff --git a/src/maildir_gtd/email_viewer.tcss b/src/maildir_gtd/email_viewer.tcss index 6f91026..8b58f34 100644 --- a/src/maildir_gtd/email_viewer.tcss +++ b/src/maildir_gtd/email_viewer.tcss @@ -70,6 +70,106 @@ Markdown { padding: 1 2; } +/* ===================================================== + NEW EnvelopeListItem and GroupHeader styles + ===================================================== */ + +/* EnvelopeListItem - the main envelope display widget */ +EnvelopeListItem { + height: auto; + width: 1fr; + padding: 0; +} + +EnvelopeListItem .envelope-content { + height: auto; + width: 1fr; +} + +EnvelopeListItem .envelope-row-1 { + height: 1; + width: 1fr; +} + +EnvelopeListItem .envelope-row-2 { + height: 1; + width: 1fr; +} + +EnvelopeListItem .envelope-row-3 { + height: 1; + width: 1fr; +} + +EnvelopeListItem .status-icon { + width: 3; + padding: 0 1 0 0; + color: $text-muted; +} + +EnvelopeListItem .status-icon.unread { + color: $accent; +} + +EnvelopeListItem .checkbox { + width: 2; + padding: 0 1 0 0; +} + +EnvelopeListItem .sender-name { + width: 1fr; +} + +EnvelopeListItem .message-datetime { + width: auto; + padding: 0 1; + color: $secondary; +} + +EnvelopeListItem .email-subject { + width: 1fr; + padding: 0 4; +} + +EnvelopeListItem .email-preview { + width: 1fr; + padding: 0 4; + color: $text-muted; +} + +/* Unread message styling */ +EnvelopeListItem.unread .sender-name { + text-style: bold; +} + +EnvelopeListItem.unread .email-subject { + text-style: bold; +} + +/* Selected message styling */ +EnvelopeListItem.selected { + tint: $accent 20%; +} + +/* GroupHeader - date group separator */ +GroupHeader { + height: 1; + width: 1fr; + background: rgb(64, 62, 65); +} + +GroupHeader .group-header-label { + color: rgb(160, 160, 160); + text-style: bold; + padding: 0 1; + width: 1fr; +} + +/* ===================================================== + END NEW styles + ===================================================== */ + +/* Legacy styles (keeping for backward compatibility) */ .email_subject { width: 1fr; padding: 0 2; diff --git a/src/maildir_gtd/screens/LinkPanel.py b/src/maildir_gtd/screens/LinkPanel.py new file mode 100644 index 0000000..323dfe6 --- /dev/null +++ b/src/maildir_gtd/screens/LinkPanel.py @@ -0,0 +1,457 @@ +"""Link panel for viewing and opening URLs from email messages.""" + +import re +import webbrowser +from dataclasses import dataclass, field +from typing import List, Optional +from urllib.parse import urlparse + +from textual import on +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Container, Vertical +from textual.screen import ModalScreen +from textual.widgets import Label, ListView, ListItem, Static + + +@dataclass +class LinkItem: + """Represents a link extracted from an email.""" + + url: str + label: str # Derived from anchor text or URL + domain: str # Extracted for display + short_display: str # Truncated/friendly display + context: str = "" # Surrounding text for context + mnemonic: str = "" # Quick-select key hint + + @classmethod + def from_url( + cls, + url: str, + anchor_text: str = "", + context: str = "", + max_display_len: int = 60, + ) -> "LinkItem": + """Create a LinkItem from a URL and optional anchor text.""" + parsed = urlparse(url) + domain = parsed.netloc.replace("www.", "") + + # Use anchor text as label if available, otherwise derive from URL + if anchor_text and anchor_text.strip(): + label = anchor_text.strip() + else: + # Try to derive a meaningful label from the URL path + label = cls._derive_label_from_url(parsed) + + # Create short display version + short_display = cls._shorten_url(url, domain, parsed.path, max_display_len) + + return cls( + url=url, + label=label, + domain=domain, + short_display=short_display, + context=context[:80] if context else "", + ) + + @staticmethod + def _derive_label_from_url(parsed) -> str: + """Derive a human-readable label from URL components.""" + path = parsed.path.strip("/") + if not path: + return parsed.netloc + + # Split path and take last meaningful segment + segments = [s for s in path.split("/") if s] + if segments: + last = segments[-1] + # Remove file extensions + last = re.sub(r"\.[a-zA-Z0-9]+$", "", last) + # Replace common separators with spaces + last = re.sub(r"[-_]", " ", last) + # Capitalize words + return last.title()[:40] + + return parsed.netloc + + @staticmethod + def _shorten_url(url: str, domain: str, path: str, max_len: int) -> str: + """Create a shortened, readable version of the URL.""" + # Special handling for common sites + path = path.strip("/") + + # GitHub: user/repo/issues/123 -> user/repo #123 + if "github.com" in domain: + match = re.match(r"([^/]+/[^/]+)/(issues|pull)/(\d+)", path) + if match: + repo, type_, num = match.groups() + icon = "#" if type_ == "issues" else "PR#" + return f"{domain} > {repo} {icon}{num}" + + match = re.match(r"([^/]+/[^/]+)", path) + if match: + return f"{domain} > {match.group(1)}" + + # Google Docs + if "docs.google.com" in domain: + if "/document/" in path: + return f"{domain} > Document" + if "/spreadsheets/" in path: + return f"{domain} > Spreadsheet" + if "/presentation/" in path: + return f"{domain} > Slides" + + # Jira/Atlassian + if "atlassian.net" in domain or "jira" in domain.lower(): + match = re.search(r"([A-Z]+-\d+)", path) + if match: + return f"{domain} > {match.group(1)}" + + # GitLab + if "gitlab" in domain.lower(): + match = re.match(r"([^/]+/[^/]+)/-/(issues|merge_requests)/(\d+)", path) + if match: + repo, type_, num = match.groups() + icon = "#" if type_ == "issues" else "MR!" + return f"{domain} > {repo} {icon}{num}" + + # Generic shortening + if len(url) <= max_len: + return url + + # Truncate path intelligently + path_parts = [p for p in path.split("/") if p] + if len(path_parts) > 2: + short_path = f"{path_parts[0]}/.../{path_parts[-1]}" + elif path_parts: + short_path = "/".join(path_parts) + else: + short_path = "" + + result = f"{domain}" + if short_path: + result += f" > {short_path}" + + if len(result) > max_len: + result = result[: max_len - 3] + "..." + + return result + + +def extract_links_from_content(content: str) -> List[LinkItem]: + """Extract all links from HTML or markdown content.""" + links: List[LinkItem] = [] + seen_urls: set = set() + + # Pattern for HTML links: text + html_pattern = r']*href=["\']([^"\']+)["\'][^>]*>([^<]*)' + for match in re.finditer(html_pattern, content, re.IGNORECASE): + url, anchor_text = match.groups() + if url and url not in seen_urls and _is_valid_url(url): + # Get surrounding context + start = max(0, match.start() - 40) + end = min(len(content), match.end() + 40) + context = _clean_context(content[start:end]) + + links.append(LinkItem.from_url(url, anchor_text, context)) + seen_urls.add(url) + + # Pattern for markdown links: [text](url) + md_pattern = r"\[([^\]]+)\]\(([^)]+)\)" + for match in re.finditer(md_pattern, content): + anchor_text, url = match.groups() + if url and url not in seen_urls and _is_valid_url(url): + start = max(0, match.start() - 40) + end = min(len(content), match.end() + 40) + context = _clean_context(content[start:end]) + + links.append(LinkItem.from_url(url, anchor_text, context)) + seen_urls.add(url) + + # Pattern for bare URLs + url_pattern = r'https?://[^\s<>"\'\)]+[^\s<>"\'\.\,\)\]]' + for match in re.finditer(url_pattern, content): + url = match.group(0) + if url not in seen_urls and _is_valid_url(url): + start = max(0, match.start() - 40) + end = min(len(content), match.end() + 40) + context = _clean_context(content[start:end]) + + links.append(LinkItem.from_url(url, "", context)) + seen_urls.add(url) + + # Assign mnemonic hints + _assign_mnemonics(links) + + return links + + +def _is_valid_url(url: str) -> bool: + """Check if a URL is valid and worth displaying.""" + if not url: + return False + + # Skip mailto, tel, javascript, etc. + if re.match(r"^(mailto|tel|javascript|data|#):", url, re.IGNORECASE): + return False + + # Skip very short URLs or fragments + if len(url) < 10: + return False + + # Must start with http/https + if not url.startswith(("http://", "https://")): + return False + + return True + + +def _clean_context(context: str) -> str: + """Clean up context string for display.""" + # Remove HTML tags + context = re.sub(r"<[^>]+>", "", context) + # Normalize whitespace + context = " ".join(context.split()) + return context.strip() + + +def _assign_mnemonics(links: List[LinkItem]) -> None: + """Assign unique mnemonic key hints to links.""" + used_mnemonics: set = set() + + # Characters to use for mnemonics (easily typeable) + # Exclude h,j,k,l to avoid conflicts with navigation keys + available_chars = "asdfgqwertyuiopzxcvbnm" + + for link in links: + mnemonic = None + + # Try first letter of domain + if link.domain: + first = link.domain[0].lower() + if first in available_chars and first not in used_mnemonics: + mnemonic = first + used_mnemonics.add(first) + + # Try first letter of label + if not mnemonic and link.label: + first = link.label[0].lower() + if first in available_chars and first not in used_mnemonics: + mnemonic = first + used_mnemonics.add(first) + + # Try first two letters combined logic + if not mnemonic: + # Try domain + label initials + candidates = [] + if link.domain and len(link.domain) > 1: + candidates.append(link.domain[:2].lower()) + if link.label: + words = link.label.split() + if len(words) >= 2: + candidates.append((words[0][0] + words[1][0]).lower()) + + for candidate in candidates: + if len(candidate) == 2 and candidate not in used_mnemonics: + # Check both chars are available + if all(c in available_chars for c in candidate): + mnemonic = candidate + used_mnemonics.add(candidate) + break + + # Fallback: find any unused character + if not mnemonic: + for char in available_chars: + if char not in used_mnemonics: + mnemonic = char + used_mnemonics.add(char) + break + + link.mnemonic = mnemonic or "" + + +class LinkListItem(Static): + """Widget for displaying a single link in the list.""" + + DEFAULT_CSS = """ + LinkListItem { + height: auto; + width: 1fr; + padding: 0 1; + } + """ + + def __init__(self, link: LinkItem, index: int, **kwargs): + super().__init__(**kwargs) + self.link = link + self.index = index + + def render(self) -> str: + """Render the link item using Rich markup.""" + mnemonic = self.link.mnemonic if self.link.mnemonic else "?" + # Line 1: [mnemonic] domain - label + line1 = ( + f"[bold cyan]\\[{mnemonic}][/] [dim]{self.link.domain}[/] {self.link.label}" + ) + # Line 2: shortened URL (indented) + line2 = f" [dim italic]{self.link.short_display}[/]" + return f"{line1}\n{line2}" + + +class LinkPanel(ModalScreen): + """Side panel for viewing and opening links from the current message.""" + + BINDINGS = [ + Binding("escape", "dismiss", "Close"), + Binding("enter", "open_selected", "Open Link"), + Binding("j", "next_link", "Next"), + Binding("k", "prev_link", "Previous"), + ] + + DEFAULT_CSS = """ + LinkPanel { + align: right middle; + } + + LinkPanel #link-panel-container { + dock: right; + width: 50%; + min-width: 60; + max-width: 100; + height: 100%; + background: $surface; + border: round $primary; + padding: 1; + } + + LinkPanel #link-panel-container:focus-within { + border: round $accent; + } + + LinkPanel .link-panel-title { + text-style: bold; + padding: 0 0 1 0; + color: $text; + } + + LinkPanel .link-panel-hint { + color: $text-muted; + padding: 0 0 1 0; + } + + LinkPanel #link-list { + height: 1fr; + scrollbar-size: 1 1; + } + + LinkPanel #link-list > ListItem { + height: auto; + padding: 0; + } + + LinkPanel #link-list > ListItem:hover { + background: $boost; + } + + LinkPanel #link-list > ListItem.-highlight { + background: $accent 30%; + } + + LinkPanel .no-links-label { + color: $text-muted; + padding: 2; + text-align: center; + } + """ + + def __init__(self, links: List[LinkItem], **kwargs): + super().__init__(**kwargs) + self.links = links + self._mnemonic_map: dict[str, LinkItem] = { + link.mnemonic: link for link in links if link.mnemonic + } + + def compose(self) -> ComposeResult: + with Container(id="link-panel-container"): + yield Label("\uf0c1 Links", classes="link-panel-title") # nf-fa-link + yield Label( + "j/k: navigate, enter: open, esc: close", + classes="link-panel-hint", + ) + + if self.links: + with ListView(id="link-list"): + for i, link in enumerate(self.links): + yield ListItem(LinkListItem(link, i)) + else: + yield Label("No links found in this message.", classes="no-links-label") + + def on_mount(self) -> None: + self.query_one("#link-panel-container").border_title = "Links" + self.query_one( + "#link-panel-container" + ).border_subtitle = f"{len(self.links)} found" + if self.links: + self.query_one("#link-list").focus() + + def on_key(self, event) -> None: + """Handle mnemonic key presses.""" + key = event.key.lower() + + # Check for single-char mnemonic + if key in self._mnemonic_map: + self._open_link(self._mnemonic_map[key]) + 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 + + def action_open_selected(self) -> None: + """Open the currently selected link.""" + if not self.links: + return + + try: + link_list = self.query_one("#link-list", ListView) + if link_list.index is not None and 0 <= link_list.index < len(self.links): + self._open_link(self.links[link_list.index]) + except Exception: + pass + + def action_next_link(self) -> None: + """Move to next link.""" + try: + link_list = self.query_one("#link-list", ListView) + if link_list.index is not None and link_list.index < len(self.links) - 1: + link_list.index += 1 + except Exception: + pass + + def action_prev_link(self) -> None: + """Move to previous link.""" + try: + link_list = self.query_one("#link-list", ListView) + if link_list.index is not None and link_list.index > 0: + link_list.index -= 1 + except Exception: + pass + + def _open_link(self, link: LinkItem) -> None: + """Open a link in the default browser.""" + try: + webbrowser.open(link.url) + self.app.notify(f"Opened: {link.short_display}", title="Link Opened") + self.dismiss() + except Exception as e: + self.app.notify(f"Failed to open link: {e}", severity="error") + + @on(ListView.Selected) + def on_list_selected(self, event: ListView.Selected) -> None: + """Handle list item selection (Enter key or click).""" + if event.list_view.index is not None and 0 <= event.list_view.index < len( + self.links + ): + self._open_link(self.links[event.list_view.index]) diff --git a/src/maildir_gtd/screens/__init__.py b/src/maildir_gtd/screens/__init__.py index 8aeb3fd..9a4d624 100644 --- a/src/maildir_gtd/screens/__init__.py +++ b/src/maildir_gtd/screens/__init__.py @@ -2,5 +2,13 @@ from .CreateTask import CreateTaskScreen from .OpenMessage import OpenMessageScreen from .DocumentViewer import DocumentViewerScreen +from .LinkPanel import LinkPanel, LinkItem, extract_links_from_content -__all__ = ["CreateTaskScreen", "OpenMessageScreen", "DocumentViewerScreen"] +__all__ = [ + "CreateTaskScreen", + "OpenMessageScreen", + "DocumentViewerScreen", + "LinkPanel", + "LinkItem", + "extract_links_from_content", +] diff --git a/src/maildir_gtd/widgets/ContentContainer.py b/src/maildir_gtd/widgets/ContentContainer.py index a6c003d..8be874b 100644 --- a/src/maildir_gtd/widgets/ContentContainer.py +++ b/src/maildir_gtd/widgets/ContentContainer.py @@ -3,9 +3,13 @@ from textual import work from textual.binding import Binding from textual.containers import Vertical, ScrollableContainer from textual.widgets import Static, Markdown, Label +from textual.reactive import reactive from src.services.himalaya import client as himalaya_client +from src.maildir_gtd.config import get_config +from src.maildir_gtd.screens.LinkPanel import extract_links_from_content, LinkItem import logging from datetime import datetime +from typing import Literal, List import re import os import sys @@ -57,9 +61,15 @@ class EnvelopeHeader(Vertical): class ContentContainer(ScrollableContainer): + """Container for displaying email content with toggleable view modes.""" + can_focus = True + + # Reactive to track view mode and update UI + current_mode: reactive[Literal["markdown", "html"]] = reactive("markdown") + BINDINGS = [ - Binding("m", "toggle_mode", "Toggle View Mode") + Binding("m", "toggle_mode", "Toggle View Mode"), ] def __init__(self, **kwargs): @@ -68,34 +78,53 @@ class ContentContainer(ScrollableContainer): self.header = EnvelopeHeader(id="envelope_header") self.content = Markdown("", id="markdown_content") self.html_content = Static("", id="html_content", markup=False) - self.current_mode = "html" # Default to text mode self.current_content = None self.current_message_id = None self.content_worker = None + # Load default view mode from config + config = get_config() + self.current_mode = config.content_display.default_view_mode + def compose(self): yield self.content yield self.html_content def on_mount(self): - # Hide markdown content initially - # self.action_notify("loading message...") - self.content.styles.display = "none" - self.html_content.styles.display = "block" + # Set initial display based on config default + self._apply_view_mode() + self._update_mode_indicator() - async def action_toggle_mode(self): - """Toggle between plaintext and HTML viewing modes.""" - if self.current_mode == "html": - self.current_mode = "text" + def watch_current_mode(self, old_mode: str, new_mode: str) -> None: + """React to mode changes.""" + self._apply_view_mode() + self._update_mode_indicator() + + def _apply_view_mode(self) -> None: + """Apply the current view mode to widget visibility.""" + if self.current_mode == "markdown": self.html_content.styles.display = "none" self.content.styles.display = "block" else: - self.current_mode = "html" self.content.styles.display = "none" self.html_content.styles.display = "block" - # self.action_notify(f"switched to mode {self.current_mode}") + + def _update_mode_indicator(self) -> None: + """Update the border subtitle to show current mode.""" + mode_label = "Markdown" if self.current_mode == "markdown" else "HTML/Text" + mode_icon = ( + "\ue73e" if self.current_mode == "markdown" else "\uf121" + ) # nf-md-language_markdown / nf-fa-code + self.border_subtitle = f"{mode_icon} {mode_label}" + + async def action_toggle_mode(self): + """Toggle between markdown and HTML viewing modes.""" + if self.current_mode == "html": + self.current_mode = "markdown" + else: + self.current_mode = "html" + # Reload the content if we have a message ID - self.border_sibtitle = self.current_mode; if self.current_message_id: self.display_content(self.current_message_id) @@ -113,19 +142,17 @@ class ContentContainer(ScrollableContainer): if success: self._update_content(content) else: - self.notify( - f"Failed to fetch content for message ID {message_id}.") + self.notify(f"Failed to fetch content for message ID {message_id}.") def display_content(self, message_id: int) -> None: """Display the content of a message.""" - # self.action_notify(f"recieved message_id to display {message_id}") if not message_id: return self.current_message_id = message_id # Immediately show a loading message - if self.current_mode == "text": + if self.current_mode == "markdown": self.content.update("Loading...") else: self.html_content.update("Loading...") @@ -135,15 +162,20 @@ class ContentContainer(ScrollableContainer): self.content_worker.cancel() # Fetch content in the current mode - format_type = "text" if self.current_mode == "text" else "html" - self.content_worker = self.fetch_message_content( - message_id, format_type) + format_type = "text" if self.current_mode == "markdown" else "html" + self.content_worker = self.fetch_message_content(message_id, format_type) def _update_content(self, content: str | None) -> None: """Update the content widgets with the fetched content.""" + if content is None: + content = "(No content)" + + # Store the raw content for link extraction + self.current_content = content + try: - if self.current_mode == "text": - # For text mode, use the Markdown widget + if self.current_mode == "markdown": + # For markdown mode, use the Markdown widget self.content.update(content) else: # For HTML mode, use the Static widget with markup @@ -169,7 +201,13 @@ class ContentContainer(ScrollableContainer): except Exception as e: logging.error(f"Error updating content: {e}") - if self.current_mode == "text": + if self.current_mode == "markdown": self.content.update(f"Error displaying content: {e}") else: self.html_content.update(f"Error displaying content: {e}") + + def get_links(self) -> List[LinkItem]: + """Extract and return links from the current message content.""" + if not self.current_content: + return [] + return extract_links_from_content(self.current_content) diff --git a/src/maildir_gtd/widgets/EnvelopeListItem.py b/src/maildir_gtd/widgets/EnvelopeListItem.py new file mode 100644 index 0000000..ba775ae --- /dev/null +++ b/src/maildir_gtd/widgets/EnvelopeListItem.py @@ -0,0 +1,248 @@ +"""Custom widget for rendering envelope list items with configurable display.""" + +from datetime import datetime +from typing import Any, Dict, Optional + +from textual.app import ComposeResult +from textual.containers import Horizontal, Vertical +from textual.widgets import Label, Static + +from src.maildir_gtd.config import EnvelopeDisplayConfig, get_config + + +class EnvelopeListItem(Static): + """A widget for rendering a single envelope in the list. + + Supports configurable layout: + - 2-line mode: sender/date on line 1, subject on line 2 + - 3-line mode: adds a preview line + + Displays read/unread status with NerdFont icons. + """ + + DEFAULT_CSS = """ + EnvelopeListItem { + height: auto; + width: 1fr; + padding: 0; + } + + EnvelopeListItem .envelope-row-1 { + height: 1; + width: 1fr; + } + + EnvelopeListItem .envelope-row-2 { + height: 1; + width: 1fr; + } + + EnvelopeListItem .envelope-row-3 { + height: 1; + width: 1fr; + } + + EnvelopeListItem .status-icon { + width: 2; + padding: 0 1 0 0; + } + + EnvelopeListItem .checkbox { + width: 2; + padding: 0 1 0 0; + } + + EnvelopeListItem .sender-name { + width: 1fr; + } + + EnvelopeListItem .message-datetime { + width: auto; + padding: 0 1; + color: $text-muted; + } + + EnvelopeListItem .email-subject { + width: 1fr; + padding: 0 3; + text-style: bold; + } + + EnvelopeListItem .email-preview { + width: 1fr; + padding: 0 3; + color: $text-muted; + } + + EnvelopeListItem.unread .sender-name { + text-style: bold; + } + + EnvelopeListItem.unread .email-subject { + text-style: bold; + } + """ + + def __init__( + self, + envelope: Dict[str, Any], + config: Optional[EnvelopeDisplayConfig] = None, + is_selected: bool = False, + **kwargs, + ): + """Initialize the envelope list item. + + Args: + envelope: The envelope data dictionary from himalaya + config: Display configuration (uses global config if not provided) + is_selected: Whether this item is currently selected + """ + super().__init__(**kwargs) + self.envelope = envelope + self.config = config or get_config().envelope_display + self._is_selected = is_selected + + # Parse envelope data + self._parse_envelope() + + def _parse_envelope(self) -> None: + """Parse envelope data into display-ready values.""" + # Get sender info + from_data = self.envelope.get("from", {}) + self.sender_name = from_data.get("name") or from_data.get("addr", "Unknown") + if not self.sender_name: + self.sender_name = from_data.get("addr", "Unknown") + + # Truncate sender name if needed + max_len = self.config.max_sender_length + if len(self.sender_name) > max_len: + self.sender_name = self.sender_name[: max_len - 1] + "\u2026" # ellipsis + + # Get subject + self.subject = str(self.envelope.get("subject", "")).strip() or "(No subject)" + + # Parse date + self.formatted_datetime = self._format_datetime() + + # Get read/unread status (himalaya uses "flags" field) + flags = self.envelope.get("flags", []) + self.is_read = "Seen" in flags if isinstance(flags, list) else False + self.is_flagged = "Flagged" in flags if isinstance(flags, list) else False + self.has_attachment = ( + "Attachments" in flags if isinstance(flags, list) else False + ) + + # Message ID for selection tracking + self.message_id = int(self.envelope.get("id", 0)) + + def _format_datetime(self) -> str: + """Format the message date/time according to config.""" + date_str = self.envelope.get("date", "") + if not date_str: + return "" + + try: + # Parse ISO format date + if "Z" in date_str: + date_str = date_str.replace("Z", "+00:00") + dt = datetime.fromisoformat(date_str) + + parts = [] + if self.config.show_date: + parts.append(dt.strftime(self.config.date_format)) + if self.config.show_time: + parts.append(dt.strftime(self.config.time_format)) + + return " ".join(parts) + except (ValueError, TypeError): + return "Invalid Date" + + def compose(self) -> ComposeResult: + """Compose the widget layout.""" + # Determine status icon + if self.is_read: + status_icon = self.config.icon_read + status_class = "status-icon read" + else: + status_icon = self.config.icon_unread + status_class = "status-icon unread" + + # Add flagged/attachment indicators + extra_icons = "" + if self.is_flagged: + extra_icons += f" {self.config.icon_flagged}" + if self.has_attachment: + extra_icons += f" {self.config.icon_attachment}" + + # Build the layout based on config.lines + with Vertical(classes="envelope-content"): + # Row 1: Status icon, checkbox, sender, datetime + with Horizontal(classes="envelope-row-1"): + yield Label(status_icon + extra_icons, classes=status_class) + if self.config.show_checkbox: + checkbox_char = "\uf4a7" if self._is_selected else "\ue640" + yield Label(checkbox_char, classes="checkbox") + yield Label(self.sender_name, classes="sender-name", markup=False) + yield Label(self.formatted_datetime, classes="message-datetime") + + # Row 2: Subject + with Horizontal(classes="envelope-row-2"): + yield Label(self.subject, classes="email-subject", markup=False) + + # Row 3: Preview (only in 3-line mode with preview enabled) + if self.config.lines == 3 and self.config.show_preview: + preview = self.envelope.get("preview", "")[:60] + if preview: + with Horizontal(classes="envelope-row-3"): + yield Label(preview, classes="email-preview", markup=False) + + def on_mount(self) -> None: + """Set up classes on mount.""" + if not self.is_read: + self.add_class("unread") + if self._is_selected: + self.add_class("selected") + + def set_selected(self, selected: bool) -> None: + """Update the selection state.""" + self._is_selected = selected + if selected: + self.add_class("selected") + else: + self.remove_class("selected") + + # Update checkbox display + if self.config.show_checkbox: + try: + checkbox = self.query_one(".checkbox", Label) + checkbox.update("\uf4a7" if selected else "\ue640") + except Exception: + pass # Widget may not be mounted yet + + +class GroupHeader(Static): + """A header widget for grouping envelopes by date.""" + + DEFAULT_CSS = """ + GroupHeader { + height: 1; + width: 1fr; + background: $surface; + color: $text-muted; + text-style: bold; + padding: 0 1; + } + """ + + def __init__(self, label: str, **kwargs): + """Initialize the group header. + + Args: + label: The header label (e.g., "Today", "December 2025") + """ + super().__init__(**kwargs) + self.label = label + + def compose(self) -> ComposeResult: + """Compose just returns itself as a Static.""" + yield Label(self.label, classes="group-header-label") diff --git a/src/maildir_gtd/widgets/__init__.py b/src/maildir_gtd/widgets/__init__.py index e6ab631..bc3c402 100644 --- a/src/maildir_gtd/widgets/__init__.py +++ b/src/maildir_gtd/widgets/__init__.py @@ -1 +1,6 @@ -# Initialize the screens subpackage +# Initialize the widgets subpackage +from .ContentContainer import ContentContainer +from .EnvelopeHeader import EnvelopeHeader +from .EnvelopeListItem import EnvelopeListItem, GroupHeader + +__all__ = ["ContentContainer", "EnvelopeHeader", "EnvelopeListItem", "GroupHeader"] diff --git a/uv.lock b/uv.lock index 0b4474c..83262fc 100644 --- a/uv.lock +++ b/uv.lock @@ -864,6 +864,8 @@ dependencies = [ { name = "openai" }, { name = "orjson" }, { name = "pillow" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, { name = "python-dateutil" }, { name = "python-docx" }, { name = "requests" }, @@ -871,6 +873,7 @@ dependencies = [ { name = "textual" }, { name = "textual-image" }, { name = "ticktick-py" }, + { name = "toml" }, ] [package.dev-dependencies] @@ -899,6 +902,8 @@ requires-dist = [ { name = "openai", specifier = ">=1.78.1" }, { name = "orjson", specifier = ">=3.10.18" }, { name = "pillow", specifier = ">=11.2.1" }, + { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pydantic-settings", specifier = ">=2.0.0" }, { name = "python-dateutil", specifier = ">=2.9.0.post0" }, { name = "python-docx", specifier = ">=1.1.2" }, { name = "requests", specifier = ">=2.31.0" }, @@ -906,6 +911,7 @@ requires-dist = [ { name = "textual", specifier = ">=3.2.0" }, { name = "textual-image", specifier = ">=0.8.2" }, { name = "ticktick-py", specifier = ">=2.0.0" }, + { name = "toml", specifier = ">=0.10.0" }, ] [package.metadata.requires-dev] @@ -1686,6 +1692,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, ] +[[package]] +name = "pydantic-settings" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, +] + [[package]] name = "pydub" version = "0.25.1" @@ -2126,6 +2146,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/cb/6291e38d14a52c73a4bf62a5cde88855741c1294f4a68cf38b46861d8480/ticktick_py-2.0.1-py3-none-any.whl", hash = "sha256:676c603322010ba9e508eda71698e917a3e2ba472bcfd26be2e5db198455fda5", size = 45675, upload-time = "2021-06-24T20:24:07.355Z" }, ] +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + [[package]] name = "tqdm" version = "4.67.1"