From 8e9d2a92a68ac8ef75114b90e2f50e4a0d79634d Mon Sep 17 00:00:00 2001 From: randogoth Date: Sat, 3 Oct 2026 11:04:58 +0300 Subject: [PATCH] feat: make senders tappable and replace the toasts with a fixed status line --- smolmail.png | Bin 0 -> 5371 bytes src/app.rs | 26 ++++++-- src/view.rs | 178 ++++++++++++++++++++++++++------------------------- 3 files changed, 112 insertions(+), 92 deletions(-) create mode 100644 smolmail.png diff --git a/smolmail.png b/smolmail.png new file mode 100644 index 0000000000000000000000000000000000000000..b076ec730c3df05f1650d12237e8b23ecc4b13f3 GIT binary patch literal 5371 zcmeAS@N?(olHy`uVBq!ia0y~yU_8XYz^KE)#=yW35Vmzu7bYbn``=yryp*$J$Yl}@$|HFug^SJ{;K=wjE%K* z`o6kzmgg-68K?!mq%V5IctE4_^HlwPU#;zb8^3pPsri%f_3os0<|P+2su>Kp?$tc) z&i{K!`+C0I|Ihn>p6}29_qP7u_1j1LIXt{M5;>N$ez0(Cc_ri|;c$yVPQ4?*)`_E5 z!ldCir&36}fD>0FSpE~I;+F~G4E@3`8vO!?IJ>wW6gF`j6?S?N6b2UT6L1Pq2kCj@ z&mox|$_tYIDd==8D3+;Wj)K#-mI)h!u7ix9Agh?7)8hrwBr#9G=#rZ$NEeSEhoooz zzE4ND-?QO)U{QO5Rq&U&-+jTJ#&-;5(%boWDL7R<{`U7khm~%_-D4jQ>va@tEBup} z(6WRxVSC}<1Kdh;@?!Rjuh?0~d?R;`{g)<=^U}}Xvt+$E`#Qen^;!A-b;UQHOMd6y zr*O#l@xNLJ-#wLoZ}1p2`>*5Qui(`3o=NN7PKGk!!)G{@9-sNnF!hRY!`-$&EVmi@ zgj|kIe9x$*TFS7yGw~VA#KK3`4PFu&4R?^Plht- z2bc4hPdYs|=#b}DnwnyruCSF0q~ze{@&JLAO1CX)SLQNUzfsaX`1$=jg&@^!`9Z(; z&ac(oS(do*O(M_vH5(F^@2_t15cQd2%kb*))7IA6<#P6Y9UY%H8>{xGc_c+lxcB~+ zwtigAHe>Zi=0JD^u~!id2EKT*YR0tkpV14wm_5rMGTaT;?cp zSSIyz9;Z^}Cf~_Xw*<E70Vx!mrLe^*mNY#Sor9^qGO7{w$z~4 z`YoPs6dTVfG?315aZ!Hx#;CVLMXA%xM2g)pCayN-AJ0U) zsrJ%$Zr$uWE-k%r>+a}p0V^04Rm{W1#UC&I{G2bn#q*4x@6niSExoPcj~uTy|9ImP zp5?+I*&;A$XLfgYVd}>}ml@Q0Rir#!HX8~GpG`aC<9GDVm5`XYh!uZ%0`*rrGDx-w zOq%)lx|pw0r<<~pr_{xc=gkqTC6zQqe`hx`Ok!0`$ygp6^ZUV?3&&x{r#Io+uYK`e?Y!IkF( zR#ye}hPkEu<>OFr6>=(?c%5hCB=xHkBpH|RIC-r5G)wW^)~Le5YHPXLe>)YX<^8z1 zfFVdtaf!fTwvCdWZ|u#T=k&COY5$j8R6BpCpu;K$4$UhI9DSq&dxM)YZo1B7`_1df zuu@2IiAW`D;l8U~r{8W%?hsXN2$?7lv}#S$4IinCIVSVo8O=3x)^Yd+GCbAELzPGR z$hnV4`~>1N%#z;~v{kIUD|TcOheD}BONiGs$2VsL+T+R!0|okD*-DEzymH~t^jUnd zL&dZF_jjA78_Ye`R%ta`h84|Tq*fz z@zsdBFEs*d*9bVg^5xJx(_Q@8@b)Y#i#Bsx*|&H1R_xg+7;VkUvaz`B9NaFt++<#0x3B2srL$}1WtZ36U)#PdsW@Fd zYVu@5Q$xXEVd28MGP7qc2&<$<00bnk=k-nf1L|35yamA_ir|5tu!$(|j(anCP{MS_KO zWn~-8mo3^>RyO}%(T3gi-~Q`|Zr-%*-?_P8zm}htW524>vSeB+hjrPvZ;wwt3(MJZ z+dny?Rl!e|t#IE}sn-g5Z{HkgK5w@^m%+MaLR`y)E1x(L-LE$9FtC-??+Fx`&swnh z8si%Qrz%0Gpwq@JvZ4HsR-6}b*KLq>^3Zqk$Z~Dju=tgu%vQ-qE7&$TbG&rr@LYRB zpe)3;qjbJvUlq*MQyhuznvOYN#E&$lb@a=-yL@z%na)tgp}3braphFWLw!!o6%y;} znr?jG)ZGzr`%d4~Td*{7Y+-#5svwyV{hcy@HqmA7iW_*jUzhE(1Ecxi5z4XR? zTSc{3`zt9K@ju!DcZ8SXn-jVnYoDig`0u{zE^v8HNb!w>%cr+Q-@er~_3Amj%MY6@ z-oZ^y;;=U2fAs9L@sY>nGcBZ!xP=Qyuk>H_@=ey;KYt&5c=%B7vSO1)gFC0<-W}I8 zlv5Y)6+Yb`mb|XC;OvE+VcO54)<*2A*!VwtW?fZvZCJ1ug|pYa=s~?c;Ar){T3& zi_g!`KmO{No65<9=UtU6fBm?e)+T$`anBwD{RRUS#T1&%z)?5C&sDhKqW-DGai zddX7w^Wx$g8QYbW=CKvNeAGH4+c))-6@U5cj|ZQ{m`G>(_cDG;a^p}xk=0glXK&e& z{cFr0)!z=jm|?Qm^5>hNTnEb*fvOW)9rN?lKl-n(E?h5rF~em3x``QPyVvizTm4sI z0>5I52pjLCy=BUI>)mU1iEiAzS-$t##l?bW4_6DjmHbm|4KPt`v0K8Kp1_&40VF z{PRu8#b=eTR+p)BFR*apaJJCwSiaNDTynpixumvq^~b&L)`jWXl~&4cCOBx!69`(x zZ}$9gRkpUC-U{FynvWAls6tD~B@MR1#eXf*mMwd9=uVE0zu&Qal8PZs0zsLJ z#g43vr;K;F-tu3Ojx23YB^0M_F8NyY=TP?gW9sGaj<5Z0-}Q9&_wzMpR>(TD zbSbxlTss}EfBbZKyts=(xRb|IPNfOzic8E`1Wz;z1X(w6qJr>w`5t-n8hx;kBdi@3`Ic_)wDn&<29_b%0*{y2O6Uaby?w3Z1`k^8FS z1sZ)g6}PH%IE1xKh!S>5h;TaLA>fh_;B>-6&}Bh<-UGL+_?;y$)2^NWSy=sSr+xIU zs-H(1bYpkQ_+K-7bn@4+ndg6})GGh`df%?|srC2Ae|G+iuraTHmMnJu>~a6SzmjYB zo=dybvwQd2iY?}no6r88k*k(&r{=AH#z*{DRq0l=5;m-igPrs03id#b%YxvdQro&k zw&_QYz%u2QFG?*-!lnw8aV!#8rV8P06)3x++7Z62Ea>n1m+fFhkJbdb9aBiJ7pt76 z8Q`1NvLx(V&xgs@--~DO6g70-bMKbjXUpHIZa?-EU!Rs#{r2QUdt>D#;`^5cNxPo8 zGwuDo{-?WNzAG|3^LoPGZ2SLPcYOOC-#ztv-qBm9if$fW=+-hJN;50`nS#~&&;0M} zMYiAjuQC1lgUY!KhZeLev!{IZ`r{5Q0!C~xj>}}4-<=b2=u&Ix;dRtGwlmxNs&fjz5UFy6+FgH z$L4kCi68%Hv&w_x;e_tI=g&`mDysQ^et+;cg9k2Uy-Yh_Kfkr`8e@=&B1@Ilt5te) zAGhi4@V)p(iq-Fm?13%Ezjj4&ypK?h6mrs9UVP}3{+@4HCzU1rUE{d7+&6Y-onXVE zIJM;U-~LC}e#;-d9#PpRJD$u(rRh0cLfg>I)74WxzePNw*JKA zgg57woNAd6R9pMdv*ymczW&?BE9>F}+QXkR?0T<|D&XW*zIUta_2}D8Gd63p*m+^Py2e}j2L`S<4j|L%|D9=yg%3SkBjHskw}>#qdX&|RZ_9`=ZAxbx7*iz`0!RgzUt%J^)b6jHoy5; zqMgzDP;{Q_H6TS6u&Oj|zxai#0LoXhK{ zZrxhGuljkrx$ctZI!+!-Ig?C9EAMQ$e_hbwmntZch8=cD)OuyebfQ5jm7jqv@TfxB z9I+$o^=(f6*;@DI&(1URKdU@nr@QoC>aEEC;WyRK`z7Y<1-KL{1)bb3?O@sXMqplv=9V?i z8Vyg*s$P@u4Bb~E{c_EkwR=B{%m4i?UH>!x|Hp16P>*S%TE|!A1`%HlN$#eIS|*Q3 zwNr8oA}{}&nce>jN<+#@bt!r8dMC9TZxK&h=3V>u@pVzz62(9F|6b>>`z-(d--owb z%YIMRInJJG5!tpe?EB=bRYjY6v=0WPJ8=ZwSC}Sy$WiP(w?e3L%Y~(Rjyl#Ye+3*? zd2u{sDdkL(b<$`EnJeJLAIhIJ)mfwAN~?eqS150iD2UxF;3U6F?1)xt0K-ac#TLd8 z)s9e(g$zOEP8`k})*Y#B0SuC&P8^aN+8wDa0SuC+P8^b2RvoS&@lYoYOAW0KSCDwA z6Nlv~>yAp0xVRGs>j~|SN|3m@69?-ltBy#Jc(@aX>ItomNRW8B6NjplS;yg)0EUMR z0#2+vQb!z}G#Uz~2spKVkv>ufVjC&8aCu1|F;Nt8c;U;j@X$G~Bv9}#QEmxfUe5Y) zi2#?v7k7?@Zhh<-;~L?#I_YY`uP3PMAT& hkAaa2V}J`v{`0rjN3likEEfkE=jrO_vd$@?2>=u)nrZ+5 literal 0 HcmV?d00001 diff --git a/src/app.rs b/src/app.rs index 260c940..8859f80 100644 --- a/src/app.rs +++ b/src/app.rs @@ -525,14 +525,14 @@ impl Carrier { self.stash_draft(); self.selected_own = false; self.selected_contact = Some(address); - self.open = None; + // The open message stays: the detail shows over it while + // its list highlight holds, and picking it again returns. self.page = Page::Mail; } Message::OwnSelected => { self.stash_draft(); self.selected_own = true; self.selected_contact = None; - self.open = None; self.page = Page::Mail; } Message::FetchCancelled => { @@ -1029,13 +1029,30 @@ mod tests { assert_eq!(app.busy.as_deref(), Some("fetching")); } + fn opened() -> OpenedMsg { + OpenedMsg { + id: "ab".repeat(32), + from: String::new(), + subject: String::new(), + time: 0, + body: String::new(), + sender: String::new(), + reply_to: None, + kept: false, + } + } + + #[test] #[test] fn contact_selection_and_message_selection_supersede_each_other() { let mut app = app(); + // An open message survives a contact selection: the detail pane + // shows the contact while the mail keeps its list highlight. + app.open = Some((Folder::Inbox, opened())); app.handle(Message::ContactSelected("bob@localhost".into())); assert_eq!(app.selected_contact.as_deref(), Some("bob@localhost")); assert_eq!(app.page, Page::Mail); - assert!(app.open.is_none()); + assert!(app.open.is_some()); // busy does not block selecting a contact app.busy = Some("fetching".into()); @@ -1046,10 +1063,11 @@ mod tests { #[test] fn own_and_contact_selection_supersede_each_other() { let mut app = app(); + app.open = Some((Folder::Inbox, opened())); app.handle(Message::OwnSelected); assert!(app.selected_own); assert_eq!(app.page, Page::Mail); - assert!(app.open.is_none()); + assert!(app.open.is_some()); app.handle(Message::ContactSelected("bob@localhost".into())); assert!(!app.selected_own); diff --git a/src/view.rs b/src/view.rs index b1248f8..03416ec 100644 --- a/src/view.rs +++ b/src/view.rs @@ -177,9 +177,6 @@ pub struct CarrierWidgets { /// `AlertDialog::present` needs a parent, and only the window is one. window: gtk::Window, window_title: adw::WindowTitle, - toasts: adw::ToastOverlay, - toolbar: adw::ToolbarView, - // sidebar top: actions and destinations folders: gtk::ListBox, folder_counts: Vec, @@ -242,8 +239,6 @@ pub struct CarrierWidgets { /// The dialog currently on screen, so `render` presents it once on the /// None -> Some edge instead of stacking a copy on every message. dialog: Option, - /// The note already spoken as a toast, for the same reason. - last_status: String, } impl CarrierWidgets { @@ -774,12 +769,13 @@ impl CarrierWidgets { stack.add_named(¢er(&signup), Some("signup")); // --- status bar ------------------------------------------------- - // Work in progress gets its own bottom bar, revealed only while - // something runs; finished notes speak through a toast instead, so - // the chrome is present exactly as long as the work is. + // One fixed line at the bottom: the spinner and the running + // operation while something is busy, the last finished note + // afterwards — no transient chrome, nothing moving. let status_bar = gtk::Box::new(Orientation::Horizontal, 10); status_bar.add_css_class("toolbar"); let spinner = gtk::Spinner::new(); + spinner.set_visible(false); let status_label = label("", "caption"); status_label.set_hexpand(true); let cancel_button = button("Cancel", ""); @@ -791,7 +787,6 @@ impl CarrierWidgets { let toolbar = adw::ToolbarView::new(); toolbar.set_content(Some(&stack)); toolbar.add_bottom_bar(&status_bar); - toolbar.set_reveal_bottom_bars(false); // The columns split is a paned too: the divider between sidebar // and main box drags, extra space goes to the main box. @@ -843,9 +838,7 @@ impl CarrierWidgets { } gtk::glib::ControlFlow::Continue }); - let toasts = adw::ToastOverlay::new(); - toasts.set_child(Some(&columns)); - root.set_child(Some(&toasts)); + root.set_child(Some(&columns)); // --- signal wiring --------------------------------------------- // Both sidebar lists route through row-activated, which fires only @@ -891,6 +884,12 @@ impl CarrierWidgets { contact_sender.input(Message::ContactSelected(row.widget_name().to_string())); }); + let meta_link = sender.clone(); + article_meta.connect_activate_link(move |_, uri| { + meta_link.input(Message::ContactSelected(uri.to_string())); + gtk::glib::Propagation::Stop + }); + compose_send_button.connect_clicked(input(Message::SendPressed)); compose_cancel.connect_clicked(input(Message::ComposeClosed)); let to_sender = sender.clone(); @@ -974,8 +973,6 @@ impl CarrierWidgets { CarrierWidgets { window: root.clone(), window_title, - toasts, - toolbar, folders, folder_counts, drafts_nav, @@ -1017,7 +1014,6 @@ impl CarrierWidgets { status_label, cancel_button, dialog: None, - last_status: String::new(), } } } @@ -1341,60 +1337,11 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender { - w.article_stack.set_visible_child_name("detail"); - w.detail_share.set_visible(false); - w.detail_copy.set_visible(false); - // A message body is prose; only the detail below is a - // column of keys that has to stay aligned. - w.article_view.remove_css_class("monospace"); - let subject = if msg.subject.is_empty() { - "(no subject)".to_string() - } else { - msg.subject.clone() - }; - w.article_subject.set_text(&subject); - let label = if *folder == Folder::Sent { - "to" - } else { - "from" - }; - let mut meta = format!("{label} {} · {} UTC", msg.from, ops::when(msg.time)); - meta.push_str(&format!(" · id {}…", &msg.id[..msg.id.len().min(8)])); - if msg.kept { - meta.push_str(" · a server copy still exists"); - } - w.article_meta.set_text(&meta); - w.article_body.set_text(&msg.body); - - clear(&w.actions); - let reply = icon_label_button("mail-reply-sender-symbolic", "Reply", ""); - reply.connect_clicked(input(Message::ReplyPressed)); - w.actions.append(&reply); - if *folder == Folder::Requests { - if msg.reply_to.is_some() { - let accept = icon_label_button("object-select-symbolic", "Accept", "accent"); - accept.connect_clicked(input(Message::AcceptPressed)); - let block = icon_label_button("action-unavailable-symbolic", "Block", "danger"); - block.connect_clicked(input(Message::BlockPressed)); - w.actions.append(&accept); - w.actions.append(&block); - } else { - w.actions.append(&crate::view::label( - "no reply address: cannot accept or block", - "caption", - )); - } - } - let spacer = gtk::Label::new(None); - spacer.set_hexpand(true); - w.actions.append(&spacer); - let delete = icon_label_button("user-trash-symbolic", "Delete", "danger"); - delete.connect_clicked(input(Message::DeletePressed)); - w.actions.append(&delete); - } - (None, Some(own), _) => { + (_, Some(own), _) => { w.article_stack.set_visible_child_name("detail"); w.article_view.add_css_class("monospace"); w.article_subject.set_text(&own.address); @@ -1407,7 +1354,7 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender { + (_, _, Some(contact)) => { w.article_stack.set_visible_child_name("detail"); w.article_view.add_css_class("monospace"); w.article_subject.set_text(&contact.address); @@ -1457,6 +1404,70 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender { + w.article_stack.set_visible_child_name("detail"); + w.detail_share.set_visible(false); + w.detail_copy.set_visible(false); + // A message body is prose; only the detail below is a + // column of keys that has to stay aligned. + w.article_view.remove_css_class("monospace"); + let subject = if msg.subject.is_empty() { + "(no subject)".to_string() + } else { + msg.subject.clone() + }; + w.article_subject.set_text(&subject); + let label = if *folder == Folder::Sent { + "to" + } else { + "from" + }; + // The address opens the sender's contact details when they + // are a contact; an unknown or keyless sender stays text. + let known = model + .snapshot + .as_ref() + .is_some_and(|snap| snap.contacts.iter().any(|c| c.address == msg.from)); + let address = escape(&msg.from); + let mut meta = if known { + format!("{label} {address}") + } else { + format!("{label} {address}") + }; + meta.push_str(&format!(" · {} UTC", escape(&ops::when(msg.time)))); + meta.push_str(&format!(" · id {}…", &msg.id[..msg.id.len().min(8)])); + if msg.kept { + meta.push_str(" · a server copy still exists"); + } + w.article_meta.set_markup(&meta); + w.article_body.set_text(&msg.body); + + clear(&w.actions); + let reply = icon_label_button("mail-reply-sender-symbolic", "Reply", ""); + reply.connect_clicked(input(Message::ReplyPressed)); + w.actions.append(&reply); + if *folder == Folder::Requests { + if msg.reply_to.is_some() { + let accept = icon_label_button("object-select-symbolic", "Accept", "accent"); + accept.connect_clicked(input(Message::AcceptPressed)); + let block = icon_label_button("action-unavailable-symbolic", "Block", "danger"); + block.connect_clicked(input(Message::BlockPressed)); + w.actions.append(&accept); + w.actions.append(&block); + } else { + w.actions.append(&crate::view::label( + "no reply address: cannot accept or block", + "caption", + )); + } + } + let spacer = gtk::Label::new(None); + spacer.set_hexpand(true); + w.actions.append(&spacer); + let delete = icon_label_button("user-trash-symbolic", "Delete", "danger"); + delete.connect_clicked(input(Message::DeletePressed)); + w.actions.append(&delete); + } (None, None, None) => { w.article_stack.set_visible_child_name("empty"); w.detail_share.set_visible(false); @@ -1636,9 +1647,9 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender {} } - // The bottom bar carries work in progress and nothing else, so it is - // revealed exactly as long as something runs. - w.toolbar.set_reveal_bottom_bars(model.busy.is_some()); + // The fixed bottom line: work in progress while something runs, the + // last note otherwise, errors tinted until the next note replaces them. + w.spinner.set_visible(model.busy.is_some()); if model.busy.is_some() { w.spinner.start(); } else { @@ -1650,24 +1661,15 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender format!("{busy}…"), - (None, _) => String::new(), + (None, _) => model.status.clone().unwrap_or_default(), }); - w.cancel_button.set_visible(fetching); - - // A finished result speaks once, as a toast. Errors hold until they are - // dismissed; everything else fades on its own. - let note = match (&model.busy, &model.status) { - (None, Some(status)) => status.clone(), - _ => String::new(), - }; - if !note.is_empty() && note != w.last_status { - let toast = adw::Toast::new(¬e); - if model.status_error { - toast.set_timeout(0); - } - w.toasts.add_toast(toast); + let errored = model.status_error && model.busy.is_none(); + if errored { + w.status_label.add_css_class("error"); + } else { + w.status_label.remove_css_class("error"); } - w.last_status = note; + w.cancel_button.set_visible(fetching); } /// One list row, like a note title: the read flag and subject on the left,