Dex
Dex

Reputation: 194

Qt highlighting selected line overwrites highlighting of individual words

I have a QPlainTextEdit where I want to highlight the current line the user is on as well as all words similar to the word the user selected. This word highlight works fine on all lines except the currently selected one, because the "selected line" background style overrides the "selected word" style applied to the selected words.

My question is how can I make sure the word highlighing is done after the line highlight is done, so that they can both be active at the same time?

Screenshot to illustrate:

enter image description here

The yellow line is the current line being highlighted. The first 'test' is selected, so all others should have the lightblue background applied. All except the 'test' on the highlighted line do.

Minimal reproducible example:

MainWindow.h

#pragma once
#include <QtWidgets/QMainWindow>
#include "ui_mainWindow.h"
#include "TextEditor.h"

class mainWindow : public QMainWindow
{
    Q_OBJECT

public:
    mainWindow(QWidget *parent = Q_NULLPTR) : QMainWindow(parent)
    {
        ui.setupUi(this);
        auto textEdit = new TextEditor(this);
        textEdit->setPlainText("test lorem ipsum test\n test dolor sit test\test amet test");
        ui.tabWidget->addTab(textEdit, "Editor");
    }

private:
    Ui::mainWindowClass ui;
};

TextEditor.h

#pragma once
#include <QPlainTextEdit>

class TextEditor : public QPlainTextEdit
{
    Q_OBJECT

public:
    TextEditor(QWidget* parent) : QPlainTextEdit(parent)
    {
        connect(this, &QPlainTextEdit::selectionChanged, this, &TextEditor::selectChangeHandler);
        connect(this, &QPlainTextEdit::cursorPositionChanged, this, &TextEditor::highlightCurrentLine);
    }

private:
    std::vector<std::pair<int, QTextCharFormat>> highlightedWords_;

    //Highlights the current line
    void highlightCurrentLine()
    {
        QList<QTextEdit::ExtraSelection> extraSelections;

        if (!isReadOnly())
        {
            QTextEdit::ExtraSelection selection;
            selection.format.setBackground(Qt::yellow);
            selection.format.setProperty(QTextFormat::FullWidthSelection, true);
            selection.cursor = textCursor();
            selection.cursor.clearSelection();
            extraSelections.append(selection);
        }

        setExtraSelections(extraSelections);
    }

    //Highlights all words similar to the currently selected word
    void selectChangeHandler()
    {
        //Unset previous selection
        resetHighlightedWords();

        //Ignore empty selections
        if (textCursor().selectionStart() >= textCursor().selectionEnd())
            return;

        //We only care about fully selected words (nonalphanumerical characters on either side of selection)
        auto plaintext = toPlainText();
        auto prevChar = plaintext.mid(textCursor().selectionStart() - 1, 1).toStdString()[0];
        auto nextChar = plaintext.mid(textCursor().selectionEnd(), 1).toStdString()[0];
        if (isalnum(prevChar) || isalnum(nextChar))
            return;

        auto qselection = textCursor().selectedText();
        auto selection = qselection.toStdString();

        //We also only care about selections that do not themselves contain nonalphanumerical characters
        if (std::find_if(selection.begin(), selection.end(), [](char c) { return !isalnum(c); }) != selection.end())
            return;

        //Highlight all matches of the given word in the editor
        blockSignals(true);
        highlightWord(qselection);
        blockSignals(false);
    }

    //Removes highlight from selected words
    void resetHighlightedWords()
    {
        if (highlightedWords_.empty())
            return;

        blockSignals(true);
        auto cur = textCursor();
        for (const auto& [index, oldFormat] : highlightedWords_)
        {
            cur.setPosition(index);
            cur.movePosition(QTextCursor::EndOfWord, QTextCursor::KeepAnchor, 1);
            cur.setCharFormat(oldFormat);
        }
        blockSignals(false);

        highlightedWords_.clear();
    }

    //Applies the highlight style to all appearances of the given word
    void highlightWord(const QString& word)
    {
        auto plaintext = toPlainText();

        //Prepare text format
        QTextCharFormat format;
        format.setBackground(QColor::fromRgb(0x70, 0xED, 0xE0));

        //Find all words in our document that match the selected word and apply the background format to them
        size_t pos = 0;
        auto reg = QRegExp("\\b" + word + "\\b");
        auto cur = textCursor();
        auto index = reg.indexIn(plaintext, pos);
        while (index >= 0)
        {
            //Select matched text
            cur.setPosition(index);

            //Save old text style
            highlightedWords_.push_back(std::make_pair(index, cur.charFormat()));

            //Apply format
            cur.movePosition(QTextCursor::EndOfWord, QTextCursor::KeepAnchor, 1);
            cur.mergeCharFormat(format);

            //Move to next match
            auto len = (size_t)reg.matchedLength();
            pos = index + (size_t)reg.matchedLength();
            index = reg.indexIn(plaintext, pos);
        }
    }
};

Upvotes: 2

Views: 945

Answers (1)

Yasser Malaika
Yasser Malaika

Reputation: 156

Avoiding inheritance as a first resort was the right thing to do IMO, but in this particular case, it may be the simplest approach.

#include <QPainter>

TextEditor::TextEditor( QWidget* parent ) : QPlainTextEdit( parent )
{
    connect( this, SIGNAL( cursorPositionChanged() ), viewport(), SLOT( update() ) );
    //Just for brevity. Instead of repainting the whole thing on every cursor change,
    //you'll want to filter for changes to the current block/line and only update the.
    //changed portions. And accommodate resize, etc.
}

void TextEditor::paintEvent( QPaintEvent* pEvent )
{
    QPainter painter( viewport() );
    QRect r = cursorRect();
    r.setLeft( 0 ); r.setRight( width() - 1 ); //Or more! 
    painter.setPen( Qt::NoPen );
    painter.setBrush( QColor( 228, 242, 244, 200 ) );
    painter.drawRect( r );
    QPlainTextEdit::paintEvent( pEvent );
}

A background hint behind the cursor's block is a really nice UX improvement, as it makes the cursor position more immediately apparent at a glance in all sorts of scenarios. If you have several text editors up together, little details like that become even more important.

At first glance, setExtraSelections() looks like a fast and simple way to get there. And it is... But I find that it falls short when you want to take it to the next level, and, as you discovered, it doesn't play well with ANY other highlighting.

I suspect the built-in ExtraSelection method is meant to be a kind of quick and dirty brute force tool for quickly showing errors or breakpoints, i.e. things that are meant to really visually stand out for the user. It's basically like a secondary selection highlight behind the cursor selection, and so like all other selection highlights it will render behind text but in front of everything else. That means it will also eclipse any custom text background formatting you do using QTextFormat or QTextCharFormat, or even QSyntaxHighlighter. I don't find that acceptable, personally.

Another smaller problem with built-in selections or highlighting in general for this kind of background hint use case is that they don't cover the entire background of the text block. They stop a few pixels shy at the text area boundary or worse depending on margins, etc., making it look clunky to my eyes.

In terms of UI design, a current line indication generally needs to be subtler than most other indications and all other highlights, with lower contrast, and towards the very far background. It needs to look more like a part of the widget than part of the text. It's a hint not a selection, and I've found that balancing it all visually required more than ExtraSelections or regular text formatting was able to provide.

BTW, If you plan on making this more complex, e.g. a code editor, I would also recommend that you look into using QSyntaxHighlighter for your selected word pattern highlight. ( It will remove a LOT of the cursor control logic and ALL of the signal interruption. It will also scale better for when (if?) you add keyword, variable, comment, search terms, etc. Right now, your highlighting involves editing your document/data model directly, which is fine for your post or for simple text input, but likely to be problematic for other cases. )

EDIT

Here's more code that shows using an extended highlighter together with the paintEvent override. I'm going to use headers to hopefully make it clearer how this approach might integrate with your actual projects' classes.

First, the highlighter:

#include <QSyntaxHighlighter>
class QTextDocument;

class CQSyntaxHighlighterSelectionMatch : public QSyntaxHighlighter
{
    Q_OBJECT
public:
    explicit CQSyntaxHighlighterSelectionMatch( QTextDocument *parent = 0 );

public slots:
    void    SetSelectionTerm( QString term );

protected:
    virtual void highlightBlock( const QString &text );
    void ApplySelectionTermHighlight( const QString &text );

private:
    QString m_strSelectionTerm;
    struct HighlightingRule {
        QRegExp pattern;
        QTextCharFormat format;
    };
    HighlightingRule m_HighlightRuleSelectionTerm;
};

A quick and dirty implementation:

CQSyntaxHighlighterSelectionMatch::CQSyntaxHighlighterSelectionMatch( QTextDocument *parent )
    : QSyntaxHighlighter( parent )
{
    m_strSelectionTerm.clear();

    m_HighlightRuleSelectionTerm.format.setBackground( QColor(255, 210, 120 ) );
    //m_HighlightRuleSelectionTerm.format.setFontWeight( QFont::Bold ); //or italic, etc... 
}

void CQSyntaxHighlighterSelectionMatch::SetSelectionTerm( QString txtIn )
{
    if( txtIn == m_strSelectionTerm )
        return;

    if( !txtIn.isEmpty() )
    {
        txtIn = "\\b" + txtIn + "\\b";

        if( txtIn == m_strSelectionTerm )
            return;
    }

    m_strSelectionTerm = txtIn;

    Qt::CaseSensitivity cs = Qt::CaseSensitive;
    m_HighlightRuleSelectionTerm.pattern = QRegExp( m_strSelectionTerm, cs );
    rehighlight();
}

void CQSyntaxHighlighterSelectionMatch::highlightBlock( const QString &text )
{
    if( m_strSelectionTerm.length() > 1 )
        ApplySelectionTermHighlight( text );
}


void CQSyntaxHighlighterSelectionMatch::ApplySelectionTermHighlight( const QString &text )
{
    QRegExp expression( m_HighlightRuleSelectionTerm.pattern );
    int index, length;
    index = expression.indexIn( text );
    while ( index >= 0 )
    {
        length = expression.matchedLength();
        setFormat( index, length, m_HighlightRuleSelectionTerm.format );
        index = expression.indexIn( text, index + length );
    }
}

And here's how a QPlainTextEdit derived class might use something like the above:

#include <QPlainTextEdit>

class TextEditor : public QPlainTextEdit
{
    Q_OBJECT

public:
    TextEditor( QWidget *parent = 0 );

protected:
    virtual void paintEvent( QPaintEvent *event );

private slots:
    void CheckForCurrentBlockChange();
    void FilterSelectionForSingleWholeWord();

private:
    unsigned int m_uiCurrentBlock;
    CQSyntaxHighlighterSelectionMatch *m_pHighlighter;
};

#include <QPainter>

TextEditor::TextEditor(QWidget *parent)
    : QPlainTextEdit(parent)
{
    //Instead of repainting on every cursor change, we can filter for changes to the current block/line
    //connect( this, SIGNAL(cursorPositionChanged()), viewport(), SLOT(update()) );
    connect( this, SIGNAL(cursorPositionChanged()), this, SLOT(CheckForCurrentBlockChange()) );

    m_pHighlighter = new CQSyntaxHighlighterSelectionMatch( document() );
    connect( this, SIGNAL(selectionChanged()), this, SLOT(FilterSelectionForSingleWholeWord()) );
}


void TextEditor::paintEvent( QPaintEvent* pEvent )
{
    QPainter painter( viewport() );

    QRect r = cursorRect();
    r.setLeft( 0 );
    r.setRight( width()-1 );
    painter.setPen( Qt::NoPen );
    painter.setBrush( QColor( 228, 242, 244 ) );
    painter.drawRect( r );

    QPlainTextEdit::paintEvent( pEvent );
}


void TextEditor::CheckForCurrentBlockChange()
{
    QTextCursor tc = textCursor();
    unsigned int b = (unsigned int)tc.blockNumber();
    if( b == m_uiCurrentBlock )
        return;

    m_uiCurrentBlock = b;

    viewport()->update(); //I'll just brute force paint everything for this example. Your real code can be smarter with it's repainting it matters...

}


void TextEditor::FilterSelectionForSingleWholeWord()
{
    QTextCursor tc = textCursor();
    QString currentSelection = tc.selectedText();

    QStringList list = currentSelection.split(QRegExp("\\s+"), QString::SkipEmptyParts);
    if( list.count() > 1 )
    {
        m_pHighlighter->SetSelectionTerm( "" );
        return;
    }

    tc.movePosition( QTextCursor::StartOfWord );
    tc.movePosition( QTextCursor::EndOfWord, QTextCursor::KeepAnchor );
    QString word = tc.selectedText();

    if( currentSelection != word )
    {
        m_pHighlighter->SetSelectionTerm( "" );
        return;
    }

    m_pHighlighter->SetSelectionTerm( currentSelection );
}

This is the simplest way I know to provide the selection term functionality you are after while solving the issue of the background hint interfering with the selection term highlight when they are on the same block.

Upvotes: 1

Related Questions