icons: Update application icons for Quassel
[quassel.git] / src / qtui / chatscene.cpp
1 /***************************************************************************
2  *   Copyright (C) 2005-2020 by the Quassel Project                        *
3  *   devel@quassel-irc.org                                                 *
4  *                                                                         *
5  *   This program is free software; you can redistribute it and/or modify  *
6  *   it under the terms of the GNU General Public License as published by  *
7  *   the Free Software Foundation; either version 2 of the License, or     *
8  *   (at your option) version 3.                                           *
9  *                                                                         *
10  *   This program is distributed in the hope that it will be useful,       *
11  *   but WITHOUT ANY WARRANTY; without even the implied warranty of        *
12  *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the         *
13  *   GNU General Public License for more details.                          *
14  *                                                                         *
15  *   You should have received a copy of the GNU General Public License     *
16  *   along with this program; if not, write to the                         *
17  *   Free Software Foundation, Inc.,                                       *
18  *   51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.         *
19  ***************************************************************************/
20
21 #include "chatscene.h"
22
23 #include <utility>
24
25 #include <QApplication>
26 #include <QClipboard>
27 #include <QDesktopServices>
28 #include <QDrag>
29 #include <QGraphicsSceneMouseEvent>
30 #include <QMenu>
31 #include <QMenuBar>
32 #include <QMimeData>
33 #include <QPersistentModelIndex>
34 #include <QUrl>
35
36 #ifdef HAVE_WEBENGINE
37 #    include <QWebEngineView>
38 #elif defined HAVE_WEBKIT
39 #    include <QWebView>
40 #endif
41
42 #include "chatitem.h"
43 #include "chatline.h"
44 #include "chatlinemodelitem.h"
45 #include "chatview.h"
46 #include "chatviewsettings.h"
47 #include "client.h"
48 #include "clientbacklogmanager.h"
49 #include "columnhandleitem.h"
50 #include "contextmenuactionprovider.h"
51 #include "icon.h"
52 #include "mainwin.h"
53 #include "markerlineitem.h"
54 #include "messagefilter.h"
55 #include "qtui.h"
56 #include "qtuistyle.h"
57 #include "webpreviewitem.h"
58
59 const qreal minContentsWidth = 200;
60
61 ChatScene::ChatScene(QAbstractItemModel* model, QString idString, qreal width, ChatView* parent)
62     : QGraphicsScene(0, 0, width, 0, (QObject*)parent)
63     , _chatView(parent)
64     , _idString(std::move(idString))
65     , _model(model)
66     , _singleBufferId(BufferId())
67     , _sceneRect(0, 0, width, 0)
68     , _firstLineRow(-1)
69     , _viewportHeight(0)
70     , _markerLine(new MarkerLineItem(width))
71     , _markerLineVisible(false)
72     , _markerLineValid(false)
73     , _markerLineJumpPending(false)
74     , _cutoffMode(CutoffRight)
75     , _alwaysBracketSender(false)
76     , _selectingItem(nullptr)
77     , _selectionStart(-1)
78     , _isSelecting(false)
79     , _clickMode(NoClick)
80     , _clickHandled(true)
81     , _leftButtonPressed(false)
82 {
83     auto* filter = qobject_cast<MessageFilter*>(model);
84     if (filter && filter->isSingleBufferFilter()) {
85         _singleBufferId = filter->singleBufferId();
86     }
87
88     addItem(_markerLine);
89     connect(this, &QGraphicsScene::sceneRectChanged, _markerLine, &MarkerLineItem::sceneRectChanged);
90
91     ChatViewSettings defaultSettings;
92     _defaultFirstColHandlePos = defaultSettings.value("FirstColumnHandlePos", 80).toInt();
93     _defaultSecondColHandlePos = defaultSettings.value("SecondColumnHandlePos", 200).toInt();
94
95     ChatViewSettings viewSettings(this);
96     _firstColHandlePos = viewSettings.value("FirstColumnHandlePos", _defaultFirstColHandlePos).toInt();
97     _secondColHandlePos = viewSettings.value("SecondColumnHandlePos", _defaultSecondColHandlePos).toInt();
98
99     _firstColHandle = new ColumnHandleItem(QtUi::style()->firstColumnSeparator());
100     addItem(_firstColHandle);
101     _firstColHandle->setXPos(_firstColHandlePos);
102     connect(_firstColHandle, &ColumnHandleItem::positionChanged, this, &ChatScene::firstHandlePositionChanged);
103     connect(this, &QGraphicsScene::sceneRectChanged, _firstColHandle, &ColumnHandleItem::sceneRectChanged);
104
105     _secondColHandle = new ColumnHandleItem(QtUi::style()->secondColumnSeparator());
106     addItem(_secondColHandle);
107     _secondColHandle->setXPos(_secondColHandlePos);
108     connect(_secondColHandle, &ColumnHandleItem::positionChanged, this, &ChatScene::secondHandlePositionChanged);
109
110     connect(this, &QGraphicsScene::sceneRectChanged, _secondColHandle, &ColumnHandleItem::sceneRectChanged);
111
112     setHandleXLimits();
113
114     if (model->rowCount() > 0)
115         rowsInserted(QModelIndex(), 0, model->rowCount() - 1);
116
117     connect(model, &QAbstractItemModel::rowsInserted, this, &ChatScene::rowsInserted);
118     connect(model, &QAbstractItemModel::rowsAboutToBeRemoved, this, &ChatScene::rowsAboutToBeRemoved);
119     connect(model, &QAbstractItemModel::rowsRemoved, this, &ChatScene::rowsRemoved);
120     connect(model, &QAbstractItemModel::dataChanged, this, &ChatScene::dataChanged);
121
122 #if defined HAVE_WEBKIT || defined HAVE_WEBENGINE
123     webPreview.timer.setSingleShot(true);
124     connect(&webPreview.timer, &QTimer::timeout, this, &ChatScene::webPreviewNextStep);
125 #endif
126     _showWebPreview = defaultSettings.showWebPreview();
127     defaultSettings.notify("ShowWebPreview", this, &ChatScene::showWebPreviewChanged);
128
129     _showSenderBrackets = defaultSettings.showSenderBrackets();
130     defaultSettings.notify("ShowSenderBrackets", this, &ChatScene::showSenderBracketsChanged);
131
132     _useCustomTimestampFormat = defaultSettings.useCustomTimestampFormat();
133     defaultSettings.notify("UseCustomTimestampFormat", this, &ChatScene::useCustomTimestampFormatChanged);
134
135     _timestampFormatString = defaultSettings.timestampFormatString();
136     defaultSettings.notify("TimestampFormat", this, &ChatScene::timestampFormatStringChanged);
137     updateTimestampHasBrackets();
138
139     _clickTimer.setInterval(QApplication::doubleClickInterval());
140     _clickTimer.setSingleShot(true);
141     connect(&_clickTimer, &QTimer::timeout, this, &ChatScene::clickTimeout);
142
143     setItemIndexMethod(QGraphicsScene::NoIndex);
144 }
145
146 ChatView* ChatScene::chatView() const
147 {
148     return _chatView;
149 }
150
151 ColumnHandleItem* ChatScene::firstColumnHandle() const
152 {
153     return _firstColHandle;
154 }
155
156 ColumnHandleItem* ChatScene::secondColumnHandle() const
157 {
158     return _secondColHandle;
159 }
160
161 void ChatScene::resetColumnWidths()
162 {
163     // make sure first column is at least 80 px wide, second 120 px
164     int firstColHandlePos = qMax(_defaultFirstColHandlePos, 80);
165     int secondColHandlePos = qMax(_defaultSecondColHandlePos, firstColHandlePos + 120);
166
167     _firstColHandle->setXPos(firstColHandlePos);
168     _secondColHandle->setXPos(secondColHandlePos);
169 }
170
171 ChatLine* ChatScene::chatLine(MsgId msgId, bool matchExact, bool ignoreDayChange) const
172 {
173     if (!_lines.count())
174         return nullptr;
175
176     QList<ChatLine*>::ConstIterator start = _lines.begin();
177     QList<ChatLine*>::ConstIterator end = _lines.end();
178     QList<ChatLine*>::ConstIterator middle;
179
180     auto n = int(end - start);
181     int half;
182
183     while (n > 0) {
184         half = n >> 1;
185         middle = start + half;
186         if ((*middle)->msgId() < msgId) {
187             start = middle + 1;
188             n -= half + 1;
189         }
190         else {
191             n = half;
192         }
193     }
194
195     if (start != end && (*start)->msgId() == msgId && (ignoreDayChange ? (*start)->msgType() != Message::DayChange : true))
196         return *start;
197
198     if (matchExact)
199         return nullptr;
200
201     if (start == _lines.begin())  // not (yet?) in our scene
202         return nullptr;
203
204     // if we didn't find the exact msgId, take the next-lower one (this makes sense for lastSeen)
205
206     if (start == end) {  // higher than last element
207         if (!ignoreDayChange)
208             return _lines.last();
209
210         for (int i = _lines.count() - 1; i >= 0; i--) {
211             if (_lines.at(i)->msgType() != Message::DayChange)
212                 return _lines.at(i);
213         }
214         return nullptr;
215     }
216
217     // return the next-lower line
218     if (!ignoreDayChange)
219         return *(--start);
220
221     do {
222         if ((*(--start))->msgType() != Message::DayChange)
223             return *start;
224     } while (start != _lines.begin());
225     return nullptr;
226 }
227
228 ChatItem* ChatScene::chatItemAt(const QPointF& scenePos) const
229 {
230     foreach (QGraphicsItem* item, items(scenePos, Qt::IntersectsItemBoundingRect, Qt::AscendingOrder)) {
231         auto* line = qgraphicsitem_cast<ChatLine*>(item);
232         if (line)
233             return line->itemAt(line->mapFromScene(scenePos));
234     }
235     return nullptr;
236 }
237
238 bool ChatScene::containsBuffer(const BufferId& id) const
239 {
240     auto* filter = qobject_cast<MessageFilter*>(model());
241     if (filter)
242         return filter->containsBuffer(id);
243     else
244         return false;
245 }
246
247 void ChatScene::setMarkerLineVisible(bool visible)
248 {
249     _markerLineVisible = visible;
250     if (visible && _markerLineValid)
251         markerLine()->setVisible(true);
252     else
253         markerLine()->setVisible(false);
254 }
255
256 void ChatScene::setMarkerLine(MsgId msgId)
257 {
258     if (!isSingleBufferScene())
259         return;
260
261     if (!msgId.isValid())
262         msgId = Client::markerLine(singleBufferId());
263
264     if (msgId.isValid()) {
265         ChatLine* line = chatLine(msgId, false, true);
266         if (line) {
267             markerLine()->setChatLine(line);
268             // if this was the last line, we won't see it because it's outside the sceneRect
269             // .. which is exactly what we want :)
270             markerLine()->setPos(line->pos() + QPointF(0, line->height()));
271
272             // DayChange messages might have been hidden outside the scene rect, don't make the markerline visible then!
273             if (markerLine()->pos().y() >= sceneRect().y()) {
274                 _markerLineValid = true;
275                 if (_markerLineVisible)
276                     markerLine()->setVisible(true);
277                 if (_markerLineJumpPending) {
278                     _markerLineJumpPending = false;
279                     if (markerLine()->isVisible()) {
280                         markerLine()->ensureVisible(QRectF(), 50, 50);
281                     }
282                 }
283                 return;
284             }
285         }
286     }
287     _markerLineValid = false;
288     markerLine()->setVisible(false);
289 }
290
291 void ChatScene::jumpToMarkerLine(bool requestBacklog)
292 {
293     if (!isSingleBufferScene())
294         return;
295
296     if (markerLine()->isVisible()) {
297         markerLine()->ensureVisible(QRectF(), 50, 50);
298         return;
299     }
300     if (!_markerLineValid && requestBacklog) {
301         MsgId msgId = Client::markerLine(singleBufferId());
302         if (msgId.isValid()) {
303             _markerLineJumpPending = true;
304             Client::backlogManager()->requestBacklog(singleBufferId(), msgId, -1, -1, 0);
305
306             // If we filtered out the lastSeenMsg (by changing filters after setting it), we'd never jump because the above request
307             // won't fetch any prior lines. Thus, trigger a dynamic backlog request just in case, so repeated
308             // jump tries will eventually cause enough backlog to be fetched.
309             // This is a bit hackish, but not wasteful, as jumping to the top of the ChatView would trigger a dynamic fetch anyway.
310             this->requestBacklog();
311         }
312     }
313 }
314
315 void ChatScene::rowsInserted(const QModelIndex& index, int start, int end)
316 {
317     Q_UNUSED(index);
318
319     //   QModelIndex sidx = model()->index(start, 2);
320     //   QModelIndex eidx = model()->index(end, 2);
321     //   qDebug() << "rowsInserted:";
322     //   if(start > 0) {
323     //     QModelIndex ssidx = model()->index(start - 1, 2);
324     //     qDebug() << "Start--:" << start - 1 << ssidx.data(MessageModel::MsgIdRole).value<MsgId>()
325     //           << ssidx.data(Qt::DisplayRole).toString();
326     //   }
327     //   qDebug() << "Start:" << start << sidx.data(MessageModel::MsgIdRole).value<MsgId>()
328     //         << sidx.data(Qt::DisplayRole).toString();
329     //   qDebug() << "End:" << end << eidx.data(MessageModel::MsgIdRole).value<MsgId>()
330     //         << eidx.data(Qt::DisplayRole).toString();
331     //   if(end + 1 < model()->rowCount()) {
332     //     QModelIndex eeidx = model()->index(end + 1, 2);
333     //     qDebug() << "End++:" << end + 1 << eeidx.data(MessageModel::MsgIdRole).value<MsgId>()
334     //           << eeidx.data(Qt::DisplayRole).toString();
335     //   }
336
337     qreal h = 0;
338     qreal y = 0;
339     qreal width = _sceneRect.width();
340     bool atBottom = (start == _lines.count());
341     bool atTop = !atBottom && (start == 0);
342
343     if (start < _lines.count()) {
344         y = _lines.value(start)->y();
345     }
346     else if (atBottom && !_lines.isEmpty()) {
347         y = _lines.last()->y() + _lines.last()->height();
348     }
349
350     qreal contentsWidth = width - secondColumnHandle()->sceneRight();
351     qreal senderWidth = secondColumnHandle()->sceneLeft() - firstColumnHandle()->sceneRight();
352     qreal timestampWidth = firstColumnHandle()->sceneLeft();
353     QPointF contentsPos(secondColumnHandle()->sceneRight(), 0);
354     QPointF senderPos(firstColumnHandle()->sceneRight(), 0);
355
356     if (atTop) {
357         for (int i = end; i >= start; i--) {
358             auto* line = new ChatLine(i, model(), width, timestampWidth, senderWidth, contentsWidth, senderPos, contentsPos);
359             h += line->height();
360             line->setPos(0, y - h);
361             _lines.insert(start, line);
362             addItem(line);
363         }
364     }
365     else {
366         for (int i = start; i <= end; i++) {
367             auto* line = new ChatLine(i, model(), width, timestampWidth, senderWidth, contentsWidth, senderPos, contentsPos);
368             line->setPos(0, y + h);
369             h += line->height();
370             _lines.insert(i, line);
371             addItem(line);
372         }
373     }
374
375     // update existing items
376     for (int i = end + 1; i < _lines.count(); i++) {
377         _lines[i]->setRow(i);
378     }
379
380     // update selection
381     if (_selectionStart >= 0) {
382         int offset = end - start + 1;
383         int oldStart = _selectionStart;
384         if (_selectionStart >= start)
385             _selectionStart += offset;
386         if (_selectionEnd >= start) {
387             _selectionEnd += offset;
388             if (_selectionStart == oldStart)
389                 for (int i = start; i < start + offset; i++)
390                     _lines[i]->setSelected(true);
391         }
392         if (_firstSelectionRow >= start)
393             _firstSelectionRow += offset;
394     }
395
396     // neither pre- or append means we have to do dirty work: move items...
397     if (!(atTop || atBottom)) {
398         ChatLine* line = nullptr;
399         for (int i = 0; i <= end; i++) {
400             line = _lines.at(i);
401             line->setPos(0, line->pos().y() - h);
402             if (line == markerLine()->chatLine())
403                 markerLine()->setPos(line->pos() + QPointF(0, line->height()));
404         }
405     }
406
407     // check if all went right
408     Q_ASSERT(start == 0 || _lines.at(start - 1)->pos().y() + _lines.at(start - 1)->height() == _lines.at(start)->pos().y());
409     //   if(start != 0) {
410     //     if(_lines.at(start - 1)->pos().y() + _lines.at(start - 1)->height() != _lines.at(start)->pos().y()) {
411     //       qDebug() << "lines:" << _lines.count() << "start:" << start << "end:" << end;
412     //       qDebug() << "line[start - 1]:" << _lines.at(start - 1)->pos().y() << "+" << _lines.at(start - 1)->height() << "=" <<
413     //       _lines.at(start - 1)->pos().y() + _lines.at(start - 1)->height(); qDebug() << "line[start]" << _lines.at(start)->pos().y(); qDebug()
414     //       << "needed moving:" << !(atTop || atBottom) << moveTop << moveStart << moveEnd << offset; Q_ASSERT(false)
415     //     }
416     //   }
417     Q_ASSERT(end + 1 == _lines.count() || _lines.at(end)->pos().y() + _lines.at(end)->height() == _lines.at(end + 1)->pos().y());
418     //   if(end + 1 < _lines.count()) {
419     //     if(_lines.at(end)->pos().y() + _lines.at(end)->height() != _lines.at(end + 1)->pos().y()) {
420     //       qDebug() << "lines:" << _lines.count() << "start:" << start << "end:" << end;
421     //       qDebug() << "line[end]:" << _lines.at(end)->pos().y() << "+" << _lines.at(end)->height() << "=" << _lines.at(end)->pos().y() +
422     //       _lines.at(end)->height(); qDebug() << "line[end+1]" << _lines.at(end + 1)->pos().y(); qDebug() << "needed moving:" << !(atTop
423     //       || atBottom) << moveTop << moveStart << moveEnd << offset; Q_ASSERT(false);
424     //     }
425     //   }
426
427     if (!atBottom) {
428         if (start < _firstLineRow) {
429             int prevFirstLineRow = _firstLineRow + (end - start + 1);
430             for (int i = end + 1; i < prevFirstLineRow; i++) {
431                 _lines.at(i)->show();
432             }
433         }
434         // force new search for first proper line
435         _firstLineRow = -1;
436     }
437     updateSceneRect();
438     if (atBottom) {
439         emit lastLineChanged(_lines.last(), h);
440     }
441
442     // now move the marker line if necessary. we don't need to do anything if we appended lines though...
443     if (!_markerLineValid)
444         setMarkerLine();
445 }
446
447 void ChatScene::rowsAboutToBeRemoved(const QModelIndex& parent, int start, int end)
448 {
449     Q_UNUSED(parent);
450
451     qreal h = 0;  // total height of removed items;
452
453     bool atTop = (start == 0);
454     bool atBottom = (end == _lines.count() - 1);
455
456     // clear selection
457     if (_selectingItem) {
458         int row = _selectingItem->row();
459         if (row >= start && row <= end)
460             setSelectingItem(nullptr);
461     }
462
463     // remove items from scene
464     QList<ChatLine*>::iterator lineIter = _lines.begin() + start;
465     int lineCount = start;
466     while (lineIter != _lines.end() && lineCount <= end) {
467         if ((*lineIter) == markerLine()->chatLine())
468             markerLine()->setChatLine(nullptr);
469         h += (*lineIter)->height();
470         delete *lineIter;
471         lineIter = _lines.erase(lineIter);
472         lineCount++;
473     }
474
475     // update rows of remaining chatlines
476     for (int i = start; i < _lines.count(); i++) {
477         _lines.at(i)->setRow(i);
478     }
479
480     // update selection
481     if (_selectionStart >= 0) {
482         int offset = end - start + 1;
483         if (_selectionStart >= start)
484             _selectionStart = qMax(_selectionStart - offset, start);
485         if (_selectionEnd >= start)
486             _selectionEnd -= offset;
487         if (_firstSelectionRow >= start)
488             _firstSelectionRow -= offset;
489
490         if (_selectionEnd < _selectionStart) {
491             _isSelecting = false;
492             _selectionStart = -1;
493         }
494     }
495
496     // neither removing at bottom or top means we have to move items...
497     if (!(atTop || atBottom)) {
498         qreal offset = h;
499         int moveStart = 0;
500         int moveEnd = _lines.count() - 1;
501         if (start < _lines.count() - start) {
502             // move top part
503             moveEnd = start - 1;
504         }
505         else {
506             // move bottom part
507             moveStart = start;
508             offset = -offset;
509         }
510         ChatLine* line = nullptr;
511         for (int i = moveStart; i <= moveEnd; i++) {
512             line = _lines.at(i);
513             line->setPos(0, line->pos().y() + offset);
514         }
515     }
516
517     Q_ASSERT(start == 0 || start >= _lines.count()
518              || _lines.at(start - 1)->pos().y() + _lines.at(start - 1)->height() == _lines.at(start)->pos().y());
519
520     // update sceneRect
521     // when searching for the first non-date-line we have to take into account that our
522     // model still contains the just removed lines so we cannot simply call updateSceneRect()
523     int numRows = model()->rowCount();
524     QModelIndex firstLineIdx;
525     _firstLineRow = -1;
526     bool needOffset = false;
527     do {
528         _firstLineRow++;
529         if (_firstLineRow >= start && _firstLineRow <= end) {
530             _firstLineRow = end + 1;
531             needOffset = true;
532         }
533         firstLineIdx = model()->index(_firstLineRow, 0);
534     } while ((Message::Type)(model()->data(firstLineIdx, MessageModel::TypeRole).toInt()) == Message::DayChange && _firstLineRow < numRows);
535
536     if (needOffset)
537         _firstLineRow -= end - start + 1;
538     updateSceneRect();
539 }
540
541 void ChatScene::rowsRemoved()
542 {
543     // move the marker line if necessary
544     setMarkerLine();
545 }
546
547 void ChatScene::dataChanged(const QModelIndex& tl, const QModelIndex& br)
548 {
549     layout(tl.row(), br.row(), _sceneRect.width());
550 }
551
552 void ChatScene::updateForViewport(qreal width, qreal height)
553 {
554     _viewportHeight = height;
555     setWidth(width);
556 }
557
558 void ChatScene::setWidth(qreal width)
559 {
560     if (width == _sceneRect.width())
561         return;
562     layout(0, _lines.count() - 1, width);
563 }
564
565 void ChatScene::layout(int start, int end, qreal width)
566 {
567     // clock_t startT = clock();
568
569     // disabling the index while doing this complex updates is about
570     // 2 to 10 times faster!
571     // setItemIndexMethod(QGraphicsScene::NoIndex);
572
573     if (end >= 0) {
574         int row = end;
575         qreal linePos = _lines.at(row)->scenePos().y() + _lines.at(row)->height();
576         qreal contentsWidth = width - secondColumnHandle()->sceneRight();
577         while (row >= start) {
578             _lines.at(row--)->setGeometryByWidth(width, contentsWidth, linePos);
579         }
580
581         if (row >= 0) {
582             // remaining items don't need geometry changes, but maybe repositioning?
583             ChatLine* line = _lines.at(row);
584             qreal offset = linePos - (line->scenePos().y() + line->height());
585             if (offset != 0) {
586                 while (row >= 0) {
587                     line = _lines.at(row--);
588                     line->setPos(0, line->scenePos().y() + offset);
589                 }
590             }
591         }
592     }
593
594     // setItemIndexMethod(QGraphicsScene::BspTreeIndex);
595
596     updateSceneRect(width);
597     setHandleXLimits();
598     setMarkerLine();
599     emit layoutChanged();
600
601     //   clock_t endT = clock();
602     //   qDebug() << "resized" << _lines.count() << "in" << (float)(endT - startT) / CLOCKS_PER_SEC << "sec";
603 }
604
605 void ChatScene::firstHandlePositionChanged(qreal xpos)
606 {
607     if (_firstColHandlePos == xpos)
608         return;
609
610     _firstColHandlePos = xpos >= 0 ? xpos : 0;
611     ChatViewSettings viewSettings(this);
612     viewSettings.setValue("FirstColumnHandlePos", _firstColHandlePos);
613     ChatViewSettings defaultSettings;
614     defaultSettings.setValue("FirstColumnHandlePos", _firstColHandlePos);
615
616     // clock_t startT = clock();
617
618     // disabling the index while doing this complex updates is about
619     // 2 to 10 times faster!
620     // setItemIndexMethod(QGraphicsScene::NoIndex);
621
622     QList<ChatLine*>::iterator lineIter = _lines.end();
623     QList<ChatLine*>::iterator lineIterBegin = _lines.begin();
624     qreal timestampWidth = firstColumnHandle()->sceneLeft();
625     qreal senderWidth = secondColumnHandle()->sceneLeft() - firstColumnHandle()->sceneRight();
626     QPointF senderPos(firstColumnHandle()->sceneRight(), 0);
627
628     while (lineIter != lineIterBegin) {
629         --lineIter;
630         (*lineIter)->setFirstColumn(timestampWidth, senderWidth, senderPos);
631     }
632     // setItemIndexMethod(QGraphicsScene::BspTreeIndex);
633
634     setHandleXLimits();
635
636     //   clock_t endT = clock();
637     //   qDebug() << "resized" << _lines.count() << "in" << (float)(endT - startT) / CLOCKS_PER_SEC << "sec";
638 }
639
640 void ChatScene::secondHandlePositionChanged(qreal xpos)
641 {
642     if (_secondColHandlePos == xpos)
643         return;
644
645     _secondColHandlePos = xpos;
646     ChatViewSettings viewSettings(this);
647     viewSettings.setValue("SecondColumnHandlePos", _secondColHandlePos);
648     ChatViewSettings defaultSettings;
649     defaultSettings.setValue("SecondColumnHandlePos", _secondColHandlePos);
650
651     // clock_t startT = clock();
652
653     // disabling the index while doing this complex updates is about
654     // 2 to 10 times faster!
655     // setItemIndexMethod(QGraphicsScene::NoIndex);
656
657     QList<ChatLine*>::iterator lineIter = _lines.end();
658     QList<ChatLine*>::iterator lineIterBegin = _lines.begin();
659     qreal linePos = _sceneRect.y() + _sceneRect.height();
660     qreal senderWidth = secondColumnHandle()->sceneLeft() - firstColumnHandle()->sceneRight();
661     qreal contentsWidth = _sceneRect.width() - secondColumnHandle()->sceneRight();
662     QPointF contentsPos(secondColumnHandle()->sceneRight(), 0);
663     while (lineIter != lineIterBegin) {
664         --lineIter;
665         (*lineIter)->setSecondColumn(senderWidth, contentsWidth, contentsPos, linePos);
666     }
667     // setItemIndexMethod(QGraphicsScene::BspTreeIndex);
668
669     updateSceneRect();
670     setHandleXLimits();
671     emit layoutChanged();
672
673     //   clock_t endT = clock();
674     //   qDebug() << "resized" << _lines.count() << "in" << (float)(endT - startT) / CLOCKS_PER_SEC << "sec";
675 }
676
677 void ChatScene::setHandleXLimits()
678 {
679     _firstColHandle->setXLimits(0, _secondColHandle->sceneLeft());
680     _secondColHandle->setXLimits(_firstColHandle->sceneRight(), width() - minContentsWidth);
681     update();
682 }
683
684 void ChatScene::setSelectingItem(ChatItem* item)
685 {
686     if (_selectingItem)
687         _selectingItem->clearSelection();
688     _selectingItem = item;
689 }
690
691 void ChatScene::startGlobalSelection(ChatItem* item, const QPointF& itemPos)
692 {
693     _selectionStart = _selectionEnd = _firstSelectionRow = item->row();
694     _selectionStartCol = _selectionMinCol = item->column();
695     _isSelecting = true;
696     _lines[_selectionStart]->setSelected(true, (ChatLineModel::ColumnType)_selectionMinCol);
697     updateSelection(item->mapToScene(itemPos));
698 }
699
700 void ChatScene::updateSelection(const QPointF& pos)
701 {
702     int curRow = rowByScenePos(pos);
703     if (curRow < 0)
704         return;
705     auto curColumn = (int)columnByScenePos(pos);
706     auto minColumn = (ChatLineModel::ColumnType)qMin(curColumn, _selectionStartCol);
707     if (minColumn != _selectionMinCol) {
708         _selectionMinCol = minColumn;
709         for (int l = qMin(_selectionStart, _selectionEnd); l <= qMax(_selectionStart, _selectionEnd); l++) {
710             _lines[l]->setSelected(true, minColumn);
711         }
712     }
713     int newstart = qMin(curRow, _firstSelectionRow);
714     int newend = qMax(curRow, _firstSelectionRow);
715     if (newstart < _selectionStart) {
716         for (int l = newstart; l < _selectionStart; l++)
717             _lines[l]->setSelected(true, minColumn);
718     }
719     if (newstart > _selectionStart) {
720         for (int l = _selectionStart; l < newstart; l++)
721             _lines[l]->setSelected(false);
722     }
723     if (newend > _selectionEnd) {
724         for (int l = _selectionEnd + 1; l <= newend; l++)
725             _lines[l]->setSelected(true, minColumn);
726     }
727     if (newend < _selectionEnd) {
728         for (int l = newend + 1; l <= _selectionEnd; l++)
729             _lines[l]->setSelected(false);
730     }
731
732     _selectionStart = newstart;
733     _selectionEnd = newend;
734
735     if (newstart == newend && minColumn == ChatLineModel::ContentsColumn) {
736         if (!_selectingItem) {
737             // _selectingItem has been removed already
738             return;
739         }
740         _lines[curRow]->setSelected(false);
741         _isSelecting = false;
742         _selectionStart = -1;
743         _selectingItem->continueSelecting(_selectingItem->mapFromScene(pos));
744     }
745 }
746
747 bool ChatScene::isPosOverSelection(const QPointF& pos) const
748 {
749     ChatItem* chatItem = chatItemAt(pos);
750     if (!chatItem)
751         return false;
752     if (hasGlobalSelection()) {
753         int row = chatItem->row();
754         if (row >= qMin(_selectionStart, _selectionEnd) && row <= qMax(_selectionStart, _selectionEnd))
755             return columnByScenePos(pos) >= _selectionMinCol;
756     }
757     else {
758         return chatItem->isPosOverSelection(chatItem->mapFromScene(pos));
759     }
760     return false;
761 }
762
763 bool ChatScene::isScrollingAllowed() const
764 {
765     if (_isSelecting)
766         return false;
767
768     // TODO: Handle clicks and single-item selections too
769
770     return true;
771 }
772
773 /******** MOUSE HANDLING **************************************************************************/
774
775 void ChatScene::contextMenuEvent(QGraphicsSceneContextMenuEvent* event)
776 {
777     QPointF pos = event->scenePos();
778     QMenu menu;
779
780     // zoom actions and similar
781     chatView()->addActionsToMenu(&menu, pos);
782     menu.addSeparator();
783
784     // item-specific options (select link etc)
785     ChatItem* item = chatItemAt(pos);
786     if (item)
787         item->addActionsToMenu(&menu, item->mapFromScene(pos));
788     else
789         // no item -> default scene actions
790         GraphicalUi::contextMenuActionProvider()->addActions(&menu, filter(), BufferId());
791
792     // If we have text selected, insert the Copy Selection as first item
793     if (isPosOverSelection(pos)) {
794         QAction* sep = menu.insertSeparator(menu.actions().first());
795         QAction* act = new Action(icon::get("edit-copy"),
796                                   tr("Copy Selection"),
797                                   &menu,
798                                   this,
799                                   [this]() { selectionToClipboard(); },
800                                   QKeySequence::Copy);
801         menu.insertAction(sep, act);
802
803         QString searchSelectionText = selection();
804         if (searchSelectionText.length() > _webSearchSelectionTextMaxVisible)
805             searchSelectionText = searchSelectionText.left(_webSearchSelectionTextMaxVisible).append(QString::fromUtf8("…"));
806         searchSelectionText = tr("Search '%1'").arg(searchSelectionText);
807
808         QAction* webSearchAction = new Action(icon::get("edit-find"), searchSelectionText, &menu, this, &ChatScene::webSearchOnSelection);
809         menu.insertAction(sep, webSearchAction);
810     }
811
812     if (QtUi::mainWindow()->menuBar()->isHidden())
813         menu.addAction(QtUi::actionCollection("General")->action("ToggleMenuBar"));
814
815     // show column reset action if columns have been resized in this session or there is at least one very narrow column
816     if ((_firstColHandlePos != _defaultFirstColHandlePos) || (_secondColHandlePos != _defaultSecondColHandlePos)
817         || (_firstColHandlePos <= 10) || (_secondColHandlePos - _firstColHandlePos <= 10))
818         menu.addAction(new Action(tr("Reset Column Widths"), &menu, this, &ChatScene::resetColumnWidths));
819
820     menu.exec(event->screenPos());
821 }
822
823 void ChatScene::mouseMoveEvent(QGraphicsSceneMouseEvent* event)
824 {
825     if (event->buttons() == Qt::LeftButton) {
826         if (!_clickHandled && (event->scenePos() - _clickPos).toPoint().manhattanLength() >= QApplication::startDragDistance()) {
827             if (_clickTimer.isActive())
828                 _clickTimer.stop();
829             if (_clickMode == SingleClick && isPosOverSelection(_clickPos))
830                 initiateDrag(event->widget());
831             else {
832                 _clickMode = DragStartClick;
833                 handleClick(Qt::LeftButton, _clickPos);
834             }
835             _clickMode = NoClick;
836         }
837         if (_isSelecting) {
838             updateSelection(event->scenePos());
839             emit mouseMoveWhileSelecting(event->scenePos());
840             event->accept();
841         }
842         else if (_clickHandled && _clickMode < DoubleClick)
843             QGraphicsScene::mouseMoveEvent(event);
844     }
845     else
846         QGraphicsScene::mouseMoveEvent(event);
847 }
848
849 void ChatScene::mousePressEvent(QGraphicsSceneMouseEvent* event)
850 {
851     if (event->buttons() == Qt::LeftButton) {
852         _leftButtonPressed = true;
853         _clickHandled = false;
854         if (!isPosOverSelection(event->scenePos())) {
855             // immediately clear selection if clicked outside; otherwise, wait for potential drag
856             clearSelection();
857         }
858         if (_clickMode != NoClick && _clickTimer.isActive()) {
859             switch (_clickMode) {
860             case NoClick:
861                 _clickMode = SingleClick;
862                 break;
863             case SingleClick:
864                 _clickMode = DoubleClick;
865                 break;
866             case DoubleClick:
867                 _clickMode = TripleClick;
868                 break;
869             case TripleClick:
870                 _clickMode = DoubleClick;
871                 break;
872             case DragStartClick:
873                 break;
874             }
875             handleClick(Qt::LeftButton, _clickPos);
876         }
877         else {
878             _clickMode = SingleClick;
879             _clickPos = event->scenePos();
880         }
881         _clickTimer.start();
882     }
883     if (event->type() == QEvent::GraphicsSceneMouseDoubleClick)
884         QGraphicsScene::mouseDoubleClickEvent(event);
885     else
886         QGraphicsScene::mousePressEvent(event);
887 }
888
889 void ChatScene::mouseDoubleClickEvent(QGraphicsSceneMouseEvent* event)
890 {
891     // we check for doubleclick ourselves, so just call press handler
892     mousePressEvent(event);
893 }
894
895 void ChatScene::mouseReleaseEvent(QGraphicsSceneMouseEvent* event)
896 {
897     if (event->button() == Qt::LeftButton && _leftButtonPressed) {
898         _leftButtonPressed = false;
899         if (_clickMode != NoClick) {
900             if (_clickMode == SingleClick)
901                 clearSelection();
902             event->accept();
903             if (!_clickTimer.isActive())
904                 handleClick(Qt::LeftButton, _clickPos);
905         }
906         else {
907             // no click -> drag or selection move
908             if (isGloballySelecting()) {
909                 selectionToClipboard(QClipboard::Selection);
910                 _isSelecting = false;
911                 event->accept();
912                 return;
913             }
914         }
915     }
916     QGraphicsScene::mouseReleaseEvent(event);
917 }
918
919 void ChatScene::clickTimeout()
920 {
921     if (!_leftButtonPressed && _clickMode == SingleClick)
922         handleClick(Qt::LeftButton, _clickPos);
923 }
924
925 void ChatScene::handleClick(Qt::MouseButton button, const QPointF& scenePos)
926 {
927     if (button == Qt::LeftButton) {
928         clearSelection();
929
930         // Now send click down to items
931         ChatItem* chatItem = chatItemAt(scenePos);
932         if (chatItem) {
933             chatItem->handleClick(chatItem->mapFromScene(scenePos), _clickMode);
934         }
935         _clickHandled = true;
936     }
937 }
938
939 void ChatScene::initiateDrag(QWidget* source)
940 {
941     auto* drag = new QDrag(source);
942     auto* mimeData = new QMimeData;
943     mimeData->setText(selection());
944     drag->setMimeData(mimeData);
945
946     drag->exec(Qt::CopyAction);
947 }
948
949 /******** SELECTIONS ******************************************************************************/
950
951 void ChatScene::selectionToClipboard(QClipboard::Mode mode)
952 {
953     if (!hasSelection())
954         return;
955
956     stringToClipboard(selection(), mode);
957 }
958
959 void ChatScene::stringToClipboard(const QString& str_, QClipboard::Mode mode)
960 {
961     QString str = str_;
962     // remove trailing linefeeds
963     if (str.endsWith('\n'))
964         str.chop(1);
965
966     switch (mode) {
967     case QClipboard::Clipboard:
968         QApplication::clipboard()->setText(str);
969         break;
970     case QClipboard::Selection:
971         if (QApplication::clipboard()->supportsSelection())
972             QApplication::clipboard()->setText(str, QClipboard::Selection);
973         break;
974     default:
975         break;
976     };
977 }
978
979 //!\brief Convert current selection to human-readable string.
980 QString ChatScene::selection() const
981 {
982     // TODO Make selection format configurable!
983     if (hasGlobalSelection()) {
984         int start = qMin(_selectionStart, _selectionEnd);
985         int end = qMax(_selectionStart, _selectionEnd);
986         if (start < 0 || end >= _lines.count()) {
987             qDebug() << "Invalid selection range:" << start << end;
988             return QString();
989         }
990         QString result;
991
992         for (int l = start; l <= end; l++) {
993             if (_selectionMinCol == ChatLineModel::TimestampColumn) {
994                 ChatItem* item = _lines[l]->item(ChatLineModel::TimestampColumn);
995                 if (!_showSenderBrackets && !_timestampHasBrackets) {
996                     // Only re-add brackets if the current timestamp format does not include them
997                     // -and- sender brackets are disabled.  Don't filter on Message::Plain as
998                     // timestamp brackets affect all types of messages.
999                     // Remove any spaces before and after, otherwise it may look weird.
1000                     result += QString("[%1] ").arg(item->data(MessageModel::DisplayRole).toString().trimmed());
1001                 }
1002                 else {
1003                     result += item->data(MessageModel::DisplayRole).toString() + " ";
1004                 }
1005             }
1006             if (_selectionMinCol <= ChatLineModel::SenderColumn) {
1007                 ChatItem* item = _lines[l]->item(ChatLineModel::SenderColumn);
1008                 if (!_showSenderBrackets && (_alwaysBracketSender || item->chatLine()->msgType() == Message::Plain)) {
1009                     // Copying to plain-text.  Re-add the sender brackets if they're normally hidden
1010                     // for...
1011                     // * Plain messages
1012                     // * All messages in the Chat Monitor
1013                     //
1014                     // The Chat Monitor sets alwaysBracketSender() to true.
1015                     result += QString("<%1> ").arg(item->data(MessageModel::DisplayRole).toString());
1016                 }
1017                 else {
1018                     result += item->data(MessageModel::DisplayRole).toString() + " ";
1019                 }
1020             }
1021             result += _lines[l]->item(ChatLineModel::ContentsColumn)->data(MessageModel::DisplayRole).toString() + "\n";
1022         }
1023         return result;
1024     }
1025     else if (selectingItem())
1026         return selectingItem()->selection();
1027     return QString();
1028 }
1029
1030 bool ChatScene::hasSelection() const
1031 {
1032     return hasGlobalSelection() || (selectingItem() && selectingItem()->hasSelection());
1033 }
1034
1035 bool ChatScene::hasGlobalSelection() const
1036 {
1037     return _selectionStart >= 0;
1038 }
1039
1040 bool ChatScene::isGloballySelecting() const
1041 {
1042     return _isSelecting;
1043 }
1044
1045 void ChatScene::clearGlobalSelection()
1046 {
1047     if (hasGlobalSelection()) {
1048         for (int l = qMin(_selectionStart, _selectionEnd); l <= qMax(_selectionStart, _selectionEnd); l++)
1049             _lines[l]->setSelected(false);
1050         _isSelecting = false;
1051         _selectionStart = -1;
1052     }
1053 }
1054
1055 void ChatScene::clearSelection()
1056 {
1057     clearGlobalSelection();
1058     if (selectingItem())
1059         selectingItem()->clearSelection();
1060 }
1061
1062 /******** *************************************************************************************/
1063
1064 void ChatScene::webSearchOnSelection()
1065 {
1066     if (!hasSelection())
1067         return;
1068
1069     ChatViewSettings settings;
1070     QString webSearchBaseUrl = settings.webSearchUrlFormatString();
1071     QString webSearchUrl = webSearchBaseUrl.replace(QString("%s"), selection());
1072     QUrl url = QUrl::fromUserInput(webSearchUrl);
1073     QDesktopServices::openUrl(url);
1074 }
1075
1076 /******** *************************************************************************************/
1077
1078 void ChatScene::requestBacklog()
1079 {
1080     auto* filter = qobject_cast<MessageFilter*>(model());
1081     if (filter)
1082         return filter->requestBacklog();
1083     return;
1084 }
1085
1086 ChatLineModel::ColumnType ChatScene::columnByScenePos(qreal x) const
1087 {
1088     if (x < _firstColHandle->x())
1089         return ChatLineModel::TimestampColumn;
1090     if (x < _secondColHandle->x())
1091         return ChatLineModel::SenderColumn;
1092
1093     return ChatLineModel::ContentsColumn;
1094 }
1095
1096 int ChatScene::rowByScenePos(qreal y) const
1097 {
1098     QList<QGraphicsItem*> itemList = items(QPointF(0, y));
1099
1100     // ChatLine should be at the bottom of the list
1101     for (int i = itemList.count() - 1; i >= 0; i--) {
1102         auto* line = qgraphicsitem_cast<ChatLine*>(itemList.at(i));
1103         if (line)
1104             return line->row();
1105     }
1106     return -1;
1107 }
1108
1109 void ChatScene::updateSceneRect(qreal width)
1110 {
1111     if (_lines.isEmpty()) {
1112         updateSceneRect(QRectF(0, 0, width, 0));
1113         return;
1114     }
1115
1116     // we hide day change messages at the top by making the scene rect smaller
1117     // and by calling QGraphicsItem::hide() on all leading day change messages
1118     // the first one is needed to ensure proper scrollbar ranges
1119     // the second for cases where the viewport is larger then the set scenerect
1120     //  (in this case the items are shown anyways)
1121     if (_firstLineRow == -1) {
1122         int numRows = model()->rowCount();
1123         _firstLineRow = 0;
1124         QModelIndex firstLineIdx;
1125         while (_firstLineRow < numRows) {
1126             firstLineIdx = model()->index(_firstLineRow, 0);
1127             if ((Message::Type)(model()->data(firstLineIdx, MessageModel::TypeRole).toInt()) != Message::DayChange)
1128                 break;
1129             _lines.at(_firstLineRow)->hide();
1130             _firstLineRow++;
1131         }
1132     }
1133
1134     // the following call should be safe. If it crashes something went wrong during insert/remove
1135     if (_firstLineRow < _lines.count()) {
1136         ChatLine* firstLine = _lines.at(_firstLineRow);
1137         ChatLine* lastLine = _lines.last();
1138         updateSceneRect(QRectF(0, firstLine->pos().y(), width, lastLine->pos().y() + lastLine->height() - firstLine->pos().y()));
1139     }
1140     else {
1141         // empty scene rect
1142         updateSceneRect(QRectF(0, 0, width, 0));
1143     }
1144 }
1145
1146 void ChatScene::updateSceneRect(const QRectF& rect)
1147 {
1148     _sceneRect = rect;
1149     setSceneRect(rect);
1150     update();
1151 }
1152
1153 // ========================================
1154 //  Webkit/WebEngine Only stuff
1155 // ========================================
1156 #if defined HAVE_WEBKIT || defined HAVE_WEBENGINE
1157 void ChatScene::loadWebPreview(ChatItem* parentItem, const QUrl& url, const QRectF& urlRect)
1158 {
1159     if (!_showWebPreview)
1160         return;
1161
1162     if (webPreview.urlRect != urlRect)
1163         webPreview.urlRect = urlRect;
1164
1165     if (webPreview.parentItem != parentItem)
1166         webPreview.parentItem = parentItem;
1167
1168     if (webPreview.url != url) {
1169         webPreview.url = url;
1170         // prepare to load a different URL
1171         if (webPreview.previewItem) {
1172             if (webPreview.previewItem->scene())
1173                 removeItem(webPreview.previewItem);
1174             delete webPreview.previewItem;
1175             webPreview.previewItem = nullptr;
1176         }
1177         webPreview.previewState = WebPreview::NoPreview;
1178     }
1179
1180     if (webPreview.url.isEmpty())
1181         return;
1182
1183     // qDebug() << Q_FUNC_INFO << webPreview.previewState;
1184     switch (webPreview.previewState) {
1185     case WebPreview::NoPreview:
1186         webPreview.previewState = WebPreview::NewPreview;
1187         webPreview.timer.start(500);
1188         break;
1189     case WebPreview::NewPreview:
1190     case WebPreview::DelayPreview:
1191     case WebPreview::ShowPreview:
1192         // we're already waiting for the next step or showing the preview
1193         break;
1194     case WebPreview::HidePreview:
1195         // we still have a valid preview
1196         webPreview.previewState = WebPreview::DelayPreview;
1197         webPreview.timer.start(1000);
1198         break;
1199     }
1200     // qDebug() << "  new State:" << webPreview.previewState << webPreview.timer.isActive();
1201 }
1202
1203 void ChatScene::webPreviewNextStep()
1204 {
1205     // qDebug() << Q_FUNC_INFO << webPreview.previewState;
1206     switch (webPreview.previewState) {
1207     case WebPreview::NoPreview:
1208         break;
1209     case WebPreview::NewPreview:
1210         Q_ASSERT(!webPreview.previewItem);
1211         webPreview.previewItem = new WebPreviewItem(webPreview.url);
1212         webPreview.previewState = WebPreview::DelayPreview;
1213         webPreview.timer.start(1000);
1214         break;
1215     case WebPreview::DelayPreview:
1216         Q_ASSERT(webPreview.previewItem);
1217         // calc position and show
1218         {
1219             qreal previewY = webPreview.urlRect.bottom();
1220             qreal previewX = webPreview.urlRect.x();
1221             if (previewY + webPreview.previewItem->boundingRect().height() > sceneRect().bottom())
1222                 previewY = webPreview.urlRect.y() - webPreview.previewItem->boundingRect().height();
1223
1224             if (previewX + webPreview.previewItem->boundingRect().width() > sceneRect().width())
1225                 previewX = sceneRect().right() - webPreview.previewItem->boundingRect().width();
1226
1227             webPreview.previewItem->setPos(previewX, previewY);
1228         }
1229         addItem(webPreview.previewItem);
1230         webPreview.previewState = WebPreview::ShowPreview;
1231         break;
1232     case WebPreview::ShowPreview:
1233         qWarning() << "ChatScene::webPreviewNextStep() called while in ShowPreview Step!";
1234         qWarning() << "removing preview";
1235         if (webPreview.previewItem && webPreview.previewItem->scene()) {
1236             removeItem(webPreview.previewItem);
1237         }
1238
1239         // Intentional fallthrough
1240
1241     case WebPreview::HidePreview:
1242         if (webPreview.previewItem) {
1243             delete webPreview.previewItem;
1244             webPreview.previewItem = nullptr;
1245         }
1246         webPreview.parentItem = nullptr;
1247         webPreview.url = QUrl();
1248         webPreview.urlRect = QRectF();
1249         webPreview.previewState = WebPreview::NoPreview;
1250     }
1251     // qDebug() << "  new State:" << webPreview.previewState << webPreview.timer.isActive();
1252 }
1253
1254 void ChatScene::clearWebPreview(ChatItem* parentItem)
1255 {
1256     // qDebug() << Q_FUNC_INFO << webPreview.previewState;
1257     switch (webPreview.previewState) {
1258     case WebPreview::NewPreview:
1259         webPreview.previewState = WebPreview::NoPreview;  // we haven't loaded anything yet
1260         break;
1261     case WebPreview::ShowPreview:
1262         if (parentItem == nullptr || webPreview.parentItem == parentItem) {
1263             if (webPreview.previewItem && webPreview.previewItem->scene())
1264                 removeItem(webPreview.previewItem);
1265         }
1266
1267         // Intentional fallthrough
1268
1269     case WebPreview::DelayPreview:
1270         // we're just loading, so haven't shown the preview yet.
1271         webPreview.previewState = WebPreview::HidePreview;
1272         webPreview.timer.start(5000);
1273         break;
1274     case WebPreview::NoPreview:
1275     case WebPreview::HidePreview:
1276         break;
1277     }
1278     // qDebug() << "  new State:" << webPreview.previewState << webPreview.timer.isActive();
1279 }
1280
1281 #endif
1282
1283 // ========================================
1284 //  end of webkit only
1285 // ========================================
1286
1287 // Local configuration caching
1288 void ChatScene::showWebPreviewChanged()
1289 {
1290     ChatViewSettings settings;
1291     _showWebPreview = settings.showWebPreview();
1292 }
1293
1294 void ChatScene::showSenderBracketsChanged()
1295 {
1296     ChatViewSettings settings;
1297     _showSenderBrackets = settings.showSenderBrackets();
1298 }
1299
1300 void ChatScene::useCustomTimestampFormatChanged()
1301 {
1302     ChatViewSettings settings;
1303     _useCustomTimestampFormat = settings.useCustomTimestampFormat();
1304     updateTimestampHasBrackets();
1305 }
1306
1307 void ChatScene::timestampFormatStringChanged()
1308 {
1309     ChatViewSettings settings;
1310     _timestampFormatString = settings.timestampFormatString();
1311     updateTimestampHasBrackets();
1312 }
1313
1314 void ChatScene::updateTimestampHasBrackets()
1315 {
1316     // Calculate these parameters only as needed, rather than on-demand
1317
1318     if (!_useCustomTimestampFormat) {
1319         // The default timestamp format string does not have brackets, no need to check.
1320         // If UiStyle::updateSystemTimestampFormat() has brackets added, change this, too.
1321         _timestampHasBrackets = false;
1322     }
1323     else {
1324         // Does the timestamp format contain brackets?  For example:
1325         // Classic: "[hh:mm:ss]"
1326         // Modern:  " hh:mm:ss"
1327         //
1328         // Match groups of any opening or closing brackets - (), {}, [], <>, (>, {], etc:
1329         //   ^\s*[({[<].+[)}\]>]\s*$
1330         //   [...]    is a character group containing ...
1331         //   ^        matches start of string
1332         //   \s*      matches any amount of whitespace
1333         //   [({[<]   matches (, {, [, or <
1334         //   .+       matches one or more characters
1335         //   [)}\]>]  matches ), }, ], or >, escaping the ]
1336         //   $        matches end of string
1337         // Alternatively, if opening and closing brackets must be in pairs, use this:
1338         //   (^\s*\(.+\)\s*$)|(^\s*\{.+\}\s*$)|(^\s*\[.+\]\s*$)|(^\s*<.+>\s*$)
1339         // Note that '\' must be escaped as '\\'
1340         // Helpful interactive website for debugging and explaining:  https://regex101.com/
1341         const QRegExp regExpMatchBrackets(R"(^\s*[({[<].+[)}\]>]\s*$)");
1342         _timestampHasBrackets = regExpMatchBrackets.exactMatch(_timestampFormatString);
1343     }
1344 }