From 4016df44fcc7e1c2992479ec32ebb696e8735089 Mon Sep 17 00:00:00 2001 From: Patrick Sean Klein Date: Sat, 21 Dec 2024 15:59:39 -0500 Subject: [PATCH] TagsEdit code improvements and crash fix Fix crash when pressing home on empty tag field Move completer to TagsEdit. Move cursor blink status to TagsEdit. Move paint implementation to impl. Simplify calcRect and drawTag. Hide editing_index and cursor position. Fix bug where an empty tag was shown if the tag edit was unfocused. Fix a bug where the scollbar was not updated when a tag was removed. Hide remaining TextEdit internal fields. Refactor to use QLinkedList. Remove obsolete EmptyTagIterator. Encapsulate tags and selected index in tags manager. --- src/gui/EntryPreviewWidget.cpp | 2 +- src/gui/entry/EditEntryWidget.cpp | 4 +- src/gui/tag/TagsEdit.cpp | 808 ++++++++++++++++-------------- src/gui/tag/TagsEdit.h | 31 +- 4 files changed, 454 insertions(+), 391 deletions(-) diff --git a/src/gui/EntryPreviewWidget.cpp b/src/gui/EntryPreviewWidget.cpp index 1d7dee006a..befeccd6e5 100644 --- a/src/gui/EntryPreviewWidget.cpp +++ b/src/gui/EntryPreviewWidget.cpp @@ -402,7 +402,7 @@ void EntryPreviewWidget::updateEntryGeneralTab() const TimeInfo entryTime = m_currentEntry->timeInfo(); const QString expires = entryTime.expires() ? Clock::toString(entryTime.expiryTime().toLocalTime()) : tr("Never"); m_ui->entryExpirationLabel->setText(expires); - m_ui->entryTagsList->tags(m_currentEntry->tagList()); + m_ui->entryTagsList->setTags(m_currentEntry->tagList()); m_ui->entryTagsList->setReadOnly(true); } diff --git a/src/gui/entry/EditEntryWidget.cpp b/src/gui/entry/EditEntryWidget.cpp index 9af9aa6014..ed2fb176a9 100644 --- a/src/gui/entry/EditEntryWidget.cpp +++ b/src/gui/entry/EditEntryWidget.cpp @@ -948,8 +948,8 @@ void EditEntryWidget::setForms(Entry* entry, bool restore) m_mainUi->usernameComboBox->lineEdit()->setReadOnly(m_history); m_mainUi->urlEdit->setReadOnly(m_history); m_mainUi->passwordEdit->setReadOnly(m_history); - m_mainUi->tagsList->tags(entry->tagList()); - m_mainUi->tagsList->completion(m_db->tagList()); + m_mainUi->tagsList->setTags(entry->tagList()); + m_mainUi->tagsList->setCompletion(m_db->tagList()); m_mainUi->expireCheck->setEnabled(!m_history); m_mainUi->expireDatePicker->setReadOnly(m_history); m_mainUi->revealNotesButton->setIcon(icons()->onOffIcon("password-show", false)); diff --git a/src/gui/tag/TagsEdit.cpp b/src/gui/tag/TagsEdit.cpp index 6bd0d39dfa..d2b4e1a2f4 100644 --- a/src/gui/tag/TagsEdit.cpp +++ b/src/gui/tag/TagsEdit.cpp @@ -23,12 +23,13 @@ */ #include "TagsEdit.h" -#include "gui/MainWindow.h" + #include #include #include #include #include +#include #include #include #include @@ -37,179 +38,229 @@ #include #include -#include - namespace { - constexpr int tag_v_spacing = 2; - constexpr int tag_h_spacing = 3; + constexpr int TAG_V_SPACING = 2; + constexpr int TAG_H_SPACING = 3; - constexpr QMargins tag_inner(5, 3, 4, 3); + constexpr QMargins TAG_INNER(5, 3, 4, 3); - constexpr int tag_cross_width = 5; - constexpr float tag_cross_radius = tag_cross_width / 2; - constexpr int tag_cross_padding = 5; + constexpr int TAG_CROSS_WIDTH = 5; + constexpr float TAG_CROSS_RADIUS = TAG_CROSS_WIDTH / 2.0; + constexpr int TAG_CROSS_PADDING = 5; - struct Tag + class Tag { + public: + Tag() = default; + Tag(const QString& text) : text(text.trimmed()), rect(), row() {} + bool isEmpty() const noexcept { return text.isEmpty(); } QString text; - QRect rect; - size_t row; + public: + // Render state + mutable QRect rect; + mutable size_t row; }; - /// Non empty string filtering iterator - template struct EmptySkipIterator - { - EmptySkipIterator() = default; +} // namespace - // skip until `end` - explicit EmptySkipIterator(It it, It end) - : it(it) - , end(end) - { - while (this->it != end && this->it->isEmpty()) { - ++this->it; +class TagManager { +public: + using iterator = QLinkedList::iterator; + using const_iterator = QLinkedList::const_iterator; + + TagManager() : tags{Tag()}, editing_index(tags.begin()) {} + template TagManager(InputIterator begin, InputIterator end) { + QSet unique_tags; + for (auto it = begin; it != end; ++it) { + Tag new_tag(*it); + if (unique_tags.contains(new_tag.text)) { + continue; } - begin = it; + unique_tags.insert(new_tag.text); + tags.push_back(new_tag); } - explicit EmptySkipIterator(It it) - : it(it) - , end{} - { + if (tags.isEmpty()) { + tags.push_back(Tag()); } + editing_index = tags.begin(); + } - using difference_type = typename std::iterator_traits::difference_type; - using value_type = typename std::iterator_traits::value_type; - using pointer = typename std::iterator_traits::pointer; - using reference = typename std::iterator_traits::reference; - using iterator_category = std::output_iterator_tag; + iterator begin() { return tags.begin(); } + iterator end() { return tags.end(); } + const_iterator begin() const { return tags.begin(); } + const_iterator end() const { return tags.end(); } + const_iterator cbegin() const { return tags.cbegin(); } + const_iterator cend() const { return tags.cend(); } - EmptySkipIterator& operator++() - { - assert(it != end); - while (++it != end && it->isEmpty()) - ; - return *this; - } + const Tag& back() const { + return tags.back(); + } - decltype(auto) operator*() - { - return *it; - } + const Tag& front() const { + return tags.front(); + } - pointer operator->() - { - return &(*it); + iterator editingIndex() { return editing_index; } + + const_iterator editingIndex() const { return editing_index; } + + bool isCurrentTextEmpty() const { + return editing_index->isEmpty(); + } + + void setEditingIndex(iterator it) { + if (editing_index == it) { + return; + } + // Ensure Invariant-1. If the previously edited tag is empty, remove it. + auto occurrencesOfCurrentText = + std::count_if(tags.cbegin(), tags.cend(), [this](const auto& tag) { return tag.text == editing_index->text; }); + if (isCurrentTextEmpty() || occurrencesOfCurrentText > 1) { + erase(editing_index); } + editing_index = it; + } - bool operator!=(EmptySkipIterator const& rhs) const - { - return it != rhs.it; + iterator insert(iterator it, const Tag& tag) { + return tags.insert(it, tag); + } + + iterator erase(iterator it) { + bool current_index_needs_update = it == editing_index; + + auto next = tags.erase(it); + if (next == tags.end()) { + next = std::prev(next); } - bool operator==(EmptySkipIterator const& rhs) const - { - return it == rhs.it; + if (current_index_needs_update) { + editing_index = next; } - private: - It begin; - It it; - It end; - }; + return next; - template EmptySkipIterator(It, It) -> EmptySkipIterator; + } -} // namespace + bool isEmpty() const { return tags.isEmpty(); } + int size() const { return tags.size(); } + +private: + QLinkedList tags; + // TODO Rename + iterator editing_index; +}; // Invariant-1 ensures no empty tags apart from currently being edited. // Default-state is one empty tag which is currently editing. struct TagsEdit::Impl { + using iterator = QLinkedList::iterator; + using const_iterator = QLinkedList::const_iterator; + explicit Impl(TagsEdit* ifce) : ifce(ifce) - , tags{Tag()} - , editing_index(0) + , tags() , cursor(0) - , blink_timer(0) - , blink_status(true) , select_start(0) , select_size(0) , cross_deleter(true) - , completer(std::make_unique()) { } + iterator begin() + { + return tags.begin(); + } + + iterator end() + { + return tags.end(); + } + + const_iterator begin() const + { + return tags.begin(); + } + + const_iterator end() const + { + return tags.end(); + } + inline QRectF crossRect(QRectF const& r) const { - QRectF cross(QPointF{0, 0}, QSizeF{tag_cross_width + tag_cross_padding * 2, r.top() - r.bottom()}); - cross.moveCenter(QPointF(r.right() - tag_cross_radius - tag_cross_padding, r.center().y())); + QRectF cross(QPointF{0, 0}, QSizeF{TAG_CROSS_WIDTH + TAG_CROSS_PADDING * 2, r.top() - r.bottom()}); + cross.moveCenter(QPointF(r.right() - TAG_CROSS_RADIUS - TAG_CROSS_PADDING, r.center().y())); return cross; } - bool inCrossArea(int tag_index, QPoint point) const + bool isBeingEdited(const_iterator it) const + { + return it == tags.editingIndex(); + } + + bool inCrossArea(const_iterator it, QPoint point) const { return cross_deleter - ? crossRect(tags[tag_index].rect) - .adjusted(-tag_cross_radius, 0, 0, 0) + ? crossRect(it->rect) + .adjusted(-TAG_CROSS_RADIUS, 0, 0, 0) .translated(-ifce->horizontalScrollBar()->value(), -ifce->verticalScrollBar()->value()) .contains(point) - && (!cursorVisible() || tag_index != editing_index) + && (!cursorVisible() || !isBeingEdited(it)) : false; } - template void drawTags(QPainter& p, std::pair range) const - { - for (auto it = range.first; it != range.second; ++it) { - QRect const& i_r = - it->rect.translated(-ifce->horizontalScrollBar()->value(), -ifce->verticalScrollBar()->value()); - auto const text_pos = - i_r.topLeft() - + QPointF(tag_inner.left(), - ifce->fontMetrics().ascent() + ((i_r.height() - ifce->fontMetrics().height()) / 2)); - - // draw tag rect - auto palette = getMainWindow()->palette(); - QPainterPath path; - auto cornerRadius = 4; - path.addRoundedRect(i_r, cornerRadius, cornerRadius); - p.fillPath(path, palette.brush(QPalette::ColorGroup::Inactive, QPalette::ColorRole::Highlight)); - - // draw text - p.drawText(text_pos, it->text); - - if (cross_deleter) { - // calc cross rect - auto const i_cross_r = crossRect(i_r); - - QPainterPath crossRectBg1, crossRectBg2; - crossRectBg1.addRoundedRect(i_cross_r, cornerRadius, cornerRadius); - // cover left rounded corners - crossRectBg2.addRect( - i_cross_r.left(), i_cross_r.bottom(), tag_cross_radius, i_cross_r.top() - i_cross_r.bottom()); - p.fillPath(crossRectBg1, palette.highlight()); - p.fillPath(crossRectBg2, palette.highlight()); - - QPen pen = p.pen(); - pen.setWidth(2); - pen.setBrush(palette.highlightedText()); - - p.save(); - p.setPen(pen); - p.setRenderHint(QPainter::Antialiasing); - p.drawLine(QLineF(i_cross_r.center() - QPointF(tag_cross_radius, tag_cross_radius), - i_cross_r.center() + QPointF(tag_cross_radius, tag_cross_radius))); - p.drawLine(QLineF(i_cross_r.center() - QPointF(-tag_cross_radius, tag_cross_radius), - i_cross_r.center() + QPointF(-tag_cross_radius, tag_cross_radius))); - p.restore(); - } + void drawTag(QPainter& p, const Tag& tag) const + { + QRect const& i_r = + tag.rect.translated(-ifce->horizontalScrollBar()->value(), -ifce->verticalScrollBar()->value()); + auto const text_pos = + i_r.topLeft() + + QPointF(TAG_INNER.left(), + ifce->fontMetrics().ascent() + ((i_r.height() - ifce->fontMetrics().height()) / 2)); + + // draw tag rect + auto palette = ifce->palette(); + QPainterPath path; + auto cornerRadius = 4; + path.addRoundedRect(i_r, cornerRadius, cornerRadius); + p.fillPath(path, palette.brush(QPalette::ColorGroup::Inactive, QPalette::ColorRole::Highlight)); + + // draw text + p.drawText(text_pos, tag.text); + + if (cross_deleter) { + // calc cross rect + auto const i_cross_r = crossRect(i_r); + + QPainterPath crossRectBg1, crossRectBg2; + crossRectBg1.addRoundedRect(i_cross_r, cornerRadius, cornerRadius); + // cover left rounded corners + crossRectBg2.addRect( + i_cross_r.left(), i_cross_r.bottom(), TAG_CROSS_RADIUS, i_cross_r.top() - i_cross_r.bottom()); + p.fillPath(crossRectBg1, palette.highlight()); + p.fillPath(crossRectBg2, palette.highlight()); + + QPen pen = p.pen(); + pen.setWidth(2); + pen.setBrush(palette.highlightedText()); + + p.save(); + p.setPen(pen); + p.setRenderHint(QPainter::Antialiasing); + p.drawLine(QLineF(i_cross_r.center() - QPointF(TAG_CROSS_RADIUS, TAG_CROSS_RADIUS), + i_cross_r.center() + QPointF(TAG_CROSS_RADIUS, TAG_CROSS_RADIUS))); + p.drawLine(QLineF(i_cross_r.center() - QPointF(-TAG_CROSS_RADIUS, TAG_CROSS_RADIUS), + i_cross_r.center() + QPointF(-TAG_CROSS_RADIUS, TAG_CROSS_RADIUS))); + p.restore(); } } @@ -218,108 +269,53 @@ struct TagsEdit::Impl return ifce->viewport()->contentsRect(); } - QRect calcRects(QList& tags) const + QRect updateTagRenderStates() { - return calcRects(tags, contentsRect()); + return updateTagRenderStates(contentsRect()); } - QRect calcRects(QList& tags, QRect r) const + QRect updateTagRenderStates(QRect r) { size_t row = 0; auto lt = r.topLeft(); QFontMetrics fm = ifce->fontMetrics(); - auto const b = std::begin(tags); - auto const e = std::end(tags); - if (cursorVisible()) { - auto const m = b + static_cast(editing_index); - calcRects(lt, row, r, fm, std::pair(b, m)); - calcEditorRect(lt, row, r, fm, m); - calcRects(lt, row, r, fm, std::pair(m + 1, e)); - } else { - calcRects(lt, row, r, fm, std::pair(b, e)); + for(auto it = std::begin(tags); it != std::end(tags); ++it) { + updateTagRenderState(lt, row, r, fm, *it, it == tags.editingIndex() && cursorVisible()); } - r.setBottom(lt.y() + fm.height() + fm.leading() + tag_inner.top() + tag_inner.bottom() - 1); + r.setBottom(lt.y() + fm.height() + fm.leading() + TAG_INNER.top() + TAG_INNER.bottom() - 1); return r; } - template - void calcRects(QPoint& lt, size_t& row, QRect r, QFontMetrics const& fm, std::pair range) const - { - for (auto it = range.first; it != range.second; ++it) { - // calc text rect - const auto text_w = fm.horizontalAdvance(it->text); - auto const text_h = fm.height() + fm.leading(); - auto const w = cross_deleter - ? tag_inner.left() + tag_inner.right() + tag_cross_padding * 2 + tag_cross_width - : tag_inner.left() + tag_inner.right(); - auto const h = tag_inner.top() + tag_inner.bottom(); - QRect i_r(lt, QSize(text_w + w, text_h + h)); - - // line wrapping - if (r.right() < i_r.right() && // doesn't fit in current line - i_r.left() != r.left() // doesn't occupy entire line already - ) { - i_r.moveTo(r.left(), i_r.bottom() + tag_v_spacing); - ++row; - lt = i_r.topLeft(); - } - - it->rect = i_r; - it->row = row; - lt.setX(i_r.right() + tag_h_spacing); - } - } - - template void calcEditorRect(QPoint& lt, size_t& row, QRect r, QFontMetrics const& fm, It it) const + void updateTagRenderState(QPoint& lt, size_t& row, QRect r, QFontMetrics const& fm, const Tag& tag, bool isBeingEdited) const { - auto const text_w = fm.horizontalAdvance(text_layout.text()); + // calc text rect + const auto text_w = fm.horizontalAdvance(tag.text); auto const text_h = fm.height() + fm.leading(); - auto const w = tag_inner.left() + tag_inner.right(); - auto const h = tag_inner.top() + tag_inner.bottom(); + auto const w = (cross_deleter && !isBeingEdited) + ? TAG_INNER.left() + TAG_INNER.right() + TAG_CROSS_PADDING * 2 + TAG_CROSS_WIDTH + : TAG_INNER.left() + TAG_INNER.right(); + auto const h = TAG_INNER.top() + TAG_INNER.bottom(); QRect i_r(lt, QSize(text_w + w, text_h + h)); // line wrapping if (r.right() < i_r.right() && // doesn't fit in current line i_r.left() != r.left() // doesn't occupy entire line already - ) { - i_r.moveTo(r.left(), i_r.bottom() + tag_v_spacing); + ) { + i_r.moveTo(r.left(), i_r.bottom() + TAG_V_SPACING); ++row; lt = i_r.topLeft(); } - it->rect = i_r; - it->row = row; - lt.setX(i_r.right() + tag_h_spacing); - } - - void setCursorVisible(bool visible) - { - if (blink_timer) { - ifce->killTimer(blink_timer); - blink_timer = 0; - blink_status = true; - } - - if (visible) { - int flashTime = QGuiApplication::styleHints()->cursorFlashTime(); - if (flashTime >= 2) { - blink_timer = ifce->startTimer(flashTime / 2); - } - } else { - blink_status = false; - } + tag.rect = i_r; + tag.row = row; + lt.setX(i_r.right() + TAG_H_SPACING); } bool cursorVisible() const { - return blink_timer; - } - - void updateCursorBlinking() - { - setCursorVisible(cursorVisible()); + return ifce->cursorVisible(); } void updateDisplayText() @@ -331,19 +327,23 @@ struct TagsEdit::Impl text_layout.endLayout(); } + bool isEmptyTag(iterator it) { + return it->text.trimmed().isEmpty(); + } + + bool isCurrentTagEmpty() { + return isEmptyTag(tags.editingIndex()); + } + /// Makes the tag at `i` currently editing, and ensures Invariant-1`. - void setEditingIndex(int i) + void setEditingIndex(iterator i) { - assert(i < tags.size()); - auto occurrencesOfCurrentText = - std::count_if(tags.cbegin(), tags.cend(), [this](const auto& tag) { return tag.text == currentText(); }); - if (currentText().isEmpty() || occurrencesOfCurrentText > 1) { - tags.erase(std::next(tags.begin(), std::ptrdiff_t(editing_index))); - if (editing_index <= i) { // Do we shift positions after `i`? - --i; - } - } - editing_index = i; + tags.setEditingIndex(i); + } + + void insertText(QString text) { + currentText().insert(cursor, text); + moveCursor(cursor + text.size(), false); } void calcRectsAndUpdateScrollRanges() @@ -353,7 +353,7 @@ struct TagsEdit::Impl return x.rect.width() < y.rect.width(); })->rect.width(); - calcRects(tags); + updateTagRenderStates(); if (row != tags.back().row) { updateVScrollRange(); @@ -370,6 +370,7 @@ struct TagsEdit::Impl void currentText(QString const& text) { + assert(tags.editingIndex() != tags.end()); currentText() = text; moveCursor(currentText().length(), false); updateDisplayText(); @@ -379,40 +380,32 @@ struct TagsEdit::Impl QString const& currentText() const { - return tags[editing_index].text; + assert(tags.editingIndex() != tags.end()); + return tags.editingIndex()->text; } QString& currentText() { - return tags[editing_index].text; + assert(tags.editingIndex() != tags.end()); + return tags.editingIndex()->text; } QRect const& currentRect() const { - return tags[editing_index].rect; + assert(tags.editingIndex() != tags.end()); + return tags.editingIndex()->rect; } // Inserts a new tag at `i`, makes the tag currently editing, // and ensures Invariant-1. - void editNewTag(int i) + void editNewTag(iterator i) { currentText() = currentText().trimmed(); - tags.insert(std::next(std::begin(tags), static_cast(i)), Tag()); - if (editing_index >= i) { - ++editing_index; - } - setEditingIndex(i); + auto inserted_at = tags.insert(i, Tag()); + setEditingIndex(inserted_at); moveCursor(0, false); } - void setupCompleter() - { - completer->setWidget(ifce); - connect(completer.get(), - static_cast(&QCompleter::activated), - [this](QString const& text) { currentText(text); }); - } - QVector formatting() const { if (select_size == 0) { @@ -476,6 +469,16 @@ struct TagsEdit::Impl cursor = pos; } + bool finishTag() { + // Make existing text into a tag + if (!isCurrentTagEmpty()) { + editNewTag(std::next(tags.editingIndex())); + return true; + } else { + return false; + } + } + qreal cursorToX() { return text_layout.lineAt(0).cursorToX(cursor); @@ -483,31 +486,85 @@ struct TagsEdit::Impl void editPreviousTag() { - if (editing_index > 0) { - setEditingIndex(editing_index - 1); + if (tags.editingIndex() != begin()) { + setEditingIndex(std::prev(tags.editingIndex())); moveCursor(currentText().size(), false); } } - void editNextTag() + template void setTags(InputIterator begin, InputIterator end) { + cursor = 0; + select_start = 0; + select_size = 0; + + tags = TagManager(begin, end); + } + + void editNextTag(bool add_new = false) { - if (editing_index < tags.size() - 1) { - setEditingIndex(editing_index + 1); + if (tags.editingIndex() != std::prev(end())) { + setEditingIndex(std::next(tags.editingIndex())); + moveCursor(0, false); + } else if (add_new) { + editNewTag(std::next(tags.editingIndex())); + } + } + + void previousCursorPosition() { + if (cursor == 0) { + editPreviousTag(); + } else { + moveCursor(text_layout.previousCursorPosition(cursor), false); + } + } + + void nextCursorPosition() { + if (cursor == currentText().size()) { + editNextTag(); + } else { + moveCursor(text_layout.nextCursorPosition(cursor), false); + } + } + + void jumpToFront() { + if (cursor == 0 && !isBeingEdited(tags.begin())) { + editTag(tags.begin()); + } else { moveCursor(0, false); } } - void editTag(int i) + void jumpToBack() { + if (cursor == currentText().size()) { + editTag(std::prev(tags.end())); + } else { + moveCursor(currentText().size(), false); + } + } + + void selectNext() { + moveCursor(text_layout.nextCursorPosition(cursor), true); + } + + void selectPrev() { + moveCursor(text_layout.previousCursorPosition(cursor), true); + } + + void editTag(iterator i) { - assert(i >= 0 && i < tags.size()); setEditingIndex(i); moveCursor(currentText().size(), false); } + void removeTag(iterator i) + { + tags.erase(i); + } + void updateVScrollRange() { auto fm = ifce->fontMetrics(); - auto const row_h = fm.height() + fm.leading() + tag_inner.top() + tag_inner.bottom() + tag_v_spacing; + auto const row_h = fm.height() + fm.leading() + TAG_INNER.top() + TAG_INNER.bottom() + TAG_V_SPACING; ifce->verticalScrollBar()->setPageStep(row_h); auto const h = tags.back().rect.bottom() - tags.front().rect.top() + 1; auto const contents_rect = contentsRect(); @@ -528,6 +585,7 @@ struct TagsEdit::Impl void updateHScrollRange(int width) { + // TODO Transform to getHScrollRange. Handle in iface auto const contents_rect_width = contentsRect().width(); if (width > contents_rect_width) { ifce->horizontalScrollBar()->setRange(0, width - contents_rect_width); @@ -539,7 +597,7 @@ struct TagsEdit::Impl void ensureCursorIsVisibleV() { auto fm = ifce->fontMetrics(); - auto const row_h = fm.height() + fm.leading() + tag_inner.top() + tag_inner.bottom(); + auto const row_h = fm.height() + fm.leading() + TAG_INNER.top() + TAG_INNER.bottom(); auto const vscroll = ifce->verticalScrollBar()->value(); auto const cursor_top = currentRect().topLeft() + QPoint(qRound(cursorToX()), 0); auto const cursor_bottom = cursor_top + QPoint(0, row_h - 1); @@ -555,7 +613,7 @@ struct TagsEdit::Impl { auto const hscroll = ifce->horizontalScrollBar()->value(); auto const contents_rect = contentsRect().translated(hscroll, 0); - auto const cursor_x = (currentRect() - tag_inner).left() + qRound(cursorToX()); + auto const cursor_x = (currentRect() - TAG_INNER).left() + qRound(cursorToX()); if (contents_rect.right() < cursor_x) { ifce->horizontalScrollBar()->setValue(cursor_x - contents_rect.width()); } else if (cursor_x < contents_rect.left()) { @@ -563,24 +621,58 @@ struct TagsEdit::Impl } } +private: TagsEdit* const ifce; - QList tags; - int editing_index; + TagManager tags; int cursor; - int blink_timer; - bool blink_status; - QTextLayout text_layout; int select_start; int select_size; bool cross_deleter; - std::unique_ptr completer; int hscroll{0}; + QTextLayout text_layout; + +public: + void setReadOnly(bool readOnly) { + cross_deleter = !readOnly; + }; + + QTextLine const lineAt(int i) const + { + return text_layout.lineAt(i); + } + + void paint(QPainter& p, QPointF scollOffsets, int fontHeight, bool drawCursor) + { // clip + auto const rect = contentsRect(); + p.setClipRect(rect); + + for (auto it = std::begin(tags); it != std::end(tags); ++it) { + if (cursorVisible() && isBeingEdited(it)) { + auto const& r = currentRect(); + auto const& txt_p = r.topLeft() + QPointF(TAG_INNER.left(), ((r.height() - fontHeight) / 2)); + + // Nothing to draw. Don't draw anything to avoid adding text margins. + if (!it->isEmpty()) { + // draw not terminated tag + text_layout.draw(&p, txt_p - scollOffsets, formatting()); + } + + // draw cursor + if (drawCursor) { + text_layout.drawCursor( + &p, txt_p - scollOffsets, cursor); + } + } else if(!it->isEmpty()) { + drawTag(p, *it); + } + } + } }; TagsEdit::TagsEdit(QWidget* parent) : QAbstractScrollArea(parent) - , impl(std::make_unique(this)) - , m_readOnly(false) + , impl(new Impl(this)) + , completer(new QCompleter) { QSizePolicy size_policy(QSizePolicy::Ignored, QSizePolicy::Preferred); size_policy.setHeightForWidth(true); @@ -591,8 +683,8 @@ TagsEdit::TagsEdit(QWidget* parent) setAttribute(Qt::WA_InputMethodEnabled, true); setMouseTracking(true); - impl->setupCompleter(); - impl->setCursorVisible(hasFocus()); + setupCompleter(); + setCursorVisible(hasFocus()); impl->updateDisplayText(); viewport()->setContentsMargins(1, 1, 1, 1); @@ -608,86 +700,88 @@ void TagsEdit::setReadOnly(bool readOnly) setCursor(Qt::ArrowCursor); setAttribute(Qt::WA_InputMethodEnabled, false); setFrameShape(QFrame::NoFrame); - impl->cross_deleter = false; } else { setFocusPolicy(Qt::StrongFocus); setCursor(Qt::IBeamCursor); setAttribute(Qt::WA_InputMethodEnabled, true); - impl->cross_deleter = true; } + impl->setReadOnly(m_readOnly); } void TagsEdit::resizeEvent(QResizeEvent*) { - impl->calcRects(impl->tags); + impl->updateTagRenderStates(); impl->updateVScrollRange(); impl->updateHScrollRange(); } void TagsEdit::focusInEvent(QFocusEvent*) { - impl->setCursorVisible(true); + setCursorVisible(true); impl->updateDisplayText(); - impl->calcRects(impl->tags); - impl->completer->complete(); + impl->updateTagRenderStates(); + completer->complete(); viewport()->update(); } void TagsEdit::focusOutEvent(QFocusEvent*) { - impl->setCursorVisible(false); + setCursorVisible(false); impl->updateDisplayText(); - impl->calcRects(impl->tags); - impl->completer->popup()->hide(); + impl->updateTagRenderStates(); + completer->popup()->hide(); viewport()->update(); + // TODO This fixes a bug where an empty tag was shown + impl->finishTag(); } void TagsEdit::hideEvent(QHideEvent* event) { Q_UNUSED(event) - impl->completer->popup()->hide(); + completer->popup()->hide(); } -void TagsEdit::paintEvent(QPaintEvent*) +void TagsEdit::setCursorVisible(bool visible) { - QPainter p(viewport()); + if (blink_timer) { + killTimer(blink_timer); + blink_timer = 0; + blink_status = true; + } - // clip - auto const rect = impl->contentsRect(); - p.setClipRect(rect); - if (impl->cursorVisible()) { - // not terminated tag pos - auto const& r = impl->currentRect(); - auto const& txt_p = r.topLeft() + QPointF(tag_inner.left(), ((r.height() - fontMetrics().height()) / 2)); + if (visible) { + int flashTime = QGuiApplication::styleHints()->cursorFlashTime(); + if (flashTime >= 2) { + blink_timer = startTimer(flashTime / 2); + } + } else { + blink_status = false; + } +} - // tags - impl->drawTags( - p, std::pair(impl->tags.cbegin(), std::next(impl->tags.cbegin(), std::ptrdiff_t(impl->editing_index)))); +bool TagsEdit::cursorVisible() const +{ + return blink_timer != 0; +} - // draw not terminated tag - auto const formatting = impl->formatting(); - impl->text_layout.draw( - &p, txt_p - QPointF(horizontalScrollBar()->value(), verticalScrollBar()->value()), formatting); +void TagsEdit::updateCursorBlinking() +{ + setCursorVisible(cursorVisible()); +} - // draw cursor - if (impl->blink_status) { - impl->text_layout.drawCursor( - &p, txt_p - QPointF(horizontalScrollBar()->value(), verticalScrollBar()->value()), impl->cursor); - } +void TagsEdit::paintEvent(QPaintEvent*) +{ + QPainter p(viewport()); + QPointF scrollOffsets = QPointF(horizontalScrollBar()->value(), verticalScrollBar()->value()); + auto const fontHeight = fontMetrics().height(); - // tags - impl->drawTags( - p, std::pair(std::next(impl->tags.cbegin(), std::ptrdiff_t(impl->editing_index + 1)), impl->tags.cend())); - } else { - impl->drawTags( - p, std::pair(EmptySkipIterator(impl->tags.begin(), impl->tags.end()), EmptySkipIterator(impl->tags.end()))); - } + impl->paint(p, scrollOffsets, fontHeight, blink_status); } void TagsEdit::timerEvent(QTimerEvent* event) { - if (event->timerId() == impl->blink_timer) { - impl->blink_status = !impl->blink_status; + if (event->timerId() == blink_timer) { + blink_status = !blink_status; viewport()->update(); } } @@ -695,41 +789,36 @@ void TagsEdit::timerEvent(QTimerEvent* event) void TagsEdit::mousePressEvent(QMouseEvent* event) { bool found = false; - for (int i = 0; i < impl->tags.size(); ++i) { - if (impl->inCrossArea(i, event->pos())) { - impl->tags.erase(impl->tags.begin() + std::ptrdiff_t(i)); - if (i <= impl->editing_index) { - --impl->editing_index; - } + for (auto it = std::begin(*impl); it != std::end(*impl); ++it) { + if (!it->rect.translated(-horizontalScrollBar()->value(), -verticalScrollBar()->value()) + .contains(event->pos())) { + continue; + } + + if (impl->inCrossArea(it, event->pos())) { + impl->removeTag(it); emit tagsEdited(); found = true; + // TODO This fixes a bug where the scroll bars were not updated after removing a tag + event->accept(); break; } - if (!impl->tags[i] - .rect.translated(-horizontalScrollBar()->value(), -verticalScrollBar()->value()) - .contains(event->pos())) { - continue; - } - - if (impl->editing_index == i) { - impl->moveCursor(impl->text_layout.lineAt(0).xToCursor( - (event->pos() - - impl->currentRect() - .translated(-horizontalScrollBar()->value(), -verticalScrollBar()->value()) - .topLeft()) - .x()), - false); - } else { - impl->editTag(i); - } + impl->editTag(it); + impl->moveCursor(impl->lineAt(0).xToCursor( + (event->pos() + - impl->currentRect() + .translated(-horizontalScrollBar()->value(), -verticalScrollBar()->value()) + .topLeft()) + .x()), + false); found = true; break; } if (!found) { - for (auto it = std::begin(impl->tags); it != std::end(impl->tags); ++it) { + for (auto it = std::begin(*impl); it != std::end(*impl); ++it) { // Click of a row. if (it->rect.translated(-horizontalScrollBar()->value(), -verticalScrollBar()->value()).bottom() < event->pos().y()) { @@ -738,11 +827,11 @@ void TagsEdit::mousePressEvent(QMouseEvent* event) // Last tag of the row. auto const row = it->row; - while (it != std::end(impl->tags) && it->row == row) { + while (it != std::end(*impl) && it->row == row) { ++it; } - impl->editNewTag(static_cast(std::distance(std::begin(impl->tags), it))); + impl->editNewTag(it); break; } @@ -754,7 +843,7 @@ void TagsEdit::mousePressEvent(QMouseEvent* event) impl->calcRectsAndUpdateScrollRanges(); impl->ensureCursorIsVisibleV(); impl->ensureCursorIsVisibleH(); - impl->updateCursorBlinking(); + updateCursorBlinking(); viewport()->update(); } } @@ -768,8 +857,8 @@ QSize TagsEdit::minimumSizeHint() const { ensurePolished(); QFontMetrics fm = fontMetrics(); - QRect rect(0, 0, fm.maxWidth() + tag_cross_padding + tag_cross_width, fm.height() + fm.leading()); - rect += tag_inner + contentsMargins() + viewport()->contentsMargins() + viewportMargins(); + QRect rect(0, 0, fm.maxWidth() + TAG_CROSS_PADDING + TAG_CROSS_WIDTH, fm.height() + fm.leading()); + rect += TAG_INNER + contentsMargins() + viewport()->contentsMargins() + viewportMargins(); return rect.size(); } @@ -778,8 +867,7 @@ int TagsEdit::heightForWidth(int w) const auto const content_width = w; QRect contents_rect(0, 0, content_width, 100); contents_rect -= contentsMargins() + viewport()->contentsMargins() + viewportMargins(); - auto tags = impl->tags; - contents_rect = impl->calcRects(tags, contents_rect); + contents_rect = impl->updateTagRenderStates(contents_rect); contents_rect += contentsMargins() + viewport()->contentsMargins() + viewportMargins(); return contents_rect.height(); } @@ -793,58 +881,42 @@ void TagsEdit::keyPressEvent(QKeyEvent* event) impl->selectAll(); event->accept(); } else if (event == QKeySequence::SelectPreviousChar) { - impl->moveCursor(impl->text_layout.previousCursorPosition(impl->cursor), true); + impl->selectPrev(); event->accept(); } else if (event == QKeySequence::SelectNextChar) { - impl->moveCursor(impl->text_layout.nextCursorPosition(impl->cursor), true); + impl->selectNext(); event->accept(); } else if (event == QKeySequence::Paste) { auto clipboard = QApplication::clipboard(); if (clipboard) { for (auto tagtext : clipboard->text().split(",")) { - impl->currentText().insert(impl->cursor, tagtext); - impl->editNewTag(impl->editing_index + 1); + impl->insertText(tagtext); + impl->editNextTag(true); } } event->accept(); } else { switch (event->key()) { case Qt::Key_Left: - if (impl->cursor == 0) { - impl->editPreviousTag(); - } else { - impl->moveCursor(impl->text_layout.previousCursorPosition(impl->cursor), false); - } + impl->previousCursorPosition(); event->accept(); break; case Qt::Key_Right: - if (impl->cursor == impl->currentText().size()) { - impl->editNextTag(); - } else { - impl->moveCursor(impl->text_layout.nextCursorPosition(impl->cursor), false); - } + impl->nextCursorPosition(); event->accept(); break; case Qt::Key_Home: - if (impl->cursor == 0) { - impl->editTag(0); - } else { - impl->moveCursor(0, false); - } + impl->jumpToFront(); event->accept(); break; case Qt::Key_End: - if (impl->cursor == impl->currentText().size()) { - impl->editTag(impl->tags.size() - 1); - } else { - impl->moveCursor(impl->currentText().length(), false); - } + impl->jumpToBack(); event->accept(); break; case Qt::Key_Backspace: - if (!impl->currentText().isEmpty()) { + if (!impl->isCurrentTagEmpty()) { impl->removeBackwardOne(); - } else if (impl->editing_index > 0) { + } else { impl->editPreviousTag(); } event->accept(); @@ -854,13 +926,12 @@ void TagsEdit::keyPressEvent(QKeyEvent* event) case Qt::Key_Comma: case Qt::Key_Semicolon: // If completer is visible, accept the selection or hide if no selection - if (impl->completer->popup()->isVisible() && impl->completer->popup()->selectionModel()->hasSelection()) { + if (completer->popup()->isVisible() && completer->popup()->selectionModel()->hasSelection()) { break; } - - // Make existing text into a tag - if (!impl->currentText().isEmpty()) { - impl->editNewTag(impl->editing_index + 1); + // TODO This finishes the tag, but does not split it if the cursor is in the middle of the tag. + if (impl->finishTag()) { + // TODO Accept event? Original code did not if tag was empty event->accept(); } break; @@ -873,8 +944,7 @@ void TagsEdit::keyPressEvent(QKeyEvent* event) if (impl->hasSelection()) { impl->removeSelection(); } - impl->currentText().insert(impl->cursor, event->text()); - impl->cursor = impl->cursor + event->text().length(); + impl->insertText(event->text()); event->accept(); } @@ -884,11 +954,11 @@ void TagsEdit::keyPressEvent(QKeyEvent* event) impl->calcRectsAndUpdateScrollRanges(); impl->ensureCursorIsVisibleV(); impl->ensureCursorIsVisibleH(); - impl->updateCursorBlinking(); + updateCursorBlinking(); // complete - impl->completer->setCompletionPrefix(impl->currentText()); - impl->completer->complete(); + completer->setCompletionPrefix(impl->currentText()); + completer->complete(); viewport()->update(); @@ -896,31 +966,16 @@ void TagsEdit::keyPressEvent(QKeyEvent* event) } } -void TagsEdit::completion(QStringList const& completions) +void TagsEdit::setCompletion(const QStringList& completions) { - impl->completer = std::make_unique([&] { - QStringList ret; - std::copy(completions.begin(), completions.end(), std::back_inserter(ret)); - return ret; - }()); - impl->setupCompleter(); + completer.reset(new QCompleter(completions)); + setupCompleter(); } -void TagsEdit::tags(QStringList const& tags) +void TagsEdit::setTags(const QStringList& tags) { - // Set to Default-state. - impl->editing_index = 0; - QList t{Tag()}; - - std::transform(EmptySkipIterator(tags.begin(), tags.end()), // Ensure Invariant-1 - EmptySkipIterator(tags.end()), - std::back_inserter(t), - [](QString const& text) { - return Tag{text, QRect(), 0}; - }); - - impl->tags = std::move(t); - impl->editNewTag(impl->tags.size()); + impl->setTags(tags.begin(), tags.end()); + impl->updateDisplayText(); impl->calcRectsAndUpdateScrollRanges(); viewport()->update(); @@ -930,17 +985,18 @@ void TagsEdit::tags(QStringList const& tags) QStringList TagsEdit::tags() const { QStringList ret; - std::transform(EmptySkipIterator(impl->tags.begin(), impl->tags.end()), - EmptySkipIterator(impl->tags.end()), - std::back_inserter(ret), - [](Tag const& tag) { return tag.text; }); + for (const auto& tag : *impl) { + if (!tag.isEmpty()) { + ret.push_back(tag.text); + } + } return ret; } void TagsEdit::mouseMoveEvent(QMouseEvent* event) { if (!m_readOnly) { - for (int i = 0; i < impl->tags.size(); ++i) { + for (auto i = std::begin(*impl); i != std::end(*impl); ++i) { if (impl->inCrossArea(i, event->pos())) { viewport()->setCursor(Qt::ArrowCursor); return; @@ -980,3 +1036,11 @@ bool TagsEdit::isAcceptableInput(const QKeyEvent* event) const return false; } + +void TagsEdit::setupCompleter() +{ + completer->setWidget(this); + connect(completer.get(), + static_cast(&QCompleter::activated), + [this](QString const& text) { impl->currentText(text); }); +} diff --git a/src/gui/tag/TagsEdit.h b/src/gui/tag/TagsEdit.h index 44297fb343..1ee88b9f3e 100644 --- a/src/gui/tag/TagsEdit.h +++ b/src/gui/tag/TagsEdit.h @@ -25,12 +25,10 @@ #pragma once #include +#include -#include -#include +class QCompleter; -/// Tag multi-line editor widget -/// `Space` commits a tag and initiates a new tag edition class TagsEdit : public QAbstractScrollArea { Q_OBJECT @@ -39,27 +37,20 @@ class TagsEdit : public QAbstractScrollArea explicit TagsEdit(QWidget* parent = nullptr); ~TagsEdit() override; - // QWidget QSize sizeHint() const override; QSize minimumSizeHint() const override; int heightForWidth(int w) const override; - /// Set completions - void completion(QStringList const& completions); - - /// Set tags - void tags(QStringList const& tags); + void setReadOnly(bool readOnly); + void setCompletion(const QStringList& completions); + void setTags(const QStringList& tags); - /// Get tags QStringList tags() const; - void setReadOnly(bool readOnly); - signals: void tagsEdited(); protected: - // QWidget void paintEvent(QPaintEvent* event) override; void timerEvent(QTimerEvent* event) override; void mousePressEvent(QMouseEvent* event) override; @@ -72,8 +63,16 @@ class TagsEdit : public QAbstractScrollArea private: bool isAcceptableInput(QKeyEvent const* event) const; + void setupCompleter(); + void setCursorVisible(bool visible); + bool cursorVisible() const; + void updateCursorBlinking(); struct Impl; - std::unique_ptr impl; - bool m_readOnly; + QScopedPointer impl; + QScopedPointer completer; + + bool m_readOnly = false; + int blink_timer = 0; + bool blink_status = true; };