diff --git a/smolmail.png b/smolmail.png new file mode 100644 index 0000000..b076ec7 Binary files /dev/null and b/smolmail.png differ 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,