Files
create/src/Gui/Dialogs/DlgThemeEditor.cpp
2025-11-11 13:49:01 +01:00

776 lines
23 KiB
C++

// SPDX-License-Identifier: LGPL-2.1-or-later
/****************************************************************************
* *
* Copyright (c) 2025 Kacper Donat <kacper@kadet.net> *
* *
* This file is part of FreeCAD. *
* *
* FreeCAD is free software: you can redistribute it and/or modify it *
* under the terms of the GNU Lesser General Public License as *
* published by the Free Software Foundation, either version 2.1 of the *
* License, or (at your option) any later version. *
* *
* FreeCAD is distributed in the hope that it will be useful, but *
* WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
* Lesser General Public License for more details. *
* *
* You should have received a copy of the GNU Lesser General Public *
* License along with FreeCAD. If not, see *
* <https://www.gnu.org/licenses/>. *
* *
***************************************************************************/
#include "Tools.h"
#include "Application.h"
#include "OverlayManager.h"
#include "DlgThemeEditor.h"
#include "ui_DlgThemeEditor.h"
#include "BitmapFactory.h"
#include <Utilities.h>
#include <Base/ServiceProvider.h>
#include <Base/Tools.h>
#include <ranges>
#include <QImageReader>
#include <QPainter>
#include <QStyledItemDelegate>
#include <QTimer>
QPixmap colorPreview(const QColor& color)
{
constexpr qsizetype size = 16;
QPixmap preview = Gui::BitmapFactory().empty({size, size});
QPainter painter(&preview);
painter.setRenderHint(QPainter::Antialiasing);
painter.setPen(Qt::NoPen);
painter.setBrush(color);
painter.drawEllipse(QRect {0, 0, size, size});
return preview;
}
QString typeOfTokenValue(const Gui::StyleParameters::Value& value)
{
// clang-format off
return std::visit(
Base::Overloads {
[](const std::string&) {
return QWidget::tr("Generic");
},
[](const Gui::StyleParameters::Numeric&) {
return QWidget::tr("Numeric");
},
[](const Base::Color&) {
return QWidget::tr("Color");
}
},
value
);
// clang-format on
}
namespace Gui
{
struct StyleParametersModel::Item
{
Item() = default;
virtual ~Item() = default;
FC_DEFAULT_COPY_MOVE(Item);
virtual bool isHeader() const = 0;
};
struct StyleParametersModel::GroupItem: Item
{
explicit GroupItem(QString title, ParameterSource* source)
: title(std::move(title))
, canAddNewParameters(source && source->metadata.options.testFlag(UserEditable))
, source(source)
{}
bool isHeader() const override
{
return true;
}
QString title;
bool canAddNewParameters {false};
ParameterSource* source;
std::set<std::string> deleted {};
};
struct StyleParametersModel::ParameterItem: Item
{
ParameterItem(QString name, StyleParameters::Parameter token)
: name(std::move(name))
, token(std::move(token))
{}
bool isHeader() const override
{
return false;
}
QString name;
StyleParameters::Parameter token;
QFlags<Qt::ItemFlag> flags = Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsEditable;
};
class StyleParametersModel::Node
{
public:
explicit Node(std::unique_ptr<Item> data, Node* parent = nullptr)
: _parent(parent)
, _data(std::move(data))
{}
void appendChild(std::unique_ptr<Node> child)
{
child->_parent = this;
_children.push_back(std::move(child));
}
void removeChild(const int row)
{
if (row >= 0 && row < static_cast<int>(_children.size())) {
_children.erase(_children.begin() + row);
}
}
Node* child(const int row) const
{
if (row < 0 || row >= static_cast<int>(_children.size())) {
if (!_empty) {
_empty = std::make_unique<Node>(nullptr, const_cast<Node*>(this));
}
return _empty.get();
}
return _children[row].get();
}
int childCount() const
{
return static_cast<int>(_children.size());
}
int row() const
{
if (!_parent) {
return 0;
}
const auto& siblings = _parent->_children;
for (size_t i = 0; i < siblings.size(); ++i) {
if (siblings[i].get() == this) {
return static_cast<int>(i);
}
}
return -1;
}
Item* data() const
{
return _data.get();
}
template<class T>
T* data() const
{
return dynamic_cast<T*>(_data.get());
}
Node* parent() const
{
return _parent;
}
private:
Node* _parent;
std::vector<std::unique_ptr<Node>> _children {};
mutable std::unique_ptr<Node> _empty {};
std::unique_ptr<Item> _data {};
};
class DlgThemeEditor::Delegate: public QStyledItemDelegate
{
Q_OBJECT
QRegularExpression validNameRegExp {QStringLiteral("^[A-Z][a-zA-Z0-9]*$")};
QRegularExpressionValidator* nameValidator;
public:
explicit Delegate(QObject* parent = nullptr)
: QStyledItemDelegate(parent)
, nameValidator(new QRegularExpressionValidator(validNameRegExp, this))
{}
QWidget* createEditor(
QWidget* parent,
[[maybe_unused]] const QStyleOptionViewItem& option,
const QModelIndex& index
) const override
{
auto model = dynamic_cast<const StyleParametersModel*>(index.model());
if (!model) {
return nullptr;
}
if (model->item<StyleParametersModel::ParameterItem>(index)
&& index.column() == StyleParametersModel::ParameterExpression) {
return new QLineEdit(parent);
}
if (index.column() == StyleParametersModel::ParameterName) {
auto editor = new QLineEdit(parent);
editor->setValidator(nameValidator);
return editor;
}
return nullptr;
}
void setEditorData(QWidget* editor, const QModelIndex& index) const override
{
if (auto* lineEdit = qobject_cast<QLineEdit*>(editor)) {
lineEdit->setText(index.data(Qt::DisplayRole).toString());
}
}
void setModelData(QWidget* editor, QAbstractItemModel* model, const QModelIndex& index) const override
{
if (auto* lineEdit = qobject_cast<QLineEdit*>(editor)) {
model->setData(index, lineEdit->text(), Qt::EditRole);
}
}
void updateEditorGeometry(
QWidget* editor,
const QStyleOptionViewItem& option,
[[maybe_unused]] const QModelIndex& index
) const override
{
editor->setGeometry(option.rect);
}
QSize sizeHint(const QStyleOptionViewItem& option, const QModelIndex& index) const override
{
constexpr int height = 36;
QSize base = QStyledItemDelegate::sizeHint(option, index);
return {base.width(), std::max(base.height(), height)};
}
void paintAddPlaceholder(QPainter* painter, const QStyleOptionViewItem& option) const
{
QStyle* style = option.widget ? option.widget->style() : QApplication::style();
QRect rect = style->subElementRect(QStyle::SE_ItemViewItemText, &option, option.widget);
QFont font = option.font;
font.setItalic(true);
painter->setFont(font);
painter->drawText(rect, Qt::AlignLeft | Qt::AlignVCenter, tr("New parameter..."));
}
void paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const override
{
auto model = dynamic_cast<const StyleParametersModel*>(index.model());
painter->save();
QStyleOptionViewItem opt(option);
initStyleOption(&opt, index);
if (model->isAddPlaceholder(index)) {
if (index.column() == StyleParametersModel::ParameterName) {
paintAddPlaceholder(painter, opt);
}
}
else if (model->item<StyleParametersModel::GroupItem>(index)) {
constexpr int headerContrast = 120;
const bool isLightTheme = option.palette.color(QPalette::Text).lightness() < 128;
const QColor headerBackgroundColor = QtTools::valueOr(
option.widget->property("headerBackgroundColor"),
isLightTheme ? option.palette.color(QPalette::AlternateBase).darker(headerContrast)
: option.palette.color(QPalette::AlternateBase).lighter(headerContrast)
);
painter->fillRect(option.rect, headerBackgroundColor);
QStyledItemDelegate::paint(painter, option, index);
}
else {
QStyledItemDelegate::paint(painter, option, index);
}
painter->restore();
}
};
void TokenTreeView::keyPressEvent(QKeyEvent* event)
{
static constexpr auto expressionEditKeys = {Qt::Key_Return, Qt::Key_Enter, Qt::Key_Space};
static constexpr auto nameEditKeys = {Qt::Key_F2};
static constexpr auto deleteKeys = {Qt::Key_Delete};
const auto isCorrectKey = [&event](auto key) {
return event->key() == key;
};
if (QModelIndex index = currentIndex(); index.isValid()) {
if (std::ranges::any_of(expressionEditKeys, isCorrectKey)) {
edit(index.siblingAtColumn(StyleParametersModel::ParameterExpression));
return;
}
if (std::ranges::any_of(nameEditKeys, isCorrectKey)) {
edit(index.siblingAtColumn(StyleParametersModel::ParameterName));
return;
}
if (std::ranges::any_of(deleteKeys, isCorrectKey)) {
requestRemove(currentIndex());
return;
}
}
QTreeView::keyPressEvent(event);
}
StyleParametersModel::StyleParametersModel(
const std::list<StyleParameters::ParameterSource*>& sources,
QObject* parent
)
: QAbstractItemModel(parent)
, ParameterSource({.name = QT_TR_NOOP("All Theme Editor Parameters")})
, sources(sources)
, manager(new StyleParameters::ParameterManager())
{
// The parameter model serves as the source, so the manager can compute all necessary things
manager->addSource(this);
reset();
}
StyleParametersModel::~StyleParametersModel() = default;
std::list<StyleParameters::Parameter> StyleParametersModel::all() const
{
std::map<std::string, StyleParameters::Parameter> result;
QtTools::walkTreeModel(this, [this, &result](const QModelIndex& index) {
if (auto parameterItem = item<ParameterItem>(index)) {
if (result.contains(parameterItem->token.name)) {
return;
}
result[parameterItem->token.name] = parameterItem->token;
}
});
const auto values = result | std::ranges::views::values;
return std::list<StyleParameters::Parameter>(values.begin(), values.end());
}
std::optional<StyleParameters::Parameter> StyleParametersModel::get(const std::string& name) const
{
std::optional<StyleParameters::Parameter> result = std::nullopt;
QtTools::walkTreeModel(this, [this, &name, &result](const QModelIndex& index) {
if (auto parameterItem = item<ParameterItem>(index)) {
if (parameterItem->token.name == name) {
result = parameterItem->token;
return true;
}
}
return false;
});
return result;
}
void StyleParametersModel::removeItem(const QModelIndex& index)
{
if (auto parameterItem = item<ParameterItem>(index)) {
auto groupItem = item<GroupItem>(index.parent());
if (!groupItem->source->metadata.options.testFlag(UserEditable)) {
return;
}
groupItem->deleted.insert(parameterItem->token.name);
beginRemoveRows(index.parent(), index.row(), index.row());
node(index.parent())->removeChild(index.row());
endRemoveRows();
}
}
void StyleParametersModel::reset()
{
using enum StyleParameters::ParameterSourceOption;
beginResetModel();
root = std::make_unique<Node>(std::make_unique<GroupItem>(tr("Root"), nullptr));
for (auto* source : sources) {
auto groupNode = std::make_unique<Node>(
std::make_unique<GroupItem>(tr(source->metadata.name.c_str()), source)
);
for (const auto& parameter : source->all()) {
auto item = std::make_unique<Node>(
std::make_unique<ParameterItem>(QString::fromStdString(parameter.name), parameter)
);
if (source->metadata.options.testFlag(ReadOnly)) {
item->data<ParameterItem>()->flags = Qt::ItemIsEnabled | Qt::ItemIsSelectable;
}
groupNode->appendChild(std::move(item));
}
root->appendChild(std::move(groupNode));
}
endResetModel();
}
void StyleParametersModel::flush()
{
QtTools::walkTreeModel(this, [this](const QModelIndex& index) {
if (const auto& groupItem = item<GroupItem>(index)) {
for (const auto& parameter : groupItem->deleted) {
groupItem->source->remove(parameter);
}
groupItem->deleted.clear();
}
if (const auto& parameterItem = item<ParameterItem>(index)) {
const auto& groupItem = item<GroupItem>(index.parent());
groupItem->source->define(parameterItem->token);
}
});
for (auto* source : sources) {
source->flush();
}
reset();
}
int StyleParametersModel::rowCount(const QModelIndex& index) const
{
if (index.column() > 0) {
return 0;
}
int childCount = node(index)->childCount();
if (const auto& groupItem = item<GroupItem>(index)) {
return childCount + (groupItem->canAddNewParameters ? 1 : 0);
}
return childCount;
}
int StyleParametersModel::columnCount([[maybe_unused]] const QModelIndex& index) const
{
return ColumnCount;
}
QVariant StyleParametersModel::headerData(int section, Qt::Orientation orientation, int role) const
{
if (orientation == Qt::Horizontal && role == Qt::DisplayRole) {
switch (section) {
case ParameterName:
return tr("Name");
case ParameterExpression:
return tr("Expression");
case ParameterPreview:
return tr("Preview");
case ParameterType:
return tr("Type");
default:
return {};
}
}
return {};
}
QVariant StyleParametersModel::data(const QModelIndex& index, int role) const
{
if (auto parameterItem = item<ParameterItem>(index)) {
const auto& [name, token, _] = *parameterItem;
const auto& value = manager->resolve(name.toStdString());
if (!value) {
return {};
}
if (role == Qt::DisplayRole) {
if (index.column() == ParameterName) {
return name;
}
if (index.column() == ParameterExpression) {
return QString::fromStdString(token.value);
}
if (index.column() == ParameterType) {
return typeOfTokenValue(*value);
}
if (index.column() == ParameterPreview) {
return QString::fromStdString(value->toString());
}
}
if (role == Qt::DecorationRole) {
if (index.column() == ParameterPreview && std::holds_alternative<Base::Color>(*value)) {
return colorPreview(std::get<Base::Color>(*value).asValue<QColor>());
}
}
}
if (auto groupItem = item<GroupItem>(index)) {
if (role == Qt::DisplayRole && index.column() == ParameterName) {
return groupItem->title;
}
}
return {};
}
bool StyleParametersModel::setData(
const QModelIndex& index,
const QVariant& value,
[[maybe_unused]] int role
)
{
if (auto parameterItem = item<ParameterItem>(index)) {
auto groupItem = item<GroupItem>(index.parent());
if (index.column() == ParameterName) {
QString newName = value.toString();
StyleParameters::Parameter newToken = parameterItem->token;
newToken.name = newName.toStdString();
// there is no rename operation, so we need to mark the previous token as deleted
groupItem->deleted.insert(parameterItem->token.name);
parameterItem->name = newName;
parameterItem->token = newToken;
}
if (index.column() == ParameterExpression) {
QString newValue = value.toString();
StyleParameters::Parameter newToken = parameterItem->token;
newToken.value = newValue.toStdString();
parameterItem->token = newToken;
}
}
if (isAddPlaceholder(index)) {
if (index.column() == ParameterName) {
QString newName = value.toString();
if (newName.isEmpty()) {
return false;
}
StyleParameters::Parameter token {.name = newName.toStdString(), .value = ""};
int start = rowCount(index.parent());
beginInsertRows(index.parent(), start, start + 1);
auto item = std::make_unique<Node>(std::make_unique<ParameterItem>(newName, token));
node(index.parent())->appendChild(std::move(item));
endInsertRows();
// this must be queued to basically next frame so widget has a chance to update
QTimer::singleShot(0, [this, index]() { this->newParameterAdded(index); });
}
}
this->manager->reload();
QtTools::walkTreeModel(this, [this](const QModelIndex& index) {
const QModelIndex previewColumnIndex = index.siblingAtColumn(ParameterPreview);
Q_EMIT dataChanged(previewColumnIndex, previewColumnIndex);
});
return true;
}
Qt::ItemFlags StyleParametersModel::flags(const QModelIndex& index) const
{
if (auto parameterItem = item<ParameterItem>(index)) {
if (index.column() == ParameterName || index.column() == ParameterExpression) {
return parameterItem->flags | QAbstractItemModel::flags(index);
}
}
if (isAddPlaceholder(index)) {
if (index.column() == ParameterName) {
return Qt::ItemIsEnabled | Qt::ItemIsEditable | QAbstractItemModel::flags(index);
}
}
return QAbstractItemModel::flags(index);
}
QModelIndex StyleParametersModel::index(int row, int col, const QModelIndex& parent) const
{
if (!hasIndex(row, col, parent)) {
return {};
}
if (auto child = node(parent)->child(row)) {
return createIndex(row, col, child);
}
return {};
}
QModelIndex StyleParametersModel::parent(const QModelIndex& index) const
{
if (!index.isValid()) {
return {};
}
auto node = static_cast<Node*>(index.internalPointer());
auto parent = node->parent();
if (!parent || parent == root.get()) {
return {};
}
return createIndex(parent->row(), 0, parent);
}
bool StyleParametersModel::isAddPlaceholder(const QModelIndex& index) const
{
return item(index) == nullptr;
}
StyleParametersModel::Node* StyleParametersModel::node(const QModelIndex& index) const
{
return index.isValid() ? static_cast<Node*>(index.internalPointer()) : root.get();
}
StyleParametersModel::Item* StyleParametersModel::item(const QModelIndex& index) const
{
return node(index)->data();
}
DlgThemeEditor::DlgThemeEditor(QWidget* parent)
: QDialog(parent)
, ui(new Ui::DlgThemeEditor)
, model(
std::make_unique<StyleParametersModel>(
Base::provideServiceImplementations<StyleParameters::ParameterSource>(),
this
)
)
{
ui->setupUi(this);
ui->tokensTreeView->setMouseTracking(true);
ui->tokensTreeView->setItemDelegate(new Delegate(ui->tokensTreeView));
ui->tokensTreeView->setModel(model.get());
constexpr int typeColumnWidth = 80;
constexpr int nameColumnWidth = 200;
struct ColumnDefinition // NOLINT(*-pro-type-member-init)
{
StyleParametersModel::Column column;
QHeaderView::ResizeMode mode;
qsizetype defaultWidth = 0;
};
static constexpr std::initializer_list<ColumnDefinition> columnSizingDefinitions = {
{StyleParametersModel::ParameterName, QHeaderView::ResizeMode::ResizeToContents},
{StyleParametersModel::ParameterExpression, QHeaderView::ResizeMode::Stretch},
{StyleParametersModel::ParameterPreview, QHeaderView::ResizeMode::Stretch},
{StyleParametersModel::ParameterType, QHeaderView::ResizeMode::Fixed, typeColumnWidth},
};
for (const auto& [column, mode, defaultWidth] : columnSizingDefinitions) {
ui->tokensTreeView->header()->setSectionResizeMode(column, mode);
if (defaultWidth > 0) {
ui->tokensTreeView->header()->setDefaultSectionSize(defaultWidth);
}
}
ui->tokensTreeView->setColumnWidth(StyleParametersModel::ParameterName, nameColumnWidth);
ui->tokensTreeView->expandAll();
connect(ui->buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(ui->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
connect(ui->buttonBox, &QDialogButtonBox::clicked, this, &DlgThemeEditor::handleButtonClick);
connect(
ui->tokensTreeView,
&TokenTreeView::requestRemove,
model.get(),
qOverload<const QModelIndex&>(&StyleParametersModel::removeItem)
);
connect(model.get(), &StyleParametersModel::modelReset, ui->tokensTreeView, [this] {
ui->tokensTreeView->expandAll();
});
connect(model.get(), &StyleParametersModel::newParameterAdded, this, [this](const QModelIndex& index) {
const auto newParameterExpressionIndex = index.siblingAtColumn(
StyleParametersModel::ParameterExpression
);
ui->tokensTreeView->scrollTo(newParameterExpressionIndex);
ui->tokensTreeView->setCurrentIndex(newParameterExpressionIndex);
ui->tokensTreeView->edit(newParameterExpressionIndex);
});
}
DlgThemeEditor::~DlgThemeEditor() = default;
void DlgThemeEditor::handleButtonClick(QAbstractButton* button)
{
auto role = ui->buttonBox->buttonRole(button);
switch (role) {
case QDialogButtonBox::ApplyRole:
case QDialogButtonBox::AcceptRole:
model->flush();
Application::Instance->reloadStyleSheet();
break;
case QDialogButtonBox::ResetRole:
model->reset();
break;
default:
// no-op
break;
}
}
} // namespace Gui
#include "DlgThemeEditor.moc"