Implementing chat type listview with text bubbles
-
Hi,
AFAIK, you're on the right track. I just stumbled upon this for HTML rendering. Might of interest.
Hope it helps
-
Thank you very much! After your message I totally ran with it (and that example you provided helped a great deal), and got it mostly working. I have a few small problems still that I need to work out, but I think they would be better off in a new thread as they would be off topic here.
Thanks again!
-
@bepaald said in Implementing chat type listview with text bubbles:
Thank you very much! After your message I totally ran with it (and that example you provided helped a great deal), and got it mostly working. I have a few small problems still that I need to work out, but I think they would be better off in a new thread as they would be off topic here.
Thanks again!
I'm trying to create bubble chat for my desktop application. Can you please share how you end up implementing it? Would you mind sharing some of the code? Thanks!
-
@Taytoo said in Implementing chat type listview with text bubbles:
@bepaald said in Implementing chat type listview with text bubbles:
[...]
I'm trying to create bubble chat for my desktop application. Can you please share how you end up implementing it? Would you mind sharing some of the code? Thanks!
Hi Taytoo! As I said I totally ran with it and, after 7 months, the project is now huge (though still far from finished). As it is now sharing the code is impossible, it is thousands of lines of code and probably totally unreadable. I will try to come up with some minimal example in the near future and post it back here, but it will probably take a while as I am very busy.
Just as a starting point, you might want to check out this link https://stackoverflow.com/questions/1956542/how-to-make-item-view-render-rich-html-text-in-qt/1956781#1956781, I think it was how I started as well.
-
@Taytoo
Ok, I got around to creating a little example. It shows just a normalQListView
, but painting the data in its model with a custom delegate. This delegate has two main functions: first it needs to report the size in the view an item is going to need to be painted. Second, it needs to paint.The main file just sets up some dummy data in a standard
QStandardItemModel
. Make sure you add any data to the model that your delegate might need to paint the item properly. In this example I only added an "outgoing" or "incoming" datafield to get some different messages, but you may need a ton more dataroles (I did!). Then it creates a pretty standardQListView
. The only custom stuff is the ListViewDelegate set on the view usingQListView::setItemDelegate()
.//main.cc #include <QtWidgets> #include "listviewdelegate.h" int main(int argc, char *argv[]) { QApplication app(argc, argv); // create some data and put it in a model QStandardItemModel mymodel; QStandardItem *item1 = new QStandardItem("This is item one"); item1->setData("Incoming", Qt::UserRole + 1); mymodel.appendRow(item1); QStandardItem *item2 = new QStandardItem("This is item two, it is a very long item, but it's not the item's fault, it is me typing all this text."); item2->setData("Outgoing", Qt::UserRole + 1); mymodel.appendRow(item2); QStandardItem *item3 = new QStandardItem("This is the third item"); item3->setData("Incoming", Qt::UserRole + 1); mymodel.appendRow(item3); // create a view and set our data QListView listview; listview.setResizeMode(QListView::Adjust); listview.setWordWrap(true); listview.setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); listview.setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); listview.setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); listview.setModel(&mymodel); listview.setMinimumSize(200,350); // NOW tell the view to rely on out custom delegate for drawing listview.setItemDelegate(new ListViewDelegate()); // show it listview.show(); return app.exec(); }
As said, the delegate just gives size hints and paints. You can paint anything you want in the items, just make sure to also update the
sizeHint()
for any new elements you're painting. I am using aQTextDocument
to store the body text, as it can easily report its own size (especially its height given a certain width). Also, the HTML capabilities make it easy to add font styles and possibly inline images. In my own code I did end up implementing my own class (derived fromQTextDocument
) to support more content and work around an annoying bug.// listviewdelegate.h #ifndef LISTVIEWDELEGATE_H_ #define LISTVIEWDELEGATE_H_ #include <QAbstractItemDelegate> #include <QPainter> class ListViewDelegate : public QAbstractItemDelegate { int d_radius; int d_toppadding; int d_bottompadding; int d_leftpadding; int d_rightpadding; int d_verticalmargin; int d_horizontalmargin; int d_pointerwidth; int d_pointerheight; float d_widthfraction; public: inline ListViewDelegate(QObject *parent = nullptr); protected: inline void paint(QPainter *painter, QStyleOptionViewItem const &option, QModelIndex const &index) const; inline QSize sizeHint(QStyleOptionViewItem const &option, QModelIndex const &index) const; }; inline ListViewDelegate::ListViewDelegate(QObject *parent) : QAbstractItemDelegate(parent), d_radius(5), d_toppadding(5), d_bottompadding(3), d_leftpadding(5), d_rightpadding(5), d_verticalmargin(15), d_horizontalmargin(10), d_pointerwidth(10), d_pointerheight(17), d_widthfraction(.7) {} inline void ListViewDelegate::paint(QPainter *painter, QStyleOptionViewItem const &option, QModelIndex const &index) const { QTextDocument bodydoc; QTextOption textOption(bodydoc.defaultTextOption()); textOption.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere); bodydoc.setDefaultTextOption(textOption); bodydoc.setDefaultFont(QFont("Roboto", 12)); QString bodytext(index.data(Qt::DisplayRole).toString()); bodydoc.setHtml(bodytext); qreal contentswidth = option.rect.width() * d_widthfraction - d_horizontalmargin - d_pointerwidth - d_leftpadding - d_rightpadding; bodydoc.setTextWidth(contentswidth); qreal bodyheight = bodydoc.size().height(); bool outgoing = index.data(Qt::UserRole + 1).toString() == "Outgoing"; painter->save(); painter->setRenderHint(QPainter::Antialiasing); // uncomment to see the area provided to paint this item //painter->drawRect(option.rect); painter->translate(option.rect.left() + d_horizontalmargin, option.rect.top() + ((index.row() == 0) ? d_verticalmargin : 0)); // background color for chat bubble QColor bgcolor("#DD1212"); if (outgoing) bgcolor = "#DDDDDD"; // create chat bubble QPainterPath pointie; // left bottom pointie.moveTo(0, bodyheight + d_toppadding + d_bottompadding); // right bottom pointie.lineTo(0 + contentswidth + d_pointerwidth + d_leftpadding + d_rightpadding - d_radius, bodyheight + d_toppadding + d_bottompadding); pointie.arcTo(0 + contentswidth + d_pointerwidth + d_leftpadding + d_rightpadding - 2 * d_radius, bodyheight + d_toppadding + d_bottompadding - 2 * d_radius, 2 * d_radius, 2 * d_radius, 270, 90); // right top pointie.lineTo(0 + contentswidth + d_pointerwidth + d_leftpadding + d_rightpadding, 0 + d_radius); pointie.arcTo(0 + contentswidth + d_pointerwidth + d_leftpadding + d_rightpadding - 2 * d_radius, 0, 2 * d_radius, 2 * d_radius, 0, 90); // left top pointie.lineTo(0 + d_pointerwidth + d_radius, 0); pointie.arcTo(0 + d_pointerwidth, 0, 2 * d_radius, 2 * d_radius, 90, 90); // left bottom almost (here is the pointie) pointie.lineTo(0 + d_pointerwidth, bodyheight + d_toppadding + d_bottompadding - d_pointerheight); pointie.closeSubpath(); // rotate bubble for outgoing messages if (outgoing) { painter->translate(option.rect.width() - pointie.boundingRect().width() - d_horizontalmargin - d_pointerwidth, 0); painter->translate(pointie.boundingRect().center()); painter->rotate(180); painter->translate(-pointie.boundingRect().center()); } // now paint it! painter->setPen(QPen(bgcolor)); painter->drawPath(pointie); painter->fillPath(pointie, QBrush(bgcolor)); // rotate back or painter is going to paint the text rotated... if (outgoing) { painter->translate(pointie.boundingRect().center()); painter->rotate(-180); painter->translate(-pointie.boundingRect().center()); } // set text color used to draw message body QAbstractTextDocumentLayout::PaintContext ctx; if (outgoing) ctx.palette.setColor(QPalette::Text, QColor("black")); else ctx.palette.setColor(QPalette::Text, QColor("white")); // draw body text painter->translate((outgoing ? 0 : d_pointerwidth) + d_leftpadding, 0); bodydoc.documentLayout()->draw(painter, ctx); painter->restore(); } inline QSize ListViewDelegate::sizeHint(QStyleOptionViewItem const &option, QModelIndex const &index) const { QTextDocument bodydoc; QTextOption textOption(bodydoc.defaultTextOption()); textOption.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere); bodydoc.setDefaultTextOption(textOption); bodydoc.setDefaultFont(QFont("Roboto", 12)); QString bodytext(index.data(Qt::DisplayRole).toString()); bodydoc.setHtml(bodytext); // the width of the contents are the (a fraction of the window width) minus (margins + padding + width of the bubble's tail) qreal contentswidth = option.rect.width() * d_widthfraction - d_horizontalmargin - d_pointerwidth - d_leftpadding - d_rightpadding; // set this available width on the text document bodydoc.setTextWidth(contentswidth); QSize size(bodydoc.idealWidth() + d_horizontalmargin + d_pointerwidth + d_leftpadding + d_rightpadding, bodydoc.size().height() + d_bottompadding + d_toppadding + d_verticalmargin + 1); // I dont remember why +1, haha, might not be necessary if (index.row() == 0) // have extra margin at top of first item size += QSize(0, d_verticalmargin); return size; } #endif
It should compile with
qmake -project QT+=widgets qmake make
-
Sorry, I just saw that you responded with a solution. It works really well :)
One issue though, since the text is painted, it doesn't allow selecting and copying text from the chat bubble. How did you solve that limitation?
-
Hi @bepaald ,
Firstly, thank you for your delegate. It works really well! I have added several more UserRoles and modified your code accordingly. I'm really happy with the way it is working.
A question though, embedding html works really well, as does embedding inline images using the html tag <img src= etc.
A can also embed a hyperlink, however the link is not clickable, probably because it painted into the bubble.
Do you have a suggestion as to how I can get around this please?
Thanks so much,
Steve Q.
-
Hi,
You need to add custom handling of mouse click and check whether what's under it matches the position of an URL and then act accordingly. For example use QDesktopServices::openUrl to open it.
-
@Taytoo
As requested, here is my modified chatbubbledelegate.h:#ifndef CHATBUBBLEDELEGATE_H #define CHATBUBBLEDELEGATE_H #include <QAbstractItemDelegate> #include <QPainter> #include <QTextDocument> #include <QAbstractTextDocumentLayout> class ChatBubbleDelegate : public QAbstractItemDelegate { int d_radius; int d_toppadding; int d_bottompadding; int d_leftpadding; int d_rightpadding; int d_verticalmargin; int d_horizontalmargin; int d_pointerwidth; int d_pointerheight; float d_widthfraction; public: inline ChatBubbleDelegate(QObject *parent = nullptr); protected: inline void paint(QPainter *painter, QStyleOptionViewItem const &option, QModelIndex const &index) const; inline QSize sizeHint(QStyleOptionViewItem const &option, QModelIndex const &index) const; }; inline ChatBubbleDelegate::ChatBubbleDelegate(QObject *parent) : QAbstractItemDelegate(parent), d_radius(5), d_toppadding(2), d_bottompadding(1), d_leftpadding(5), d_rightpadding(5), d_verticalmargin(10), d_horizontalmargin(10), d_pointerwidth(10), d_pointerheight(17), d_widthfraction(.7) {} inline void ChatBubbleDelegate::paint(QPainter *painter, QStyleOptionViewItem const &option, QModelIndex const &index) const { // if (!index.data(Qt::UserRole + 3).toString().isEmpty()) // { // bool outgoing = index.data(Qt::UserRole + 3).toString() == "Outgoing"; // QTextDocument bodydoc; // QTextOption textOption(bodydoc.defaultTextOption()); // textOption.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere); // bodydoc.setDefaultTextOption(textOption); //#if defined (Q_OS_OSX) // bodydoc.setDefaultFont(QFont("Roboto", 13)); //#elif defined (Q_OS_WIN) // bodydoc.setDefaultFont(QFont("Roboto", 8)); //#endif // QString bodytext(index.data(Qt::DisplayRole).toString()); // bodydoc.setHtml(bodytext); // painter->save(); // painter->setRenderHint(QPainter::Antialiasing); // painter->translate(option.rect.left() + d_horizontalmargin, option.rect.top() + ((index.row() == 0) ? d_verticalmargin : 0)); //// painter->drawRect(option.rect); // QAbstractTextDocumentLayout::PaintContext ctx; // ctx.palette.setColor(QPalette::Text, QColor("grey")); // // draw body text // painter->translate((outgoing ? 0 : d_pointerwidth) + d_leftpadding, 0); // bodydoc.documentLayout()->draw(painter, ctx); // painter->restore(); // } if (!index.data(Qt::UserRole + 3).toString().isEmpty()) { bool outgoing = index.data(Qt::UserRole + 3).toString() == "Outgoing"; QTextDocument bodydoc; QTextOption textOption(bodydoc.defaultTextOption()); textOption.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere); bodydoc.setDefaultTextOption(textOption); #if defined (Q_OS_OSX) bodydoc.setDefaultFont(QFont("Roboto", 13)); #elif defined (Q_OS_WIN) bodydoc.setDefaultFont(QFont("Roboto", 8)); #endif QString bodytext(index.data(Qt::DisplayRole).toString()); bodydoc.setHtml(bodytext); qreal contentswidth = option.rect.width() * d_widthfraction - d_horizontalmargin - d_pointerwidth - d_leftpadding - d_rightpadding; bodydoc.setTextWidth(contentswidth); qreal bodyheight = bodydoc.size().height(); painter->save(); painter->setRenderHint(QPainter::Antialiasing); painter->translate(option.rect.left() + d_horizontalmargin, option.rect.top() + ((index.row() == 0) ? d_verticalmargin : 0)); // Start drawing the chat bubble, but we don't use it. This is just to get the correct horizontal offset position for the // name and/or timestamp text QPainterPath pointie; // left bottom pointie.moveTo(0, bodyheight + d_toppadding + d_bottompadding); // right bottom pointie.lineTo(0 + contentswidth + d_pointerwidth + d_leftpadding + d_rightpadding - d_radius, bodyheight + d_toppadding + d_bottompadding); pointie.closeSubpath(); if (outgoing) painter->translate(option.rect.width() - pointie.boundingRect().width() - d_horizontalmargin - d_pointerwidth, 0); // uncomment to see the area provided to paint this item // painter->drawRect(option.rect); QAbstractTextDocumentLayout::PaintContext ctx; ctx.palette.setColor(QPalette::Text, QColor("grey")); // draw body text painter->translate((outgoing ? 0 : d_pointerwidth + d_leftpadding), 0); bodydoc.documentLayout()->draw(painter, ctx); painter->restore(); } else { QTextDocument bodydoc; QTextOption textOption(bodydoc.defaultTextOption()); textOption.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere); bodydoc.setDefaultTextOption(textOption); #if defined (Q_OS_OSX) bodydoc.setDefaultFont(QFont("Roboto", 14)); #elif defined (Q_OS_WIN) bodydoc.setDefaultFont(QFont("Roboto", 9)); #endif QString bodytext(index.data(Qt::DisplayRole).toString()); bodydoc.setHtml(bodytext); if ( index.data(Qt::UserRole + 1).toString().contains("Status") ) { painter->save(); painter->setRenderHint(QPainter::Antialiasing); painter->translate(option.rect.left() + d_horizontalmargin, option.rect.top() + ((index.row() == 0) ? d_verticalmargin : 0)); //painter->drawRect(option.rect); QAbstractTextDocumentLayout::PaintContext ctx; ctx.palette.setColor(QPalette::Text, QColor("grey")); // draw body text painter->translate((false ? 0 : d_pointerwidth) + d_leftpadding, 0); bodydoc.documentLayout()->draw(painter, ctx); painter->restore(); } else { qreal contentswidth = option.rect.width() * d_widthfraction - d_horizontalmargin - d_pointerwidth - d_leftpadding - d_rightpadding; bodydoc.setTextWidth(contentswidth); qreal bodyheight = bodydoc.size().height(); bool outgoing = index.data(Qt::UserRole + 1).toString() == "Outgoing"; QString colour = index.data(Qt::UserRole + 2).toString(); painter->save(); painter->setRenderHint(QPainter::Antialiasing); // uncomment to see the area provided to paint this item //painter->drawRect(option.rect); painter->translate(option.rect.left() + d_horizontalmargin, option.rect.top() + ((index.row() == 0) ? d_verticalmargin : 0)); // background color for chat bubble QColor bgcolor(colour); // if (outgoing) // bgcolor = "#DDDDDD"; // create chat bubble QPainterPath pointie; // left bottom pointie.moveTo(0, bodyheight + d_toppadding + d_bottompadding); // right bottom pointie.lineTo(0 + contentswidth + d_pointerwidth + d_leftpadding + d_rightpadding - d_radius, bodyheight + d_toppadding + d_bottompadding); pointie.arcTo(0 + contentswidth + d_pointerwidth + d_leftpadding + d_rightpadding - 2 * d_radius, bodyheight + d_toppadding + d_bottompadding - 2 * d_radius, 2 * d_radius, 2 * d_radius, 270, 90); // right top pointie.lineTo(0 + contentswidth + d_pointerwidth + d_leftpadding + d_rightpadding, 0 + d_radius); pointie.arcTo(0 + contentswidth + d_pointerwidth + d_leftpadding + d_rightpadding - 2 * d_radius, 0, 2 * d_radius, 2 * d_radius, 0, 90); // left top pointie.lineTo(0 + d_pointerwidth + d_radius, 0); pointie.arcTo(0 + d_pointerwidth, 0, 2 * d_radius, 2 * d_radius, 90, 90); // left bottom almost (here is the pointie) pointie.lineTo(0 + d_pointerwidth, bodyheight + d_toppadding + d_bottompadding - d_pointerheight); pointie.closeSubpath(); // rotate bubble for outgoing messages if (outgoing) { painter->translate(option.rect.width() - pointie.boundingRect().width() - d_horizontalmargin - d_pointerwidth, 0); painter->translate(pointie.boundingRect().center()); painter->rotate(180); painter->translate(-pointie.boundingRect().center()); } // now paint it! painter->setPen(QPen(bgcolor)); painter->drawPath(pointie); painter->fillPath(pointie, QBrush(bgcolor)); // rotate back or painter is going to paint the text rotated... if (outgoing) { painter->translate(pointie.boundingRect().center()); painter->rotate(-180); painter->translate(-pointie.boundingRect().center()); } // set text color used to draw message body QAbstractTextDocumentLayout::PaintContext ctx; ctx.palette.setColor(QPalette::Text, QColor("black")); // draw body text painter->translate((outgoing ? 0 : d_pointerwidth) + d_leftpadding, 0); bodydoc.documentLayout()->draw(painter, ctx); painter->restore(); } } } inline QSize ChatBubbleDelegate::sizeHint(QStyleOptionViewItem const &option, QModelIndex const &index) const { if (!index.data(Qt::UserRole + 3).toString().isEmpty()) { QTextDocument bodydoc; QTextOption textOption(bodydoc.defaultTextOption()); textOption.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere); bodydoc.setDefaultTextOption(textOption); #if defined (Q_OS_OSX) bodydoc.setDefaultFont(QFont("Roboto", 13)); #elif defined (Q_OS_WIN) bodydoc.setDefaultFont(QFont("Roboto", 8)); #endif QString bodytext(index.data(Qt::DisplayRole).toString()); bodydoc.setHtml(bodytext); QSize size(bodydoc.idealWidth() + d_horizontalmargin + d_pointerwidth + d_leftpadding + d_rightpadding, bodydoc.size().height() + d_bottompadding); return size; } else { QTextDocument bodydoc; QTextOption textOption(bodydoc.defaultTextOption()); textOption.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere); bodydoc.setDefaultTextOption(textOption); #if defined (Q_OS_OSX) bodydoc.setDefaultFont(QFont("Roboto", 14)); #elif defined (Q_OS_WIN) bodydoc.setDefaultFont(QFont("Roboto", 9)); #endif QString bodytext(index.data(Qt::DisplayRole).toString()); bodydoc.setHtml(bodytext); // the width of the contents are the (a fraction of the window width) minus (margins + padding + width of the bubble's tail) qreal contentswidth = option.rect.width() * d_widthfraction - d_horizontalmargin - d_pointerwidth - d_leftpadding - d_rightpadding; // set this available width on the text document bodydoc.setTextWidth(contentswidth); if ( index.data(Qt::UserRole + 1).toString().contains("Status") ) { QSize size(bodydoc.idealWidth() + d_horizontalmargin + d_pointerwidth + d_leftpadding + d_rightpadding, bodydoc.size().height() + d_bottompadding + d_toppadding + d_verticalmargin); return size; } else { if ( index.data(Qt::UserRole + 1).toString().contains("Outgoing") ) { QSize size(bodydoc.idealWidth() + d_horizontalmargin + d_pointerwidth + d_leftpadding + d_rightpadding, bodydoc.size().height() + d_bottompadding + d_toppadding + d_verticalmargin + 1); return size; } else { QSize size(bodydoc.idealWidth() + d_horizontalmargin + d_pointerwidth + d_leftpadding + d_rightpadding, bodydoc.size().height() + d_bottompadding + d_toppadding + d_verticalmargin + 1); return size; } } } } #endif // CHATBUBBLEDELEGATE_H
And this is my function for writing a message to the chat bubble:
void ChatTab::addMessage(QString userColour, QString username, QString message, int itemType, QString itemData) { QString roleMode; QColor color(userColour), newColor = color; int lightnessAmount = 100; // Convert carriage returns to html carriage returns so they are displayed properly message.replace("\n", "<br>"); // Crappy way of doing it, but make sure the user colour has a lightness colour of 230 or greater. // 230 is an arbitrary colour I have chosen for ( ;; ) { if ( newColor.lightness() < 230 ) { lightnessAmount += 10; newColor = color.lighter(lightnessAmount); } else break; } // Print the senders name and or time stamp QString statusText; if ( m_settings->username == username ) roleMode = "Outgoing"; else roleMode = "Incoming"; if ( m_membersList->count() > 1 && roleMode == "Incoming" ) statusText = username + " "; statusText += QTime::currentTime().toString( "HH:mm:ss"); struct ChatListItem *cli; QStandardItem *item1 = new QStandardItem(statusText); item1->setData(roleMode, Qt::UserRole + 3); cli = new struct ChatListItem; cli->itemData = ""; cli->itemText = username; cli->itemType = CHATLISTITEMTYPE_STATUSMESSAGE; item1->setData( QVariant(QVariant::fromValue(static_cast<void*>(cli))), Qt::UserRole + 20 ); m_mymodel.appendRow(item1); // Print the message if ( m_settings->username == username ) roleMode = "Outgoing"; else roleMode = "Incoming"; QStandardItem *item2 = new QStandardItem(message); item2->setData(roleMode, Qt::UserRole + 1); item2->setData(newColor.name(), Qt::UserRole + 2); item2->setData("", Qt::UserRole + 3); cli = new struct ChatListItem; cli->itemData = itemData; cli->itemText = message; cli->itemType = itemType; item2->setData( QVariant(QVariant::fromValue(static_cast<void*>(cli))), Qt::UserRole + 20 ); m_mymodel.appendRow(item2); m_chatList->scrollToBottom(); addToTranscript( username, message ); }
As I said in my email, it was a while ago when I wrote this. If you have any questions, let me know and I'll try and figure out what I was doing! Lol
This is a screen grab of my chat window. I couldn't grab it with the context menu being displayed, but each bubble has its own context menu which do various things depending on the content of the bubble. Such as downloading an image and displaying it, opening a web page, or copying the bubble text to the clipboard. HTML codes can also be embedded into the bubble and display correctly.
I hope all this helps.
Steve Q. -
@steveq Thanks so much! Were u able to figure out how to make URLs, sandwiched between other text, clickable? e.g. if message was:
Hey, checkout this website: http://forum.qt.io, its really cool!
In that only the url should be click, and maybe show hover effect as well. I read some suggestions about using mouse hit-testing to achieve that, but it seems like a lot of work.
Another thing I really wanted to do was to make text selectable, but even that seems complicated.