feat: comprehensive Wino Mail updates

- ReaderView: complete rewrite with CSS styling, headers, attachments, zoom, find, dark mode, image blocking
- Categories: DAO + UI tree widget with colors, nested categories, assignment
- Rules engine: DAO + evaluation + actions (move, mark read, flag, delete, category, forward)
- Templates: DAO + editor + manager with variables support
- Signatures: DAO + manager + auto-per-account + manual selector in compose
- ComposeView: signature combo, template selector, auto-signature on new email
- MainWindow: frameless with rounded corners, 1px border, resize from edges, no status bar
- Database: new tables (Category, MailCategory, Rule, Template, Signature) with indexes
This commit is contained in:
2026-08-23 14:49:58 +02:00
parent 5fcedfa129
commit 0bb8738607
7399 changed files with 44711 additions and 209 deletions
+134
View File
@@ -0,0 +1,134 @@
/*
* MIT License
*
* Copyright (c) 2025 Nicolai Trandafil
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
#pragma once
#include <QColor>
#include <QFontMetrics>
#include <QMargins>
#include <QPoint>
#include <QRect>
#include <QSize>
#include <optional>
#include <sstream>
#include <string>
#include <vector>
namespace everload_tags {
struct Tag {
QString text;
QRect rect;
bool operator==(Tag const& rhs) const {
return text == rhs.text && rect == rhs.rect;
}
};
struct StyleConfig {
/// Padding from the text to the the pill border
QMargins pill_thickness = {7, 7, 8, 7};
/// Space between pills
int pills_h_spacing = 7;
/// Space between rows of pills (for multi line tags)
int tag_v_spacing = 2;
/// Size of cross side
qreal tag_cross_size = 8;
/// Distance between text and the cross
int tag_cross_spacing = 3;
QColor color{255, 164, 100, 100};
/// Rounding of the pill
qreal rounding_x_radius = 5;
/// Rounding of the pill
qreal rounding_y_radius = 5;
/// Calculate the width that a tag would have with the given text width
int pillWidth(int text_width, bool has_cross) const {
return text_width + pill_thickness.left() + (has_cross ? (tag_cross_spacing + tag_cross_size) : 0) +
pill_thickness.right();
}
/// Calculate the height that a tag would have with the given text height
int pillHeight(int text_height) const {
return text_height + pill_thickness.top() + pill_thickness.bottom();
}
/// \param fit When nullopt arranges the tags in a line
void calcRects(QPoint& lt, std::vector<Tag>& tags, QFontMetrics const& fm, std::optional<QRect> const& fit,
bool has_cross) const;
void drawTags(QPainter& p, std::vector<Tag> const& tags, QFontMetrics const& fm, QPoint const& translate,
bool has_cross) const;
std::string debugString() const {
std::ostringstream os;
os << "StyleConfig{"
<< "pill_thickness: "
<< "QMargins{"
<< "left: " << pill_thickness.left() << "; "
<< "top: " << pill_thickness.top() << "; "
<< "right: " << pill_thickness.right() << "; "
<< "bottom: " << pill_thickness.bottom() << "}; "
<< "pills_h_spacing: " << pills_h_spacing << "; "
<< "tag_v_spacing: " << tag_v_spacing << "; "
<< "tag_cross_size: " << tag_cross_size << "; "
<< "tag_cross_spacing: " << tag_cross_spacing << "; "
<< "color: rgba(" << color.red() << ", " << color.green() << ", " << color.blue() << ", " << color.alpha()
<< "); "
<< "rounding_x_radius: " << rounding_x_radius << "; "
<< "rounding_y_radius: " << rounding_y_radius << "}";
return os.str();
}
};
struct BehaviorConfig {
/// Maintain only unique tags
bool unique = true;
bool restore_cursor_position_on_focus_click = false;
bool read_only = false;
std::string debugString() const {
std::ostringstream os;
os << "BehaviorConfig{"
<< "unique: " << unique << "; "
<< "restore_cursor_position_on_focus_click: " << restore_cursor_position_on_focus_click << "; "
<< "read_only: " << read_only << "}";
return os.str();
}
};
struct Config {
StyleConfig style{};
BehaviorConfig behavior{};
};
} // namespace everload_tags
+88
View File
@@ -0,0 +1,88 @@
/*
MIT License
Copyright (c) 2019 Nicolai Trandafil
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#pragma once
#include "config.hpp"
#include <QAbstractScrollArea>
#include <memory>
#include <vector>
namespace everload_tags {
/// Tag multi-line tags editor widget, similar to `QTextEdit`.
/// `Space` commits a tag and initiates a new tag edition.
class TagsEdit : public QAbstractScrollArea {
Q_OBJECT
public:
/// \param unique Ensure tags uniqueness
explicit TagsEdit(QWidget* parent = nullptr, Config config = {});
~TagsEdit() override;
// QWidget
QSize sizeHint() const override;
QSize minimumSizeHint() const override;
int heightForWidth(int w) const override;
/// Set completions
void completion(std::vector<QString> const& completions);
void completion(QStringList const& completions);
/// Set tags
void tags(std::vector<QString> const& tags);
void tags(QStringList const& tags);
/// Get tags
std::vector<QString> tags() const;
QStringList tags2() const;
/// Set config
void config(Config config);
/// Get config
Config config() const;
signals:
void tagsEdited();
protected:
// QWidget
void paintEvent(QPaintEvent* event) override;
void timerEvent(QTimerEvent* event) override;
void mousePressEvent(QMouseEvent* event) override;
void resizeEvent(QResizeEvent* event) override;
void focusInEvent(QFocusEvent* event) override;
void focusOutEvent(QFocusEvent* event) override;
void keyPressEvent(QKeyEvent* event) override;
void mouseMoveEvent(QMouseEvent* event) override;
private:
struct Impl;
std::unique_ptr<Impl> impl;
};
} // namespace everload_tags
+87
View File
@@ -0,0 +1,87 @@
/*
MIT License
Copyright (c) 2019 Nicolai Trandafil
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#pragma once
#include "config.hpp"
#include <QWidget>
#include <memory>
#include <vector>
namespace everload_tags {
/// Single line tag editor widget, simial to `QLineEdit`.
/// `Space` commits a tag and initiates a new tag edition.
class TagsLineEdit : public QWidget {
Q_OBJECT
public:
explicit TagsLineEdit(QWidget* parent = nullptr, Config config = {});
~TagsLineEdit() override;
// QWidget
QSize sizeHint() const override;
QSize minimumSizeHint() const override;
/// Set completions
void completion(std::vector<QString> const& completions);
void completion(QStringList const& completions);
/// Set tags
void tags(std::vector<QString> const& tags);
void tags(QStringList const& tags);
/// Get tags
std::vector<QString> tags() const;
QStringList tags2() const;
/// Set config
void config(Config config);
/// Get config
Config config() const;
signals:
void tagsEdited();
protected:
// QWidget
void paintEvent(QPaintEvent* event) override;
void timerEvent(QTimerEvent* event) override;
void mousePressEvent(QMouseEvent* event) override;
void resizeEvent(QResizeEvent* event) override;
void focusInEvent(QFocusEvent* event) override;
void focusOutEvent(QFocusEvent* event) override;
void keyPressEvent(QKeyEvent* event) override;
void mouseMoveEvent(QMouseEvent* event) override;
void wheelEvent(QWheelEvent* event) override;
private:
struct Impl;
std::unique_ptr<Impl> impl;
};
} // namespace everload_tags
+428
View File
@@ -0,0 +1,428 @@
/*
* MIT License
*
* Copyright (c) 2021 Nicolai Trandafil
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
#pragma once
#include "util.hpp"
#include <QCompleter>
#include <QFontMetrics>
#include <QGuiApplication>
#include <QKeyEvent>
#include <QPainter>
#include <QPainterPath>
#include <QPoint>
#include <QRect>
#include <QString>
#include <QStyleHints>
#include <QStyleOptionFrame>
#include <QTextLayout>
#include <chrono>
#include "config.hpp"
#include <ranges>
#include <unordered_map>
#include <unordered_set>
#if QT_VERSION < QT_VERSION_CHECK(5, 11, 0)
#define FONT_METRICS_WIDTH(fmt, ...) fmt.width(__VA_ARGS__)
#else
#define FONT_METRICS_WIDTH(fmt, ...) fmt.horizontalAdvance(__VA_ARGS__)
#endif
namespace everload_tags {
struct Style : StyleConfig {
static QRectF crossRect(QRectF const& r, qreal cross_size) {
QRectF cross(QPointF{0, 0}, QSizeF{cross_size, cross_size});
cross.moveCenter(QPointF(r.right() - cross_size, r.center().y()));
return cross;
}
QRectF crossRect(QRectF const& r) const {
return crossRect(r, tag_cross_size);
}
template <std::ranges::output_range<Tag> Range>
static void calcRects(QPoint& lt, Range&& tags, StyleConfig const& style, QFontMetrics const& fm,
std::optional<QRect> const& fit, bool has_cross) {
for (auto& tag : tags) {
auto const text_width = FONT_METRICS_WIDTH(fm, tag.text);
QRect rect(lt, QSize(style.pillWidth(text_width, has_cross), style.pillHeight(fm.height())));
if (fit) {
if (fit->right() < rect.right() && // doesn't fit in current line
rect.left() != fit->left() // doesn't occupy entire line already
) {
rect.moveTo(fit->left(), rect.bottom() + style.tag_v_spacing);
lt = rect.topLeft();
}
}
tag.rect = rect;
lt.setX(rect.right() + style.pills_h_spacing);
}
}
template <std::ranges::output_range<Tag> Range>
void calcRects(QPoint& lt, Range&& tags, QFontMetrics const& fm, std::optional<QRect> const& fit = std::nullopt,
bool has_cross = true) const {
calcRects(lt, tags, *this, fm, fit, has_cross);
}
template <std::ranges::input_range Range>
static void drawTags(QPainter& p, Range&& tags, StyleConfig const& style, QFontMetrics const& fm,
QPoint const& offset, bool has_cross) {
for (auto const& tag : tags) {
QRect const& i_r = tag.rect.translated(offset);
auto const text_pos =
i_r.topLeft() + QPointF(style.pill_thickness.left(), fm.ascent() + ((i_r.height() - fm.height()) / 2));
// draw tag rect
QPainterPath path;
path.addRoundedRect(i_r, style.rounding_x_radius, style.rounding_y_radius);
p.fillPath(path, style.color);
// draw text
p.drawText(text_pos, tag.text);
if (has_cross) {
auto const i_cross_r = crossRect(i_r, style.tag_cross_size);
QPen pen = p.pen();
pen.setWidth(2);
p.save();
p.setPen(pen);
p.setRenderHint(QPainter::Antialiasing);
p.drawLine(QLineF(i_cross_r.topLeft(), i_cross_r.bottomRight()));
p.drawLine(QLineF(i_cross_r.bottomLeft(), i_cross_r.topRight()));
p.restore();
}
}
}
template <std::ranges::input_range Range>
void drawTags(QPainter& p, Range&& tags, QFontMetrics const& fm, QPoint const& offset,
bool has_cross = true) const {
drawTags(p, tags, *this, fm, offset, has_cross);
}
};
struct Behavior : BehaviorConfig {
using BehaviorConfig::unique; /// Turn on/off Invariant-2
};
// Invariant-1 no empty tags apart from currently being edited.
// Invariant-2 tags are unique.
// Default-state is one empty tag which is editing.
struct State {
std::vector<Tag> tags{Tag{}};
size_t editing_index{0};
int blink_timer{0};
bool blink_status{true};
int cursor{0};
int select_start{0};
int select_size{0};
QTextLayout text_layout;
std::unique_ptr<QCompleter> completer{new QCompleter{}};
std::chrono::steady_clock::time_point focused_at{};
QRect const& editorRect() const {
return tags[editing_index].rect;
}
QString const& editorText() const {
return tags[editing_index].text;
}
QString& editorText() {
return tags[editing_index].text;
}
void updateCursorBlinking(QObject* ifce) {
setCursorVisible(blink_timer, ifce);
}
void updateDisplayText() {
text_layout.clearLayout();
text_layout.setText(editorText());
text_layout.beginLayout();
text_layout.createLine();
text_layout.endLayout();
}
void setCursorVisible(bool visible, QObject* ifce) {
if (blink_timer) {
ifce->killTimer(blink_timer);
blink_timer = 0;
}
if (visible) {
blink_status = true;
int flashTime = QGuiApplication::styleHints()->cursorFlashTime();
if (flashTime >= 2) {
blink_timer = ifce->startTimer(flashTime / 2);
}
} else {
blink_status = false;
}
}
QVector<QTextLayout::FormatRange> formatting(QPalette const& palette) const {
if (select_size == 0) {
return {};
}
QTextLayout::FormatRange selection;
selection.start = select_start;
selection.length = select_size;
selection.format.setBackground(palette.brush(QPalette::Highlight));
selection.format.setForeground(palette.brush(QPalette::HighlightedText));
return {selection};
}
qreal cursorToX() {
return text_layout.lineAt(0).cursorToX(cursor);
}
void moveCursor(int pos, bool mark) {
if (mark) {
auto e = select_start + select_size;
int anchor = select_size > 0 && cursor == select_start ? e
: select_size > 0 && cursor == e ? select_start
: cursor;
select_start = qMin(anchor, pos);
select_size = qMax(anchor, pos) - select_start;
} else {
deselectAll();
}
cursor = pos;
}
void deselectAll() {
select_start = 0;
select_size = 0;
}
bool hasSelection() const noexcept {
return select_size > 0;
}
void selectAll() {
select_start = 0;
select_size = editorText().size();
}
void removeSelection() {
assert(select_start + select_size <= editorText().size());
cursor = select_start;
editorText().remove(cursor, select_size);
deselectAll();
}
void removeBackwardOne() {
if (hasSelection()) {
removeSelection();
} else {
editorText().remove(--cursor, 1);
}
}
void removeDuplicates() {
everload_tags::removeDuplicates(tags);
auto const it = std::find_if(tags.begin(), tags.end(), [](auto const& x) {
return x.text.isEmpty(); // Thanks to Invariant-1 we can track back the editing_index.
});
assert(it != tags.end());
editing_index = static_cast<size_t>(std::distance(tags.begin(), it));
}
};
struct Common : Style, Behavior, State {
void drawEditor(QPainter& p, QPalette const& palette, QPoint const& offset) const {
auto const& r = editorRect();
auto const& txt_p = r.topLeft() + QPointF(pill_thickness.left(), pill_thickness.top());
auto const f = formatting(palette);
text_layout.draw(&p, txt_p - offset, f);
if (blink_status) {
text_layout.drawCursor(&p, txt_p - offset, cursor);
}
}
bool inCrossArea(size_t tag_index, QPoint const& point, QPoint const& offset) const {
return crossRect(tags[tag_index].rect).adjusted(-1, -1, 1, 1).translated(-offset).contains(point) &&
(!cursorVisible() || tag_index != editing_index);
}
bool isCurrentTagADuplicate() const {
assert(editing_index < tags.size());
auto const mid = tags.begin() + static_cast<std::ptrdiff_t>(editing_index);
auto const text_eq = [this](auto const& x) { return x.text == editorText(); };
return std::find_if(tags.begin(), mid, text_eq) != mid ||
std::find_if(mid + 1, tags.end(), text_eq) != tags.end();
}
/// Makes the tag at `i` currently editing, and ensures Invariant-1 and Invariant-2`.
void setEditorIndex(size_t i) {
assert(i < tags.size());
if (editorText().isEmpty() || (unique && isCurrentTagADuplicate())) {
tags.erase(std::next(begin(tags), static_cast<std::ptrdiff_t>(editing_index)));
if (editing_index <= i) { // Did we shift `i`?
--i;
}
}
editing_index = i;
}
// Inserts a new tag at `i`, makes the tag currently editing, and ensures Invariant-1.
void editNewTag(size_t i) {
assert(i <= tags.size());
tags.insert(begin(tags) + static_cast<std::ptrdiff_t>(i), Tag{});
if (i <= editing_index) { // Did we shift `editing_index`?
++editing_index;
}
setEditorIndex(i);
moveCursor(0, false);
}
void editPreviousTag() {
if (editing_index > 0) {
setEditorIndex(editing_index - 1);
moveCursor(editorText().size(), false);
}
}
void editNextTag() {
if (editing_index < tags.size() - 1) {
setEditorIndex(editing_index + 1);
moveCursor(0, false);
}
}
void editTag(size_t i) {
assert(i < tags.size());
setEditorIndex(i);
moveCursor(editorText().size(), false);
}
void removeTag(size_t i) {
tags.erase(tags.begin() + static_cast<ptrdiff_t>(i));
if (i <= editing_index) {
--editing_index;
}
}
void setTags(std::ranges::forward_range auto const& tags) {
std::unordered_set<QString> unique_tags;
std::vector<Tag> t;
for (auto const& x : tags) {
if (/* Invariant-1 */ !x.isEmpty() && /* Invariant-2 */ (!unique || unique_tags.insert(x).second)) {
t.emplace_back(Tag{x, QRect{}});
}
}
this->tags = std::move(t);
this->tags.push_back(Tag{});
editing_index = this->tags.size() - 1;
moveCursor(0, false);
}
template <class T>
void getTags(T& out) {
out.resize(tags.size());
std::transform(tags.begin(), tags.end(), out.begin(), [](auto const& tag) { return tag.text; });
if (editorText().isEmpty() || (unique && std::count(out.begin(), out.end(), editorText()) > 1)) {
out.erase(out.begin() + static_cast<ptrdiff_t>(editing_index));
}
}
bool cursorVisible() const {
return !read_only && blink_timer;
}
enum class HandleClickResult {
unhandled,
removed_a_tag,
moved_cursor_in_a_tag,
moved_cursor_across_tags,
};
HandleClickResult handleClick(QMouseEvent* event, QPoint const& offset) {
auto const it = std::find_if(begin(tags), end(tags), [&](auto const& tag) {
return tag.rect.translated(-offset).contains(event->pos());
});
if (it != end(tags)) {
auto const i = static_cast<size_t>(std::distance(begin(tags), it));
if (inCrossArea(i, event->pos(), offset)) {
removeTag(i);
return HandleClickResult::removed_a_tag;
} else if (editing_index == i) {
moveCursor(text_layout.lineAt(0).xToCursor(
(event->pos() - (editorRect() - pill_thickness).translated(-offset).topLeft()).x()),
false);
return HandleClickResult::moved_cursor_in_a_tag;
} else {
editTag(i);
return HandleClickResult::moved_cursor_across_tags;
}
}
return HandleClickResult::unhandled;
}
};
/// \ref `bool QInputControl::isAcceptableInput(QKeyEvent const* event) const`
inline bool isAcceptableInput(QKeyEvent const& event) {
auto const text = event.text();
if (text.isEmpty()) {
return false;
}
auto const c = text.at(0);
if (c.category() == QChar::Other_Format) {
return true;
}
if (event.modifiers() == Qt::ControlModifier || event.modifiers() == (Qt::ShiftModifier | Qt::ControlModifier)) {
return false;
}
if (c.isPrint()) {
return true;
}
if (c.category() == QChar::Other_PrivateUse) {
return true;
}
return false;
}
inline auto elapsed(std::chrono::steady_clock::time_point const& ts) {
return std::chrono::steady_clock::now() - ts;
}
} // namespace everload_tags
+17
View File
@@ -0,0 +1,17 @@
#include "config.hpp"
#include "common.hpp"
namespace everload_tags {
void StyleConfig::calcRects(QPoint& lt, std::vector<Tag>& tags, QFontMetrics const& fm, std::optional<QRect> const& fit,
bool has_cross) const {
Style::calcRects(lt, tags, *this, fm, fit, has_cross);
}
void StyleConfig::drawTags(QPainter& p, std::vector<Tag> const& tags, QFontMetrics const& fm, QPoint const& offset,
bool has_cross) const {
Style::drawTags(p, tags, *this, fm, offset, has_cross);
}
} // namespace everload_tags
+1
View File
@@ -0,0 +1 @@
<RCC/>
+82
View File
@@ -0,0 +1,82 @@
/*
* MIT License
*
* Copyright (c) 2025 Nicolai Trandafil
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
#pragma once
#include <QDebug>
#include <exception>
#include <utility>
namespace everload_tags {
template <class Fn>
struct ScopeExit : Fn {
~ScopeExit() {
try {
(*this)();
} catch (...) {
qDebug() << "exception durring scope exit";
}
}
};
struct MakeScopeExit {
template <class Fn>
auto operator->*(Fn&& fn) const {
return ScopeExit<Fn>{std::forward<Fn>(fn)};
}
};
template <class Fn>
struct ScopeFail : Fn {
~ScopeFail() {
if (std::current_exception()) {
try {
(*this)();
} catch (...) {
qDebug() << "exception during scope fail";
}
}
}
};
struct MakeScopeFail {
template <class Fn>
auto operator->*(Fn&& fn) const {
return ScopeFail<Fn>{std::forward<Fn>(fn)};
}
};
} // namespace everload_tags
#define EVERLOAD_TAGS_CONCATENATE_IMPL(s1, s2) s1##s2
#define EVERLOAD_TAGS_CONCATENATE(s1, s2) EVERLOAD_TAGS_CONCATENATE_IMPL(s1, s2)
#define EVERLOAD_TAGS_UNIQUE_IDENTIFIER EVERLOAD_TAGS_CONCATENATE(UNIQUE_IDENTIFIER_, __LINE__)
#define EVERLOAD_TAGS_SCOPE_EXIT auto const EVERLOAD_TAGS_UNIQUE_IDENTIFIER = everload_tags::MakeScopeExit{}->*[&]
#define EVERLOAD_TAGS_SCOPE_FAIL auto const EVERLOAD_TAGS_UNIQUE_IDENTIFIER = everload_tags::MakeScopeFail{}->*[&]
+507
View File
@@ -0,0 +1,507 @@
/*
MIT License
Copyright (c) 2021 Nicolai Trandafil
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#include "tags_edit.hpp"
#include "common.hpp"
#include "scope_exit.hpp"
#include <QApplication>
#include <QDebug>
#include <QKeyEvent>
#include <QPainter>
#include <QPainterPath>
#include <QScrollBar>
#include <QStyle>
#include <QStyleHints>
#include <QStyleOptionFrame>
#include <QTextLayout>
#include <cassert>
namespace everload_tags {
struct TagsEdit::Impl : Common {
explicit Impl(TagsEdit* ifce, Config config) : Common{{config.style}, {config.behavior}, {}}, ifce{ifce} {}
QPoint offset() const {
return QPoint{ifce->horizontalScrollBar()->value(), ifce->verticalScrollBar()->value()};
}
using Common::drawTags;
template <std::ranges::input_range Range>
void drawTags(QPainter& p, Range range) const {
drawTags(p, range, ifce->fontMetrics(), -offset(),
!read_only && (!restore_cursor_position_on_focus_click || ifce->hasFocus()));
}
void setEditorText(QString const& text) {
editorText() = text;
moveCursor(editorText().length(), false);
update1();
}
void setupCompleter() {
completer->setWidget(ifce);
QObject::connect(completer.get(), qOverload<QString const&>(&QCompleter::activated),
[this](QString const& text) { setEditorText(text); });
}
using Common::calcRects;
void calcRects(QRect r, QPoint& lt, QFontMetrics const& fm) {
auto const middle = tags.begin() + static_cast<ptrdiff_t>(editing_index);
calcRects(lt, std::ranges::subrange(tags.begin(), middle), fm, r, !read_only);
if (cursorVisible() || !editorText().isEmpty()) {
calcRects(lt, std::ranges::subrange(middle, middle + 1), fm, r, !read_only);
}
calcRects(lt, std::ranges::subrange(middle + 1, tags.end()), fm, r, !read_only);
}
QRect calcRects(QRect r) {
auto lt = r.topLeft();
auto const fm = ifce->fontMetrics();
calcRects(r, lt, fm);
r.setBottom(lt.y() + pillHeight(fm.height()) - 1);
return r;
}
QRect calcRects() {
return calcRects(contentsRect());
}
QRect contentsRect() const {
return ifce->viewport()->contentsRect();
}
void calcRectsUpdateScrollRanges() {
calcRects();
updateVScrollRange();
updateHScrollRange();
}
void updateVScrollRange() {
if (tags.size() == 1 && tags.front().text.isEmpty()) {
ifce->verticalScrollBar()->setRange(0, 0);
return;
}
auto const fm = ifce->fontMetrics();
auto const row_h = pillHeight(fm.height()) + tag_v_spacing;
ifce->verticalScrollBar()->setPageStep(row_h);
assert(!tags.empty()); // Invariant-1
int top = tags.front().rect.top();
int bottom = tags.back().rect.bottom();
if (editing_index == 0 && !(cursorVisible() || !editorText().isEmpty())) {
top = tags[1].rect.top();
} else if (editing_index == tags.size() - 1 && !(cursorVisible() || !editorText().isEmpty())) {
bottom = tags[tags.size() - 2].rect.bottom();
}
auto const h = bottom - top + 1;
auto const contents_rect = contentsRect();
if (contents_rect.height() < h) {
ifce->verticalScrollBar()->setRange(0, h - contents_rect.height());
} else {
ifce->verticalScrollBar()->setRange(0, 0);
}
}
void updateHScrollRange() {
assert(!tags.empty()); // Invariant-1
auto const width = std::max_element(begin(tags), end(tags), [](auto const& x, auto const& y) {
return x.rect.width() < y.rect.width();
})->rect.width();
auto const contents_rect_width = contentsRect().width();
if (contents_rect_width < width) {
ifce->horizontalScrollBar()->setRange(0, width - contents_rect_width);
} else {
ifce->horizontalScrollBar()->setRange(0, 0);
}
}
void ensureCursorIsVisibleV() {
if (!cursorVisible()) {
return;
}
auto const fm = ifce->fontMetrics();
auto const row_h = pillHeight(fm.height());
auto const vscroll = ifce->verticalScrollBar()->value();
auto const cursor_top = editorRect().topLeft() + QPoint(qRound(cursorToX()), 0);
auto const cursor_bottom = cursor_top + QPoint(0, row_h - 1);
auto const 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()) {
ifce->verticalScrollBar()->setValue(cursor_top.y() - 1);
}
}
void ensureCursorIsVisibleH() {
if (!cursorVisible()) {
return;
}
auto const contents_rect = contentsRect().translated(ifce->horizontalScrollBar()->value(), 0);
auto const cursor_x = (editorRect() - pill_thickness).left() + qRound(cursorToX());
if (contents_rect.right() < cursor_x) {
ifce->horizontalScrollBar()->setValue(cursor_x - contents_rect.width());
} else if (cursor_x < contents_rect.left()) {
ifce->horizontalScrollBar()->setValue(cursor_x - 1);
}
}
void update1(bool keep_cursor_visible = true) {
updateDisplayText();
calcRectsUpdateScrollRanges();
if (keep_cursor_visible) {
ensureCursorIsVisibleV();
ensureCursorIsVisibleH();
}
updateCursorBlinking(ifce);
ifce->viewport()->update();
}
TagsEdit* const ifce;
};
TagsEdit::TagsEdit(QWidget* parent, Config config)
: QAbstractScrollArea(parent), impl(std::make_unique<Impl>(this, config)) {
QSizePolicy size_policy(QSizePolicy::Ignored, QSizePolicy::Preferred);
size_policy.setHeightForWidth(true);
setSizePolicy(size_policy);
setFocusPolicy(Qt::StrongFocus);
viewport()->setCursor(Qt::IBeamCursor);
setAttribute(Qt::WA_InputMethodEnabled, true);
setMouseTracking(true);
setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
impl->setupCompleter();
impl->setCursorVisible(hasFocus(), this);
impl->updateDisplayText();
viewport()->setContentsMargins(1, 1, 1, 1);
}
TagsEdit::~TagsEdit() = default;
void TagsEdit::resizeEvent(QResizeEvent* event) {
QAbstractScrollArea::resizeEvent(event);
impl->calcRectsUpdateScrollRanges();
}
void TagsEdit::focusInEvent(QFocusEvent* event) {
QAbstractScrollArea::focusInEvent(event);
impl->focused_at = std::chrono::steady_clock::now();
impl->setCursorVisible(true, this);
impl->updateDisplayText();
impl->calcRectsUpdateScrollRanges();
if (event->reason() != Qt::FocusReason::MouseFocusReason || impl->restore_cursor_position_on_focus_click) {
impl->ensureCursorIsVisibleH();
impl->ensureCursorIsVisibleV();
}
viewport()->update();
}
void TagsEdit::focusOutEvent(QFocusEvent* event) {
QAbstractScrollArea::focusOutEvent(event);
impl->setCursorVisible(false, this);
impl->updateDisplayText();
impl->calcRectsUpdateScrollRanges();
viewport()->update();
}
void TagsEdit::paintEvent(QPaintEvent* e) {
QAbstractScrollArea::paintEvent(e);
QPainter p(viewport());
p.setClipRect(impl->contentsRect());
auto const middle = impl->tags.cbegin() + static_cast<ptrdiff_t>(impl->editing_index);
// tags
impl->drawTags(p, std::ranges::subrange(impl->tags.cbegin(), middle));
if (impl->cursorVisible()) {
impl->drawEditor(p, palette(), impl->offset());
} else if (!impl->editorText().isEmpty()) {
impl->drawTags(p, std::ranges::subrange(middle, middle + 1));
}
// tags
impl->drawTags(p, std::ranges::subrange(middle + 1, impl->tags.cend()));
}
void TagsEdit::timerEvent(QTimerEvent* event) {
if (event->timerId() == impl->blink_timer) {
impl->blink_status = !impl->blink_status;
viewport()->update();
}
}
void TagsEdit::mousePressEvent(QMouseEvent* event) {
// we don't want to change cursor position if this event is part of focusIn
using namespace std::chrono_literals;
if (impl->restore_cursor_position_on_focus_click && elapsed(impl->focused_at) < 1ms) {
return;
}
bool keep_cursor_visible = true;
EVERLOAD_TAGS_SCOPE_EXIT {
impl->update1(keep_cursor_visible);
};
switch (impl->handleClick(event, impl->offset())) {
using enum Impl::HandleClickResult;
case removed_a_tag:
keep_cursor_visible = false;
return;
case moved_cursor_in_a_tag:
// keep cursor visible because the cursor can be at the border, we need to enlarge the space around it;
case moved_cursor_across_tags:
// keep the cursor visible because it can move out of the view
return;
case unhandled:
break;
}
// add new tag closest to the cursor
for (auto it = begin(impl->tags); it != end(impl->tags); ++it) {
// find the row
if (it->rect.translated(-impl->offset()).bottom() < event->pos().y()) {
continue;
}
// find the closest spot
auto const row = it->rect.translated(-impl->offset()).top();
while (it != end(impl->tags) && it->rect.translated(-impl->offset()).top() == row &&
event->pos().x() > it->rect.translated(-impl->offset()).left()) {
++it;
}
impl->editNewTag(static_cast<size_t>(std::distance(begin(impl->tags), it)));
return;
}
// append a new nag
impl->editNewTag(impl->tags.size());
}
QSize TagsEdit::sizeHint() const {
return minimumSizeHint();
}
QSize TagsEdit::minimumSizeHint() const {
ensurePolished();
QFontMetrics fm = fontMetrics();
QRect rect(0, 0, impl->pillWidth(fm.maxWidth(), true), impl->pillHeight(fm.height()));
rect += contentsMargins() + viewport()->contentsMargins() + viewportMargins();
return rect.size();
}
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(contents_rect);
contents_rect += contentsMargins() + viewport()->contentsMargins() + viewportMargins();
return contents_rect.height();
}
void TagsEdit::keyPressEvent(QKeyEvent* event) {
if (impl->read_only) {
return;
}
if (event == QKeySequence::SelectAll) {
impl->selectAll();
} else if (event == QKeySequence::SelectPreviousChar) {
impl->moveCursor(impl->text_layout.previousCursorPosition(impl->cursor), true);
} else if (event == QKeySequence::SelectNextChar) {
impl->moveCursor(impl->text_layout.nextCursorPosition(impl->cursor), true);
} else {
switch (event->key()) {
case Qt::Key_Left:
if (event->modifiers() == Qt::ControlModifier || impl->cursor == 0) {
impl->editPreviousTag();
} else {
impl->moveCursor(impl->text_layout.previousCursorPosition(impl->cursor), false);
}
break;
case Qt::Key_Right:
if (event->modifiers() == Qt::ControlModifier || impl->cursor == impl->editorText().size()) {
impl->editNextTag();
} else {
impl->moveCursor(impl->text_layout.nextCursorPosition(impl->cursor), false);
}
break;
case Qt::Key_Up: {
auto const before = impl->tags | std::views::take(impl->editing_index);
auto const it = std::ranges::find_if(before, [&](auto const& tag) {
return impl->editorRect()
.translated(0, -impl->tag_v_spacing - fontMetrics().height())
.intersects(tag.rect);
});
if (it != end(before)) {
impl->editTag(std::distance(begin(before), it));
}
} break;
case Qt::Key_Down: {
auto const after = impl->tags | std::views::drop(impl->editing_index + 1);
auto const it = std::ranges::find_if(after, [&](auto const& tag) {
return impl->editorRect()
.translated(0, impl->tag_v_spacing + fontMetrics().height())
.intersects(tag.rect);
});
if (it != end(after)) {
impl->editTag(impl->editing_index + 1 + std::distance(begin(after), it));
}
} break;
case Qt::Key_Home:
if (event->modifiers() == Qt::ControlModifier) {
impl->editTag(0);
} else {
impl->moveCursor(0, false);
}
break;
case Qt::Key_End:
if (event->modifiers() == Qt::ControlModifier) {
impl->editTag(impl->tags.size() - 1);
} else {
impl->moveCursor(impl->editorText().length(), false);
}
break;
case Qt::Key_Backspace:
if (!impl->editorText().isEmpty()) {
impl->removeBackwardOne();
} else if (impl->editing_index > 0) {
impl->editPreviousTag();
}
break;
case Qt::Key_Space:
if (!impl->editorText().isEmpty()) {
impl->editNewTag(impl->editing_index + 1);
}
break;
default:
if (isAcceptableInput(*event)) {
if (impl->hasSelection()) {
impl->removeSelection();
}
impl->editorText().insert(impl->cursor, event->text());
impl->cursor = impl->cursor + event->text().length();
break;
} else {
event->setAccepted(false);
return;
}
}
}
impl->update1();
impl->completer->setCompletionPrefix(impl->editorText());
impl->completer->complete();
emit tagsEdited();
}
void TagsEdit::completion(std::vector<QString> const& completions) {
impl->completer = std::make_unique<QCompleter>([&] {
QStringList ret;
std::copy(completions.begin(), completions.end(), std::back_inserter(ret));
return ret;
}());
impl->setupCompleter();
}
void TagsEdit::completion(QStringList const& completions) {
impl->completer = std::make_unique<QCompleter>(completions);
impl->setupCompleter();
}
void TagsEdit::tags(std::vector<QString> const& tags) {
impl->setTags(tags);
impl->update1();
}
void TagsEdit::tags(QStringList const& tags) {
impl->setTags(tags);
impl->update1();
}
std::vector<QString> TagsEdit::tags() const {
std::vector<QString> ret;
impl->getTags(ret);
return ret;
}
QStringList TagsEdit::tags2() const {
QStringList ret;
impl->getTags(ret);
return ret;
}
void TagsEdit::mouseMoveEvent(QMouseEvent* event) {
for (size_t i = 0; i < impl->tags.size(); ++i) {
if (impl->inCrossArea(i, event->pos(), impl->offset())) {
viewport()->setCursor(Qt::ArrowCursor);
return;
}
}
if (impl->contentsRect().contains(event->pos())) {
viewport()->setCursor(Qt::IBeamCursor);
} else {
QAbstractScrollArea::mouseMoveEvent(event);
}
}
void TagsEdit::config(Config config) {
if (impl->unique && impl->unique != config.behavior.unique) {
impl->removeDuplicates();
}
static_cast<StyleConfig&>(*impl) = config.style;
static_cast<BehaviorConfig&>(*impl) = config.behavior;
impl->update1();
}
Config TagsEdit::config() const {
return Config{
.style = static_cast<StyleConfig const&>(*impl),
.behavior = static_cast<BehaviorConfig const&>(*impl),
};
}
} // namespace everload_tags
+468
View File
@@ -0,0 +1,468 @@
/*
MIT License
Copyright (c) 2019 Nicolai Trandafil
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#include "tags_line_edit.hpp"
#include "common.hpp"
#include "scope_exit.hpp"
#include <QApplication>
#include <QCompleter>
#include <QDebug>
#include <QPainter>
#include <QPainterPath>
#include <QStyle>
#include <QStyleHints>
#include <QStyleOptionFrame>
#include <QTextLayout>
#include <algorithm>
#include <cassert>
namespace everload_tags {
inline constexpr QMargins magic_margins = {2, 2, 2, 3};
struct TagsLineEdit::Impl : Common {
explicit Impl(TagsLineEdit* const& ifce, Config config)
: Common{{config.style}, {config.behavior}, {}}, ifce{ifce} {}
QPoint offset() const {
return {hscroll, 0};
}
using Common::drawTags;
template <std::ranges::input_range Range>
void drawTags(QPainter& p, Range range) const {
drawTags(p, range, ifce->fontMetrics(), -offset(),
!read_only && (!restore_cursor_position_on_focus_click || ifce->hasFocus()));
}
QRect contentsRect() const {
return ifce->contentsRect() - magic_margins;
}
using Common::calcRects;
void calcRects() {
auto const r = contentsRect();
auto lt = r.topLeft();
auto const middle = tags.begin() + static_cast<ptrdiff_t>(editing_index);
calcRects(lt, std::ranges::subrange(tags.begin(), middle), ifce->fontMetrics(), std::nullopt, !read_only);
if (cursorVisible() || !editorText().isEmpty()) {
calcRects(lt, std::ranges::subrange(middle, middle + 1), ifce->fontMetrics(), std::nullopt, !read_only);
}
calcRects(lt, std::ranges::subrange(middle + 1, tags.end()), ifce->fontMetrics(), std::nullopt, !read_only);
}
void setEditorText(QString const& text) {
tags[editing_index].text = text;
moveCursor(editorText().length(), false);
update1();
}
void setupCompleter() {
completer->setWidget(ifce);
connect(completer.get(), static_cast<void (QCompleter::*)(QString const&)>(&QCompleter::activated), ifce,
[this](QString const& text) { setEditorText(text); });
}
int pillsWidth() const {
if (tags.size() == 1 && tags.front().text.isEmpty()) {
return 0;
}
int left = tags.front().rect.left();
int right = tags.back().rect.right();
if (editing_index == 0 && !(cursorVisible() || !editorText().isEmpty())) {
left = tags[1].rect.left();
} else if (editing_index == tags.size() - 1 && !(cursorVisible() || !editorText().isEmpty())) {
right = tags[tags.size() - 2].rect.right();
}
return right - left + 1;
}
void updateHScrollRange() {
auto const contents_rect = contentsRect();
auto const width_used = pillsWidth();
if (contents_rect.width() < width_used) {
hscroll_max = width_used - contents_rect.width();
} else {
hscroll_max = 0;
}
hscroll = std::clamp(hscroll, hscroll_min, hscroll_max);
}
// scroll to the cursor
void ensureCursorIsVisible() {
auto const contents_rect = contentsRect().translated(offset());
int const cursor_x = (editorRect() - pill_thickness).left() + qRound(cursorToX());
if (contents_rect.right() < cursor_x) {
hscroll = cursor_x - contents_rect.width();
} else if (cursor_x < contents_rect.left()) {
hscroll = cursor_x - 1;
}
hscroll = std::clamp(hscroll, hscroll_min, hscroll_max);
}
// scrolls to the cursor if `keep_cursor_visible`
void update1(bool keep_cursor_visible = true) {
updateDisplayText();
calcRects();
updateHScrollRange();
if (keep_cursor_visible) {
ensureCursorIsVisible();
}
updateCursorBlinking(ifce);
ifce->update();
}
void initStyleOption(QStyleOptionFrame* option) const {
assert(option);
option->initFrom(ifce);
option->rect = ifce->contentsRect();
option->lineWidth = 1;
option->midLineWidth = 0;
option->state |= QStyle::State_Sunken;
if (ifce->underMouse()) {
option->state |= QStyle::State_MouseOver;
}
option->features = QStyleOptionFrame::None;
}
TagsLineEdit* const ifce;
int const hscroll_min = 0;
int hscroll = 0;
int hscroll_max = 0;
};
TagsLineEdit::TagsLineEdit(QWidget* parent, Config config)
: QWidget(parent), impl(std::make_unique<Impl>(this, config)) {
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
setFocusPolicy(Qt::StrongFocus);
setCursor(Qt::IBeamCursor);
setAttribute(Qt::WA_InputMethodEnabled, true);
setAttribute(Qt::WA_Hover, true);
setMouseTracking(true);
impl->setupCompleter();
impl->setCursorVisible(hasFocus(), this);
impl->updateDisplayText();
}
TagsLineEdit::~TagsLineEdit() = default;
void TagsLineEdit::resizeEvent(QResizeEvent*) {
impl->calcRects();
}
void TagsLineEdit::focusInEvent(QFocusEvent* e) {
QWidget::focusInEvent(e);
impl->focused_at = std::chrono::steady_clock::now();
impl->setCursorVisible(true, this);
impl->updateDisplayText();
impl->calcRects();
impl->updateHScrollRange();
if (e->reason() != Qt::FocusReason::MouseFocusReason || impl->restore_cursor_position_on_focus_click) {
impl->ensureCursorIsVisible();
}
update();
}
void TagsLineEdit::focusOutEvent(QFocusEvent* e) {
QWidget::focusOutEvent(e);
impl->setCursorVisible(false, this);
impl->updateDisplayText();
impl->calcRects();
impl->updateHScrollRange();
update();
}
void TagsLineEdit::paintEvent(QPaintEvent* e) {
QWidget::paintEvent(e);
QPainter p(this);
// opt
auto const panel = [this] {
QStyleOptionFrame panel;
impl->initStyleOption(&panel);
return panel;
}();
// draw frame
style()->drawPrimitive(QStyle::PE_PanelLineEdit, &panel, &p, this);
// clip
auto const rect = impl->contentsRect();
p.setClipRect(rect);
auto const middle = impl->tags.cbegin() + static_cast<ptrdiff_t>(impl->editing_index);
// tags
impl->drawTags(p, std::ranges::subrange(impl->tags.cbegin(), middle));
if (impl->cursorVisible()) {
impl->drawEditor(p, palette(), impl->offset());
} else if (!impl->editorText().isEmpty()) {
impl->drawTags(p, std::ranges::subrange(middle, middle + 1));
}
// tags
impl->drawTags(p, std::ranges::subrange(middle + 1, impl->tags.cend()));
}
void TagsLineEdit::timerEvent(QTimerEvent* event) {
if (event->timerId() == impl->blink_timer) {
impl->blink_status = !impl->blink_status;
update();
}
}
void TagsLineEdit::mousePressEvent(QMouseEvent* event) {
// we don't want to change cursor position if this event is part of focusIn
using namespace std::chrono_literals;
if (impl->read_only || (impl->restore_cursor_position_on_focus_click && elapsed(impl->focused_at) < 1ms)) {
return;
}
bool keep_cursor_visible = true;
EVERLOAD_TAGS_SCOPE_EXIT {
impl->update1(keep_cursor_visible);
};
switch (impl->handleClick(event, impl->offset())) {
using enum Impl::HandleClickResult;
case removed_a_tag:
keep_cursor_visible = false;
return;
case moved_cursor_in_a_tag:
// keep cursor visible because the cursor can be at the border, we need to enlarge the space around it;
case moved_cursor_across_tags:
// keep the cursor visible because it can move out of the view
return;
case unhandled:
break;
}
// add new tag closed to the cursor
for (auto it = begin(impl->tags); it != end(impl->tags); ++it) {
// find the closest spot
if (event->pos().x() > it->rect.translated(-impl->offset()).left()) {
continue;
}
impl->editNewTag(static_cast<size_t>(std::distance(begin(impl->tags), it)));
return;
}
// append a new nag
impl->editNewTag(impl->tags.size());
}
QSize TagsLineEdit::sizeHint() const {
ensurePolished();
auto const fm = fontMetrics();
QRect rect(0, 0, impl->pillWidth(fm.boundingRect(QLatin1Char('x')).width() * 17, true),
impl->pillHeight(fm.height()));
rect += magic_margins;
QStyleOptionFrame opt;
impl->initStyleOption(&opt);
return (style()->sizeFromContents(QStyle::CT_LineEdit, &opt, rect.size(), this));
}
QSize TagsLineEdit::minimumSizeHint() const {
ensurePolished();
auto const fm = fontMetrics();
QRect rect(0, 0, impl->pillWidth(fm.maxWidth(), true), impl->pillHeight(fm.height()));
rect += magic_margins;
QStyleOptionFrame opt;
impl->initStyleOption(&opt);
return (style()->sizeFromContents(QStyle::CT_LineEdit, &opt, rect.size(), this));
}
void TagsLineEdit::keyPressEvent(QKeyEvent* event) {
if (impl->read_only) {
return;
}
if (event == QKeySequence::SelectAll) {
impl->selectAll();
} else if (event == QKeySequence::SelectPreviousChar) {
impl->moveCursor(impl->text_layout.previousCursorPosition(impl->cursor), true);
} else if (event == QKeySequence::SelectNextChar) {
impl->moveCursor(impl->text_layout.nextCursorPosition(impl->cursor), true);
} else {
switch (event->key()) {
case Qt::Key_Left:
if (event->modifiers() == Qt::ControlModifier || impl->cursor == 0) {
impl->editPreviousTag();
} else {
impl->moveCursor(impl->text_layout.previousCursorPosition(impl->cursor), false);
}
break;
case Qt::Key_Right:
if (event->modifiers() == Qt::ControlModifier || impl->cursor == impl->editorText().size()) {
impl->editNextTag();
} else {
impl->moveCursor(impl->text_layout.nextCursorPosition(impl->cursor), false);
}
break;
case Qt::Key_Home:
if (event->modifiers() == Qt::ControlModifier) {
impl->editTag(0);
} else {
impl->moveCursor(0, false);
}
break;
case Qt::Key_End:
if (event->modifiers() == Qt::ControlModifier) {
impl->editTag(impl->tags.size() - 1);
} else {
impl->moveCursor(impl->editorText().length(), false);
}
break;
case Qt::Key_Backspace:
if (!impl->editorText().isEmpty()) {
impl->removeBackwardOne();
} else if (impl->editing_index > 0) {
impl->editPreviousTag();
}
break;
case Qt::Key_Space:
if (!impl->editorText().isEmpty()) {
impl->editNewTag(impl->editing_index + 1);
}
break;
default:
if (isAcceptableInput(*event)) {
if (impl->hasSelection()) {
impl->removeSelection();
}
impl->tags[impl->editing_index].text.insert(impl->cursor, event->text());
impl->cursor += event->text().length();
break;
} else {
event->setAccepted(false);
return;
}
}
}
impl->update1();
impl->completer->setCompletionPrefix(impl->editorText());
impl->completer->complete();
emit tagsEdited();
}
void TagsLineEdit::completion(std::vector<QString> const& completions) {
QStringList tmp;
std::copy(begin(completions), end(completions), std::back_inserter(tmp));
impl->completer = std::make_unique<QCompleter>(std::move(tmp));
impl->setupCompleter();
}
void TagsLineEdit::completion(QStringList const& completions) {
impl->completer = std::make_unique<QCompleter>(completions);
impl->setupCompleter();
}
void TagsLineEdit::tags(std::vector<QString> const& tags) {
impl->setTags(tags);
impl->update1();
}
void TagsLineEdit::tags(QStringList const& tags) {
impl->setTags(tags);
impl->update1();
}
std::vector<QString> TagsLineEdit::tags() const {
std::vector<QString> ret;
impl->getTags(ret);
return ret;
}
QStringList TagsLineEdit::tags2() const {
QStringList ret;
impl->getTags(ret);
return ret;
}
void TagsLineEdit::mouseMoveEvent(QMouseEvent* event) {
event->accept();
for (size_t i = 0; i < impl->tags.size(); ++i) {
if (impl->inCrossArea(i, event->pos(), impl->offset())) {
setCursor(Qt::ArrowCursor);
return;
}
}
setCursor(Qt::IBeamCursor);
}
void TagsLineEdit::wheelEvent(QWheelEvent* event) {
event->accept();
impl->calcRects();
impl->updateHScrollRange();
impl->hscroll = std::clamp(impl->hscroll - event->pixelDelta().x(), impl->hscroll_min, impl->hscroll_max);
update();
}
void TagsLineEdit::config(Config config) {
if (impl->unique && impl->unique != config.behavior.unique) {
impl->removeDuplicates();
}
static_cast<StyleConfig&>(*impl) = config.style;
static_cast<BehaviorConfig&>(*impl) = config.behavior;
impl->update1();
}
Config TagsLineEdit::config() const {
return Config{
.style = static_cast<StyleConfig const&>(*impl),
.behavior = static_cast<BehaviorConfig const&>(*impl),
};
}
} // namespace everload_tags
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include <QRect>
#include <QString>
#include "../include/config.hpp"
#include <ranges>
#include <unordered_map>
#include <vector>
namespace everload_tags {
inline void removeDuplicates(std::vector<Tag>& tags) {
std::unordered_map<QString, size_t> unique;
for (auto const i : std::views::iota(size_t{0}, tags.size())) {
unique.emplace(tags[i].text, i);
}
for (auto b = tags.rbegin(), it = b, e = tags.rend(); it != e;) {
if (auto const i = static_cast<size_t>(std::distance(it, e) - 1); unique.at(it->text) != i) {
tags.erase(it++.base() - 1);
} else {
++it;
}
}
}
} // namespace everload_tags