feat: let composes carry custom frontmatter headers

This commit is contained in:
randogoth 2026-10-03 16:45:07 +03:00
parent 74ec9a2492
commit 68f5620d35
5 changed files with 401 additions and 10 deletions

View file

@ -202,6 +202,7 @@ pub struct CarrierWidgets {
// compose page
compose_to: adw::EntryRow,
compose_headers: gtk::ListBox,
compose_subject: adw::EntryRow,
compose_body: gtk::TextBuffer,
compose_send: adw::ButtonContent,
@ -531,6 +532,14 @@ impl CarrierWidgets {
compose_fields.append(&compose_to);
compose_fields.append(&compose_subject);
compose.append(&compose_fields);
// Frontmatter rows: one `Key: value` entry each, plus a flat
// add button below. Rows are rebuilt in render on structural
// changes only, so the entries never lose focus while typing.
let compose_headers = boxed_list();
compose.append(&compose_headers);
let add_header = button("+ header", "quiet");
add_header.set_halign(Align::Start);
compose.append(&add_header);
let compose_view = gtk::TextView::new();
compose_view.set_wrap_mode(WrapMode::WordChar);
compose_view.set_top_margin(8);
@ -890,6 +899,8 @@ impl CarrierWidgets {
gtk::glib::Propagation::Stop
});
add_header.connect_clicked(input(Message::ComposeHeaderAdded));
compose_send_button.connect_clicked(input(Message::SendPressed));
compose_cancel.connect_clicked(input(Message::ComposeClosed));
let to_sender = sender.clone();
@ -991,6 +1002,7 @@ impl CarrierWidgets {
article_body,
actions,
compose_to,
compose_headers,
compose_subject,
compose_body,
compose_send,
@ -1128,6 +1140,7 @@ fn spawn_op(model: &Carrier, op: PendingOp, sender: &ComponentSender<Carrier>) {
subject,
body,
reply_to,
headers,
} => sender.spawn_command(move |out| {
let res = ops::send(
&store,
@ -1140,6 +1153,7 @@ fn spawn_op(model: &Carrier, op: PendingOp, sender: &ComponentSender<Carrier>) {
},
&body,
reply_to.as_deref(),
&headers,
timeout,
);
let _ = out.send(OpResult::Generic(res));
@ -1203,15 +1217,44 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
move |_: &gtk::Button| sender.input(msg.clone())
};
// Prefill entries once after a reply or write-to-contact; afterwards
// typing flows entry -> model only, so the view never fights the user.
// Prefill entries once after a reply, write or draft reopen;
// afterwards typing flows entry -> model only, so the view never
// fights the user. The fresh flag also rebuilds the header rows,
// which otherwise sync on count changes alone.
let mut compose_fresh = false;
if model.compose_fresh.take() {
compose_fresh = true;
if let Some(compose) = &model.compose {
w.compose_to.set_text(&compose.to);
w.compose_subject.set_text(&compose.subject);
w.compose_body.set_text(&compose.body);
}
}
let rows = model.compose.as_ref().map_or(0, |c| c.headers.len());
let mut current = 0;
let mut child = w.compose_headers.first_child();
while let Some(item) = child {
current += 1;
child = item.next_sibling();
}
if compose_fresh || current != rows {
let added = rows > current;
clear_list(&w.compose_headers);
if let Some(compose) = &model.compose {
let mut last_entry = None;
for (index, text) in compose.headers.iter().enumerate() {
let (row, entry) = compose_header_row(index, text, sender);
last_entry = Some(entry);
w.compose_headers.append(&row);
}
// A freshly appended row is the one being typed into.
if added {
if let Some(entry) = last_entry {
entry.grab_focus();
}
}
}
}
// Header and sidebar identity.
let address = model
@ -1711,6 +1754,57 @@ fn mail_row(folder: Folder, row: &Row) -> adw::ActionRow {
item
}
/// One compose header row: a `Key: value` entry plus a remove button.
/// The row carries its own validation state — anything the send would
/// reject is tinted and explained right where it is typed, and the
/// empty rows the send skips are left quiet.
fn compose_header_row<'a>(
index: usize,
text: &str,
sender: &ComponentSender<Carrier>,
) -> (gtk::ListBoxRow, gtk::Entry) {
let entry = gtk::Entry::new();
entry.set_hexpand(true);
entry.set_placeholder_text(Some("Key: value"));
entry.set_text(text);
let style = |entry: &gtk::Entry, text: &str| match crate::ops::header_row(text) {
Err(reason) => {
entry.add_css_class("error");
entry.set_tooltip_text(Some(&reason));
}
Ok(_) => {
entry.remove_css_class("error");
entry.set_tooltip_text(None);
}
};
style(&entry, text);
let change_sender = sender.clone();
let style_entry = entry.clone();
entry.connect_changed(move |changed| {
let text = changed.text().to_string();
style(&style_entry, &text);
change_sender.input(Message::ComposeHeaderChanged(index, text));
});
let remove = icon_button("user-trash-symbolic", "Remove this header");
let remove_sender = sender.clone();
remove.connect_clicked(move |_| {
remove_sender.input(Message::ComposeHeaderRemoved(index));
});
let line = gtk::Box::new(Orientation::Horizontal, 6);
line.set_margin_top(6);
line.set_margin_bottom(6);
line.set_margin_start(12);
line.set_margin_end(6);
line.append(&entry);
line.append(&remove);
let row = gtk::ListBoxRow::new();
row.set_child(Some(&line));
(row, entry)
}
/// One contact in the sidebar's bottom panel: the address over its trust
/// state; activating it opens the detail pane.
fn sidebar_contact_row(contact: &Contact) -> adw::ActionRow {