diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index feeb746b..39fab13e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,6 +30,139 @@ If the results look stupid, fix the clang-format file if possible, or disable clang-format in the affected area using `// clang-format off` and `// clang-format on`. +#### Style preferences not caught by clang-format +These are flexible. You can ignore them if it looks or works better to +for one reason or another. + +Use `auto` if the type of a variable can be deduced automatically, instead of +redeclaring the returned value's type. Additionally, auto should be used when a +constructor takes arguments. + +```cpp +auto x = ; // ok +auto x = QString::number(3); // ok +QString x; // ok +QString x = "foo"; // ok +auto x = QString("foo"); // ok + +auto x = QString(); // avoid +QString x(); // avoid +QString x("foo"); // avoid +``` + +Put newlines around logical units of code, and after closing braces. If the +most reasonable logical unit of code takes only a single line, it should be +merged into the next single line logical unit if applicable. +```cpp +// multiple units +auto x = ; // unit 1 +auto y = ; // unit 2 + +auto x = ; // unit 1 +emit this->y(); // unit 2 + +auto x1 = ; // unit 1 +auto x2 = ; // unit 1 +auto x3 = ; // unit 1 + +auto y1 = ; // unit 2 +auto y2 = ; // unit 2 +auto y3 = ; // unit 2 + +// one unit +auto x = ; +if (x...) { + // ... +} + +// if more than one variable needs to be used then add a newline +auto x = ; +auto y = ; + +if (x && y) { + // ... +} +``` + +Class formatting: +```cpp +//! Doc comment summary +/// Doc comment body +class Foo: public QObject { + // The Q_OBJECT macro comes first. Macros are ; terminated. + Q_OBJECT; + QML_ELEMENT; + QML_CLASSINFO(...); + // Properties must stay on a single line or the doc generator won't be able to pick them up + Q_PROPERTY(...); + /// Doc comment + Q_PROPERTY(...); + /// Doc comment + Q_PROPERTY(...); + +public: + // Classes should have explicit constructors if they aren't intended to + // implicitly cast. The constructor can be inline in the header if it has no body. + explicit Foo(QObject* parent = nullptr): QObject(parent) {} + + // Instance functions if applicable. + static Foo* instance(); + + // Member functions unrelated to properties come next + void function(); + void function(); + void function(); + + // Then Q_INVOKABLEs + Q_INVOKABLE function(); + /// Doc comment + Q_INVOKABLE function(); + /// Doc comment + Q_INVOKABLE function(); + + // Then property related functions, in the order (bindable, getter, setter). + // Related functions may be included here as well. Function bodies may be inline + // if they are a single expression. There should be a newline between each + // property's methods. + [[nodiscard]] QBindable bindableFoo() { return &this->bFoo; } + [[nodiscard]] T foo() const { return this->foo; } + void setFoo(); + + [[nodiscard]] T bar() const { return this->foo; } + void setBar(); + +signals: + // Signals that are not property change related go first. + // Property change signals go in property definition order. + void asd(); + void asd2(); + void fooChanged(); + void barChanged(); + +public slots: + // generally Q_INVOKABLEs are preferred to public slots. + void slot(); + +private slots: + // ... + +private: + // statics, then functions, then fields + static const foo BAR; + static void foo(); + + void foo(); + void bar(); + + // property related members are prefixed with `m`. + QString mFoo; + QString bar; + + // Bindables go last and should be prefixed with `b`. + Q_OBJECT_BINDABLE_PROPERTY(Foo, QString, bFoo, &Foo::fooChanged); +}; +``` + ### Linter All contributions should pass the linter. @@ -37,11 +170,11 @@ Note that running the linter requires disabling precompiled headers and including the test codepaths: ```sh $ just configure debug -DNO_PCH=ON -DBUILD_TESTING=ON -$ just lint +$ just lint-changed ``` If the linter is complaining about something that you think it should not, -please disable the lint in your MR and explain your reasoning. +please disable the lint in your MR and explain your reasoning if it isn't obvious. ### Tests If you feel like the feature you are working on is very complex or likely to break, @@ -77,7 +210,7 @@ and list of headers to scan for documentation. ### Commits Please structure your commit messages as `scope[!]: commit` where the scope is something like `core` or `service/mpris`. (pick what has been -used historically or what makes sense if new.) Add `!` for changes that break +used historically or what makes sense if new). Add `!` for changes that break existing APIs or functionality. Commit descriptions should contain a summary of the changes if they are not diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index 6778e984..eca7270d 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -37,6 +37,7 @@ qt_add_library(quickshell-core STATIC common.cpp iconprovider.cpp scriptmodel.cpp + colorquantizer.cpp ) qt_add_qml_module(quickshell-core diff --git a/src/core/colorquantizer.cpp b/src/core/colorquantizer.cpp new file mode 100644 index 00000000..abe6af8e --- /dev/null +++ b/src/core/colorquantizer.cpp @@ -0,0 +1,230 @@ +#include "colorquantizer.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace { +Q_LOGGING_CATEGORY(logColorQuantizer, "quickshell.colorquantizer", QtWarningMsg); +} + +ColorQuantizerOperation::ColorQuantizerOperation(QUrl* source, qreal depth, qreal rescaleSize) + : source(source) + , maxDepth(depth) + , rescaleSize(rescaleSize) { + setAutoDelete(false); +} + +void ColorQuantizerOperation::quantizeImage(const QAtomicInteger& shouldCancel) { + if (shouldCancel.loadAcquire() || source->isEmpty()) return; + + colors.clear(); + + auto image = QImage(source->toLocalFile()); + if ((image.width() > rescaleSize || image.height() > rescaleSize) && rescaleSize > 0) { + image = image.scaled( + static_cast(rescaleSize), + static_cast(rescaleSize), + Qt::KeepAspectRatio, + Qt::SmoothTransformation + ); + } + + if (image.isNull()) { + qCWarning(logColorQuantizer) << "Failed to load image from" << source; + return; + } + + QList pixels; + for (int y = 0; y != image.height(); ++y) { + for (int x = 0; x != image.width(); ++x) { + auto pixel = image.pixel(x, y); + if (qAlpha(pixel) == 0) continue; + + pixels.append(QColor::fromRgb(pixel)); + } + } + + auto startTime = QDateTime::currentDateTime(); + + colors = quantization(pixels, 0); + + auto endTime = QDateTime::currentDateTime(); + auto milliseconds = startTime.msecsTo(endTime); + qCDebug(logColorQuantizer) << "Color Quantization took: " << milliseconds << "ms"; +} + +QList ColorQuantizerOperation::quantization( + QList& rgbValues, + qreal depth, + const QAtomicInteger& shouldCancel +) { + if (shouldCancel.loadAcquire()) return QList(); + + if (depth >= maxDepth || rgbValues.isEmpty()) { + if (rgbValues.isEmpty()) return QList(); + + auto totalR = 0; + auto totalG = 0; + auto totalB = 0; + + for (const auto& color: rgbValues) { + if (shouldCancel.loadAcquire()) return QList(); + + totalR += color.red(); + totalG += color.green(); + totalB += color.blue(); + } + + auto avgColor = QColor( + qRound(totalR / static_cast(rgbValues.size())), + qRound(totalG / static_cast(rgbValues.size())), + qRound(totalB / static_cast(rgbValues.size())) + ); + + return QList() << avgColor; + } + + auto dominantChannel = findBiggestColorRange(rgbValues); + std::ranges::sort(rgbValues, [dominantChannel](const auto& a, const auto& b) { + if (dominantChannel == 'r') return a.red() < b.red(); + else if (dominantChannel == 'g') return a.green() < b.green(); + return a.blue() < b.blue(); + }); + + auto mid = rgbValues.size() / 2; + + auto leftHalf = rgbValues.mid(0, mid); + auto rightHalf = rgbValues.mid(mid); + + QList result; + result.append(quantization(leftHalf, depth + 1)); + result.append(quantization(rightHalf, depth + 1)); + + return result; +} + +char ColorQuantizerOperation::findBiggestColorRange(const QList& rgbValues) { + if (rgbValues.isEmpty()) return 'r'; + + auto rMin = 255; + auto gMin = 255; + auto bMin = 255; + auto rMax = 0; + auto gMax = 0; + auto bMax = 0; + + for (const auto& color: rgbValues) { + rMin = qMin(rMin, color.red()); + gMin = qMin(gMin, color.green()); + bMin = qMin(bMin, color.blue()); + + rMax = qMax(rMax, color.red()); + gMax = qMax(gMax, color.green()); + bMax = qMax(bMax, color.blue()); + } + + auto rRange = rMax - rMin; + auto gRange = gMax - gMin; + auto bRange = bMax - bMin; + + auto biggestRange = qMax(rRange, qMax(gRange, bRange)); + if (biggestRange == rRange) { + return 'r'; + } else if (biggestRange == gRange) { + return 'g'; + } else { + return 'b'; + } +} + +void ColorQuantizerOperation::finishRun() { + QMetaObject::invokeMethod(this, &ColorQuantizerOperation::finished, Qt::QueuedConnection); +} + +void ColorQuantizerOperation::finished() { + emit this->done(colors); + delete this; +} + +void ColorQuantizerOperation::run() { + if (!this->shouldCancel) { + this->quantizeImage(); + + if (this->shouldCancel.loadAcquire()) { + qCDebug(logColorQuantizer) << "Color quantization" << this << "cancelled"; + } + } + + this->finishRun(); +} + +void ColorQuantizerOperation::tryCancel() { this->shouldCancel.storeRelease(true); } + +void ColorQuantizer::componentComplete() { + componentCompleted = true; + if (!mSource.isEmpty()) quantizeAsync(); +} + +void ColorQuantizer::setSource(const QUrl& source) { + if (mSource != source) { + mSource = source; + emit this->sourceChanged(); + + if (this->componentCompleted && !mSource.isEmpty()) quantizeAsync(); + } +} + +void ColorQuantizer::setDepth(qreal depth) { + if (mDepth != depth) { + mDepth = depth; + emit this->depthChanged(); + + if (this->componentCompleted) quantizeAsync(); + } +} + +void ColorQuantizer::setRescaleSize(int rescaleSize) { + if (mRescaleSize != rescaleSize) { + mRescaleSize = rescaleSize; + emit this->rescaleSizeChanged(); + + if (this->componentCompleted) quantizeAsync(); + } +} + +void ColorQuantizer::operationFinished(const QList& result) { + bColors = result; + this->liveOperation = nullptr; + emit this->colorsChanged(); +} + +void ColorQuantizer::quantizeAsync() { + if (this->liveOperation) this->cancelAsync(); + + qCDebug(logColorQuantizer) << "Starting color quantization asynchronously"; + this->liveOperation = new ColorQuantizerOperation(&mSource, mDepth, mRescaleSize); + + QObject::connect( + this->liveOperation, + &ColorQuantizerOperation::done, + this, + &ColorQuantizer::operationFinished + ); + + QThreadPool::globalInstance()->start(this->liveOperation); +} + +void ColorQuantizer::cancelAsync() { + if (!this->liveOperation) return; + + this->liveOperation->tryCancel(); + QThreadPool::globalInstance()->waitForDone(); + + QObject::disconnect(this->liveOperation, nullptr, this, nullptr); + this->liveOperation = nullptr; +} diff --git a/src/core/colorquantizer.hpp b/src/core/colorquantizer.hpp new file mode 100644 index 00000000..b8456db4 --- /dev/null +++ b/src/core/colorquantizer.hpp @@ -0,0 +1,128 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +class ColorQuantizerOperation + : public QObject + , public QRunnable { + Q_OBJECT; + +public: + explicit ColorQuantizerOperation(QUrl* source, qreal depth, qreal rescaleSize); + + void run() override; + void tryCancel(); + +signals: + void done(QList colors); + +private slots: + void finished(); + +private: + static char findBiggestColorRange(const QList& rgbValues); + + void quantizeImage(const QAtomicInteger& shouldCancel = false); + + QList quantization( + QList& rgbValues, + qreal depth, + const QAtomicInteger& shouldCancel = false + ); + + void finishRun(); + + QAtomicInteger shouldCancel = false; + QList colors; + QUrl* source; + qreal maxDepth; + qreal rescaleSize; +}; + +///! Color Quantization Utility +/// A color quantization utility used for getting prevalent colors in an image, by +/// averaging out the image's color data recursively. +/// +/// #### Example +/// ```qml +/// ColorQuantizer { +/// id: colorQuantizer +/// source: Qt.resolvedUrl("./yourImage.png") +/// depth: 3 // Will produce 8 colors (2³) +/// rescaleSize: 64 // Rescale to 64x64 for faster processing +/// } +/// ``` +class ColorQuantizer + : public QObject + , public QQmlParserStatus { + + Q_OBJECT; + QML_ELEMENT; + Q_INTERFACES(QQmlParserStatus); + /// Access the colors resulting from the color quantization performed. + /// > [!NOTE] The amount of colors returned from the quantization is determined by + /// > the property depth, specifically 2ⁿ where n is the depth. + Q_PROPERTY(QList colors READ default NOTIFY colorsChanged BINDABLE bindableColors); + + /// Path to the image you'd like to run the color quantization on. + Q_PROPERTY(QUrl source READ source WRITE setSource NOTIFY sourceChanged); + + /// Max depth for the color quantization. Each level of depth represents another + /// binary split of the color space + Q_PROPERTY(qreal depth READ depth WRITE setDepth NOTIFY depthChanged); + + /// The size to rescale the image to, when rescaleSize is 0 then no scaling will be done. + /// > [!NOTE] Results from color quantization doesn't suffer much when rescaling, it's + /// > reccommended to rescale, otherwise the quantization process will take much longer. + Q_PROPERTY(qreal rescaleSize READ rescaleSize WRITE setRescaleSize NOTIFY rescaleSizeChanged); + +public: + explicit ColorQuantizer(QObject* parent = nullptr): QObject(parent) {} + + void componentComplete() override; + void classBegin() override {} + + [[nodiscard]] QBindable> bindableColors() { return &this->bColors; } + + [[nodiscard]] QUrl source() const { return mSource; } + void setSource(const QUrl& source); + + [[nodiscard]] qreal depth() const { return mDepth; } + void setDepth(qreal depth); + + [[nodiscard]] qreal rescaleSize() const { return mRescaleSize; } + void setRescaleSize(int rescaleSize); + +signals: + void colorsChanged(); + void sourceChanged(); + void depthChanged(); + void rescaleSizeChanged(); + +public slots: + void operationFinished(const QList& result); + +private: + void quantizeAsync(); + void cancelAsync(); + + bool componentCompleted = false; + ColorQuantizerOperation* liveOperation = nullptr; + QUrl mSource; + qreal mDepth = 0; + qreal mRescaleSize = 0; + + Q_OBJECT_BINDABLE_PROPERTY( + ColorQuantizer, + QList, + bColors, + &ColorQuantizer::colorsChanged + ); +}; diff --git a/src/core/module.md b/src/core/module.md index c8b17ab9..b9404ea9 100644 --- a/src/core/module.md +++ b/src/core/module.md @@ -29,5 +29,6 @@ headers = [ "qsmenuanchor.hpp", "clock.hpp", "scriptmodel.hpp", + "colorquantizer.hpp", ] ----- diff --git a/src/wayland/hyprland/ipc/connection.cpp b/src/wayland/hyprland/ipc/connection.cpp index 794ecff6..c797b609 100644 --- a/src/wayland/hyprland/ipc/connection.cpp +++ b/src/wayland/hyprland/ipc/connection.cpp @@ -333,6 +333,7 @@ void HyprlandIpc::onEvent(HyprlandIpcEvent* event) { auto* monitor = this->findMonitorByName(name, true); this->setFocusedMonitor(monitor); monitor->setActiveWorkspace(workspace); + qCDebug(logHyprlandIpc) << "Monitor" << name << "focused with workspace" << workspace->id(); } else if (event->name == "workspacev2") { auto args = event->parseView(2); auto id = args.at(0).toInt(); @@ -341,6 +342,8 @@ void HyprlandIpc::onEvent(HyprlandIpcEvent* event) { if (this->mFocusedMonitor != nullptr) { auto* workspace = this->findWorkspaceByName(name, true, id); this->mFocusedMonitor->setActiveWorkspace(workspace); + qCDebug(logHyprlandIpc) << "Workspace" << id << "activated on" + << this->mFocusedMonitor->name(); } } else if (event->name == "moveworkspacev2") { auto args = event->parseView(3); @@ -351,6 +354,7 @@ void HyprlandIpc::onEvent(HyprlandIpcEvent* event) { auto* workspace = this->findWorkspaceByName(name, true, id); auto* monitor = this->findMonitorByName(monitorName, true); + qCDebug(logHyprlandIpc) << "Workspace" << id << "moved to monitor" << monitorName; workspace->setMonitor(monitor); } else if (event->name == "renameworkspace") { auto args = event->parseView(2); @@ -374,15 +378,28 @@ void HyprlandIpc::onEvent(HyprlandIpcEvent* event) { HyprlandWorkspace* HyprlandIpc::findWorkspaceByName(const QString& name, bool createIfMissing, qint32 id) { const auto& mList = this->mWorkspaces.valueList(); + HyprlandWorkspace* workspace = nullptr; - auto workspaceIter = - std::ranges::find_if(mList, [name](const HyprlandWorkspace* m) { return m->name() == name; }); + if (id != -1) { + auto workspaceIter = + std::ranges::find_if(mList, [&](const HyprlandWorkspace* m) { return m->id() == id; }); - if (workspaceIter != mList.end()) { - return *workspaceIter; + workspace = workspaceIter == mList.end() ? nullptr : *workspaceIter; + } + + if (!workspace) { + auto workspaceIter = + std::ranges::find_if(mList, [&](const HyprlandWorkspace* m) { return m->name() == name; }); + + workspace = workspaceIter == mList.end() ? nullptr : *workspaceIter; + } + + if (workspace) { + return workspace; } else if (createIfMissing) { qCDebug(logHyprlandIpc) << "Workspace" << name - << "requested before creation, performing early init"; + << "requested before creation, performing early init with id" << id; + auto* workspace = new HyprlandWorkspace(this); workspace->updateInitial(id, name); this->mWorkspaces.insertObject(workspace); @@ -400,24 +417,34 @@ void HyprlandIpc::refreshWorkspaces(bool canCreate) { this->requestingWorkspaces = false; if (!success) return; - qCDebug(logHyprlandIpc) << "parsing workspaces response"; + qCDebug(logHyprlandIpc) << "Parsing workspaces response"; auto json = QJsonDocument::fromJson(resp).array(); const auto& mList = this->mWorkspaces.valueList(); - auto names = QVector(); + auto ids = QVector(); for (auto entry: json) { auto object = entry.toObject().toVariantMap(); - auto name = object.value("name").toString(); - auto workspaceIter = std::ranges::find_if(mList, [name](const HyprlandWorkspace* m) { - return m->name() == name; - }); + auto id = object.value("id").toInt(); + + auto workspaceIter = + std::ranges::find_if(mList, [&](const HyprlandWorkspace* m) { return m->id() == id; }); + + // Only fall back to name-based filtering as a last resort, for workspaces where + // no ID has been determined yet. + if (workspaceIter == mList.end()) { + auto name = object.value("name").toString(); + + workspaceIter = std::ranges::find_if(mList, [&](const HyprlandWorkspace* m) { + return m->id() == -1 && m->name() == name; + }); + } auto* workspace = workspaceIter == mList.end() ? nullptr : *workspaceIter; auto existed = workspace != nullptr; - if (workspace == nullptr) { + if (!existed) { if (!canCreate) continue; workspace = new HyprlandWorkspace(this); } @@ -428,20 +455,22 @@ void HyprlandIpc::refreshWorkspaces(bool canCreate) { this->mWorkspaces.insertObject(workspace); } - names.push_back(name); + ids.push_back(id); } - auto removedWorkspaces = QVector(); + if (canCreate) { + auto removedWorkspaces = QVector(); - for (auto* workspace: mList) { - if (!names.contains(workspace->name())) { - removedWorkspaces.push_back(workspace); + for (auto* workspace: mList) { + if (!ids.contains(workspace->id())) { + removedWorkspaces.push_back(workspace); + } } - } - for (auto* workspace: removedWorkspaces) { - this->mWorkspaces.removeObject(workspace); - delete workspace; + for (auto* workspace: removedWorkspaces) { + this->mWorkspaces.removeObject(workspace); + delete workspace; + } } }); } diff --git a/src/wayland/hyprland/ipc/connection.hpp b/src/wayland/hyprland/ipc/connection.hpp index 856d4173..287b1ee8 100644 --- a/src/wayland/hyprland/ipc/connection.hpp +++ b/src/wayland/hyprland/ipc/connection.hpp @@ -81,7 +81,7 @@ public: [[nodiscard]] ObjectModel* workspaces(); // No byId because these preemptively create objects. The given id is set if created. - HyprlandWorkspace* findWorkspaceByName(const QString& name, bool createIfMissing, qint32 id = 0); + HyprlandWorkspace* findWorkspaceByName(const QString& name, bool createIfMissing, qint32 id = -1); HyprlandMonitor* findMonitorByName(const QString& name, bool createIfMissing, qint32 id = -1); // canCreate avoids making ghost workspaces when the connection races diff --git a/src/wayland/hyprland/ipc/monitor.cpp b/src/wayland/hyprland/ipc/monitor.cpp index 8ee5e207..190ab668 100644 --- a/src/wayland/hyprland/ipc/monitor.cpp +++ b/src/wayland/hyprland/ipc/monitor.cpp @@ -117,6 +117,8 @@ void HyprlandMonitor::setActiveWorkspace(HyprlandWorkspace* workspace) { this->mActiveWorkspace = workspace; if (workspace != nullptr) { + workspace->setMonitor(this); + QObject::connect( workspace, &QObject::destroyed, diff --git a/src/wayland/hyprland/ipc/workspace.cpp b/src/wayland/hyprland/ipc/workspace.cpp index 153dea6b..428edd6b 100644 --- a/src/wayland/hyprland/ipc/workspace.cpp +++ b/src/wayland/hyprland/ipc/workspace.cpp @@ -35,18 +35,22 @@ void HyprlandWorkspace::updateInitial(qint32 id, QString name) { } void HyprlandWorkspace::updateFromObject(QVariantMap object) { - auto id = object.value("id").value(); auto name = object.value("name").value(); auto monitorId = object.value("monitorID").value(); auto monitorName = object.value("monitor").value(); - if (id != this->mId) { - this->mId = id; + auto initial = this->mId = -1; + + // ID cannot be updated after creation + if (initial) { + this->mId = object.value("id").value(); emit this->idChanged(); } - if (name != this->mName) { - this->mName = std::move(name); + // No events we currently handle give a workspace id but not a name, + // so we shouldn't set this if it isn't an initial query + if (initial && name != this->mName) { + this->mName = name; emit this->nameChanged(); }