diff --git a/src/attachment_controller.rs b/src/attachment_controller.rs index 949a7dc2..4eae2a7c 100644 --- a/src/attachment_controller.rs +++ b/src/attachment_controller.rs @@ -1,12 +1,13 @@ //! Mail attachment metadata and an independent dialog session. Files and Mail //! share bounded decoders/PDF workers; opening Mail never changes Files state. -use crate::document_preview::{Preview, preview}; +use crate::document_preview::{Preview, image_thumbnail, preview}; use crate::{ AppWindow, EmailReader, InboxState, MailAttachment, MailAttachments, mail::MailMessage, }; -use slint::{ComponentHandle, ModelRc, Rgba8Pixel, SharedPixelBuffer, VecModel}; +use slint::{ComponentHandle, Model, ModelRc, Rgba8Pixel, SharedPixelBuffer, VecModel}; use std::{ cell::RefCell, + collections::HashSet, rc::Rc, sync::{ Arc, @@ -29,12 +30,60 @@ pub(crate) fn clear(app: &AppWindow) { ui.set_rows(ModelRc::default()); } pub(crate) fn project(app: &AppWindow, email: &MailMessage, same: bool) { + let previous = same.then(|| { + let rows = app.global::().get_rows(); + (0..rows.row_count()) + .filter_map(|index| rows.row_data(index)) + .filter(|row| row.has_thumbnail) + .map(|row| (row.id.to_string(), row.thumbnail)) + .collect::>() + }); if !same { clear(app); } - let rows = attachment_rows(&email.attachments); + let mut rows = attachment_rows(&email.attachments); + if let Some(previous) = previous { + for row in &mut rows { + if let Some(thumbnail) = previous.get(row.id.as_str()) { + row.thumbnail = thumbnail.clone(); + row.has_thumbnail = true; + } + } + } + let thumbnail_ids = email + .attachments + .iter() + .filter(|attachment| { + thumbnail_candidate(attachment) + && rows + .iter() + .find(|row| row.id.as_str() == attachment.id.to_string()) + .is_some_and(|row| !row.has_thumbnail) + }) + .take(4) + .map(|attachment| attachment.id.to_string()) + .collect::>(); app.global::() .set_rows(ModelRc::new(VecModel::from(rows))); + for id in thumbnail_ids { + app.global::() + .invoke_command("thumbnail".into(), id.into()); + } +} + +fn thumbnail_candidate(attachment: &flectar_mail_core::models::AttachmentMeta) -> bool { + let media = attachment + .mime_type + .as_deref() + .unwrap_or("") + .split(';') + .next() + .unwrap_or("") + .trim() + .to_ascii_lowercase(); + attachment.size.is_none_or(|size| size <= 4 * 1024 * 1024) + && media.starts_with("image/") + && media != "image/svg+xml" } fn attachment_rows( attachments: &[flectar_mail_core::models::AttachmentMeta], @@ -71,6 +120,8 @@ fn attachment_rows( || media.starts_with("text/") || matches!(media.as_str(), "application/json" | "application/xml") || name.to_ascii_lowercase().ends_with(".pdf"), + thumbnail: Default::default(), + has_thumbnail: false, } }) .collect::>() @@ -169,9 +220,56 @@ pub(crate) fn register( Arc, Result, String>, )>(2); + let (thumbnail_sender, mut thumbnail_receiver) = tokio::sync::mpsc::channel::<( + i32, + i64, + Arc, + Result<(Vec, u32, u32), String>, + )>(8); + let (thumbnail_job_sender, mut thumbnail_job_receiver) = + tokio::sync::mpsc::channel::<( + i32, + flectar_mail_core::models::AttachmentMeta, + Arc, + )>(8); + let thumbnail_window = weak.clone(); + runtime.handle().spawn(async move { + while let Some((message, attachment, core)) = thumbnail_job_receiver.recv().await { + let attachment_id = attachment.id; + let result = async { + let path = core + .get_attachment(attachment_id) + .await + .map_err(|error| error.to_string())?; + use tokio::io::AsyncReadExt; + let file = tokio::fs::File::open(path) + .await + .map_err(|error| error.to_string())?; + let mut bytes = Vec::new(); + file.take(4 * 1024 * 1024 + 1) + .read_to_end(&mut bytes) + .await + .map_err(|error| error.to_string())?; + image_thumbnail(bytes).await + } + .await; + if thumbnail_sender + .send((message, attachment_id, core, result)) + .await + .is_err() + { + break; + } + let _ = thumbnail_window.upgrade_in_event_loop(|app| { + app.global::().invoke_deliver() + }); + } + }); + let thumbnail_pending = Rc::new(RefCell::new(HashSet::<(usize, i32, i64)>::new())); let state = inbox.clone(); let session_for_delivery = session.clone(); let current = generation.clone(); + let pending_for_delivery = thumbnail_pending.clone(); let window = weak.clone(); app.global::().on_deliver(move || { let Some(app) = window.upgrade() else { @@ -221,9 +319,43 @@ pub(crate) fn register( Err(error) => ui.set_status(error.into()), } } + while let Ok((message, attachment_id, core, result)) = thumbnail_receiver.try_recv() { + let core_id = Arc::as_ptr(&core) as usize; + pending_for_delivery + .borrow_mut() + .remove(&(core_id, message, attachment_id)); + if app.global::().get_message_id() != message + || !state + .borrow() + .core + .as_ref() + .is_some_and(|candidate| Arc::ptr_eq(&core, &candidate.file_core())) + { + continue; + } + let Ok((pixels, width, height)) = result else { + continue; + }; + let rows = app.global::().get_rows(); + for index in 0..rows.row_count() { + let Some(mut row) = rows.row_data(index) else { + continue; + }; + if row.id.as_str() != attachment_id.to_string() { + continue; + } + row.thumbnail = slint::Image::from_rgba8( + SharedPixelBuffer::::clone_from_slice(&pixels, width, height), + ); + row.has_thumbnail = true; + rows.set_row_data(index, row); + break; + } + } }); let state = inbox.clone(); let handle = runtime.handle().clone(); + let pending_thumbnails = thumbnail_pending.clone(); app.global::() .on_command(move |action, value| { let _keep_timer = &timer; @@ -235,9 +367,6 @@ pub(crate) fn register( close(&ui, &generation, &session); return; } - if ui.get_busy() { - return; - } let Some(core) = state.borrow().core.as_ref().map(|core| core.file_core()) else { return; }; @@ -270,6 +399,28 @@ pub(crate) fn register( let Some(selected) = selected else { return; }; + if action == "thumbnail" { + let core_id = Arc::as_ptr(&core) as usize; + if !thumbnail_candidate(&selected) + || !pending_thumbnails + .borrow_mut() + .insert((core_id, message, selected.id)) + { + return; + } + if thumbnail_job_sender + .try_send((message, selected.clone(), core)) + .is_err() + { + pending_thumbnails + .borrow_mut() + .remove(&(core_id, message, selected.id)); + } + return; + } + if ui.get_busy() { + return; + } let page = value.parse::().unwrap_or(0); if action == "page" && page >= ui.get_pages().max(0) as u32 { return; @@ -412,5 +563,8 @@ mod tests { assert_eq!(rows[0].id.as_str(), i64::MAX.to_string()); assert!(rows[0].previewable && rows[1].previewable); assert!(!rows[2].previewable); + assert!(!thumbnail_candidate(&files[0])); + assert!(thumbnail_candidate(&files[1])); + assert!(!rows[1].has_thumbnail); } } diff --git a/src/document_preview.rs b/src/document_preview.rs index fff9f2fe..363a76c0 100644 --- a/src/document_preview.rs +++ b/src/document_preview.rs @@ -3,6 +3,7 @@ use std::sync::Arc; // Cancelling spawn_blocking does not stop an active decoder. Keep admission // inside the worker until decoding really ends, and never queue more images. static IMAGE_SLOT: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(1); +static THUMBNAIL_SLOT: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(1); type Result = std::result::Result; pub(crate) enum Preview { Text(String), @@ -77,9 +78,58 @@ async fn decode_image(work: impl FnOnce() -> Result + Send + 'static) - .map_err(|e| e.to_string())? } +/// Decode a small attachment thumbnail on a separate, serialized lane so +/// background list decoration cannot make an explicitly requested preview +/// report that its decoder is busy. +pub(crate) async fn image_thumbnail(data: Vec) -> Result<(Vec, u32, u32)> { + if data.len() > 4 * 1024 * 1024 { + return Err("Image is too large for an attachment thumbnail".into()); + } + let permit = THUMBNAIL_SLOT.acquire().await.map_err(|e| e.to_string())?; + tokio::task::spawn_blocking(move || { + let _permit = permit; + let mut reader = image::ImageReader::new(std::io::Cursor::new(data)) + .with_guessed_format() + .map_err(|e| e.to_string())?; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(4096); + limits.max_image_height = Some(4096); + limits.max_alloc = Some(64 * 1024 * 1024); + reader.limits(limits); + let image = reader + .decode() + .map_err(|_| "Could not decode attachment thumbnail".to_string())? + .thumbnail(96, 96) + .into_rgba8(); + let (width, height) = image.dimensions(); + Ok((image.into_raw(), width, height)) + }) + .await + .map_err(|e| e.to_string())? +} + #[cfg(test)] mod tests { use super::*; + + #[test] + fn attachment_thumbnail_is_bounded_and_preserves_aspect_ratio() { + let source = image::RgbaImage::from_pixel(200, 100, image::Rgba([24, 120, 176, 255])); + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(source) + .write_to(&mut encoded, image::ImageFormat::Png) + .unwrap(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let (pixels, width, height) = runtime + .block_on(image_thumbnail(encoded.into_inner())) + .unwrap(); + assert_eq!((width, height), (96, 48)); + assert_eq!(pixels.len(), (width * height * 4) as usize); + } + #[test] fn cancelling_a_preview_does_not_admit_another_decoder_until_the_worker_exits() { let runtime = tokio::runtime::Builder::new_multi_thread() diff --git a/src/mail_render_projection.rs b/src/mail_render_projection.rs index b2c95826..790a6cb0 100644 --- a/src/mail_render_projection.rs +++ b/src/mail_render_projection.rs @@ -185,7 +185,6 @@ pub(super) fn apply_email( reader.set_body_pending(email.body_pending); reader.set_authored_text(email.text.clone().unwrap_or_default().into()); let same_message = reader.get_message_id() == email.id; - crate::attachment_controller::project(app, &email, same_message); let mut scroll = if same_message { app.get_email_scroll_y() } else { @@ -202,6 +201,7 @@ pub(super) fn apply_email( reader.set_notice("".into()); reader.set_destination("".into()); } + crate::attachment_controller::project(app, &email, same_message); app.set_email_scroll_y(scroll); let preview = display_preview(&email.preview); diff --git a/src/reader_validation.rs b/src/reader_validation.rs index a9439aa1..22cfcc6e 100644 --- a/src/reader_validation.rs +++ b/src/reader_validation.rs @@ -312,15 +312,24 @@ fn automatic_selection_hydrates_and_reader_controls_are_responsive_and_keyboard_ assert!(app.get_has_selection()); // Hydrated attachments must survive a body-rendering failure and remain // projected independently of the HTML/plain-text reader. - state.borrow_mut().messages[0].attachments = vec![flectar_mail_core::models::AttachmentMeta { - id: 42, - filename: Some("Project overview.pdf".into()), - mime_type: Some("application/pdf".into()), - size: Some(1024), - is_inline: false, - }]; + state.borrow_mut().messages[0].attachments = vec![ + flectar_mail_core::models::AttachmentMeta { + id: 42, + filename: Some("Project overview.pdf".into()), + mime_type: Some("application/pdf".into()), + size: Some(1024), + is_inline: false, + }, + flectar_mail_core::models::AttachmentMeta { + id: 43, + filename: Some("email-logo.png".into()), + mime_type: Some("image/png".into()), + size: Some(512), + is_inline: false, + }, + ]; render_current(&app, &state, &runtime).unwrap(); - assert_eq!(app.global::().get_rows().row_count(), 1); + assert_eq!(app.global::().get_rows().row_count(), 2); // A structural rejection while the reader is open must never leave the // previous message's accessible content visible over the fallback. state.borrow_mut().messages[0].html = Some("x".repeat(16_000)); @@ -335,7 +344,16 @@ fn automatic_selection_hydrates_and_reader_controls_are_responsive_and_keyboard_ !app.get_selected_plain_text() .contains("Reader verification") ); - assert_eq!(app.global::().get_rows().row_count(), 1); + assert_eq!(app.global::().get_rows().row_count(), 2); + let attachment_rows = app.global::().get_rows(); + let mut image_attachment = attachment_rows.row_data(1).unwrap(); + let mut thumbnail = slint::SharedPixelBuffer::::new(96, 64); + for (index, pixel) in thumbnail.make_mut_slice().iter_mut().enumerate() { + *pixel = slint::Rgba8Pixel::new(24, 120 + (index % 96) as u8, 176, 255); + } + image_attachment.thumbnail = slint::Image::from_rgba8(thumbnail); + image_attachment.has_thumbnail = true; + attachment_rows.set_row_data(1, image_attachment); app.set_theme_mode("light".into()); draw("mail-attachments-fallback", 1280, 900); draw("mail-attachments-landscape", 844, 390); @@ -343,6 +361,10 @@ fn automatic_selection_hydrates_and_reader_controls_are_responsive_and_keyboard_ app.get_email_viewport_height() >= 40.0, "Short windows must retain room for the body while attachments remain available in the toolbar" ); + app.set_text_mode(false); + app.set_email_content_aspect(8.0); + app.global::().invoke_command("end".into()); + draw("mail-long-message-actions", 1280, 900); // Exercise the same percentage editor in the attachment dialog. A wide // bitmap verifies fit on resize and manual zoom preservation without a diff --git a/src/renderer_input_controller.rs b/src/renderer_input_controller.rs index 5bdc5c8f..bf91db1e 100644 --- a/src/renderer_input_controller.rs +++ b/src/renderer_input_controller.rs @@ -299,6 +299,7 @@ pub(super) fn register_renderer_input_callbacks( * app.get_email_viewport_width() * reader.get_width_ratio() + banner_height(&app) + + app.get_email_scroll_tail_height() - height) .max(0.0); let y = match command { diff --git a/ui/app.slint b/ui/app.slint index 7f7e2be9..b7fd2111 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -707,6 +707,7 @@ export component AppWindow inherits Window { // callback and reflows Blitz at the current logical viewport size. in-out property email-viewport-width: 0px; in-out property email-viewport-height: 0px; + in-out property email-scroll-tail-height: 0px; in-out property email-scroll-y: 0px; in-out property settings_open: false; changed settings_open => { root.product-changed(root.active_view); } @@ -1044,6 +1045,7 @@ export component AppWindow inherits Window { MailView { changed email-viewport-width => { root.email-viewport-width = self.email-viewport-width; } changed email-viewport-height => { root.email-viewport-height = self.email-viewport-height; } + changed email-scroll-tail-height => { root.email-scroll-tail-height = self.email-scroll-tail-height; } compact-page <=> root.mail-compact-page; folders-open <=> root.mail-folders-open; loading: !root.startup_hydrated || root.mail_page_loading; diff --git a/ui/models.slint b/ui/models.slint index 951d1911..55d8393c 100644 --- a/ui/models.slint +++ b/ui/models.slint @@ -360,7 +360,14 @@ export global EmailReader { in-out property use-authored-text: false; } -export struct MailAttachment { id: string, name: string, detail: string, previewable: bool } +export struct MailAttachment { + id: string, + name: string, + detail: string, + previewable: bool, + thumbnail: image, + has_thumbnail: bool, +} export global MailAttachments { callback deliver(); in-out property <[MailAttachment]> rows; diff --git a/ui/views/mail-reading-pane.slint b/ui/views/mail-reading-pane.slint index 134b3930..a5da27ea 100644 --- a/ui/views/mail-reading-pane.slint +++ b/ui/views/mail-reading-pane.slint @@ -3,7 +3,6 @@ import { ActionMenu, ActionMenuEntry } from "../components/action-menu.slint"; import { ZoomControls } from "../components/zoom-controls.slint"; import { Button as ReaderButton, CheckBox, HorizontalBox, ListView, ScrollView, VerticalBox } from "std-widgets.slint"; import { MailAttachments, EmailReader, EmailLink, EmailTile, MailLabelRow, ThreadMessageRow } from "../models.slint"; -import { SidebarItem } from "../components/sidebar-controls.slint"; import { Avatar } from "../components/avatar.slint"; import { Button } from "../components/button.slint"; import { IconButton, LineEdit, TextEdit, ToolbarIconButton } from "../components/controls.slint"; @@ -407,6 +406,7 @@ export component MailReadingPane inherits Rectangle { : root.rendered-email-height; private property attachment-flow-y: root.message-body-content-height; private property message-actions-height: root.compact ? 52px : 42px; + private property message-bottom-padding: root.compact ? 10px : 12px; private property thread-header-height: 64px; private property thread-item-stride: root.thread-header-height + root.thread-item-gap; private property accordion-message-count: max(1, root.thread-messages.length); @@ -420,7 +420,12 @@ export component MailReadingPane inherits Rectangle { private property message-actions-y: root.message-body-y + root.message-document-height; private property expanded-message-height: root.renderer-controls-height + root.message-document-height - + root.message-actions-height; + + root.message-actions-height + + root.message-bottom-padding; + out property email-scroll-tail-height: max( + 0px, + root.message-document-height - root.rendered-email-height + ) + root.message-actions-height + root.message-bottom-padding; animate selected-header-y { duration: 240ms; easing: spring(0); } in property accent; in property accent-soft; @@ -1471,19 +1476,82 @@ export component MailReadingPane inherits Rectangle { } reject } - SidebarItem { + Rectangle { width: parent.width; - row-height: 44px; - left-padding: 8px; - label: attachment.name; - selected: attachment-focus.has-focus; - icon: @image-url("../icons/lucide/outline/paperclip.svg"); - sidebar-background: root.surface; - selection-background: root.accent-soft; - accent-color: root.accent; - body-color: root.heading; - muted-color: root.secondary-text; - clicked(scope) => { attachment-focus.focus(); MailAttachments.command(attachment.previewable ? "preview" : "download", attachment.id); } + height: 44px; + border-radius: 7px; + background: attachment-hit.has-hover || attachment-focus.has-focus + ? root.accent-soft + : transparent; + + Rectangle { + x: 4px; + y: 4px; + width: 36px; + height: 36px; + border-radius: 6px; + border-width: 1px; + border-color: root.border-subtle; + background: root.control-bg; + clip: true; + + if attachment.has_thumbnail : Image { + width: parent.width; + height: parent.height; + source: attachment.thumbnail; + image-fit: cover; + accessible-role: none; + } + if !attachment.has_thumbnail : Image { + x: 9px; + y: 9px; + width: 18px; + height: 18px; + source: @image-url("../icons/lucide/outline/file.svg"); + colorize: root.secondary-text; + image-fit: contain; + accessible-role: none; + } + } + + Text { + x: 48px; + y: attachment.detail == "" ? 0px : 3px; + width: max(0px, parent.width - self.x - 6px); + height: attachment.detail == "" ? parent.height : 22px; + text: attachment.name; + color: root.heading; + font-size: TypeScale.body; + font-weight: attachment-focus.has-focus ? 600 : 500; + overflow: elide; + vertical-alignment: center; + } + if attachment.detail != "" : Text { + x: 48px; + y: 23px; + width: max(0px, parent.width - self.x - 6px); + height: 17px; + text: attachment.detail; + color: root.secondary-text; + font-size: TypeScale.caption; + overflow: elide; + vertical-alignment: center; + } + + attachment-hit := TouchArea { + accessible-role: button; + accessible-label: attachment.name; + accessible-description: attachment.detail; + accessible-action-default => { + attachment-focus.focus(); + MailAttachments.command(attachment.previewable ? "preview" : "download", attachment.id); + } + clicked => { + attachment-focus.focus(); + MailAttachments.command(attachment.previewable ? "preview" : "download", attachment.id); + } + mouse-cursor: pointer; + } } } ToolbarIconButton { @@ -1496,16 +1564,15 @@ export component MailReadingPane inherits Rectangle { } } - } - if EmailReader.reader-mode : Rectangle { - x: -email-scroll.content-x + root.message-card-inset + root.message-card-border-width; - y: root.message-body-y; - width: root.email-viewport-width; height: root.message-body-content-height; - background: EmailReader.dark-reader ? #171a21 : root.surface; - reader-list := ListView { - width: parent.width; height: parent.height; - content-width: self.visible-width; - for item in EmailReader.items : VerticalBox { + if EmailReader.reader-mode : Rectangle { + x: -email-scroll.content-x + root.message-card-inset + root.message-card-border-width; + y: root.message-body-y; + width: root.email-viewport-width; height: root.message-body-content-height; + background: EmailReader.dark-reader ? #171a21 : root.surface; + reader-list := ListView { + width: parent.width; height: parent.height; + content-width: self.visible-width; + for item in EmailReader.items : VerticalBox { width: reader-list.visible-width; padding: 16px; spacing: 0px; if item.url != "" : ReaderButton { @@ -1520,84 +1587,83 @@ export component MailReadingPane inherits Rectangle { accessible-role: text; accessible-label: item.kind == "heading" ? @tr("Heading: {}", item.name) : item.name; } + } } } - } - // Conversation actions belong to the expanded - // message and follow its real content height. This - // keeps short notifications compact while long - // messages reveal the actions at their natural end. - message-actions := Rectangle { - x: -email-scroll.content-x + root.message-card-inset + root.message-card-border-width; - y: root.message-actions-y; - width: root.email-viewport-width; - height: root.message-actions-height; - background: root.surface; + // These actions are the natural end of the selected + // message, after its body and attachments. The + // ScrollView content height above includes this row + // and a small reveal margin beneath it. + message-actions := Rectangle { + x: -email-scroll.content-x + root.message-card-inset + root.message-card-border-width; + y: root.message-actions-y; + width: root.email-viewport-width; + height: root.message-actions-height; + background: root.surface; - Rectangle { - x: 0px; - y: 0px; - width: parent.width; - height: 1px; - background: root.border-subtle; - } - - HorizontalBox { - padding-top: root.compact ? 4px : 5px; - padding-right: 10px; - padding-bottom: root.compact ? 4px : 5px; - padding-left: 10px; - spacing: 4px; - alignment: start; - cross-axis-alignment: center; - - if !root.selected_is_draft : ReaderControl { - compact: root.compact; - interactive: root.has_selected; - icon: @image-url("../icons/lucide/outline/reply.svg"); - tooltip-text: @tr("Reply"); - accessible-id: "message-reply-button"; - clicked => { root.compose_message_action("reply"); } - } - if !root.selected_is_draft : ReaderControl { - compact: root.compact; - interactive: root.has_selected; - icon: @image-url("../icons/lucide/outline/reply-all.svg"); - tooltip-text: @tr("Reply all"); - accessible-id: "message-reply-all-button"; - clicked => { root.compose_message_action("reply_all"); } - } - if !root.selected_is_draft : ReaderControl { - compact: root.compact; - interactive: root.has_selected; - icon: @image-url("../icons/lucide/outline/forward.svg"); - tooltip-text: @tr("Forward"); - accessible-id: "message-forward-button"; - clicked => { root.compose_message_action("forward"); } - } - if root.selected_is_draft : ReaderControl { - compact: root.compact; - interactive: root.has_selected; - icon: @image-url("../icons/lucide/filled/compose.svg"); - tooltip-text: @tr("Continue editing"); - clicked => { root.compose_message_action("edit_draft"); } + Rectangle { + width: parent.width; + height: 1px; + background: root.border-subtle; } - Rectangle { horizontal-stretch: 1; } + HorizontalBox { + padding-top: root.compact ? 4px : 5px; + padding-right: 10px; + padding-bottom: root.compact ? 4px : 5px; + padding-left: 10px; + spacing: 4px; + alignment: start; + cross-axis-alignment: center; - if !root.compact && !root.selected_is_draft : ReaderControl { - interactive: root.has_selected; - icon: @image-url("../icons/lucide/outline/browser.svg"); - tooltip-text: @tr("Open in browser"); - accessible-id: "message-open-in-browser-button"; - clicked => { root.open_message_in_browser(); } - } - if !root.compact && !root.selected_is_draft : ReaderControl { - interactive: root.has_selected; - icon: @image-url("../icons/lucide/outline/info.svg"); - tooltip-text: @tr("About email rendering"); - clicked => { root.rendering_info_open = true; } + if !root.selected_is_draft : ReaderControl { + compact: root.compact; + interactive: root.has_selected; + icon: @image-url("../icons/lucide/outline/reply.svg"); + tooltip-text: @tr("Reply"); + accessible-id: "message-reply-button"; + clicked => { root.compose_message_action("reply"); } + } + if !root.selected_is_draft : ReaderControl { + compact: root.compact; + interactive: root.has_selected; + icon: @image-url("../icons/lucide/outline/reply-all.svg"); + tooltip-text: @tr("Reply all"); + accessible-id: "message-reply-all-button"; + clicked => { root.compose_message_action("reply_all"); } + } + if !root.selected_is_draft : ReaderControl { + compact: root.compact; + interactive: root.has_selected; + icon: @image-url("../icons/lucide/outline/forward.svg"); + tooltip-text: @tr("Forward"); + accessible-id: "message-forward-button"; + clicked => { root.compose_message_action("forward"); } + } + if root.selected_is_draft : ReaderControl { + compact: root.compact; + interactive: root.has_selected; + icon: @image-url("../icons/lucide/filled/compose.svg"); + tooltip-text: @tr("Continue editing"); + clicked => { root.compose_message_action("edit_draft"); } + } + + Rectangle { horizontal-stretch: 1; } + + if !root.compact && !root.selected_is_draft : ReaderControl { + interactive: root.has_selected; + icon: @image-url("../icons/lucide/outline/browser.svg"); + tooltip-text: @tr("Open in browser"); + accessible-id: "message-open-in-browser-button"; + clicked => { root.open_message_in_browser(); } + } + if !root.compact && !root.selected_is_draft : ReaderControl { + interactive: root.has_selected; + icon: @image-url("../icons/lucide/outline/info.svg"); + tooltip-text: @tr("About email rendering"); + clicked => { root.rendering_info_open = true; } + } } } } diff --git a/ui/views/mail-view.slint b/ui/views/mail-view.slint index 427ff73b..3d780e00 100644 --- a/ui/views/mail-view.slint +++ b/ui/views/mail-view.slint @@ -81,6 +81,7 @@ export component MailView inherits Rectangle { : min(96px, root.single-pane-content-width); out property email-viewport-width: root.single-pane ? compact-reading-pane.email-viewport-width : desktop-reading-pane.email-viewport-width; out property email-viewport-height: root.single-pane ? compact-reading-pane.email-viewport-height : desktop-reading-pane.email-viewport-height; + out property email-scroll-tail-height: root.single-pane ? compact-reading-pane.email-scroll-tail-height : desktop-reading-pane.email-scroll-tail-height; in property accent; in property accent-soft; in property avatar-bg;