From a4e8c837a07014be3863a40f371d02a492b7efb7 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 24 Aug 2026 11:47:52 +0200 Subject: feat(compose): lay the composer out by scope Items 142, 143, 144 and 145, to the layout the user described. The composer had one addToolBar carrying three scopes at once: text formatting, message composition, and the terminal action. It read as a menu bar that is not one. There is now no window toolbar at all. From: [.............] +--------+ To: [.........] [v Cc/Bcc] | Send | Subject: [...........................] + [B][I][][S][link]["] [Attach] [Send as HTML] +---------------------------------------------+ | message text | +---------------------------------------------+ [Remove] * report.pdf <- only when attached Send is a large icon-above-text button beside the headers: it is the terminal action and carries the weight to match. Formatting is a toolbar widget in the central column directly above the text it formats, icon-only with the words kept as tooltips, which is where a tooltip stops being decoration. Attach and the HTML toggle ride the right end of that bar, past a stretch, because neither formats text. Remove attachment sits with the list it acts on and appears only once something is attached. "Also send a formatted copy" becomes "Send as HTML": the old label described a mechanism without naming it, leaving the reader to infer that "formatted" meant HTML and that "copy" meant a MIME part rather than a second message. Cc and Bcc hide behind a disclosure beside To:. revealCcBccIfUsed() only ever shows, never hides, so nothing but the user's own click can make a field holding an address invisible: a hidden recipient is a message going somewhere the sender cannot see, which is worse than the clutter this removes. The label is hidden with each field, since a QFormLayout holds the two as separate items and hiding the line edit alone strands a "Cc:" over empty space. Two send-lock faults, one predicted and one not. The backlog warned that setInputsEnabled() disabled the single toolbar wholesale, so the send-path test was strengthened to name every control BEFORE the split; it then caught Attach live during a countdown, where a file appended after MessageBuilder has run is either dropped or added to bytes already sent, silently either way. With every control named it failed again on format_bold: disabling a QToolBar greys its buttons but leaves each QAction enabled, so Ctrl+B during a send would have edited a message already being built, through a button that looked unavailable. setInputsEnabled() now walks the bar's actions too. The Italian translation is refreshed; lrelease reports 477 finished, 0 unfinished. --- src/composewindow.cpp | 216 ++++++++++++++++++++++++++++++++++++++++++++------ src/composewindow.h | 23 +++++- 2 files changed, 215 insertions(+), 24 deletions(-) (limited to 'src') diff --git a/src/composewindow.cpp b/src/composewindow.cpp index 1c9b492..ccf606e 100644 --- a/src/composewindow.cpp +++ b/src/composewindow.cpp @@ -46,6 +46,7 @@ #include #include #include +#include #include #include @@ -141,6 +142,10 @@ ComposeWindow::ComposeWindow(const ComposeContext &context, // before the user has typed anything; a composer opened and closed at once // would then write a draft nobody asked for. The timer is stopped as well // as the flag cleared, since markDirty() started it. + // After seedFields(): a reply that carries Cc, or a draft that carries + // either, must show what the message is addressed to. + revealCcBccIfUsed(); + m_dirty = false; m_autosaveTimer->stop(); @@ -244,48 +249,106 @@ void ComposeWindow::buildUi() m_banner->hide(); layout->addWidget(m_banner); + // The headers take the left, Send the right. Send is the terminal action + // and carries the weight to match, rather than sitting as one more entry + // in a row of formatting buttons (item 142). + auto *headerRow = new QHBoxLayout; auto *form = new QFormLayout; m_from = new QComboBox(central); m_from->setObjectName(QStringLiteral("from")); form->addRow(tr("From:"), m_from); + // To, with the Cc/Bcc disclosure beside it: the two hidden fields are + // revealed from the row they belong to. m_to = new QLineEdit(central); m_to->setObjectName(QStringLiteral("to")); - form->addRow(tr("To:"), m_to); - + auto *toRow = new QWidget(central); + auto *toLayout = new QHBoxLayout(toRow); + toLayout->setContentsMargins(0, 0, 0, 0); + toLayout->addWidget(m_to, 1); + + m_ccBccDisclosure = new QToolButton(toRow); + m_ccBccDisclosure->setObjectName(QStringLiteral("ccBccDisclosure")); + m_ccBccDisclosure->setText(tr("Cc/Bcc")); + m_ccBccDisclosure->setToolTip(tr("Show or hide the Cc and Bcc fields")); + m_ccBccDisclosure->setCheckable(true); + m_ccBccDisclosure->setArrowType(Qt::DownArrow); + m_ccBccDisclosure->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + toLayout->addWidget(m_ccBccDisclosure); + form->addRow(tr("To:"), toRow); + + // Cc and Bcc are hidden by default (item 145). The LABEL has to be hidden + // with the field: a QFormLayout keeps the two as separate items, so + // hiding only the QLineEdit leaves a stranded "Cc:" over empty space. m_cc = new QLineEdit(central); m_cc->setObjectName(QStringLiteral("cc")); - form->addRow(tr("Cc:"), m_cc); + auto *ccLabel = new QLabel(tr("Cc:"), central); + form->addRow(ccLabel, m_cc); m_bcc = new QLineEdit(central); m_bcc->setObjectName(QStringLiteral("bcc")); - form->addRow(tr("Bcc:"), m_bcc); + auto *bccLabel = new QLabel(tr("Bcc:"), central); + form->addRow(bccLabel, m_bcc); + + const auto setCcBccVisible = [this, ccLabel, bccLabel](bool visible) { + ccLabel->setVisible(visible); + m_cc->setVisible(visible); + bccLabel->setVisible(visible); + m_bcc->setVisible(visible); + m_ccBccDisclosure->setChecked(visible); + m_ccBccDisclosure->setArrowType(visible ? Qt::UpArrow : Qt::DownArrow); + }; + m_setCcBccVisible = setCcBccVisible; + setCcBccVisible(false); + connect(m_ccBccDisclosure, &QToolButton::toggled, this, + [setCcBccVisible](bool on) { setCcBccVisible(on); }); m_subject = new QLineEdit(central); m_subject->setObjectName(QStringLiteral("subject")); form->addRow(tr("Subject:"), m_subject); - layout->addLayout(form); + headerRow->addLayout(form, 1); - // Labelled for what it does, a formatted copy riding along with the plain - // text, rather than "HTML", which reads as an either/or that it is not. - m_sendHtml = new QCheckBox(tr("Also send a formatted copy"), central); - m_sendHtml->setObjectName(QStringLiteral("sendHtml")); - m_sendHtml->setToolTip( - tr("Sends the message as plain text with a formatted version " - "alongside it. The plain text is what you typed.")); - layout->addWidget(m_sendHtml); + // Icon above text, at the user's choice: a big target, with the word + // removing any doubt about what it does. + m_sendButton = new QToolButton(central); + m_sendButton->setObjectName(QStringLiteral("sendButton")); + m_sendButton->setToolButtonStyle(Qt::ToolButtonTextUnderIcon); + m_sendButton->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding); + headerRow->addWidget(m_sendButton, 0); + + layout->addLayout(headerRow); + + // The editor bar is created by buildFormatToolbar(), which runs after + // this, and inserted directly above the body. Recorded here so that + // insertion has a stable index rather than counting widgets. + m_editorBarIndex = layout->count(); m_body = new QPlainTextEdit(central); m_body->setObjectName(QStringLiteral("body")); layout->addWidget(m_body, 1); - m_attachmentList = new QListWidget(central); + // The attachment list, with Remove beside it: the control acts on the + // list, so it lives with it, and both appear only once something is + // attached. A Remove button that can never do anything is worse than no + // button, since it invites a click that reports nothing. + m_attachmentRow = new QWidget(central); + auto *attachmentLayout = new QHBoxLayout(m_attachmentRow); + attachmentLayout->setContentsMargins(0, 0, 0, 0); + + m_detachButton = new QToolButton(m_attachmentRow); + m_detachButton->setObjectName(QStringLiteral("detachButton")); + m_detachButton->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + attachmentLayout->addWidget(m_detachButton, 0, Qt::AlignTop); + + m_attachmentList = new QListWidget(m_attachmentRow); m_attachmentList->setObjectName(QStringLiteral("attachments")); m_attachmentList->setMaximumHeight(90); - m_attachmentList->hide(); - layout->addWidget(m_attachmentList); + attachmentLayout->addWidget(m_attachmentList, 1); + + m_attachmentRow->hide(); + layout->addWidget(m_attachmentRow); // The send-failure pane, in the shape MainWindow's sync log already has: // a header with a Close button and a read-only QPlainTextEdit under it. A @@ -331,19 +394,47 @@ void ComposeWindow::buildUi() void ComposeWindow::buildFormatToolbar() { - m_formatToolbar = addToolBar(tr("Formatting")); + // A toolbar WIDGET in the central column, not addToolBar(): the row sits + // directly above the text it formats, the way every editor puts it, and a + // window toolbar cannot (item 142). The window has no toolbar at all now. + m_formatToolbar = new QToolBar(centralWidget()); m_formatToolbar->setObjectName(QStringLiteral("formatToolbar")); + m_formatToolbar->setMovable(false); + // Icon-only for the formatting half, per the user's request and item 143. + m_formatToolbar->setToolButtonStyle(Qt::ToolButtonIconOnly); + const int editorIconSize = qMax(16, m_config.toolbarIconSize()); + m_formatToolbar->setIconSize(QSize(editorIconSize, editorIconSize)); + if (auto *column = qobject_cast(centralWidget()->layout())) + column->insertWidget(m_editorBarIndex, m_formatToolbar); // A QAction parented to THIS WINDOW, not registered in KeyMap. Its // shortcut is therefore scoped to the composer: Qt dispatches a // WindowShortcut to the active window only, so the main window's Ctrl+B is // untouched and the two namespaces stay apart. These six do not // participate in item 132's reachability rule for the same reason. - const auto addFormat = [this](const QString &name, const QString &text, - const QString &token, - const QKeySequence &shortcut) { + // The theme's icon, with the WORDS kept as the tooltip: icon-only is + // exactly the state where a tooltip stops being decoration, and a theme + // that lacks one of these names must fall back to text rather than render + // an empty button (item 143). + const auto decorate = [](QAction *action, const QString &iconName, + const QString &text) { + action->setToolTip(text); + const QIcon icon = QIcon::fromTheme(iconName); + if (icon.isNull()) + return; + action->setIcon(icon); + // The text stays on the action for the tooltip and for any menu, but + // an icon-only toolbar shows the icon alone. + }; + + const auto addFormat = [this, decorate](const QString &name, + const QString &text, + const QString &iconName, + const QString &token, + const QKeySequence &shortcut) { QAction *action = m_formatToolbar->addAction(text); action->setObjectName(name); + decorate(action, iconName, text); if (!shortcut.isEmpty()) action->setShortcut(shortcut); connect(action, &QAction::triggered, this, @@ -351,18 +442,23 @@ void ComposeWindow::buildFormatToolbar() }; addFormat(QStringLiteral("format_bold"), tr("Bold"), + QStringLiteral("format-text-bold"), QStringLiteral("**"), QKeySequence(QStringLiteral("Ctrl+B"))); addFormat(QStringLiteral("format_italic"), tr("Italic"), + QStringLiteral("format-text-italic"), QStringLiteral("*"), QKeySequence(QStringLiteral("Ctrl+I"))); addFormat(QStringLiteral("format_code"), tr("Code"), + QStringLiteral("format-text-code"), QStringLiteral("`"), QKeySequence(QStringLiteral("Ctrl+`"))); // No shortcut, per the spec's table. addFormat(QStringLiteral("format_strike"), tr("Strikethrough"), + QStringLiteral("format-text-strikethrough"), QStringLiteral("~~"), QKeySequence()); // Link and Quote are not wraps and cannot go through applyFormat(). QAction *link = m_formatToolbar->addAction(tr("Link")); link->setObjectName(QStringLiteral("format_link")); + decorate(link, QStringLiteral("insert-link"), tr("Link")); link->setShortcut(QKeySequence(QStringLiteral("Ctrl+K"))); connect(link, &QAction::triggered, this, [this]() { const QTextCursor cursor = m_body->textCursor(); @@ -373,6 +469,7 @@ void ComposeWindow::buildFormatToolbar() QAction *quote = m_formatToolbar->addAction(tr("Quote")); quote->setObjectName(QStringLiteral("format_quote")); + decorate(quote, QStringLiteral("format-text-blockquote"), tr("Quote")); connect(quote, &QAction::triggered, this, [this]() { const QTextCursor cursor = m_body->textCursor(); applyEdit(MarkdownFormat::quote(m_body->toPlainText(), @@ -380,10 +477,15 @@ void ComposeWindow::buildFormatToolbar() cursor.selectionEnd())); }); - m_formatToolbar->addSeparator(); + // Everything after this sits at the RIGHT of the row, apart from the + // formatting buttons, because none of it formats text. + auto *spacer = new QWidget(m_formatToolbar); + spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); + m_formatToolbar->addWidget(spacer); m_attachAction = m_formatToolbar->addAction(tr("Attach...")); m_attachAction->setObjectName(QStringLiteral("compose_attach")); + decorate(m_attachAction, QStringLiteral("mail-attachment"), tr("Attach...")); connect(m_attachAction, &QAction::triggered, this, [this]() { const QStringList chosen = QFileDialog::getOpenFileNames( this, tr("Attach files")); @@ -402,10 +504,39 @@ void ComposeWindow::buildFormatToolbar() markDirty(); }); - m_sendAction = m_formatToolbar->addAction(tr("Send")); + // The HTML toggle rides at the right end of the same row, icon AND text + // (item 144). Alone on its side, and worded for what it does rather than + // for the mechanism: "a formatted copy" never said HTML, which is what the + // user had to infer. It is an action on the bar rather than a checkbox + // under it, so it reads as a control of the editor. + m_sendHtml = new QToolButton(m_formatToolbar); + m_sendHtml->setObjectName(QStringLiteral("sendHtml")); + m_sendHtml->setCheckable(true); + m_sendHtml->setText(tr("Send as HTML")); + m_sendHtml->setToolTip( + tr("Sends the message as plain text with an HTML version alongside " + "it. The plain text is what you typed.")); + m_sendHtml->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + const QIcon htmlIcon = QIcon::fromTheme(QStringLiteral("text-html")); + if (!htmlIcon.isNull()) + m_sendHtml->setIcon(htmlIcon); + m_formatToolbar->addWidget(m_sendHtml); + + // Send is NOT on this row: it is the terminal action, and it lives on the + // button beside the headers. The QAction survives because it carries the + // shortcut and is what the button triggers. + m_sendAction = new QAction(tr("Send"), this); m_sendAction->setObjectName(QStringLiteral("compose_send")); m_sendAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+Return"))); + const QIcon sendIcon = QIcon::fromTheme(QStringLiteral("mail-send")); + if (!sendIcon.isNull()) + m_sendAction->setIcon(sendIcon); connect(m_sendAction, &QAction::triggered, this, &ComposeWindow::send); + addAction(m_sendAction); + m_sendButton->setDefaultAction(m_sendAction); + + // Remove attachment moves to its own button beside the list it acts on. + m_detachButton->setDefaultAction(m_detachAction); } void ComposeWindow::seedFields() @@ -440,6 +571,18 @@ void ComposeWindow::seedFields() : m_config.compose().sendHtml); } +void ComposeWindow::revealCcBccIfUsed() +{ + // Never hides: only the user's own click on the disclosure does that. A + // field holding an address must not become invisible because something + // else changed, which is the whole reason this exists rather than a plain + // "start collapsed". + if (!m_setCcBccVisible) + return; + if (!m_cc->text().trimmed().isEmpty() || !m_bcc->text().trimmed().isEmpty()) + m_setCcBccVisible(true); +} + void ComposeWindow::seedBody() { if (m_context.quotedBody.isEmpty()) @@ -477,7 +620,13 @@ void ComposeWindow::refreshAttachmentList() m_attachmentList->clear(); for (const QString &path : m_attachments) m_attachmentList->addItem(QFileInfo(path).fileName()); - m_attachmentList->setVisible(!m_attachments.isEmpty()); + // The ROW, so Remove goes with the list it acts on. The action's own + // visibility follows, which is what keeps it off screen with nothing + // attached even though it lives on a button rather than in a toolbar. + const bool any = !m_attachments.isEmpty(); + m_attachmentRow->setVisible(any); + if (m_detachAction) + m_detachAction->setVisible(any); } bool ComposeWindow::attachmentNeedsWarning(qint64 size) const @@ -701,6 +850,27 @@ void ComposeWindow::setInputsEnabled(bool enabled) m_sendHtml->setEnabled(enabled); m_attachmentList->setEnabled(enabled); m_formatToolbar->setEnabled(enabled); + + // Every control that used to live in the one toolbar, now that item 142 + // has split it four ways. m_formatToolbar->setEnabled() covered Bold + // through Send when they shared a row; it now reaches only the editor bar, + // and the rest have to be named. An Attach left live during the countdown + // appends to m_attachments after MessageBuilder has already run, so the + // file is either silently dropped or added to bytes already handed to the + // send command, with nothing reported either way. + m_sendButton->setEnabled(enabled); + m_sendAction->setEnabled(enabled); + m_detachButton->setEnabled(enabled); + m_detachAction->setEnabled(enabled); + m_attachAction->setEnabled(enabled); + m_ccBccDisclosure->setEnabled(enabled); + + // The ACTIONS, not only the bar that holds them. Disabling a QToolBar + // greys its buttons but leaves each QAction enabled, so the keyboard + // shortcut still fires: Ctrl+B during a send would edit a message already + // being built, through a button that looks unavailable. + for (QAction *action : m_formatToolbar->actions()) + action->setEnabled(enabled); } void ComposeWindow::showSendFailure(const QString &stderrText) diff --git a/src/composewindow.h b/src/composewindow.h index af50be6..c44fcda 100644 --- a/src/composewindow.h +++ b/src/composewindow.h @@ -19,6 +19,8 @@ #pragma once #include + +#include #include #include @@ -38,6 +40,8 @@ class QListWidget; class QPlainTextEdit; class QTimer; class QToolBar; +class QAbstractButton; +class QToolButton; class QTemporaryDir; class QWidget; @@ -117,6 +121,15 @@ public: /// server, which this application cannot know, so the user decides. void attachFile(const QString &path); + /// Shows Cc and Bcc when either already carries a value, and leaves them + /// shown. Called after seeding and after a draft is loaded. + /// + /// The load-bearing half of item 145: a hidden field holding an address is + /// a message going somewhere the sender cannot see, which is worse than + /// the clutter the disclosure removes. Never hides: only the user's own + /// click does that. + void revealCcBccIfUsed(); + /// A byte count as a figure a person reads. /// /// Static and public so the formatting is testable without a modal. The @@ -200,12 +213,20 @@ private: std::unique_ptr m_forwardedParts; QLineEdit *m_to = nullptr; + QWidget *m_attachmentRow = nullptr; + QToolButton *m_detachButton = nullptr; + int m_editorBarIndex = -1; + std::function m_setCcBccVisible; + QWidget *m_ccRow = nullptr; + QWidget *m_bccRow = nullptr; + QToolButton *m_ccBccDisclosure = nullptr; + QToolButton *m_sendButton = nullptr; QLineEdit *m_cc = nullptr; QLineEdit *m_bcc = nullptr; QLineEdit *m_subject = nullptr; QComboBox *m_from = nullptr; QPlainTextEdit *m_body = nullptr; - QCheckBox *m_sendHtml = nullptr; + QToolButton *m_sendHtml = nullptr; QLabel *m_banner = nullptr; QListWidget *m_attachmentList = nullptr; QWidget *m_sendLogPane = nullptr; -- cgit v1.2.3