Skip to content

qt/qml: labels to require opt-in to rich text - #10972

Open
SomberNight wants to merge 11 commits into
spesmilo:masterfrom
SomberNight:202609_qt_label_plaintext_by_default
Open

SomberNight wants to merge 11 commits into
spesmilo:masterfrom
SomberNight:202609_qt_label_plaintext_by_default

Conversation

@SomberNight

@SomberNight SomberNight commented Sep 14, 2026

Copy link
Copy Markdown
Member

I want ~all text in the GUIs to be plaintext by default. Every text field that expects/wants to render rich text must explicitly opt-in to that behaviour.

QLabel in QtWidgets, and Label in QML, default their textFormat to AutoText, which means they allow and render rich text as needed.
It is not just labels though: QML also has at least TextArea and TextEdit, and QtWidgets has QMessageBox.

The designers of Qt chose convenience-by-default, but I want security (or rather, sanity)-by-default.

(I don't know whether this PR is the best way to implement this though, and I don't mind if we come up with something else instead, all I care about is the end-result described in the first paragraph.)

@SomberNight
SomberNight force-pushed the 202609_qt_label_plaintext_by_default branch from b180bd5 to 0c88fea Compare September 14, 2026 21:55
@SomberNight
SomberNight marked this pull request as draft September 14, 2026 23:56
@SomberNight
SomberNight force-pushed the 202609_qt_label_plaintext_by_default branch 3 times, most recently from 1e37407 to d94ef5c Compare September 15, 2026 15:14
This will allow creating custom components with the same name as
the built-in components, and shadowing the built-in components.

i.e. we can have a file `electrum/gui/qml/components/controls/Label.qml`:

```
import QtQuick.Controls as Controls

Controls.Label {
    textFormat: Text.PlainText
}
```
When referring to `Label` from another qml file that imports
`electrum/gui/qml/components/controls`, our custom Label.qml
will be used instead of the built-in `QtQuick.Controls.Label`.
You have to opt-in to rich text if you want it for your label!
Upstream chose convenience-by-default for their GUI framework. Shame. :)

-----

earlier, less satisfactory attempt:
```
    def eventFilter(self, obj: QObject, event: QEvent) -> bool:
        if not isinstance(event, QtCore.QChildEvent):
            return False
        # - ChildAdded event: already constructed QWidget gets parented
        #   - note: does *not* fire if parent gets set at construction time, e.g. Label("", parent=self)
        # - ChildPolished event:
        #   - see https://doc.qt.io/qt-6/qstyle.html#polish :
        #     > This function is called for every widget at some point after it has been fully created
        #     > but just before it is shown for the very first time.
        if event.type() not in (QEvent.Type.ChildAdded, QEvent.Type.ChildPolished):
            return False
        child = event.child()
        if not isinstance(child, QLabel):
            return False
        if child.textFormat() != Qt.TextFormat.AutoText:
            # non-default textFormat => we leave it alone
            return False
        child.setTextFormat(Qt.TextFormat.PlainText)
        return False
```
- InjectNoRichTextEventFilter is changing AutoText to PlainText,
  and that also affects the internal QLabel inside the message box
@SomberNight
SomberNight force-pushed the 202609_qt_label_plaintext_by_default branch from d94ef5c to 28db4ce Compare September 15, 2026 17:51
@SomberNight
SomberNight marked this pull request as ready for review September 16, 2026 14:51
@Alwoch

Alwoch commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

The Timelock recovery plugin now renders HTML as text.
Here's a before:

pic1 Screenshot 2026-09-17 at 17 00 49

And here's the after:

pic2 Screenshot 2026-09-17 at 17 04 20

Removing the markup from plugins/timelock_recovery/manifest.json should resolve this.

-  "description": "<br/>This plug-in allows you to create Timelock Recovery Plans for your wallet. See: <a href='https://timelockrecovery.com'>timelockrecovery.com</a>",
+  "description": "This plug-in allows you to create Timelock Recovery Plans for your wallet. See: https://timelockrecovery.com",

@SomberNight

Copy link
Copy Markdown
Member Author

The Timelock recovery plugin now renders HTML as text.

Thanks. Fixed in f26429e

@Alwoch

Alwoch commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

I was thinking of a workaround the long tooltips and I think a utility function that wraps the the text before setting the tooltip could do? something like:

import textwrap

def wrap_tooltip_text(text: str, *, width: int = 60) -> str:
    return "\n".join(
        textwrap.fill(line, width=width, break_on_hyphens=False)
        for line in text.split("\n")
    )

and then here:

cb.setToolTip(wrap_tooltip_text(long_desc))

and the same in QMenuWithConfig.addConfig (tooltip = wrap_tooltip_text(long_desc)).

Here's a before:

pic1 Screenshot 2026-09-17 at 18 05 59

and after:

pic2 Screenshot 2026-09-17 at 18 08 23

SomberNight and others added 2 commits September 17, 2026 17:18
tooltips use QLabel internally, so due to InjectNoRichTextEventFilter,
their textFormat is no longer AutoText, but is now PlainText instead.

note: messages.to_rtf() was specifically introduced for usage with tooltips [0],
I guess to nudge Qt to break longer lines more often (so that tooltips widths are smaller).
Tooltips being forced to PlainText breaks that. I also cannot see an easy way
to opt-in the tooltip of e.g. a QCheckBox to RichText.

Instead, now we use the "textwrap" module from the stdlib to manually word-wrap the strings.

[0]: spesmilo@345c2b4

Co-authored-by: Alwoch <salwoch@gmail.com>
@SomberNight
SomberNight force-pushed the 202609_qt_label_plaintext_by_default branch from f26429e to 3067352 Compare September 17, 2026 17:21
@SomberNight

Copy link
Copy Markdown
Member Author

I was thinking of a workaround the long tooltips and I think a utility function that wraps the the text before setting the tooltip could do? something like: [...]

Oh cool! Thanks. I did not know about that module in the standard library :)
Added in 51b47ae. The default params look fine.

Comment thread electrum/gui/qt/plugins_dialog.py Outdated
- rich text can contain embedded base64-encoded images
- qt docs say [0] it is not safe to parse untrusted input as an image

[0]: https://doc.qt.io/qt-6/qtimageformats-index.html :

> Security Considerations:
> Since these file formats are more rarely used, the codecs may be
> less thoroughly debugged against potential security holes. As always,
> care should be taken when creating applications that may be used
> to decode uncontrolled data files.
Comment on lines +125 to +135
class InjectNoRichTextEventFilter(QObject):
"""Set the default textFormat of all QLabels to PlainText.

note: this also affects e.g. QMessageBox as it uses a QLabel internally.
"""
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
if event.type() != QEvent.Type.Polish:
# see https://doc.qt.io/qt-6/qstyle.html#polish :
# > This function [QStyle.polish()] is called for every widget at some point after
# > it has been fully created but just before it is shown for the very first time.
return False

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: if obj is a QLabel and it contains rich text, has the rich text already been parsed and acted upon by the time we get here and the Polish event is emitted? For example, if the rich text contains and embedded base64-encoded PNG and there is a vuln in the PNG parser, is it already too late?

@SomberNight
SomberNight force-pushed the 202609_qt_label_plaintext_by_default branch from 3067352 to 1d5de54 Compare September 18, 2026 17:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants