diff --git a/src/gui/EntryPreviewWidget.cpp b/src/gui/EntryPreviewWidget.cpp index 1d7dee006..befeccd6e 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 9af9aa601..ed2fb176a 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/entry/EditEntryWidgetMain.ui b/src/gui/entry/EditEntryWidgetMain.ui index 6fe6637e7..087406432 100644 --- a/src/gui/entry/EditEntryWidgetMain.ui +++ b/src/gui/entry/EditEntryWidgetMain.ui @@ -143,13 +143,25 @@ - + + + + 16777215 + 70 + + Qt::StrongFocus Tags list + + QFrame::Panel + + + QAbstractScrollArea::AdjustToContents + @@ -345,7 +357,7 @@ TagsEdit - QWidget + QScrollArea
gui/tag/TagsEdit.h
1
diff --git a/src/gui/tag/TagsEdit.cpp b/src/gui/tag/TagsEdit.cpp index 6bd0d39df..8ba6ed4ce 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,227 @@ #include #include -#include - namespace { + constexpr int TAG_V_SPACING = 4; + constexpr int TAG_H_SPACING = 4; - 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; - 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; - - // skip until `end` - explicit EmptySkipIterator(It it, It end) - : it(it) - , end(end) - { - while (this->it != end && this->it->isEmpty()) { - ++this->it; - } - begin = it; - } - - explicit EmptySkipIterator(It it) - : it(it) - , end{} - { - } - - 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; - - EmptySkipIterator& operator++() - { - assert(it != end); - while (++it != end && it->isEmpty()) - ; - return *this; - } - - decltype(auto) operator*() - { - return *it; - } - - pointer operator->() - { - return &(*it); - } - - bool operator!=(EmptySkipIterator const& rhs) const - { - return it != rhs.it; - } - - bool operator==(EmptySkipIterator const& rhs) const - { - return it == rhs.it; - } - - private: - It begin; - It it; - It end; - }; - - template EmptySkipIterator(It, It) -> EmptySkipIterator; - } // namespace +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; + } + unique_tags.insert(new_tag.text); + tags.push_back(new_tag); + } + + if (tags.isEmpty()) { + tags.push_back(Tag()); + } + editing_index = tags.begin(); + } + + 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(); } + + const Tag& back() const { + return tags.back(); + } + + const Tag& front() const { + return tags.front(); + } + + iterator editingIndex() { return editing_index; } + + const_iterator editingIndex() const { return editing_index; } + + bool isCurrentTextEmpty() const { + return editing_index->isEmpty(); + } + + void setEditingIndex(const 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; + } + + iterator insert(const iterator& it, const Tag& tag) { + return tags.insert(it, tag); + } + + iterator erase(const iterator& it) { + bool current_index_needs_update = it == editing_index; + + auto next = tags.erase(it); + if (next == tags.end()) { + next = std::prev(next); + } + + if (current_index_needs_update) { + editing_index = next; + } + + return next; + + } + + 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) , cursor(0) - , blink_timer(0) - , blink_status(true) , select_start(0) , select_size(0) , cross_deleter(true) - , completer(std::make_unique()) { } - inline QRectF crossRect(QRectF const& r) const + iterator begin() { - 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 tags.begin(); + } + + iterator end() + { + return tags.end(); + } + + const_iterator begin() const + { + return tags.begin(); + } + + const_iterator end() const + { + return tags.end(); + } + + QRectF crossRect(const QRectF& 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())); return cross; } - bool inCrossArea(int tag_index, QPoint point) const + bool isBeingEdited(const const_iterator& it) const + { + return it == tags.editingIndex(); + } + + bool inCrossArea(const const_iterator& it, const 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 + void drawTag(QPainter& p, const Tag& tag) 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)); + QRect const& i_r = + tag.rect.translated(-ifce->horizontalScrollBar()->value(), -ifce->verticalScrollBar()->value()); + const auto 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 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, it->text); + // draw text + p.drawText(text_pos, tag.text); - if (cross_deleter) { - // calc cross rect - auto const i_cross_r = crossRect(i_r); + if (cross_deleter) { + // calc cross rect + const auto 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()); + 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()); + 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(); - } + 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 +267,52 @@ 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 + void updateTagRenderState(QPoint& lt, size_t& row, QRect r, QFontMetrics const& fm, const Tag& tag, bool isBeingEdited) 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 - { - auto const text_w = fm.horizontalAdvance(text_layout.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(); + // calc text rect + const auto text_w = fm.horizontalAdvance(tag.text); + const auto text_h = fm.height() + fm.leading(); + const auto w = (cross_deleter && !isBeingEdited) + ? TAG_INNER.left() + TAG_INNER.right() + TAG_CROSS_PADDING * 2 + TAG_CROSS_WIDTH + : TAG_INNER.left() + TAG_INNER.right(); + const auto 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); + // doesn't fit in current line && doesn't occupy entire line already + if (r.right() < i_r.right() && i_r.left() != r.left()) { + 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,35 +324,39 @@ struct TagsEdit::Impl text_layout.endLayout(); } + bool isEmptyTag(const 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(const iterator& it) { - 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(it); + } + + void insertText(const QString& text) { + currentText().insert(cursor, text); + moveCursor(cursor + text.size(), false); } void calcRectsAndUpdateScrollRanges() { - auto const row = tags.back().row; - auto const max_width = std::max_element(std::begin(tags), std::end(tags), [](auto const& x, auto const& y) { + const auto row = tags.back().row; + const auto max_width = std::max_element(std::begin(tags), std::end(tags), [](const auto& x, const auto& y) { return x.rect.width() < y.rect.width(); })->rect.width(); - calcRects(tags); + updateTagRenderStates(); if (row != tags.back().row) { updateVScrollRange(); } - auto const new_max_width = std::max_element(std::begin(tags), std::end(tags), [](auto const& x, auto const& y) { + const auto new_max_width = std::max_element(std::begin(tags), std::end(tags), [](const auto& x, const auto& y) { return x.rect.width() < y.rect.width(); })->rect.width(); @@ -368,8 +365,9 @@ struct TagsEdit::Impl } } - void currentText(QString const& text) + void setCurrentText(const QString& text) { + Q_ASSERT(tags.editingIndex() != tags.end()); currentText() = text; moveCursor(currentText().length(), false); updateDisplayText(); @@ -377,42 +375,28 @@ struct TagsEdit::Impl ifce->viewport()->update(); } - QString const& currentText() const + QString currentText() const { - return tags[editing_index].text; + Q_ASSERT(tags.editingIndex() != tags.end()); + return tags.editingIndex()->text; } - QString& currentText() + QRect currentRect() const { - return tags[editing_index].text; - } - - QRect const& currentRect() const - { - return tags[editing_index].rect; + Q_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(const 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 +460,15 @@ struct TagsEdit::Impl cursor = pos; } + bool finishTag() { + // Make existing text into a tag + if (!isCurrentTagEmpty()) { + editNewTag(std::next(tags.editingIndex())); + return true; + } + return false; + } + qreal cursorToX() { return text_layout.lineAt(0).cursorToX(cursor); @@ -483,34 +476,88 @@ 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(const iterator& i) { - assert(i >= 0 && i < tags.size()); setEditingIndex(i); moveCursor(currentText().size(), false); } + void removeTag(const 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; + const auto 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(); + const auto h = tags.back().rect.bottom() - tags.front().rect.top() + 1; + const auto contents_rect = contentsRect(); if (h > contents_rect.height()) { ifce->verticalScrollBar()->setRange(0, h - contents_rect.height()); } else { @@ -520,7 +567,7 @@ struct TagsEdit::Impl void updateHScrollRange() { - auto const max_width = std::max_element(std::begin(tags), std::end(tags), [](auto const& x, auto const& y) { + const auto max_width = std::max_element(std::begin(tags), std::end(tags), [](const auto& x, const auto& y) { return x.rect.width() < y.rect.width(); })->rect.width(); updateHScrollRange(max_width); @@ -528,7 +575,8 @@ struct TagsEdit::Impl void updateHScrollRange(int width) { - auto const contents_rect_width = contentsRect().width(); + // TODO Transform to getHScrollRange. Handle in iface + const auto contents_rect_width = contentsRect().width(); if (width > contents_rect_width) { ifce->horizontalScrollBar()->setRange(0, width - contents_rect_width); } else { @@ -539,11 +587,11 @@ 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 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); - auto const contents_rect = contentsRect().translated(0, vscroll); + const auto row_h = fm.height() + fm.leading() + TAG_INNER.top() + TAG_INNER.bottom(); + const auto vscroll = ifce->verticalScrollBar()->value(); + const auto cursor_top = currentRect().topLeft() + QPoint(qRound(cursorToX()), 0); + const auto cursor_bottom = cursor_top + QPoint(0, row_h - 1); + const auto contents_rect = contentsRect().translated(0, vscroll); if (contents_rect.bottom() < cursor_bottom.y()) { ifce->verticalScrollBar()->setValue(cursor_bottom.y() - row_h); } else if (cursor_top.y() < contents_rect.top()) { @@ -553,9 +601,9 @@ struct TagsEdit::Impl void ensureCursorIsVisibleH() { - auto const hscroll = ifce->horizontalScrollBar()->value(); - auto const contents_rect = contentsRect().translated(hscroll, 0); - auto const cursor_x = (currentRect() - tag_inner).left() + qRound(cursorToX()); + const auto hscroll = ifce->horizontalScrollBar()->value(); + const auto contents_rect = contentsRect().translated(hscroll, 0); + const auto 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 +611,57 @@ 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 lineAt(int i) const + { + return text_layout.lineAt(i); + } + + void paint(QPainter& p, QPointF scrollOffsets, int fontHeight, bool drawCursor) + { // clip + const auto rect = contentsRect(); + p.setClipRect(rect); + + for (auto it = std::begin(tags); it != std::end(tags); ++it) { + if (cursorVisible() && isBeingEdited(it)) { + const auto r = currentRect(); + const auto txt_p = r.topLeft() + QPoint(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 - scrollOffsets, formatting()); + } + + // draw cursor + if (drawCursor) { + text_layout.drawCursor(&p, txt_p - scrollOffsets, 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,11 +672,11 @@ 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); + viewport()->setContentsMargins(TAG_H_SPACING, TAG_V_SPACING, TAG_H_SPACING, TAG_V_SPACING); } TagsEdit::~TagsEdit() = default; @@ -608,86 +689,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::setCursorVisible(bool visible) +{ + if (blink_timer) { + killTimer(blink_timer); + blink_timer = 0; + blink_status = true; + } + + if (visible) { + int flashTime = QGuiApplication::styleHints()->cursorFlashTime(); + if (flashTime >= 2) { + blink_timer = startTimer(flashTime / 2); + } + } else { + blink_status = false; + } +} + +bool TagsEdit::cursorVisible() const +{ + return blink_timer != 0; +} + +void TagsEdit::updateCursorBlinking() +{ + setCursorVisible(cursorVisible()); } void TagsEdit::paintEvent(QPaintEvent*) { QPainter p(viewport()); + QPointF scrollOffsets = QPointF(horizontalScrollBar()->value(), verticalScrollBar()->value()); + const auto fontHeight = fontMetrics().height(); - // 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)); - - // tags - impl->drawTags( - p, std::pair(impl->tags.cbegin(), std::next(impl->tags.cbegin(), std::ptrdiff_t(impl->editing_index)))); - - // draw not terminated tag - auto const formatting = impl->formatting(); - impl->text_layout.draw( - &p, txt_p - QPointF(horizontalScrollBar()->value(), verticalScrollBar()->value()), formatting); - - // draw cursor - if (impl->blink_status) { - impl->text_layout.drawCursor( - &p, txt_p - QPointF(horizontalScrollBar()->value(), verticalScrollBar()->value()), impl->cursor); - } - - // 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 +778,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; - } - emit tagsEdited(); - found = true; - break; - } - - if (!impl->tags[i] - .rect.translated(-horizontalScrollBar()->value(), -verticalScrollBar()->value()) - .contains(event->pos())) { + 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->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); + 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; } + 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()) { @@ -737,12 +815,12 @@ void TagsEdit::mousePressEvent(QMouseEvent* event) } // Last tag of the row. - auto const row = it->row; - while (it != std::end(impl->tags) && it->row == row) { + const auto row = it->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 +832,7 @@ void TagsEdit::mousePressEvent(QMouseEvent* event) impl->calcRectsAndUpdateScrollRanges(); impl->ensureCursorIsVisibleV(); impl->ensureCursorIsVisibleH(); - impl->updateCursorBlinking(); + updateCursorBlinking(); viewport()->update(); } } @@ -768,18 +846,17 @@ 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(); } int TagsEdit::heightForWidth(int w) const { - auto const content_width = w; + const auto 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 +870,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 +915,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 +933,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 +943,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 +955,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()}; + impl->setTags(tags.begin(), tags.end()); - 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->updateDisplayText(); impl->calcRectsAndUpdateScrollRanges(); viewport()->update(); @@ -930,17 +974,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 +1025,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->setCurrentText(text); }); +} diff --git a/src/gui/tag/TagsEdit.h b/src/gui/tag/TagsEdit.h index 44297fb34..1ee88b9f3 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 @@ public: 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); - - /// Get tags - QStringList tags() const; - void setReadOnly(bool readOnly); + void setCompletion(const QStringList& completions); + void setTags(const QStringList& tags); + + QStringList tags() const; signals: void tagsEdited(); protected: - // QWidget void paintEvent(QPaintEvent* event) override; void timerEvent(QTimerEvent* event) override; void mousePressEvent(QMouseEvent* event) override; @@ -72,8 +63,16 @@ protected: 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; }; diff --git a/tests/gui/TestGui.cpp b/tests/gui/TestGui.cpp index f54c97137..1aa20e6f1 100644 --- a/tests/gui/TestGui.cpp +++ b/tests/gui/TestGui.cpp @@ -2421,7 +2421,7 @@ void TestGui::addCannedEntries() QTest::mouseClick(entryNewWidget, Qt::LeftButton); QTest::keyClicks(titleEdit, "test"); auto* editEntryWidgetTagsEdit = editEntryWidget->findChild("tagsList"); - editEntryWidgetTagsEdit->tags(QStringList() << "testTag"); + editEntryWidgetTagsEdit->setTags({"testTag"}); auto* editEntryWidgetButtonBox = editEntryWidget->findChild("buttonBox"); QTest::mouseClick(editEntryWidgetButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton);