Formatting Selected Text in QML
Motivation
Let’s say we’re working on a QML project that involves a TextEdit
.
There’s some text in it:
here is some text
We want to select part of this text and hit ctrl+B to make it bold:
here is some text
In Qt Widgets, this is trivial, but not so much in QML – we can get font.bold
of the entire TextEdit
, but not of just the text in the selection. We have to implement formattable selections manually.
To do this, there are two approaches we’ll look at:
- The first is to hack it together by getting the formatted text from the selection and editing this. Rather than setting properties of selected text, this solution actually inserts or removes formatting symbols from the underlying rich text source.
- The other way to do this is to create a QML object that is implemented in C++ and exposed to
TextEdit
as a property. This way we can make use ofQTextDocument
andQTextCursor
to actually set text properties within the selection area. This more closely follows the patterns expected in Qt.
In Qt 6.7, the TextEdit
QML element does have a cursorSelection
property that works in this way, and by dissecting its implementation, we can write a pseudo-backport for other Qt versions.
Before we do this, let’s take a look at the hacky QML/JS solution.
Hacky Approach
We start by focusing on just making ctrl+B bold shortcuts work:
TextEdit {
id: txtEdit
anchors.fill: parent
selectByMouse: true
textFormat: TextEdit.RichText
}
Shortcut {
sequence: StandardKey.Bold
onActivated: {
if (txtEdit.selectedText.length > 0)
{
const start = txtEdit.selectionStart
const end = txtEdit.selectionEnd
let sel = txtEdit.getFormattedText(start, end)
.split("<!--StartFragment-->")[1]
.split("<!--EndFragment-->")[0]
txtEdit.remove(start, end)
if (sel.includes("font-weight:600;"))
sel = sel.replace("font-weight:600;", "")
else
sel = "<b>" + sel + "</b>"
txtEdit.insert(txtEdit.cursorPosition, sel)
txtEdit.select(start, end)
}
}
}
Notice that we actually remove and replace the selected text, and reselect the insertion manually.
We can set up similar shortcuts for italics and underline trivially, but what if we want to set font properties of only the text in the selected area?
To keep things simple, let’s see what happens if we want to set just the font family and size:
FontDialog {
id: fontDlg
}
Shortcut {
id: fontShortcut
property string sel: ""
property int start: 0
property int end: 0
sequence: StandardKey.Find
onActivated: {
if (txtEdit.selectedText.length > 0)
{
start = txtEdit.selectionStart
end = txtEdit.selectionEnd
sel = txtEdit.getFormattedText(start, end)
.split("<!--StartFragment-->")[1]
.split("<!--EndFragment-->")[0]
fontDlg.open()
}
}
}
Connections {
target: fontDlg
function onAccepted() {
txtEdit.remove(fontShortcut.start, fontShortcut.end)
if (fontShortcut.sel.includes("font-family:")) {
let fontToReplace = fontShortcut.sel.split("font-family:'")[1].split("';")[0]
fontShortcut.sel = fontShortcut.sel.replace(fontToReplace, fontDlg.font.family)
} else {
fontShortcut.sel = "<span style=\"font-family: '"
+ fontDlg.font.family + "'; font-size:"
+ (fontDlg.font.pixelSize ? fontDlg.font.pixelSize
: fontDlg.font.pointSize)
+ "\">" + fontShortcut.sel + "</span>"
}
txtEdit.insert(txtEdit.cursorPosition, fontShortcut.sel)
txtEdit.select(fontShortcut.start, fontShortcut.end)
}
}
If we start messing with other font style properties like italic
, bold
, spacing
, etc., we will end up with almost unreadably nasty string manipulation here.
This solution is overall hacky, as we replace HTML-formatted text from a snipped out section. It would be more Qt-idiomatic to retrieve QFont
info from a selection and set the properties without editing raw rich text. Furthermore, it’s better to do as much logic as possible in C++ rather than with JavaScript in QML.
Implementation of cursorSelection
in Qt 6.7 QML
Let’s take a look at the cursorSelection
property of QtQuick TextEdit
in Qt 6.7.
By looking at its property declaration in qquicktextedit_p.h
, the type of cursorSelection
is QQuickTextSelection
.
This type is very basic. It has four read/write properties.
Here is the header qquicktextselection_p.h
:
class Q_QUICK_EXPORT QQuickTextSelection : public QObject
{
Q_OBJECT
Q_PROPERTY(QString text READ text WRITE setText NOTIFY textChanged FINAL)
Q_PROPERTY(QFont font READ font WRITE setFont NOTIFY fontChanged FINAL)
Q_PROPERTY(QColor color READ color WRITE setColor NOTIFY colorChanged FINAL)
Q_PROPERTY(Qt::Alignment alignment READ alignment WRITE setAlignment NOTIFY alignmentChanged FINAL)
QML_ANONYMOUS
QML_ADDED_IN_VERSION(6, 7)
public:
explicit QQuickTextSelection(QObject *parent = nullptr);
QString text() const;
void setText(const QString &text);
QFont font() const;
void setFont(const QFont &font);
QColor color() const;
void setColor(QColor color);
Qt::Alignment alignment() const;
void setAlignment(Qt::Alignment align);
Q_SIGNALS:
void textChanged();
void fontChanged();
void colorChanged();
void alignmentChanged();
private:
QTextCursor cursor() const;
void updateFromCharFormat(const QTextCharFormat &fmt);
void updateFromBlockFormat();
private:
QTextCursor m_cursor;
QTextCharFormat m_charFormat;
QTextBlockFormat m_blockFormat;
QQuickTextDocument *m_doc = nullptr;
QQuickTextControl *m_control = nullptr;
};
Notice we’ve got these private data members:
QTextCursor m_cursor;
QTextCharFormat m_charFormat;
QTextBlockFormat m_blockFormat;
QQuickTextDocument *m_doc = nullptr;
QQuickTextControl *m_control = nullptr;
The m_doc
and m_control
are retrieved from the TextEdit
which parents the selection object. The object is always constructed by a QQuickTextEdit
, so in the constructor, the parent is cast to one using qmlobject_cast
. Then we set these two fields.
QQuickTextSelection::QQuickTextSelection(QObject *parent)
: QObject(parent)
{
// When QQuickTextEdit creates its cursorSelection, it passes itself as the parent
if (auto *textEdit = qmlobject_cast<QQuickTextEdit *>(parent)) {
m_doc = textEdit->textDocument();
m_control = QQuickTextEditPrivate::get(textEdit)->control;
// ...
// ...
Now what are m_charFormat
and m_blockFormat
?
Text documents are composed of a list of text blocks, which can be paragraphs, lists, tables, images, etc. Thus, a block format represents an individual block’s alignment formatting. Char format contains formatting information at the character level, like font family, weight, style, size, color, and so forth.
To initialize these, we need to get the cursor from the text control.
QTextCursor QQuickTextSelection::cursor() const
{
if (m_control)
return m_control->textCursor();
return m_cursor;
}
The cursor will give us a char format and a block format, which we use to get the font / color / alignment at the cursor’s location.
QFont QQuickTextSelection::font() const
{
return cursor().charFormat().font();
}
// ...
QColor QQuickTextSelection::color() const
{
return cursor().charFormat().foreground().color();
}
// ...
Qt::Alignment QQuickTextSelection::alignment() const
{
return cursor().blockFormat().alignment();
}
currentCharFormatChanged
is emitted by QQuickTextControl
when the cursor moves or the document’s contents change. If this format is indeed different from the fields of the selection object, we must update them and emit the selection’s signals, just as we would in setters. Since we keep track of block alignment too, we have to do the same when the cursor moves and block format is different.
QQuickTextSelection::QQuickTextSelection(QObject *parent)
: QObject(parent)
{
// When QQuickTextEdit creates its cursorSelection, it passes itself as the parent
if (auto *textEdit = qmlobject_cast<QQuickTextEdit *>(parent)) {
m_doc = textEdit->textDocument();
m_control = QQuickTextEditPrivate::get(textEdit)->control;
connect(m_control, &QQuickTextControl::currentCharFormatChanged,
this, &QQuickTextSelection::updateFromCharFormat);
connect(m_control, &QQuickTextControl::cursorPositionChanged,
this, &QQuickTextSelection::updateFromBlockFormat);
}
}
// ...
// ...
// ...
inline void QQuickTextSelection::updateFromCharFormat(const QTextCharFormat &fmt)
{
if (fmt.font() != m_charFormat.font())
emit fontChanged();
if (fmt.foreground().color() != m_charFormat.foreground().color())
emit colorChanged();
m_charFormat = fmt;
}
inline void QQuickTextSelection::updateFromBlockFormat()
{
QTextBlockFormat fmt = cursor().blockFormat();
if (fmt.alignment() != m_blockFormat.alignment())
emit alignmentChanged();
m_blockFormat = fmt;
}
Here are the setters for the properties, which use the cursor to access and mutate the character or block properties at its position.
void QQuickTextSelection::setText(const QString &text)
{
auto cur = cursor();
if (cur.selectedText() == text)
return;
cur.insertText(text);
emit textChanged();
}
// ...
void QQuickTextSelection::setFont(const QFont &font)
{
auto cur = cursor();
if (cur.selection().isEmpty())
cur.select(QTextCursor::WordUnderCursor);
if (font == cur.charFormat().font())
return;
QTextCharFormat fmt;
fmt.setFont(font);
cur.mergeCharFormat(fmt);
emit fontChanged();
}
// ...
void QQuickTextSelection::setColor(QColor color)
{
auto cur = cursor();
if (cur.selection().isEmpty())
cur.select(QTextCursor::WordUnderCursor);
if (color == cur.charFormat().foreground().color())
return;
QTextCharFormat fmt;
fmt.setForeground(color);
cur.mergeCharFormat(fmt);
emit colorChanged();
}
// ...
void QQuickTextSelection::setAlignment(Qt::Alignment align)
{
if (align == alignment())
return;
QTextBlockFormat format;
format.setAlignment(align);
cursor().mergeBlockFormat(format);
emit alignmentChanged();
}
Now, we want to do something like this in our code. The issue is that this implementation resides in the Qt source code itself, and cursorSelection
is a property of QQuickTextEdit
. If we want to do something like this without changing Qt source code, we have to use attached properties.
Implementing an Attached Property
Using CursorSelection
as an attached property for a TextEdit
in QML might look something like this:
Item {
// ...
// ...
// ...
Shortcut {
// ctrl+B to toggle bold / not bold for selection
sequence: StandardKey.Bold
onActivated: {
txtEdit.CursorSelection.font = Qt.font({
bold: txtEdit.CursorSelection.font.bold !== true
})
}
}
TextEdit {
id: txtEdit
// ...
CursorSelection.font {
bold: false
italic: false
underline: false
}
}
}
To create our own attached property, we have to create two classes: CursorSelectionAttached
and CursorSelection
.
CursorSelectionAttached
will contain the implementation of the selection, while CursorSelection
serves as the attaching type, using the qmlAttachedProperties()
method to expose the signals and properties of an instance of CursorSelectionAttached
to the parent to which it is attached.
CursorSelection
also needs the QML_ATTACHED()
macro in its header declaration, and we must specify that it has an attached property with the macro QML_DECLARE_TYPEINFO()
outside the class scope.
Thus, CursorSelection
will just look like this:
// CursorSelection.h
class CursorSelection : public QObject
{
Q_OBJECT
QML_ATTACHED(CursorSelectionAttached)
QML_ELEMENT
public:
static CursorSelectionAttached *qmlAttachedProperties(QObject *object);
};
QML_DECLARE_TYPEINFO(CursorSelection, QML_HAS_ATTACHED_PROPERTIES)
Where the entire implementation is just this function definition:
// CursorSelection.cpp
CursorSelectionAttached *CursorSelection::qmlAttachedProperties(QObject *object)
{
if (auto *textEdit = qobject_cast<QQuickTextEdit *>(object))
return new CursorSelectionAttached(textEdit);
return nullptr;
}
Notice that we perform the qobject_cast
here and forward the result as the parent of the attached object. This way we only construct an attached object if we can cast the parent object to a TextEdit
.
Now, let’s see how CursorSelectionAttached
should be implemented. We begin with the constructor:
// we know that parent will be a QQuickTextEdit *
CursorSelectionAttached::CursorSelectionAttached(QQuickTextEdit *parent) noexcept
: QObject(parent)
, mEdit(parent) // this is the TextEdit we are attached to
{
// make sure the QTextDocument exists
const auto *const quickDoc = mEdit->textDocument(); // QQuickTextDocument *
auto *doc = quickDoc->textDocument(); // QTextDocument *
Q_ASSERT(doc != nullptr);
// retrieve QTextCursor from the QTextDocument
mCursor = QTextCursor(doc);
// When deselecting, the cursor position and anchor are
// set to the TextEdit's cursor position
connect(mEdit, &QQuickTextEdit::selectedTextChanged,
this, &CursorSelectionAttached::moveAnchorIfDeselected);
connect(mEdit, &QQuickTextEdit::cursorPositionChanged,
this, &CursorSelectionAttached::updatePosition);
// if we set a format with no selection, we keep it in an optional
// then when new text is added, it will have this formatting
// for example, with no selection we press ctrl+B and then start
// typing. we expect the text to be bold.
connect(mEdit->textDocument()->textDocument(),
&QTextDocument::contentsChange,
this,
&CursorSelectionAttached::applyFormatToNewTextIfNeeded);
}
Note that we connect to these three slots:
moveAnchorIfDeselected
updatePosition
applyFormatToNewTextIfNeeded
Let’s investigate the purpose of these.
moveAnchorIfDeselected
is invoked when the TextEdit’s selected text changes. A QTextCursor
has an anchor, which controls selection area. If text is being selected, the anchor is fixed in place where the selection is started, and the cursor position moves independently of the anchor. The selection area is located between the two positions. When a cursor moves without selecting anything, the anchor is located at and moves along with the cursor position.
Thus, when a cursor’s position is moved, we need to know if the anchor should be moved with it.
Since we invoke moveAnchorIfDeselected
when the selected text changes, we know that if the selection is now empty, this means there was a selection that has been deselected. Thus, the cursor and anchor should be equal to one another.
void CursorSelectionAttached::moveAnchorIfDeselected()
{
if (mEdit->selectedText().isEmpty())
mCursor.setPosition(mEdit->cursorPosition(), QTextCursor::MoveAnchor);
}
updatePosition
is invoked when the TextEdit’s cursor position changes. Depending on the TextEdit’s selection start and end positions, there are a few ways the cursor could be updated.
If there is no selected area in the TextEdit, the cursor and anchor should move together. If a selection’s start and end position both change, we must move the cursor twice: once to the start position, with the anchor moving, and once to the end position, with the anchor fixed in place. If the selection area is being resized, for example by dragging or using Shift+ArrowKeys, the cursor should move with the anchor fixed in place.
void CursorSelectionAttached::updatePosition()
{
// if there's no selection, just move the cursor & anchor
if (mEdit->selectionEnd() == mEdit->selectionStart())
{
mCursor.setPosition(mEdit->cursorPosition(), QTextCursor::MoveAnchor);
}
// if both the start and end need to be updated:
// move cursor and anchor to selection start, and
// move cursor to selection end while keeping anchor at start
//
// we have to make sure the anchor is moved correctly so the
// whole selection matches up -- otherwise cursor selection
// start or end might be in the middle of the actual
// selection, wherever the anchor is
else if (mEdit->selectionStart() != mCursor.selectionStart() &&
mEdit->selectionEnd() != mCursor.selectionEnd())
{
mCursor.setPosition(mEdit->selectionStart(), QTextCursor::MoveAnchor);
mCursor.setPosition(mEdit->selectionEnd(), QTextCursor::KeepAnchor);
}
// these two cases are for selection dragging, only start or
// end will move, so anchor stays in place
else if (mEdit->selectionStart() != mCursor.selectionStart())
{
mCursor.setPosition(mEdit->selectionStart(), QTextCursor::KeepAnchor);
}
else if (mEdit->selectionEnd() != mCursor.selectionEnd())
{
mCursor.setPosition(mEdit->selectionEnd(), QTextCursor::KeepAnchor);
}
}
applyFormatToNewTextIfNeeded
is invoked when the contents of the text document change. This is because font properties might be set without an active selection. In this case, the expected behavior is for the characters added afterwards will have these properties.
For example, if the font family is changed with no selection, and we start typing, we expect our text to be in this new font. To do this, we need an optional
in which we can save a format to apply to new text if needed, or otherwise contains nullopt
. We will call it mOptFormat
. It can be set in property setters, which you will see later. For now, we just make sure to use it when the text document content changes and there exists a value in the optional.
void CursorSelectionAttached::applyFormatToNewTextIfNeeded(int from, int charsRemoved, int charsAdded)
{
if (charsAdded && mOptFormat)
{
mCursor.setPosition(mCursor.position() - 1, QTextCursor::KeepAnchor);
mCursor.mergeCharFormat(mOptFormat.value());
mOptFormat.reset();
}
}
Now, let’s take a look at the properties to expose to QML, and how they can be retrieved and set using the cursor. Like the QQuickTextSelection
implementation, we will have properties text
and font
. We can implement the others as well, but for the sake of brevity, we will just focus on these two.
Q_PROPERTY(QString text READ text WRITE setText NOTIFY textChanged FINAL)
Q_PROPERTY(QFont font READ font WRITE setFont NOTIFY fontChanged FINAL)
We’ll need to declare and define these getters and setters, and declare the signals:
Getters:
[[nodiscard]] QString text() const;
[[nodiscard]] QFont font() const;
Setters:
void setText(const QString &text);
void setFont(const QFont &font);
Signals:
void textChanged();
void fontChanged();
The getter and setter implementations will look very similar to the previous implementations shown for QQuickTextSelection
, with some minor differences.
Getter implementations:
QString CursorSelectionAttached::text() const
{
return mCursor.selectedText();
}
QFont CursorSelectionAttached::font() const
{
// simply get the font at the cursor position using charFormat
auto ret = mCursor.charFormat().font();
// if the cursor is at the start of a selection, we need to take the font
// at the position right in front of it. otherwise, the font will refer to the
// character at the position right before the selection begins
if (mCursor.hasSelection() && mCursor.position() == mCursor.selectionStart())
{
auto cur = mCursor;
cur.setPosition(cur.position() + 1);
ret = cur.charFormat().font();
}
return ret;
}
Setter implementations:
void CursorSelectionAttached::setText(const QString &text)
{
if (mCursor.selectedText() == text)
return;
mCursor.insertText(text);
emit textChanged();
}
void CursorSelectionAttached::setFont(const QFont &font)
{
if (font == mCursor.charFormat().font())
return;
QTextCharFormat fmt = mCursor.charFormat();
fmt.setFont(font, QTextCharFormat::FontPropertiesSpecifiedOnly);
// when no selection, formatting must be set on the next insertion
if (mCursor.selection().isEmpty())
mOptFormat = fmt;
else
mCursor.mergeCharFormat(fmt);
emit fontChanged();
}
The only thing that needs to be done now is override the destructor, which can just be set to default:
~CursorSelectionAttached() override = default;
Now we have all the implementation we need to use the attached property. If we put the two classes in one header file, it will look like this:
#pragma once
#include <QObject>
#include <QTextCursor>
#include <QtQml>
#include <optional>
class QQuickTextEdit;
class CursorSelectionAttached : public QObject
{
Q_OBJECT
Q_PROPERTY(QString text READ text WRITE setText NOTIFY textChanged FINAL)
Q_PROPERTY(QFont font READ font WRITE setFont NOTIFY fontChanged FINAL)
QML_ANONYMOUS
public:
explicit CursorSelectionAttached(QQuickTextEdit *parent) noexcept;
~CursorSelectionAttached() override = default;
[[nodiscard]] QString text() const;
[[nodiscard]] QFont font() const;
void setText(const QString &text);
void setFont(const QFont &font);
signals:
void textChanged();
void fontChanged();
private slots:
void moveAnchorIfDeselected();
void updatePosition();
void applyFormatToNewTextIfNeeded(int from, int charsRemoved, int charsAdded);
private:
QTextCursor mCursor;
QQuickTextEdit *mEdit;
std::optional<QTextCharFormat> mOptFormat;
};
class CursorSelection : public QObject
{
Q_OBJECT
QML_ATTACHED(CursorSelectionAttached)
QML_ELEMENT
public:
static CursorSelectionAttached *qmlAttachedProperties(QObject *object);
};
QML_DECLARE_TYPEINFO(CursorSelection, QML_HAS_ATTACHED_PROPERTIES)
With this header, an implementation file containing the definitions, and a call to qmlRegisterUncreatableType<CursorSelection>
in your main.cpp
, the attached property can be used in QML.
Final Remarks
Though this is not a perfect backport, this code allows us to set font properties for selected text in QML in a nearly identical way to its implementation in Qt 6.7. This is especially useful to implement any kind of richtext editing in a QML application, where this functionality is severely lacking in any Qt version prior to 6.7. Hopefully this is a helpful guide to backporting features, implementing attached properties, and doing more sane text editing in QML apps.
If you like this article and want to read similar material, consider subscribing via our RSS feed.
Subscribe to KDAB TV for similar informative short video content.
KDAB provides market leading software consulting and development services and training in Qt, C++ and 3D/OpenGL. Contact us.
The post Formatting Selected Text in QML appeared first on KDAB.