diff options
| author | saturneric <[email protected]> | 2026-09-29 19:51:52 +0200 |
|---|---|---|
| committer | saturneric <[email protected]> | 2026-09-29 19:51:52 +0200 |
| commit | 391168fb8e5f353a318026278c890981c440ad5d (patch) | |
| tree | 62edd8da294dceefd1f205990f83e70e309329db /src | |
| parent | style(i18n): normalize instant messaging catalogs (diff) | |
| download | modules-391168fb8e5f353a318026278c890981c440ad5d.tar.gz modules-391168fb8e5f353a318026278c890981c440ad5d.zip | |
feat(email): redesign action and attachment bars
- centralize message commands as reusable actions with shared shortcuts
- add compact attachment summaries, menus, and expandable lists
- move secondary commands into More and show read-only status separately
- centralize action, attachment, security, and surface layout decisions
- enable compact Status Panel behavior for the email editor
Diffstat (limited to 'src')
| -rw-r--r-- | src/m_email/EMailPageView.cpp | 731 | ||||
| -rw-r--r-- | src/m_email/EMailPageView.h | 58 | ||||
| -rw-r--r-- | src/m_email/EMailViewLayout.cpp | 124 | ||||
| -rw-r--r-- | src/m_email/EMailViewLayout.h | 135 | ||||
| -rw-r--r-- | src/m_email/test/EMailViewLayoutTest.cpp | 228 | ||||
| -rw-r--r-- | src/m_email/ui/main.lua | 8 |
6 files changed, 961 insertions, 323 deletions
diff --git a/src/m_email/EMailPageView.cpp b/src/m_email/EMailPageView.cpp index 8351b01..4c7e8f7 100644 --- a/src/m_email/EMailPageView.cpp +++ b/src/m_email/EMailPageView.cpp @@ -60,6 +60,7 @@ #include <QMenu> #include <QMessageBox> #include <QMimeData> +#include <QPainter> #include <QPlainTextEdit> #include <QPushButton> #include <QSaveFile> @@ -111,6 +112,35 @@ constexpr int kPersistDelayMs = 500; /// below it the list is smaller than its own header plus a row. constexpr int kMinAttachmentHeight = 120; +/// A chevron in the palette's button text colour: the same Expand / Collapse +/// mark as the host's collapsed Status Panel, so the two compact rows read as +/// one pattern. Drawn rather than taken from the style, whose arrows are +/// coloured and differ between platforms. +auto EMailChevronIcon(const QPalette& palette, bool up) -> QIcon { + constexpr int kSize = 16; + const qreal dpr = qApp != nullptr ? qApp->devicePixelRatio() : 1.0; + QPixmap pixmap(QSize(kSize, kSize) * dpr); + pixmap.setDevicePixelRatio(dpr); + pixmap.fill(Qt::transparent); + + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing); + QPen pen(palette.color(QPalette::ButtonText), 1.6); + pen.setCapStyle(Qt::RoundCap); + pen.setJoinStyle(Qt::RoundJoin); + painter.setPen(pen); + + const qreal mid = kSize / 2.0; + const qreal half = 4.0; + const qreal rise = up ? -2.0 : 2.0; + const std::array<QPointF, 3> points = {QPointF(mid - half, mid - rise), + QPointF(mid, mid + rise), + QPointF(mid + half, mid - rise)}; + painter.drawPolyline(points.data(), static_cast<int>(points.size())); + painter.end(); + return QIcon(pixmap); +} + /// Names the completer so it can be found again on the line edit it belongs /// to. Needed because a multi-value completer is not reachable through /// QLineEdit::completer(): it is driven by hand and only parented to the edit. @@ -475,7 +505,6 @@ void EMailPageView::apply_colors() { } if (envelope_rule_ != nullptr) EMailPaintRule(envelope_rule_); - if (action_separator_ != nullptr) EMailPaintRule(action_separator_); if (locked_banner_ != nullptr) { EMailTintBanner(locked_banner_, MutedColor(this), kLockedTint); @@ -489,6 +518,7 @@ void EMailPageView::apply_colors() { if (attachment_heading_ != nullptr) { EMailSetLabelColor(attachment_heading_, MutedColor(this)); } + if (attachment_toggle_ != nullptr) apply_attachment_bar(); if (unsigned_notice_ != nullptr) { EMailSetLabelColor(unsigned_notice_, ThemeColor(this, GF_UI_COLOR_WARNING)); } @@ -685,110 +715,160 @@ auto EMailPageView::build_message_surface() -> QWidget* { body_row->addWidget(view_switcher_); body_row->addSpacing(4); - // Message-level actions. Each produces a NEW document in a new tab; none of - // them writes to this one. + // Every command is a QAction owned by the view and registered on it, so its + // shortcut works wherever the command is currently shown -- on the row, or + // in a menu. Buttons and menus show the same actions; nothing is a copy. // - // Icons come from the desktop theme where there is one, because these three + // Icons come from the desktop theme where there is one, because these // actions have long-settled, instantly recognisable icons that no bundled // substitute would improve on. The bundled files are the fallback for the // platforms that have no icon theme at all. - const auto make_action = - [this, body_row](const QString& theme_icon, const QString& fallback_icon, - const QString& text, const QString& tip, - const QKeySequence& shortcut) { - auto* button = new QToolButton(this); - button->setIcon(QIcon::fromTheme(theme_icon, QIcon(fallback_icon))); - button->setText(text); - button->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); - button->setAutoRaise(true); - button->setVisible(false); - - // The key and the sentence advertising it are set together, so they - // cannot drift apart. A shortcut nothing mentions is a shortcut only - // the person who wrote it knows about. - button->setShortcut(shortcut); - button->setToolTip( - shortcut.isEmpty() - ? tip - : tr("%1 (%2)").arg( - tip, shortcut.toString(QKeySequence::NativeText))); - - body_row->addWidget(button); - return button; - }; + const auto make_command = [this](const QString& theme_icon, + const QString& fallback_icon, + const QString& text, const QString& tip, + const QKeySequence& shortcut) { + auto* action = new QAction( + QIcon::fromTheme(theme_icon, QIcon(fallback_icon)), text, this); + // The key and the sentence advertising it are set together, so they + // cannot drift apart. A shortcut nothing mentions is a shortcut only the + // person who wrote it knows about. + action->setShortcut(shortcut); + action->setShortcutContext(Qt::WidgetWithChildrenShortcut); + action->setToolTip( + shortcut.isEmpty() + ? tip + : tr("%1 (%2)").arg(tip, + shortcut.toString(QKeySequence::NativeText))); + addAction(action); + return action; + }; + const auto make_button = [this, body_row](QAction* action) { + auto* button = new QToolButton(this); + button->setDefaultAction(action); + button->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + button->setAutoRaise(true); + button->setVisible(false); + body_row->addWidget(button); + return button; + }; - reply_button_ = make_action( + // Message-level actions. Each produces a NEW document in a new tab; none of + // them writes to this one. + reply_act_ = make_command( QStringLiteral("mail-reply-sender"), ":/icons/reply.png", tr("Reply"), tr("Write a reply to the sender."), QKeySequence(Qt::CTRL | Qt::Key_R)); - reply_all_button_ = make_action( + reply_all_act_ = make_command( QStringLiteral("mail-reply-all"), ":/icons/reply-all.png", tr("Reply All"), tr("Write a reply to the sender and everyone else who was addressed. " "Blind recipients are not included."), QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_R)); - forward_button_ = make_action( + forward_act_ = make_command( QStringLiteral("mail-forward"), ":/icons/redo.png", tr("Forward"), tr("Pass this message on, with its attachments."), QKeySequence(Qt::CTRL | Qt::Key_L)); + send_act_ = make_command(QStringLiteral("mail-send"), + ":/icons/export-email.png", tr("Send..."), + tr("Send this message through a configured " + "mail account."), + QKeySequence(Qt::CTRL | Qt::Key_Return)); + // The attachment menu's Attach is this same action, so the row and the + // menu cannot disagree about it. + attachment_attach_act_ = make_command( + QStringLiteral("mail-attachment"), ":/icons/attachment.png", + tr("Attach File..."), + tr("Add one or more files to this message. Files can also be dropped " + "onto the message."), + QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_A)); + details_act_ = make_command( + QStringLiteral("dialog-information"), ":/icons/detail.png", + // The ellipsis is doing its usual job: this opens a window rather than + // changing anything here. + tr("Details..."), + tr("Show what is known about this message: its signatures, its " + "structure and its headers."), + QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_D)); + forensic_act_ = make_command( + QStringLiteral("object-locked"), ":/icons/read-only.png", tr("Read-only"), + tr("Lock this message so it cannot be edited or rewritten. Reply and " + "Forward still work and produce new messages."), + QKeySequence()); + forensic_act_->setCheckable(true); - connect(reply_button_, &QToolButton::clicked, this, [this]() { + connect(reply_act_, &QAction::triggered, this, [this]() { slot_derive_message(static_cast<int>(EMailReplyMode::kREPLY)); }); - connect(reply_all_button_, &QToolButton::clicked, this, [this]() { + connect(reply_all_act_, &QAction::triggered, this, [this]() { slot_derive_message(static_cast<int>(EMailReplyMode::kREPLY_ALL)); }); - connect(forward_button_, &QToolButton::clicked, this, [this]() { + connect(forward_act_, &QAction::triggered, this, [this]() { slot_derive_message(static_cast<int>(EMailReplyMode::kFORWARD)); }); - - send_button_ = make_action(QStringLiteral("mail-send"), - ":/icons/export-email.png", tr("Send..."), - tr("Send this message through a configured " - "mail account."), - QKeySequence(Qt::CTRL | Qt::Key_Return)); - connect(send_button_, &QToolButton::clicked, this, + connect(send_act_, &QAction::triggered, this, &EMailPageView::slot_send_message); + connect(details_act_, &QAction::triggered, this, + [this]() { open_details(); }); + connect(forensic_act_, &QAction::toggled, this, + [this](bool on) { SetForensicMode(on); }); - // Locking the document is a different kind of act from deriving a new - // message out of it, so it is set apart rather than lined up with them. - auto* action_separator = EMailRule(this, Qt::Vertical); - action_separator->setVisible(false); - body_row->addSpacing(4); - body_row->addWidget(action_separator); - body_row->addSpacing(4); - action_separator_ = action_separator; + // On the row: what this kind of document is for. A received message is + // answered, a draft is sent; see EMailActionBar(). + reply_button_ = make_button(reply_act_); + reply_all_button_ = make_button(reply_all_act_); + forward_button_ = make_button(forward_act_); + send_button_ = make_button(send_act_); + add_button_ = make_button(attachment_attach_act_); - forensic_toggle_ = make_action( - QStringLiteral("object-locked"), ":/icons/read-only.png", tr("Read-only"), - tr("Lock this message so it cannot be edited or rewritten. Reply and " - "Forward still work and produce new messages."), - QKeySequence()); - forensic_toggle_->setCheckable(true); - connect(forensic_toggle_, &QToolButton::toggled, this, - [this](bool on) { SetForensicMode(on); }); + // The attachment operations, in one place, and only while there are + // attachments to operate on. Built here, filled in with the section below. + attachment_menu_ = new QMenu(this); + attachments_button_ = new QToolButton(this); + attachments_button_->setIcon(QIcon::fromTheme( + QStringLiteral("mail-attachment"), QIcon(":/icons/attachment.png"))); + attachments_button_->setText(tr("Attachments")); + attachments_button_->setToolTip(tr("Open, save and manage attachments")); + attachments_button_->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + attachments_button_->setAutoRaise(true); + attachments_button_->setPopupMode(QToolButton::InstantPopup); + attachments_button_->setMenu(attachment_menu_); + attachments_button_->setVisible(false); + body_row->addWidget(attachments_button_); body_row->addStretch(); - // Opens the pane beside the message. Next to the security button because - // the two are the same subject: what this message is, and the detail behind - // that answer. - details_button_ = new QToolButton(this); - details_button_->setIcon(QIcon::fromTheme( - QStringLiteral("dialog-information"), QIcon(":/icons/detail.png"))); - // The ellipsis is doing its usual job: this opens a window rather than - // changing anything here. - details_button_->setText(tr("Details...")); - details_button_->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); - details_button_->setAutoRaise(true); - details_button_->setShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_D)); - details_button_->setToolTip(tr("%1 (%2)").arg( - tr("Show what is known about this message: its signatures, its " - "structure and its headers."), - details_button_->shortcut().toString(QKeySequence::NativeText))); - details_button_->setMinimumWidth(details_button_->sizeHint().width()); - connect(details_button_, &QToolButton::clicked, this, - [this]() { open_details(); }); - body_row->addWidget(details_button_); + // Everything reachable that is not done with every message. + more_menu_ = new QMenu(this); + more_menu_->setToolTipsVisible(true); + more_answer_separator_ = more_menu_->addSeparator(); + more_menu_->addAction(details_act_); + more_lock_separator_ = more_menu_->addSeparator(); + more_menu_->addAction(forensic_act_); + + more_button_ = new QToolButton(this); + more_button_->setText(tr("More")); + more_button_->setToolTip(tr("More actions for this message")); + more_button_->setIcon(QIcon::fromTheme(QStringLiteral("view-more"), + QIcon(":/icons/detail.png"))); + more_button_->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + more_button_->setAutoRaise(true); + more_button_->setPopupMode(QToolButton::InstantPopup); + more_button_->setMenu(more_menu_); + body_row->addWidget(more_button_); + body_row->addSpacing(4); + + // A state, not a control: the lock itself is toggled from More. + read_only_label_ = new QLabel(this); + read_only_label_->setObjectName(QStringLiteral("emailReadOnlyStatus")); + read_only_label_->setText(tr("Read-only")); + read_only_label_->setToolTip( + tr("This message is open for inspection only. Turn it off in More.")); + { + auto font = read_only_label_->font(); + font.setPointSizeF(font.pointSizeF() * 0.92); + read_only_label_->setFont(font); + } + read_only_label_->setVisible(false); + body_row->addWidget(read_only_label_); body_row->addSpacing(4); // What the message IS, and which action applies, on the same row as the @@ -820,6 +900,10 @@ auto EMailPageView::build_message_surface() -> QWidget* { layout->addLayout(body_row); body_edit_ = new QPlainTextEdit(this); + // It wraps at the viewport's width: a scrollbar appearing because the + // window's Status Panel expanded would reflow the message sideways, so the + // gutter is kept whether or not there is anything to scroll. + body_edit_->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn); body_edit_->setPlaceholderText(tr("Write your message here.")); body_view_ = new EMailBodyView(this); @@ -856,35 +940,54 @@ auto EMailPageView::build_message_surface() -> QWidget* { // Colour belongs to apply_colors(), like every other colour in this view. banner_row->addWidget(locked_notice_, 1); - layout->addWidget(locked_banner_); - locked_panel_ = build_locked_panel(); body_stack_ = new QStackedWidget(this); body_stack_->addWidget(body_edit_); body_stack_->addWidget(body_view_); body_stack_->addWidget(locked_panel_); - layout->addWidget(body_stack_, 1); // Everything about attachments, wrapped for the same reason as the envelope: // the raw source mode hides it whole. attachment_box_ = new QWidget(page); auto* attachment_column = new QVBoxLayout(attachment_box_); - attachment_column->setContentsMargins(0, 0, 0, 0); - attachment_column->setSpacing(6); - layout->addWidget(attachment_box_); + attachment_column->setContentsMargins(0, 2, 0, 0); + attachment_column->setSpacing(4); - attachment_heading_ = new QLabel(this); - { - auto font = attachment_heading_->font(); + // One row, the shape of the collapsed Status Panel: what it is, a summary, + // then only the actions that fit the mode, a menu for the rest, and Expand. + // The full table only exists on request, so a message with one part costs + // one line of height rather than a header, a row and a button strip. + auto* bar = new QHBoxLayout(); + bar->setContentsMargins(0, 0, 0, 0); + bar->setSpacing(4); + + const auto small_font = [this]() { + auto font = this->font(); font.setPointSizeF(font.pointSizeF() * 0.92); - attachment_heading_->setFont(font); + return font; + }; + + attachment_icon_ = new QLabel(this); + attachment_icon_->setPixmap( + QIcon::fromTheme(QStringLiteral("mail-attachment"), + QIcon(":/icons/attachment.png")) + .pixmap(16, 16)); + + attachment_title_ = new QLabel(tr("Attachments"), this); + { + auto font = small_font(); + font.setWeight(QFont::DemiBold); + attachment_title_->setFont(font); } - attachment_heading_->setVisible(false); - // Not welded to the body above it: this is a new group, not a caption on the - // editor. - attachment_column->addSpacing(2); - attachment_column->addWidget(attachment_heading_); + + // The summary: the one part's name and size, or how many there are. Allowed + // to shrink, so a long file name gives way before the actions do. + attachment_heading_ = new QLabel(this); + attachment_heading_->setFont(small_font()); + attachment_heading_->setSizePolicy(QSizePolicy::Ignored, + QSizePolicy::Preferred); + attachment_heading_->setTextFormat(Qt::PlainText); attachment_list_ = new QTreeWidget(this); attachment_list_->setRootIsDecorated(false); @@ -898,84 +1001,93 @@ auto EMailPageView::build_message_surface() -> QWidget* { QHeaderView::Stretch); EMailPolishTree(attachment_list_); attachment_list_->setVisible(false); - attachment_column->addWidget(attachment_list_); unsigned_notice_ = new QLabel(this); unsigned_notice_->setWordWrap(true); unsigned_notice_->setVisible(false); - { - auto font = unsigned_notice_->font(); - font.setPointSizeF(font.pointSizeF() * 0.92); - unsigned_notice_->setFont(font); - } - attachment_column->addWidget(unsigned_notice_); - - // The same shape as the actions above: flat, icon beside text. These used - // to be raised push buttons, which gave the attachment list a heavier - // footer than the message itself. - auto* buttons = new QHBoxLayout(); - buttons->setContentsMargins(0, 0, 0, 0); - buttons->setSpacing(2); - - const auto make_attachment_action = - [this](const QString& theme_icon, const QString& fallback_icon, - const QString& text, const QString& tip, - const QKeySequence& shortcut = {}) { - auto* button = new QToolButton(this); - button->setIcon(QIcon::fromTheme(theme_icon, QIcon(fallback_icon))); - button->setText(text); - button->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); - button->setAutoRaise(true); + unsigned_notice_->setFont(small_font()); - button->setShortcut(shortcut); - button->setToolTip( - shortcut.isEmpty() - ? tip - : tr("%1 (%2)").arg( - tip, shortcut.toString(QKeySequence::NativeText))); - return button; - }; - - add_button_ = make_attachment_action( - QStringLiteral("mail-attachment"), ":/icons/attachment.png", - tr("Attach File..."), - tr("Add one or more files to this message. Files can also be dropped " - "onto the message."), - QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_A)); + // The operations live in the action row's Attachments menu (built above), + // so this bar is information only and never repeats them. Open acts on one + // part, Save on the parts the user can see chosen, Save All on every one. + attachment_open_act_ = attachment_menu_->addAction(tr("Open")); + attachment_save_act_ = attachment_menu_->addAction( + QIcon::fromTheme(QStringLiteral("document-save"), + QIcon(":/icons/filesave.png")), + tr("Save...")); + attachment_save_act_->setToolTip( + tr("Write the selected attachments to a folder.")); + attachment_save_all_act_ = attachment_menu_->addAction( + QIcon::fromTheme(QStringLiteral("document-save-all"), + QIcon(":/icons/save-all.png")), + tr("Save All...")); + attachment_save_all_act_->setToolTip(tr("Write every attachment to a folder.")); + attachment_edit_separator_ = attachment_menu_->addSeparator(); + attachment_menu_->addAction(attachment_attach_act_); // Removing takes a part out of the message being composed; it deletes // nothing on disk, and the wording and icon both stay away from suggesting // it does. - remove_button_ = make_attachment_action( - QStringLiteral("list-remove"), ":/icons/remove.png", tr("Remove"), + attachment_remove_act_ = attachment_menu_->addAction( + QIcon::fromTheme(QStringLiteral("list-remove"), + QIcon(":/icons/remove.png")), + tr("Remove")); + attachment_remove_act_->setToolTip( tr("Take the selected attachments out of this message.")); - save_button_ = make_attachment_action( - QStringLiteral("document-save"), ":/icons/filesave.png", tr("Save..."), - tr("Write the selected attachments to a folder.")); - save_all_button_ = make_attachment_action( - QStringLiteral("document-save-all"), ":/icons/save-all.png", - tr("Save All..."), tr("Write every attachment to a folder.")); + attachment_menu_->setToolTipsVisible(true); - buttons->addWidget(add_button_); - buttons->addWidget(remove_button_); - buttons->addStretch(); - buttons->addWidget(save_button_); - buttons->addWidget(save_all_button_); - attachment_column->addLayout(buttons); + attachment_toggle_ = new QToolButton(this); + attachment_toggle_->setAutoRaise(true); + attachment_toggle_->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + connect(attachment_toggle_, &QToolButton::clicked, this, [this]() { + attachments_expanded_ = !attachments_expanded_; + apply_attachment_bar(); + }); - connect(add_button_, &QToolButton::clicked, this, - &EMailPageView::slot_add_attachment); - connect(remove_button_, &QToolButton::clicked, this, - &EMailPageView::slot_remove_attachment); - connect(save_button_, &QToolButton::clicked, this, + bar->addWidget(attachment_icon_); + bar->addWidget(attachment_title_); + bar->addSpacing(4); + bar->addWidget(attachment_heading_, 1); + bar->addWidget(attachment_toggle_); + attachment_column->addLayout(bar); + attachment_column->addWidget(attachment_list_); + attachment_column->addWidget(unsigned_notice_); + + connect(attachment_open_act_, &QAction::triggered, this, [this]() { + const auto rows = attachment_target_rows(); + if (rows.size() == 1) { + open_attachment(attachment_list_->topLevelItem(rows.first())); + } + }); + connect(attachment_save_act_, &QAction::triggered, this, &EMailPageView::slot_save_attachment); - connect(save_all_button_, &QToolButton::clicked, this, + connect(attachment_save_all_act_, &QAction::triggered, this, &EMailPageView::slot_save_all_attachments); + connect(attachment_attach_act_, &QAction::triggered, this, + &EMailPageView::slot_add_attachment); + connect(attachment_remove_act_, &QAction::triggered, this, + &EMailPageView::slot_remove_attachment); + + // Body, attachments, then the notice: see kEMailSurfaceOrder. + for (const auto part : kEMailSurfaceOrder) { + switch (part) { + case EMailSurfacePart::kBODY: + layout->addWidget(body_stack_, 1); + break; + case EMailSurfacePart::kATTACHMENTS: + layout->addWidget(attachment_box_); + break; + case EMailSurfacePart::kNOTICE: + layout->addWidget(locked_banner_); + break; + } + } + connect(attachment_list_, &QTreeWidget::itemSelectionChanged, this, &EMailPageView::slot_selection_changed); - // Everything the four buttons below do, on the row the user is pointing at. - // The buttons stay: this is the second way to reach them, for the people who - // look for it on the thing itself. + // Everything the row's actions do, on the row the user is pointing at: the + // second way to reach them, for the people who look for it on the thing + // itself. attachment_list_->setContextMenuPolicy(Qt::CustomContextMenu); connect(attachment_list_, &QTreeWidget::customContextMenuRequested, this, &EMailPageView::slot_attachment_menu); @@ -1092,7 +1204,7 @@ void EMailPageView::set_source_mode(bool on) { // looking at, so they go away and the document gets the whole area. envelope_box_->setVisible(!on); envelope_rule_->setVisible(!on); - attachment_box_->setVisible(!on); + apply_attachment_bar(); if (on) { body_stack_->setCurrentWidget(raw_tab_); @@ -1116,7 +1228,8 @@ void EMailPageView::refresh_action_density() { const auto style = compact ? Qt::ToolButtonIconOnly : Qt::ToolButtonTextBesideIcon; for (auto* button : {reply_button_, reply_all_button_, forward_button_, - send_button_, forensic_toggle_}) { + send_button_, add_button_, attachments_button_, + more_button_}) { if (button != nullptr) button->setToolButtonStyle(style); } } @@ -1182,8 +1295,8 @@ void EMailPageView::apply_content_lock() { body_edit_->setReadOnly(locked); // Attaching and removing change the message; saving a part out does not. - add_button_->setEnabled(!locked); - remove_button_->setEnabled(!locked); + // The attachment row decides from the same lock. + if (attachment_box_ != nullptr) apply_attachment_bar(); // The raw editor writes straight into the document, so leaving it writable // would be a hole right through the lock. Forensic mode locks it outright; @@ -1320,6 +1433,8 @@ void EMailPageView::SetForensicMode(bool on) { if (on) dirty_ = false; apply_content_lock(); + // The lock's status label, and whether More still offers it. + apply_action_bar(); } void EMailPageView::mark_dirty() { @@ -2121,51 +2236,18 @@ void EMailPageView::refresh_body_view() { // Structure, Security or Headers tab is enough -- and gating on that made // these three switch themselves on partway through writing a message, with // nothing to reply to. - const bool is_message = document_is_received_; - - // Every action stays where it is and is turned off instead of taken away. A - // control that disappears sends the user looking for a feature they think - // they have lost; one that is merely grey, with a tooltip, tells them what - // to do next. The reasons here -- no account, no recipient, nothing parsed - // yet -- are all things they can act on once told. - send_button_->setVisible(true); - refresh_send_state(); - - const auto not_a_message = - tr("This is a draft you are still writing, not a message that was " - "received, so there is nothing here to act on yet."); - - reply_button_->setVisible(true); - reply_all_button_->setVisible(true); - forward_button_->setVisible(true); - forensic_toggle_->setVisible(true); - if (action_separator_ != nullptr) action_separator_->setVisible(true); - - for (auto* button : {reply_button_, reply_all_button_, forward_button_}) { - set_action_available(button, is_message, not_a_message); - } - - // Read-only is offered only where it CHANGES something. A signed or - // encrypted message is already locked by content_lock(), so on those -- the - // common case in this program -- the toggle appeared to do nothing at all, - // which is how a real protection came to look like a dead control. + // Which actions are on the row, and which are in More, follows what the + // document is: a received message is answered, a draft is sent. Neither + // shows the other's actions greyed out; see EMailActionBar(). // - // What it uniquely does, and only on a plain received message: it locks the - // Raw Source tab outright, and it stops SaveToSource() rebuilding the - // message. That second one is the point. A rebuild rewrites header order and - // encodings, and for a message whose HEADERS are the evidence -- a forged - // Received chain, say -- one stray keystroke and a save would destroy the - // very thing the message was opened to examine. - if (!is_message) { - set_action_available(forensic_toggle_, false, not_a_message); - } else if (security_state_ != EMailSecurityState::kPLAIN && !forensic_) { - set_action_available( - forensic_toggle_, false, - tr("This message cannot be edited anyway: it is signed or encrypted, " - "so it is already protected from being rewritten.")); - } else { - set_action_available(forensic_toggle_, true, {}); - } + // Read-only is offered only where it CHANGES something. What it uniquely + // does, and only on a plain received message: it locks the Raw Source tab + // outright, and it stops SaveToSource() rebuilding the message -- which + // for a message whose HEADERS are the evidence (a forged Received chain, + // say) is the point. On a signed or encrypted message content_lock() + // already protects it, so there the lock is simply not offered. + refresh_send_state(); + apply_action_bar(); // Ciphertext is not text the user can read or edit, so it is not offered as // either. Deliberately after the block above: Reply and Forward derive a new @@ -2235,18 +2317,18 @@ auto EMailPageView::eventFilter(QObject* watched, QEvent* event) -> bool { * where it is and its tooltip says what is missing, which is the only thing a * disabled control can still do. */ -void EMailPageView::set_action_available(QToolButton* button, bool available, +void EMailPageView::set_action_available(QAction* action, bool available, const QString& why) { - if (button == nullptr) return; + if (action == nullptr) return; // Captured on first use rather than at construction, so this cannot fall out - // of step with whatever wording make_action() gave the button. - if (!action_tooltips_.contains(button)) { - action_tooltips_.insert(button, button->toolTip()); + // of step with whatever wording make_command() gave the action. + if (!action_tooltips_.contains(action)) { + action_tooltips_.insert(action, action->toolTip()); } - button->setEnabled(available); - button->setToolTip(available ? action_tooltips_.value(button) : why); + action->setEnabled(available); + action->setToolTip(available ? action_tooltips_.value(action) : why); } void EMailPageView::refresh_send_state() { @@ -2300,7 +2382,7 @@ void EMailPageView::refresh_send_state() { } } - set_action_available(send_button_, blocker.isEmpty(), blocker); + set_action_available(send_act_, blocker.isEmpty(), blocker); } /** @@ -2406,66 +2488,15 @@ void EMailPageView::paint_security_button() { } void EMailPageView::refresh_security_button() { - QString text; - QString icon; - const bool encrypted = security_state_ == EMailSecurityState::kENCRYPTED || security_state_ == EMailSecurityState::kSIGNED_ENCRYPTED; - switch (security_badge()) { - case EMailBadgeState::kENCRYPTED_ONLY: - text = tr("Encrypted"); - icon = ":/icons/lock.png"; - break; - case EMailBadgeState::kSIGNED_UNVERIFIED: - // What is actually known at this point: the message CARRIES a - // signature. Whether it is any good is a separate question that has - // not been asked yet, and the wording must not answer it. - text = encrypted ? tr("Encrypted, signature not checked") - : tr("Signature not checked"); - icon = ":/icons/signature.png"; - break; - case EMailBadgeState::kSIGNED_GOOD: - text = encrypted ? tr("Encrypted, signature verified") - : tr("Signature verified"); - icon = ":/icons/signature.png"; - break; - case EMailBadgeState::kSIGNED_MISMATCH: - text = tr("Signed by a different address"); - icon = ":/icons/warning.png"; - break; - case EMailBadgeState::kSIGNED_EXPIRED: - text = tr("Signature or key expired"); - icon = ":/icons/warning.png"; - break; - case EMailBadgeState::kSIGNED_UNKNOWN_KEY: - text = tr("Signed by an unknown key"); - icon = ":/icons/warning.png"; - break; - case EMailBadgeState::kSIGNED_BAD: - text = tr("Bad signature"); - icon = ":/icons/warning.png"; - break; - case EMailBadgeState::kSIGNED_ERROR: - // Not the same claim as a bad signature, and not the same as one nobody - // has looked at. The check was made and could not produce an answer, - // which is the one outcome trying again might change. - text = tr("Signature could not be checked"); - icon = ":/icons/warning.png"; - break; - case EMailBadgeState::kMALFORMED: - text = tr("Malformed OpenPGP structure"); - icon = ":/icons/warning.png"; - break; - case EMailBadgeState::kNOT_PROTECTED: - // Stated plainly and quietly. An unprotected message is the ordinary - // case, not a fault, and painting it as a warning would train the user - // to ignore the one that matters. - text = tr("Not signed or encrypted"); - icon = ":/icons/email.png"; - break; - } + // The wording is the one rule in EMailSecurityStatus(), so what the row says + // about a message is checked, not merely written down here. + const auto status = EMailSecurityStatus(security_badge(), encrypted); + const auto& text = status.text; + const auto& icon = status.icon; // Always shown, including on a tab the user is still composing. This is not // only a statement about what the message IS -- it is also where Sign and @@ -2840,17 +2871,7 @@ void EMailPageView::refresh_attachments() { } const bool has_any = !message_.attachments.isEmpty(); - - // An empty table is a large blank box that reads as a fault and takes the - // room the body wants, so the list only exists once there is something in - // it. "Attach File..." stays available either way. - attachment_list_->setVisible(has_any); - attachment_heading_->setVisible(has_any); - if (has_any) { - const auto count = static_cast<int>(message_.attachments.size()); - attachment_heading_->setText(count == 1 ? tr("1 attachment") - : tr("%1 attachments").arg(count)); - } + attachment_heading_->setText(attachment_summary()); // "Signed" is only an answer on a message that was received and verified. // While composing, nothing has been signed yet and a column of "no" would @@ -2881,11 +2902,124 @@ void EMailPageView::refresh_attachments() { slot_selection_changed(); } +auto EMailPageView::attachment_summary() const -> QString { + const auto count = static_cast<int>(message_.attachments.size()); + if (count == 0) return {}; + if (count == 1) { + // Complete on its own: name, size and type, so one part never needs the + // table to be told apart. + const auto& att = message_.attachments.first(); + return tr("%1 · %2 · %3") + .arg(SanitizeAttachmentFileName(att.filename, att.mime_type), + HumanSize(att.data.size()), att.mime_type); + } + qint64 total = 0; + for (const auto& att : message_.attachments) total += att.data.size(); + return tr("%1 files · %2").arg(count).arg(HumanSize(total)); +} + +auto EMailPageView::attachment_list_open() const -> bool { + return attachments_expanded_ && !message_.attachments.isEmpty(); +} + +auto EMailPageView::attachment_target_rows() const -> QList<int> { + QList<int> selected; + for (auto* item : attachment_list_->selectedItems()) { + selected.append(attachment_list_->indexOfTopLevelItem(item)); + } + // The section's own decision, not the widget's visibility: a list inside a + // hidden raw-source page is still the list the user left open. + return EMailAttachmentTargets(static_cast<int>(message_.attachments.size()), + selected, attachment_list_open()); +} + +void EMailPageView::apply_attachment_bar() { + if (attachment_box_ == nullptr) return; + const auto locked = content_lock() != EMailLockReason::kNONE; + const auto s = EMailAttachmentBar( + {static_cast<int>(message_.attachments.size()), + static_cast<int>(attachment_list_->selectedItems().size()), !locked, + attachments_expanded_}); + + // The raw source mode takes the whole section away, whatever it would say. + attachment_box_->setVisible(s.section_visible && !source_mode_); + attachment_list_->setVisible(s.list_visible); + + // The menu is the one place the operations are. What cannot apply in this + // mode is left out; what applies but has nothing chosen is greyed. + attachment_open_act_->setVisible(s.show_open); + attachment_save_act_->setVisible(s.show_save); + attachment_save_all_act_->setVisible(s.show_save_all); + attachment_edit_separator_->setVisible(s.show_attach || s.show_remove); + attachment_attach_act_->setVisible(s.show_attach); + attachment_remove_act_->setVisible(s.show_remove); + attachment_open_act_->setEnabled(s.can_open); + attachment_save_act_->setEnabled(s.can_save); + attachment_save_all_act_->setEnabled(s.can_save_all); + attachment_attach_act_->setEnabled(s.can_attach); + attachment_remove_act_->setEnabled(s.can_remove); + + attachment_toggle_->setVisible(s.toggle_visible); + attachment_toggle_->setText(s.list_visible ? tr("Collapse") : tr("Expand")); + attachment_toggle_->setToolTip(s.list_visible ? tr("Show only the summary") + : tr("Show every attachment")); + attachment_toggle_->setIcon(EMailChevronIcon(palette(), !s.list_visible)); + + // The row's Attachments menu and Attach button follow the same counts. + apply_action_bar(); +} + +void EMailPageView::apply_action_bar() { + if (reply_button_ == nullptr) return; + + // A plain received message is the only one the lock changes anything on: + // a signed or encrypted one is already protected by content_lock(). And a + // message nobody has sent yet has no Message-ID -- one is only minted when + // it goes out -- which is what tells a reply being written from the message + // it answers. + const bool is_message = document_is_received_; + const bool draft = !is_message || message_.message_id.trimmed().isEmpty(); + const auto s = EMailActionBar( + {is_message, draft, content_lock() == EMailLockReason::kNONE, + is_message && security_state_ == EMailSecurityState::kPLAIN, forensic_, + static_cast<int>(message_.attachments.size())}); + + for (auto* button : {reply_button_, reply_all_button_, forward_button_}) { + button->setVisible(s.reply_visible); + } + send_button_->setVisible(s.send_visible); + add_button_->setVisible(s.attach_visible); + attachments_button_->setVisible(s.attachments_menu_visible); + + details_act_->setVisible(s.more_details); + // Each command is in one of two places, on the row or in More, and its + // shortcut works in both. Moved by taking the entry out of the menu rather + // than by hiding the action: a hidden action's shortcut stops working too. + for (auto* action : {reply_act_, reply_all_act_, forward_act_, send_act_}) { + more_menu_->removeAction(action); + } + if (s.more_reply) { + for (auto* action : {reply_act_, reply_all_act_, forward_act_}) { + more_menu_->insertAction(more_answer_separator_, action); + } + } + more_answer_separator_->setVisible(s.more_reply); + if (s.more_send) more_menu_->insertAction(more_lock_separator_, send_act_); + more_lock_separator_->setVisible(s.more_read_only); + // The lock has no shortcut, so this one may simply be hidden. + forensic_act_->setVisible(s.more_read_only); + if (forensic_act_->isChecked() != forensic_) { + const QSignalBlocker blocker(forensic_act_); + forensic_act_->setChecked(forensic_); + } + + read_only_label_->setVisible(s.read_only_label); +} + void EMailPageView::report_attachment_status(const QString& note) { // Said where the attachments are, rather than in a dialog the user has to // dismiss: this is the outcome of something they just asked for and were // watching, not news that has to interrupt them. - attachment_heading_->setVisible(true); attachment_heading_->setText(note); // Back to describing the list afterwards. The heading is the list's label @@ -3065,25 +3199,10 @@ void EMailPageView::WipeContent() { } void EMailPageView::slot_selection_changed() { - const auto selected = attachment_list_->selectedItems().size(); - const auto total = attachment_list_->topLevelItemCount(); - - // Selection alone is not enough to remove a part: a signed or encrypted - // message does not change, and this runs AFTER apply_content_lock() on every - // refresh, so without the lock here it handed the button straight back. - // Saving a part out does not change the message, so those two only follow - // the selection. - const auto locked = content_lock() != EMailLockReason::kNONE; - remove_button_->setEnabled(selected > 0 && !locked); - save_button_->setEnabled(selected > 0); - save_all_button_->setEnabled(total > 0); - - // Hidden rather than permanently greyed out: on a message with no - // attachments these two can never apply, and a row of dead buttons is just - // clutter. - save_button_->setVisible(total > 0); - save_all_button_->setVisible(total > 0); - remove_button_->setVisible(total > 0); + // What is shown, and what each action would act on, is one decision; see + // EMailAttachmentBar(). It also carries the lock, which runs before this on + // every refresh and must not be undone by a selection. + apply_attachment_bar(); } void EMailPageView::slot_add_attachment() { @@ -3361,14 +3480,9 @@ void EMailPageView::slot_remove_attachment() { // adding one. The button is already disabled; this is the backstop. if (content_lock() != EMailLockReason::kNONE) return; - const auto selected = attachment_list_->selectedItems(); - if (selected.isEmpty()) return; - - // Collect indices first: removing while iterating would shift the rest. - QList<int> rows; - for (auto* item : selected) { - rows.append(attachment_list_->indexOfTopLevelItem(item)); - } + // Indices first: removing while iterating would shift the rest. + auto rows = attachment_target_rows(); + if (rows.isEmpty()) return; std::sort(rows.begin(), rows.end(), std::greater<>()); // On a message that arrived from somewhere else, these bytes may be the only @@ -3435,12 +3549,11 @@ auto EMailPageView::write_attachment(const EMailAttachment& att, } void EMailPageView::slot_save_attachment() { - const auto selected = attachment_list_->selectedItems(); - if (selected.isEmpty()) return; + const auto rows = attachment_target_rows(); + if (rows.isEmpty()) return; QList<EMailAttachment> chosen; - for (auto* item : selected) { - const auto row = attachment_list_->indexOfTopLevelItem(item); + for (const auto row : rows) { if (row >= 0 && row < message_.attachments.size()) { chosen.append(message_.attachments[row]); } diff --git a/src/m_email/EMailPageView.h b/src/m_email/EMailPageView.h index 94500ae..060b09e 100644 --- a/src/m_email/EMailPageView.h +++ b/src/m_email/EMailPageView.h @@ -355,6 +355,16 @@ class EMailPageView : public QWidget, public gf::ui::DocumentWidget { private: /// Rebuilds the attachment table from `message_`. void refresh_attachments(); + /// Shows and enables the attachment row from EMailAttachmentBar(). + void apply_attachment_bar(); + /// Shows the message's actions on the row or in More, by EMailActionBar(). + void apply_action_bar(); + /// The row's summary text for the attachments in `message_`. + [[nodiscard]] auto attachment_summary() const -> QString; + /// Whether the full list is open, as the section decides it. + [[nodiscard]] auto attachment_list_open() const -> bool; + /// The rows Save, Open and Remove act on; see EMailAttachmentTargets(). + [[nodiscard]] auto attachment_target_rows() const -> QList<int>; /// Writes the header and body widgets from `message_`. void refresh_fields(); /// Reads the header and body widgets back into `message_`. @@ -502,11 +512,11 @@ class EMailPageView : public QWidget, public gf::ui::DocumentWidget { /// shows the toggle only when the message actually offers both. void refresh_body_view(); /// Enables Send when the message could actually go out, and says why not - /// when it could not. Never hides the button. + /// when it could not. void refresh_send_state(); /// Enables or disables one action, and when disabled puts @p why in its /// tooltip in place of what it normally says. Never changes visibility. - void set_action_available(QToolButton* button, bool available, + void set_action_available(QAction* action, bool available, const QString& why); /// Offers the keyring's addresses as hints on the address fields. void install_address_hints(); @@ -589,27 +599,51 @@ class EMailPageView : public QWidget, public gf::ui::DocumentWidget { /// reading. Never fetches anything from the network. EMailBodyView* body_view_{}; QStackedWidget* body_stack_{}; + /// The message's commands. Each owns its shortcut and is registered on the + /// view, so the shortcut works wherever the command is shown. + QAction* reply_act_{}; + QAction* reply_all_act_{}; + QAction* forward_act_{}; + QAction* send_act_{}; + QAction* details_act_{}; + /// The Read-only lock; its state is shown by read_only_label_. + QAction* forensic_act_{}; + /// The row's buttons for those commands (see EMailActionBar()). QToolButton* reply_button_{}; QToolButton* reply_all_button_{}; QToolButton* forward_button_{}; QToolButton* send_button_{}; + QToolButton* add_button_{}; + QToolButton* attachments_button_{}; + QToolButton* more_button_{}; + QMenu* more_menu_{}; + QAction* more_answer_separator_{}; + QAction* more_lock_separator_{}; + QLabel* read_only_label_{}; /// What each action says when it is available, captured the first time it /// is disabled so the explanation can be swapped in and back out. - QHash<QToolButton*, QString> action_tooltips_; - QToolButton* forensic_toggle_{}; + QHash<QAction*, QString> action_tooltips_; QTreeWidget* attachment_list_{}; - QToolButton* add_button_{}; - QToolButton* remove_button_{}; - QToolButton* save_button_{}; - QToolButton* save_all_button_{}; - /// Divider between the actions that derive a new message and the toggle - /// that locks this one. Hidden with them when there is no message. - QFrame* action_separator_{}; /// The hairline between the envelope and the body. QFrame* envelope_rule_{}; /// The "From:", "To:" and so on. Kept so apply_colors() can find them again. QList<QLabel*> captions_; + /// The attachment row's summary: the one part, or how many there are. QLabel* attachment_heading_{}; + QLabel* attachment_icon_{}; + QLabel* attachment_title_{}; + /// The action row's Attachments menu and what is in it. + QMenu* attachment_menu_{}; + QAction* attachment_edit_separator_{}; + QAction* attachment_open_act_{}; + QAction* attachment_save_act_{}; + QAction* attachment_save_all_act_{}; + QAction* attachment_attach_act_{}; + QAction* attachment_remove_act_{}; + /// Expand / Collapse for the full attachment list. + QToolButton* attachment_toggle_{}; + /// The user's choice for this tab; the list starts collapsed. + bool attachments_expanded_ = false; QLabel* unsigned_notice_{}; QToolButton* security_button_{}; QMenu* security_menu_{}; @@ -642,8 +676,6 @@ class EMailPageView : public QWidget, public gf::ui::DocumentWidget { /// lose the tab or the size. QDialog* details_dialog_{}; QTabWidget* details_tabs_{}; - /// Opens that window. - QToolButton* details_button_{}; /// Message / Raw Source. Hidden until an editor is actually adopted: a host /// that kept its own switcher leaves this view with only one mode. QWidget* view_switcher_{}; diff --git a/src/m_email/EMailViewLayout.cpp b/src/m_email/EMailViewLayout.cpp index 552d1d1..82c7b4c 100644 --- a/src/m_email/EMailViewLayout.cpp +++ b/src/m_email/EMailViewLayout.cpp @@ -28,8 +28,11 @@ #include "EMailViewLayout.h" +#include <QCoreApplication> #include <algorithm> +#include "EMailModel.h" + auto EMailClampDetailsSize(int width, int height) -> std::pair<int, int> { const auto wanted_width = width > 0 ? width : kEMailDetailsDefaultWidth; const auto wanted_height = height > 0 ? height : kEMailDetailsDefaultHeight; @@ -58,3 +61,124 @@ auto EMailNeedsSecurityRefresh(bool details_visible, bool security_current) -> bool { return details_visible && security_current; } + +auto EMailAttachmentBar(const EMailAttachmentBarInput& in) + -> EMailAttachmentBarState { + EMailAttachmentBarState s; + const bool any = in.count > 0; + + s.section_visible = any; + s.toggle_visible = any; + s.list_visible = any && in.expanded; + + const auto targets = in.count == 1 ? 1 : (s.list_visible ? in.selected : 0); + + s.show_open = any; + s.show_save = any; + s.show_save_all = any; + // Changing the parts is a mode, not a selection: a read-only message has + // no Attach or Remove to offer, not even greyed out. + s.show_attach = in.editable; + s.show_remove = in.editable && any; + + s.can_open = targets == 1; + s.can_save = targets > 0; + s.can_save_all = any; + s.can_attach = in.editable; + s.can_remove = in.editable && targets > 0; + return s; +} + +auto EMailActionBar(const EMailActionBarInput& in) -> EMailActionBarState { + EMailActionBarState s; + const bool received = in.is_message && !in.draft; + s.reply_visible = received; + s.send_visible = !received; + s.attach_visible = !received && in.editable; + s.attachments_menu_visible = in.attachments > 0; + + s.more_details = true; + s.more_reply = in.is_message && in.draft; + s.more_send = received; + // Offered only where it changes something, and always while it is on so it + // can be turned off again. + s.more_read_only = received && (in.read_only_offered || in.read_only); + s.read_only_label = in.read_only; + return s; +} + +auto EMailSecurityStatus(EMailBadgeState badge, bool encrypted) + -> EMailStatusLabel { + // Translated under the view's own context, spelled out at every call so + // lupdate sees each one: these are the view's strings, moved here only so + // they can be checked. + const auto warning = QStringLiteral(":/icons/warning.png"); + const auto signature = QStringLiteral(":/icons/signature.png"); + + switch (badge) { + case EMailBadgeState::kENCRYPTED_ONLY: + return {QCoreApplication::translate("EMailPageView", "Encrypted"), + QStringLiteral(":/icons/lock.png")}; + case EMailBadgeState::kSIGNED_UNVERIFIED: + // What is actually known at this point: the message CARRIES a + // signature. Whether it is any good is a separate question that has + // not been asked yet, and the wording must not answer it. + return {encrypted + ? QCoreApplication::translate( + "EMailPageView", "Encrypted, signature not checked") + : QCoreApplication::translate("EMailPageView", + "Signature not checked"), + signature}; + case EMailBadgeState::kSIGNED_GOOD: + return {encrypted ? QCoreApplication::translate( + "EMailPageView", "Encrypted, signature verified") + : QCoreApplication::translate("EMailPageView", + "Signature verified"), + signature}; + case EMailBadgeState::kSIGNED_MISMATCH: + return {QCoreApplication::translate("EMailPageView", + "Signed by a different address"), + warning}; + case EMailBadgeState::kSIGNED_EXPIRED: + return {QCoreApplication::translate("EMailPageView", + "Signature or key expired"), + warning}; + case EMailBadgeState::kSIGNED_UNKNOWN_KEY: + return {QCoreApplication::translate("EMailPageView", + "Signed by an unknown key"), + warning}; + case EMailBadgeState::kSIGNED_BAD: + return {QCoreApplication::translate("EMailPageView", "Bad signature"), + warning}; + case EMailBadgeState::kSIGNED_ERROR: + // Not the same claim as a bad signature, and not the same as one nobody + // has looked at. The check was made and could not produce an answer, + // which is the one outcome trying again might change. + return {QCoreApplication::translate("EMailPageView", + "Signature could not be checked"), + warning}; + case EMailBadgeState::kMALFORMED: + return {QCoreApplication::translate("EMailPageView", + "Malformed OpenPGP structure"), + warning}; + case EMailBadgeState::kNOT_PROTECTED: + // Stated plainly and quietly. An unprotected message is the ordinary + // case, not a fault, and painting it as a warning would train the user + // to ignore the one that matters. + break; + } + return { + QCoreApplication::translate("EMailPageView", "Not signed or encrypted"), + QStringLiteral(":/icons/email.png")}; +} + +auto EMailAttachmentTargets(int count, const QList<int>& selected_rows, + bool list_visible) -> QList<int> { + if (count == 1) return {0}; + if (!list_visible) return {}; + QList<int> rows; + for (const auto row : selected_rows) { + if (row >= 0 && row < count && !rows.contains(row)) rows.append(row); + } + return rows; +} diff --git a/src/m_email/EMailViewLayout.h b/src/m_email/EMailViewLayout.h index 3dfde88..14bb2b9 100644 --- a/src/m_email/EMailViewLayout.h +++ b/src/m_email/EMailViewLayout.h @@ -28,6 +28,10 @@ #pragma once +#include <QList> +#include <QString> +#include <array> +#include <cstdint> #include <utility> /** @@ -41,6 +45,8 @@ * here are checked; the same rules written inline in the view would not be. */ +enum class EMailBadgeState : uint8_t; + /// The smallest useful details window, and what it opens at when nothing has /// been remembered. Below the minimum its lists are narrower than their own /// column headings. @@ -107,3 +113,132 @@ auto EMailShouldCompactActions(int surface_width, bool currently_compact) */ auto EMailNeedsSecurityRefresh(bool details_visible, bool security_current) -> bool; + +/// What the attachment section shows and offers, given the message's state. +struct EMailAttachmentBarInput { + int count = 0; ///< attachments in the message + int selected = 0; ///< rows selected in the (possibly hidden) list + bool editable = false; ///< the message may gain and lose parts + bool expanded = false; ///< the user asked for the full list +}; + +struct EMailAttachmentBarState { + /// The bar itself is information only: what is attached, and Expand. + bool section_visible = false; + bool list_visible = false; ///< the full table, not only the summary row + bool toggle_visible = false; ///< Expand / Collapse is offered + + /// The Attachments menu in the message's action row. Entries that cannot + /// apply in this mode are left out rather than greyed; entries that apply + /// but have nothing to act on yet are there, disabled. + bool show_open = false; + bool show_save = false; + bool show_save_all = false; + bool show_attach = false; + bool show_remove = false; + bool can_open = false; + bool can_save = false; + bool can_save_all = false; + bool can_attach = false; + bool can_remove = false; +}; + +/** + * @brief Decides the attachment bar and menu from the counts and the mode. + * + * No attachments: no bar at all. Otherwise a summary row that can expand + * into the list. The operations live in the action row's Attachments menu, + * so the bar never repeats them; a read-only message's menu has no Attach or + * Remove in it at all. + */ +auto EMailAttachmentBar(const EMailAttachmentBarInput& in) + -> EMailAttachmentBarState; + +/// What decides the message's action row. +struct EMailActionBarInput { + bool is_message = + false; ///< parsed as a message: there is something to answer + /// Still being written: not a message at all yet, or one that was never + /// sent (it has no Message-ID; one is only minted when sending). + bool draft = false; + bool editable = false; ///< not locked: may be changed in place + bool read_only_offered = false; ///< the Read-only lock would change anything + bool read_only = false; ///< the Read-only lock is on + int attachments = 0; +}; + +struct EMailActionBarState { + /// Directly on the row: what is done with this kind of document most. + bool reply_visible = false; ///< Reply, Reply All, Forward + bool send_visible = false; ///< Send, for a draft + bool attach_visible = false; ///< Attach File, for a draft + bool attachments_menu_visible = false; + + /// In More: reachable, never in the way. + bool more_details = false; + bool more_reply = false; ///< Reply, Reply All, Forward, for a draft + bool more_send = false; ///< Send, for a received message + bool more_read_only = false; ///< the Read-only lock + + /// A status, not a control: shown when the lock is on. + bool read_only_label = false; +}; + +/** + * @brief Decides which message actions are on the row and which are in More. + * + * A received message is read and answered: Reply, Reply All and Forward. A + * draft is written and sent: Send and Attach File. Neither mode shows the + * other's actions greyed out; what still applies (answering a draft that is + * itself a message, sending a received one) is in More, with Details and the + * Read-only lock where it would change something. + */ +auto EMailActionBar(const EMailActionBarInput& in) -> EMailActionBarState; + +/// The security status as the action row states it. +struct EMailStatusLabel { + QString text; + QString icon; +}; + +/** + * @brief The wording and icon for a message's security badge. + * + * @param encrypted the message was encrypted (so a signature is inside it) + * + * Says only what is known: a signature nobody has checked is "not checked", + * never "signed" in the colour of a verified one. + */ +auto EMailSecurityStatus(EMailBadgeState badge, bool encrypted) + -> EMailStatusLabel; + +/** + * @brief The rows a Save, Open or Remove acts on. + * + * A single attachment is the target whether or not the list is open. With + * several, only a selection the user can see counts: a selection left behind + * in a collapsed list is kept for when it opens again, but acting on rows + * that are not on screen would surprise. + */ +auto EMailAttachmentTargets(int count, const QList<int>& selected_rows, + bool list_visible) -> QList<int>; + +/// The lower parts of the message surface, top to bottom. +enum class EMailSurfacePart : uint8_t { + kBODY, ///< the message body, which takes the spare height + kATTACHMENTS, ///< the attachment section + kNOTICE, ///< the message's information banner (lock, HTML source) +}; + +/** + * @brief The order the view stacks its lower parts in. + * + * The notice sits under the body and the attachments rather than between the + * actions and the body: it is read once, and should not push every line of + * the message down while it is being read or written. A notice that blocks + * editing still has its action in the body itself (the locked panel's + * Decrypt), so moving the sentence down does not move the way out. + */ +constexpr std::array<EMailSurfacePart, 3> kEMailSurfaceOrder = { + EMailSurfacePart::kBODY, EMailSurfacePart::kATTACHMENTS, + EMailSurfacePart::kNOTICE}; diff --git a/src/m_email/test/EMailViewLayoutTest.cpp b/src/m_email/test/EMailViewLayoutTest.cpp index 65fe753..beae5ce 100644 --- a/src/m_email/test/EMailViewLayoutTest.cpp +++ b/src/m_email/test/EMailViewLayoutTest.cpp @@ -28,6 +28,7 @@ #include <gtest/gtest.h> +#include "EMailModel.h" #include "EMailViewLayout.h" // The details inspector's rules, checked without a widget. The view itself @@ -99,4 +100,231 @@ TEST(EMailViewLayoutTest, SecurityIsOnlyReDerivedWhereItCanBeSeen) { EXPECT_FALSE(EMailNeedsSecurityRefresh(false, false)); } +// ------------------------------------------------------ attachment section + +auto Bar(int count, int selected, bool editable, bool expanded) { + return EMailAttachmentBar({count, selected, editable, expanded}); +} + +TEST(EMailViewLayoutTest, NoAttachmentsTakeNoRoomInEitherMode) { + // Composing, Attach is on the action row; the bar has nothing to say. + for (const bool editable : {false, true}) { + const auto s = Bar(0, 0, editable, true); + EXPECT_FALSE(s.section_visible); + EXPECT_FALSE(s.list_visible); + EXPECT_FALSE(s.toggle_visible); + } +} + +TEST(EMailViewLayoutTest, OneAttachmentIsACompleteCompactRow) { + const auto s = Bar(1, 0, false, false); + EXPECT_TRUE(s.section_visible); + EXPECT_FALSE(s.list_visible) << "one row, not a table"; + EXPECT_TRUE(s.toggle_visible); + // The one part is the target without a selection. + EXPECT_TRUE(s.can_open); + EXPECT_TRUE(s.can_save); +} + +TEST(EMailViewLayoutTest, SeveralAttachmentsExpandIntoTheList) { + EXPECT_FALSE(Bar(3, 0, false, false).list_visible); + EXPECT_TRUE(Bar(3, 0, false, false).toggle_visible); + EXPECT_TRUE(Bar(3, 0, false, true).list_visible); +} + +TEST(EMailViewLayoutTest, ReadOnlyMenusLeaveOutEditActions) { + for (const auto& s : {Bar(1, 1, false, true), Bar(4, 2, false, true), + Bar(4, 0, false, false)}) { + EXPECT_FALSE(s.show_attach) << "left out, not greyed"; + EXPECT_FALSE(s.show_remove); + EXPECT_TRUE(s.show_save_all); + EXPECT_TRUE(s.can_save_all) << "saving out is what a received part is for"; + EXPECT_TRUE(s.show_open); + EXPECT_TRUE(s.show_save); + } +} + +TEST(EMailViewLayoutTest, ComposingMenusOfferAttachAndRemove) { + const auto s = Bar(2, 0, true, false); + EXPECT_TRUE(s.show_attach); + EXPECT_TRUE(s.can_attach); + EXPECT_TRUE(s.show_remove); + EXPECT_FALSE(s.can_remove) << "a selection in a collapsed list is hidden"; + EXPECT_TRUE(Bar(2, 1, true, true).can_remove); + EXPECT_TRUE(Bar(1, 0, true, false).can_remove) + << "the one part is the target without a selection"; + EXPECT_FALSE(Bar(0, 0, true, false).show_remove) << "nothing to remove"; + EXPECT_TRUE(Bar(0, 0, true, false).show_attach); +} + +TEST(EMailViewLayoutTest, MenuEntriesFollowTheTargets) { + const auto none = Bar(3, 0, true, true); + EXPECT_FALSE(none.can_open); + EXPECT_FALSE(none.can_save); + EXPECT_FALSE(none.can_remove); + EXPECT_TRUE(none.can_save_all); + + const auto one = Bar(3, 1, true, true); + EXPECT_TRUE(one.can_open); + EXPECT_TRUE(one.can_save); + EXPECT_TRUE(one.can_remove); + + const auto two = Bar(3, 2, true, true); + EXPECT_FALSE(two.can_open) << "Open takes exactly one"; + EXPECT_TRUE(two.can_save); + EXPECT_TRUE(two.can_remove); +} + +// ------------------------------------------------------ action row + +/// is_message, draft, editable, lock offered, lock on, attachments. +auto Actions(bool is_message, bool draft, bool editable, bool offered, + bool read_only, int attachments) { + return EMailActionBar( + {is_message, draft, editable, offered, read_only, attachments}); +} + +TEST(EMailViewLayoutTest, AReceivedMessageIsAnsweredFromTheRow) { + const auto s = Actions(true, false, false, false, false, 0); + EXPECT_TRUE(s.reply_visible) << "Reply, Reply All and Forward"; + EXPECT_FALSE(s.send_visible); + EXPECT_FALSE(s.attach_visible); + EXPECT_FALSE(s.more_reply) << "not twice"; + EXPECT_TRUE(s.more_send) << "still reachable, in More"; + EXPECT_TRUE(s.more_details); +} + +TEST(EMailViewLayoutTest, ANewDraftIsSentFromTheRow) { + const auto s = Actions(false, true, true, false, false, 0); + EXPECT_FALSE(s.reply_visible) << "nothing to reply to: hidden, not greyed"; + EXPECT_FALSE(s.more_reply) << "not even in More"; + EXPECT_TRUE(s.send_visible); + EXPECT_TRUE(s.attach_visible); + EXPECT_FALSE(s.more_send) << "not twice"; + EXPECT_FALSE(s.more_read_only); + EXPECT_TRUE(s.more_details); +} + +TEST(EMailViewLayoutTest, AReplyBeingWrittenIsADraftThatCanStillBeAnswered) { + // Parsed as a message, but never sent: a reply or a forward in progress. + const auto s = Actions(true, true, true, true, false, 0); + EXPECT_TRUE(s.send_visible); + EXPECT_TRUE(s.attach_visible); + EXPECT_FALSE(s.reply_visible); + EXPECT_TRUE(s.more_reply) << "reachable, out of the way"; + EXPECT_FALSE(s.more_read_only) << "there is no evidence in a draft to lock"; +} + +TEST(EMailViewLayoutTest, ALockedDraftHasNoAttachButStillSends) { + // Signed or encrypted by its author, before sending. + const auto s = Actions(true, true, false, false, false, 1); + EXPECT_TRUE(s.send_visible); + EXPECT_FALSE(s.attach_visible); + EXPECT_TRUE(s.attachments_menu_visible); +} + +TEST(EMailViewLayoutTest, TheAttachmentsMenuExistsOnlyWithAttachments) { + EXPECT_FALSE( + Actions(true, false, false, false, false, 0).attachments_menu_visible); + EXPECT_TRUE( + Actions(true, false, false, false, false, 2).attachments_menu_visible); + EXPECT_TRUE( + Actions(false, true, true, false, false, 1).attachments_menu_visible); +} + +TEST(EMailViewLayoutTest, ReadOnlyIsOfferedWhereItChangesSomething) { + EXPECT_TRUE(Actions(true, false, true, true, false, 0).more_read_only); + EXPECT_FALSE(Actions(true, false, false, false, false, 0).more_read_only) + << "a signed or encrypted message is protected already"; + EXPECT_TRUE(Actions(true, false, false, false, true, 0).more_read_only) + << "while it is on, it can always be turned off"; +} + +TEST(EMailViewLayoutTest, ReadOnlyIsAStatusLabelWhileOn) { + EXPECT_TRUE(Actions(true, false, false, true, true, 0).read_only_label); + EXPECT_FALSE(Actions(true, false, true, true, false, 0).read_only_label); +} + +TEST(EMailViewLayoutTest, EveryCommandIsReachableInEveryMode) { + for (const bool is_message : {false, true}) { + for (const bool draft : {false, true}) { + if (!is_message && !draft) continue; // not a message is always a draft + for (const bool editable : {false, true}) { + const auto s = Actions(is_message, draft, editable, true, false, 1); + EXPECT_TRUE(s.send_visible || s.more_send) << "Send"; + EXPECT_TRUE(s.more_details) << "Details"; + if (is_message) { + EXPECT_TRUE(s.reply_visible || s.more_reply) << "Reply"; + } + EXPECT_FALSE(s.reply_visible && s.more_reply) << "never twice"; + EXPECT_FALSE(s.send_visible && s.more_send) << "never twice"; + } + } + } +} + +TEST(EMailViewLayoutTest, TheSecurityStatusSaysOnlyWhatIsKnown) { + EXPECT_EQ(EMailSecurityStatus(EMailBadgeState::kENCRYPTED_ONLY, true).text, + QStringLiteral("Encrypted")); + EXPECT_EQ( + EMailSecurityStatus(EMailBadgeState::kSIGNED_UNVERIFIED, false).text, + QStringLiteral("Signature not checked")); + EXPECT_EQ(EMailSecurityStatus(EMailBadgeState::kSIGNED_UNVERIFIED, true).text, + QStringLiteral("Encrypted, signature not checked")); + EXPECT_EQ(EMailSecurityStatus(EMailBadgeState::kSIGNED_GOOD, false).text, + QStringLiteral("Signature verified")); + EXPECT_EQ(EMailSecurityStatus(EMailBadgeState::kSIGNED_BAD, false).text, + QStringLiteral("Bad signature")); + EXPECT_EQ(EMailSecurityStatus(EMailBadgeState::kNOT_PROTECTED, false).text, + QStringLiteral("Not signed or encrypted")); + // A verdict against the signature never wears the signature's icon. + for (const auto badge : + {EMailBadgeState::kSIGNED_BAD, EMailBadgeState::kSIGNED_ERROR, + EMailBadgeState::kSIGNED_MISMATCH, EMailBadgeState::kMALFORMED}) { + EXPECT_EQ(EMailSecurityStatus(badge, false).icon, + QStringLiteral(":/icons/warning.png")); + } +} + +TEST(EMailViewLayoutTest, TargetsAreWhatTheUserCanSee) { + EXPECT_EQ(EMailAttachmentTargets(1, {}, false), QList<int>{0}); + EXPECT_EQ(EMailAttachmentTargets(1, {}, true), QList<int>{0}); + EXPECT_TRUE(EMailAttachmentTargets(3, {0, 2}, false).isEmpty()) + << "a hidden selection is kept, not acted on"; + EXPECT_EQ(EMailAttachmentTargets(3, {2, 0}, true), (QList<int>{2, 0})); + EXPECT_EQ(EMailAttachmentTargets(3, {1, 1, 7, -1}, true), QList<int>{1}) + << "duplicates and stale rows are dropped"; + EXPECT_TRUE(EMailAttachmentTargets(0, {0}, true).isEmpty()); +} + +TEST(EMailViewLayoutTest, CollapsingChangesOnlyWhatIsShown) { + // The same counts and selection, collapsed and expanded: nothing about the + // attachments themselves is an input, so nothing about them can change. + const auto open = Bar(3, 2, true, true); + const auto shut = Bar(3, 2, true, false); + EXPECT_TRUE(open.list_visible); + EXPECT_FALSE(shut.list_visible); + EXPECT_EQ(open.section_visible, shut.section_visible); + EXPECT_EQ(open.can_save_all, shut.can_save_all); + EXPECT_EQ(EMailAttachmentTargets(3, {0, 1}, true).size(), 2) + << "reopening brings the kept selection back as the target"; +} + +// ------------------------------------------------------ surface order + +TEST(EMailViewLayoutTest, TheNoticeSitsBelowTheBodyAndAttachments) { + const auto at = [](EMailSurfacePart part) { + for (size_t i = 0; i < kEMailSurfaceOrder.size(); ++i) { + if (kEMailSurfaceOrder[i] == part) return static_cast<int>(i); + } + return -1; + }; + ASSERT_NE(at(EMailSurfacePart::kNOTICE), -1); + EXPECT_LT(at(EMailSurfacePart::kBODY), at(EMailSurfacePart::kATTACHMENTS)); + EXPECT_LT(at(EMailSurfacePart::kATTACHMENTS), at(EMailSurfacePart::kNOTICE)); + EXPECT_EQ(at(EMailSurfacePart::kNOTICE), + static_cast<int>(kEMailSurfaceOrder.size()) - 1) + << "last in the view, directly above the Status Panel"; +} + } // namespace diff --git a/src/m_email/ui/main.lua b/src/m_email/ui/main.lua index c298ea9..78fb6ef 100644 --- a/src/m_email/ui/main.lua +++ b/src/m_email/ui/main.lua @@ -10,7 +10,13 @@ local open_imap = commands.get("com.bktus.gpgfrontend.module.email.open_imap_con ui.mount { id = "editor", - anchor = ui.anchor.editor { document_type = "email", extensions = { "eml" } }, + anchor = ui.anchor.editor { + document_type = "email", + extensions = { "eml" }, + -- The message has its own headers and attachments to show; the Status + -- Panel starts as a bar here and opens on a new result. + compact_status = true, + }, widget = native.factory("editor"), } |
