import { AccountMailPreferences } from "account-mail-preferences.slint"; import { SettingsSelectRow } from "settings-components.slint"; import { ScrollView, VerticalBox } from "std-widgets.slint"; import { AccountRow, ComposeAttachment, ComposeContact, ComposeEditorTile, EmailTemplateRow } from "../models.slint"; import { ComposerFromRow, ComposerHeader, ComposerRecipients, ComposerSubject } from "composer-fields.slint"; import { ComposerAttachments, ComposerFooter, ComposerNotice, ComposerToolbar, DiscardConfirmation, LinkEditor } from "composer-actions.slint"; import { EmailTemplates } from "email-templates.slint"; import { TypeScale } from "../typography.slint"; import { IconButton } from "controls.slint"; // cosmic-text owns visible layout, selection, hit-testing, and caret geometry. // The transparent TextInput only preserves Slint's native IME and clipboard // integration; it never supplies coordinates for anything the user can see. export component EmailComposer inherits Rectangle { in property compact: false; // In its own window: no frame of its own, and the window's title bar // replaces the heading and its close button. in property windowed: false; in property attachment-picker-supported: true; in property open: false; in property mode: "new"; in-out property to; in-out property cc; in-out property bcc; in-out property subject; in-out property body; in property sending: false; in property <[AccountRow]> accounts: []; in-out property selected-account-id: -1; in property <[ComposeContact]> contact-suggestions: []; in property <[ComposeAttachment]> attachments: []; in property <[EmailTemplateRow]> templates: []; in property attachment-summary: ""; in-out property from-label: @tr("No connected account"); in property notice: ""; in property notice-is-error: false; in property selection-start: 0; in property selection-end: 0; in property <[ComposeEditorTile]> editor-tiles: []; in property editor-content-height: 18; in property editor-caret-x: 0; in property editor-caret-y: 0; in property editor-caret-height: 18; in property bold-active: false; in property italic-active: false; in property underline-active: false; in property strike-active: false; in property code-active: false; in property active-link-url: ""; in property can-pop-out: false; // "reply" or "forward" when the original goes below the editor. in property quote: ""; in property quote-text: ""; in-out property quote-expanded: false; in property <[ComposeEditorTile]> quote-tiles: []; in property quote-height: 0; in property background-color; in property raised-color; in property control-color; in property control-hover-color; in property frame-border-color; in property subtle-border-color; in property title-color; in property body-color; in property secondary-color; in property muted-color; in property accent-color; in property accent-background; out property editor-width: max(1px, body-scroll.visible-width - 36px); out property editor-viewport-height: max(1px, body-scroll.visible-height - 28px); out property editor-scroll-y: max(0px, -body-scroll.content-y); out property editor-preedit-text: body-input.preedit-text; // Where the original starts below the editor, for the Rust layout. out property quote-top: 14px + root.editor-area-height + 48px; // A message carrying the original keeps a few lines for the reply // above it rather than the whole viewport. private property editor-area-height: root.quote == "" ? max(root.editor-viewport-height, root.editor-content-height * 1px) : max(96px, root.editor-content-height * 1px + 8px); callback close; callback pop-out; callback back-requested; callback submit(bool); callback pick-attachments; callback remove-attachment(int); callback format-requested(string, string, int, int); callback link-requested(string, string, int, int); callback body-edited(string, int, int, string); callback selection-changed(string, int, int); callback history-requested(string); callback editor-pointer-event(float, float, string, bool, bool, bool, bool); callback editor-key-event(string, bool, bool, bool, bool) -> bool; callback editor-layout-changed; callback search-contacts(string, int); callback select-contact(int, string) -> string; callback refresh-templates; callback use-template(int) -> bool; callback save-template(int, string, string, string, string) -> bool; callback delete-template(int) -> bool; callback edit-quote(); callback remove-quote(); private property confirm-discard: false; private property link-editor-open: false; private property templates-open: false; private property link-url: ""; private property link-selection-start: 0; private property link-selection-end: 0; private property caret-visible: true; private property pending-key-text: ""; private property ime-edit-pending: false; private property input-caret-x: 0px; private property input-caret-y: 0px; private property has-content: root.to != "" || root.cc != "" || root.bcc != "" || root.subject != "" || root.body != "" || root.attachments.length > 0 || root.quote != ""; private property can-send: !root.sending && root.selected-account-id > 0 && root.to != "" && (root.body != "" || root.quote != "") && AccountMailPreferences.composer-preferences-ready; function apply-format(kind: string) { if (kind == "link") { if (body-input.anchor-position-byte-offset == body-input.cursor-position_byte_offset) { return; } root.link-selection-start = body-input.anchor-position-byte-offset; root.link-selection-end = body-input.cursor-position_byte_offset; root.link-url = root.active-link-url; root.link-editor-open = true; return; } root.format-requested( kind, root.body, body-input.anchor-position-byte-offset, body-input.cursor-position_byte-offset, ); body-input.set-selection-offsets(root.selection-start, root.selection-end); body-input.focus(); } function request-close() { if (root.sending) { return; } if (root.has-content) { root.confirm-discard = true; } else { root.close(); } } back-requested => { root.request-close(); } function reveal-editor-caret() { if (root.editor-caret-y * 1px + body-scroll.content-y < 8px) { body-scroll.content-y = min( 0px, max( body-scroll.visible-height - body-input.height, 8px - root.editor-caret-y * 1px, ), ); } else if ((root.editor-caret-y + root.editor-caret-height) * 1px + body-scroll.content-y > body-scroll.visible-height - 8px) { body-scroll.content-y = min( 0px, max( body-scroll.visible-height - body-input.height, body-scroll.visible-height - (root.editor-caret-y + root.editor-caret-height) * 1px - 8px, ), ); } } changed open => { if (!root.open) { root.confirm-discard = false; root.templates-open = false; } else { // The typing proxy starts where the visible caret is. body-input.set-selection-offsets(root.selection-start, root.selection-end); root.editor-layout-changed(); // The recipients' row takes focus only while it is empty. if root.to != "" { body-input.focus(); } } } changed editor-width => { root.editor-layout-changed(); } changed editor-viewport-height => { root.editor-layout-changed(); } // `restart` alone only re-arms a timer that has run before, so without // `start` nothing drew the text scrolled into view. changed editor-scroll-y => { editor-scroll-layout-timer.start(); editor-scroll-layout-timer.restart(); } changed quote-expanded => { root.editor-layout-changed(); } changed quote-top => { root.editor-layout-changed(); } changed quote => { root.editor-layout-changed(); } changed editor-preedit-text => { if (root.editor-preedit-text != "") { root.ime-edit-pending = true; root.pending-key-text = ""; } root.editor-layout-changed(); // Re-submit the unchanged selection so Slint refreshes the native // candidate rectangle after cosmic-text advances the preedit caret. body-input.set-selection-offsets( body-input.anchor-position-byte-offset, body-input.cursor-position_byte-offset, ); } changed body-color => { root.editor-layout-changed(); } changed accent-color => { root.editor-layout-changed(); } changed editor-caret-y => { root.reveal-editor-caret(); } changed editor-caret-height => { root.reveal-editor-caret(); } editor-scroll-layout-timer := Timer { interval: 1ms; running: false; triggered => { self.stop(); root.editor-layout-changed(); } } caret-timer := Timer { interval: 520ms; running: root.open && body-input.has-focus; triggered => { root.caret-visible = !root.caret-visible; } } private property framed: !root.compact && !root.windowed; background: root.background-color; border-radius: root.framed ? 12px : 0px; border-width: root.framed ? 1px : 0px; border-color: root.frame-border-color; drop-shadow-blur: root.framed ? 24px : 0px; drop-shadow-offset-y: root.framed ? 6px : 0px; drop-shadow-color: root.framed ? #00000035 : transparent; clip: true; VerticalBox { spacing: 0px; if !root.windowed : ComposerHeader { compact: root.compact; mode: root.mode; can-pop-out: root.can-pop-out && !root.sending; pop-out => { root.pop-out(); } control-hover-color: root.control-hover-color; muted-color: root.muted-color; raised-color: root.raised-color; secondary-color: root.secondary-color; subtle-border-color: root.subtle-border-color; title-color: root.title-color; request-close => { root.request-close(); } } ComposerFromRow { accounts: root.accounts; selected-account-id <=> root.selected-account-id; from-label <=> root.from-label; background-color: root.background-color; frame-border-color: root.subtle-border-color; hover-color: root.control-hover-color; muted-color: root.muted-color; secondary-color: root.secondary-color; selected-background: root.accent-background; title-color: root.title-color; changed(account-id) => { AccountMailPreferences.composer-sender-email = ""; root.search-contacts(root.to, account-id); AccountMailPreferences.composer-account-changed(account-id); } } SettingsSelectRow { compact: root.compact; title: @tr("From"); description: @tr("Verified sender identity"); choices: AccountMailPreferences.composer-sender-names; selected-index: AccountMailPreferences.composer-sender-index; title-color: root.title-color; description-color: root.secondary-color; selected(index, value) => { if AccountMailPreferences.composer-preferences-ready { AccountMailPreferences.composer-sender(index); } } } SettingsSelectRow { compact: root.compact; title: @tr("Signature"); description: AccountMailPreferences.composer-security; choices: AccountMailPreferences.composer-signature-names; selected-index: AccountMailPreferences.composer-signature-index; title-color: root.title-color; description-color: root.secondary-color; selected(index, value) => { if AccountMailPreferences.composer-preferences-ready { AccountMailPreferences.composer-signature(index); } } } recipients := ComposerRecipients { open: root.open; to <=> root.to; cc <=> root.cc; bcc <=> root.bcc; contacts: root.contact-suggestions; accent-background: root.accent-background; accent-color: root.accent-color; background-color: root.background-color; body-color: root.body-color; control-hover-color: root.control-hover-color; muted-color: root.muted-color; raised-color: root.raised-color; subtle-border-color: root.subtle-border-color; title-color: root.title-color; search-contacts(value) => { root.search-contacts(value, root.selected-account-id); } contact-selected(index) => { root.to = root.select-contact(index, root.to); } } Rectangle { height: 1px; background: root.subtle-border-color; } ComposerSubject { subject <=> root.subject; accent-color: root.accent-color; background-color: root.background-color; muted-color: root.muted-color; title-color: root.title-color; } ComposerToolbar { compact: root.compact; accent-background: root.accent-background; accent-color: root.accent-color; control-hover-color: root.control-hover-color; muted-color: root.muted-color; raised-color: root.raised-color; secondary-color: root.secondary-color; subtle-border-color: root.subtle-border-color; bold-active: root.bold-active; italic-active: root.italic-active; underline-active: root.underline-active; strike-active: root.strike-active; code-active: root.code-active; link-active: root.active-link-url != ""; can-link: root.selection-start != root.selection-end; apply-format(kind) => { root.apply-format(kind); } open-templates => { root.templates-open = true; } undo => { root.history-requested("undo"); body-input.set-selection-offsets(root.selection-start, root.selection-end); body-input.focus(); } redo => { root.history-requested("redo"); body-input.set-selection-offsets(root.selection-start, root.selection-end); body-input.focus(); } } editor-surface := Rectangle { vertical-stretch: 1; min-height: 110px; background: root.background-color; clip: true; body-scroll := ScrollView { x: 0px; y: 0px; width: parent.width; height: parent.height; content-width: self.visible-width; content-height: root.quote == "" ? max(self.visible-height, root.editor-content-height * 1px + 32px) : max(self.visible-height, root.quote-top + (root.quote-expanded ? root.quote-height * 1px + 16px : 0px)); horizontal-scrollbar-policy: always-off; mouse-drag-pan-enabled: root.compact; Text { x: 18px; y: 16px; width: body-scroll.visible-width - 36px; visible: root.body == "" && root.editor-preedit-text == ""; text: @tr("Write your message…"); color: root.muted-color; font-size: TypeScale.body-large; wrap: word-wrap; } for tile in root.editor-tiles : Image { x: 18px; y: 14px + tile.y * 1px; width: root.editor-width; height: tile.height * 1px; source: tile.image; image-fit: fill; image-rendering: smooth; // The transparent TextInput below exposes the editable // body; these raster tiles are its visual presentation. accessible-role: none; } body-input := TextInput { // Keep Slint's native IME candidate rectangle aligned with // the caret from cosmic-text, despite the proxy's plain layout. x: 18px + root.editor-caret-x * 1px - root.input-caret-x; y: 14px + root.editor-caret-y * 1px - root.input-caret-y; width: root.editor-width; height: root.editor-area-height; text <=> root.body; color: transparent; selection-background-color: transparent; selection-foreground-color: transparent; font-size: TypeScale.body-large; text-cursor-width: 0px; single-line: false; enabled: !root.sending; wrap: word-wrap; page-height: body-scroll.visible-height; edited => { root.body = self.text; root.body-edited( self.text, self.anchor-position-byte-offset, self.cursor-position_byte_offset, root.ime-edit-pending ? "" : root.pending-key-text, ); root.pending-key-text = ""; root.ime-edit-pending = false; self.set-selection-offsets(root.selection-start, root.selection-end); } key-pressed(event) => { root.pending-key-text = ""; if ((event.modifiers.control || event.modifiers.meta) && event.text.to-lowercase() == "b") { root.apply-format("bold"); return accept; } if ((event.modifiers.control || event.modifiers.meta) && event.text.to-lowercase() == "i") { root.apply-format("italic"); return accept; } if ((event.modifiers.control || event.modifiers.meta) && event.text.to-lowercase() == "u") { root.apply-format("underline"); return accept; } if ((event.modifiers.control || event.modifiers.meta) && event.modifiers.shift && event.text.to-lowercase() == "x") { root.apply-format("strike"); return accept; } if ((event.modifiers.control || event.modifiers.meta) && event.text.to-lowercase() == "z") { root.history-requested(event.modifiers.shift ? "redo" : "undo"); self.set-selection-offsets(root.selection-start, root.selection-end); return accept; } if ((event.modifiers.control || event.modifiers.meta) && event.text == Key.Return && root.can-send) { root.submit(true); return accept; } if (root.editor-key-event( event.text, event.modifiers.control, event.modifiers.shift, event.modifiers.alt, event.modifiers.meta, )) { self.set-selection-offsets(root.selection-start, root.selection-end); return accept; } if (!event.modifiers.control && !event.modifiers.alt && !event.modifiers.meta && root.editor-preedit-text == "" && !root.ime-edit-pending) { root.pending-key-text = event.text; } reject } cursor-position-changed(position) => { root.input-caret-x = position.x; root.input-caret-y = position.y; root.caret-visible = true; // Only a focused proxy moves the caret for the user. // Unfocused it follows the document, and a new window's // would report the start as where the user is typing. if (self.has-focus) { root.selection-changed( self.text, self.anchor-position-byte-offset, self.cursor-position_byte_offset, ); } root.reveal-editor-caret(); } } editor-hit := TouchArea { x: 18px; y: 14px; width: root.editor-width; height: root.editor-area-height; mouse-cursor: text; pointer-event(event) => { if (event.button == PointerEventButton.left || event.kind == PointerEventKind.move || event.kind == PointerEventKind.cancel) { if (event.button == PointerEventButton.left && event.kind == PointerEventKind.down) { body-input.focus(); } root.editor-pointer-event( editor-hit.mouse-x / 1px, editor-hit.mouse-y / 1px, event.kind == PointerEventKind.down ? "down" : event.kind == PointerEventKind.move ? "move" : event.kind == PointerEventKind.up ? "up" : "cancel", event.modifiers.control, event.modifiers.shift, event.modifiers.alt, event.modifiers.meta, ); body-input.set-selection-offsets(root.selection-start, root.selection-end); } } } if root.quote != "" : Rectangle { x: 18px; y: root.quote-top - 40px; width: root.editor-width; height: 32px; quote-toggle := TouchArea { width: parent.width - 80px; mouse-cursor: pointer; clicked => { root.quote-expanded = !root.quote-expanded; } } Image { x: 0px; y: (parent.height - 16px) / 2; width: 16px; height: 16px; source: root.quote-expanded ? @image-url("../icons/lucide/outline/chevron-down.svg") : @image-url("../icons/lucide/outline/chevron-right.svg"); colorize: root.secondary-color; } Text { x: 22px; width: parent.width - 102px; height: parent.height; text: root.quote == "forward" ? @tr("Forwarded message") : @tr("Quoted text"); color: quote-toggle.has-hover ? root.body-color : root.secondary-color; font-size: TypeScale.body; vertical-alignment: center; overflow: elide; accessible-role: button; accessible-label: root.quote-expanded ? @tr("Hide the quoted message") : @tr("Show the quoted message"); accessible-action-default => { root.quote-expanded = !root.quote-expanded; } } IconButton { x: parent.width - 72px; width: 32px; height: 32px; icon: @image-url("../icons/lucide/outline/pencil.svg"); icon-color: root.secondary-color; hover-background: root.control-hover-color; tooltip-text: @tr("Edit the quoted message as text"); clicked => { root.edit-quote(); } } IconButton { x: parent.width - 36px; width: 32px; height: 32px; icon: @image-url("../icons/lucide/outline/x.svg"); icon-color: root.secondary-color; hover-background: root.control-hover-color; tooltip-text: root.quote == "forward" ? @tr("Remove the forwarded message") : @tr("Remove the quoted message"); clicked => { root.remove-quote(); } } } for tile in root.quote-tiles : Image { x: 18px; y: root.quote-top + tile.y * 1px; width: root.editor-width; height: tile.height * 1px; source: tile.image; image-fit: fill; image-rendering: smooth; visible: root.quote-expanded; accessible-role: none; } // The drawn original reads out as its text, as the reader's // message does. if root.quote != "" && root.quote-expanded : Rectangle { x: 18px; y: root.quote-top; width: root.editor-width; height: root.quote-height * 1px; accessible-role: text; accessible-label: root.quote-text; } Rectangle { x: 18px + root.editor-caret-x * 1px; y: 14px + root.editor-caret-y * 1px; width: 1px; height: root.editor-caret-height * 1px; visible: body-input.has-focus && body-input.anchor-position-byte-offset == body-input.cursor-position_byte_offset && root.caret-visible; background: root.body-color; } } } ComposerAttachments { compact: root.compact; attachments: root.attachments; accent-color: root.accent-color; background-color: root.background-color; body-color: root.body-color; control-hover-color: root.control-hover-color; muted-color: root.muted-color; raised-color: root.raised-color; subtle-border-color: root.subtle-border-color; remove-attachment(index) => { root.remove-attachment(index); } } ComposerNotice { notice: root.notice; notice-is-error: root.notice-is-error; accent-background: root.accent-background; accent-color: root.accent-color; } ComposerFooter { compact: root.compact; attachment-picker-supported: root.attachment-picker-supported; attachment-summary: root.attachment-summary; can-send: root.can-send; has-content: root.has-content; sending: root.sending; control-hover-color: root.control-hover-color; muted-color: root.muted-color; raised-color: root.raised-color; secondary-color: root.secondary-color; subtle-border-color: root.subtle-border-color; submit(send-now) => { root.submit(send-now); } pick-attachments => { root.pick-attachments(); } request-close => { root.request-close(); } } } DiscardConfirmation { compact: root.compact; x: 0px; y: 0px; width: parent.width; height: parent.height; open: root.confirm-discard; background-color: root.background-color; frame-border-color: root.frame-border-color; secondary-color: root.secondary-color; title-color: root.title-color; keep => { root.confirm-discard = false; } discard => { root.confirm-discard = false; root.close(); } } LinkEditor { x: 0px; y: 0px; width: parent.width; height: parent.height; compact: root.compact; open: root.link-editor-open; url <=> root.link-url; has-existing-link: root.active-link-url != ""; background-color: root.background-color; body-color: root.body-color; frame-border-color: root.frame-border-color; muted-color: root.muted-color; title-color: root.title-color; accent-color: root.accent-color; cancel => { root.link-editor-open = false; body-input.set-selection-offsets(root.link-selection-start, root.link-selection-end); body-input.focus(); } apply(url) => { root.link-editor-open = false; root.link-requested(url, root.body, root.link-selection-start, root.link-selection-end); body-input.set-selection-offsets(root.selection-start, root.selection-end); body-input.focus(); } remove => { root.link-editor-open = false; root.link-requested("", root.body, root.link-selection-start, root.link-selection-end); body-input.set-selection-offsets(root.selection-start, root.selection-end); body-input.focus(); } } EmailTemplates { x: 0px; y: 0px; width: parent.width; height: parent.height; open <=> root.templates-open; compact: root.compact; templates: root.templates; draft-subject: root.subject; draft-body: root.body; notice: root.notice; notice-is-error: root.notice-is-error; surface-color: root.background-color; raised-color: root.raised-color; ui-border-color: root.frame-border-color; hover-color: root.control-hover-color; title-color: root.title-color; body-color: root.body-color; muted-color: root.muted-color; accent-color: root.accent-color; refresh => { root.refresh-templates(); } use-template(id) => root.use-template(id); save-template(id, name, shortcut, subject, body) => root.save-template(id, name, shortcut, subject, body); delete-template(id) => root.delete-template(id); } // Freeze the exact draft snapshot being submitted. If delivery fails the // overlay disappears and the same fields become editable again. if root.sending : TouchArea { x: 0px; y: 0px; width: parent.width; height: parent.height; mouse-cursor: wait; } }