refactor: move clock rendering to qml

This commit is contained in:
2026-05-31 00:10:54 +00:00
parent c44e9f57d2
commit e1a523ea67
15 changed files with 112 additions and 634 deletions
-2
View File
@@ -1,2 +0,0 @@
BasedOnStyle: Google
IndentWidth: 4
-3
View File
@@ -1,3 +0,0 @@
CompileFlags:
# C++20
Add: [-std=c++20]
-1
View File
@@ -15,7 +15,6 @@
# Build artifacts # Build artifacts
build/ build/
*.plasmoid *.plasmoid
package/contents/ui/cppbridge/
# Temporary files # Temporary files
*~ *~
+10 -44
View File
@@ -6,9 +6,6 @@ METADATA_FILE := $(PACKAGE_DIR)/metadata.json
WIDGET_ID := $(shell grep -oP '"Id": "\K[^"]+' $(METADATA_FILE)) WIDGET_ID := $(shell grep -oP '"Id": "\K[^"]+' $(METADATA_FILE))
BUILD_DIR := build BUILD_DIR := build
PLASMOID_FILE := $(WIDGET_ID).plasmoid PLASMOID_FILE := $(WIDGET_ID).plasmoid
CPP_DIR := cpp
CPP_BUILD_DIR := $(BUILD_DIR)/cpp
CPP_MODULE_DIR := $(PACKAGE_DIR)/contents/ui/cppbridge
# Default target # Default target
.PHONY: help .PHONY: help
@@ -18,13 +15,11 @@ help:
@echo "Usage:" @echo "Usage:"
@echo " make dev Run widget in plasmoidviewer from local package (MODE=panel|desktop|hidpi)" @echo " make dev Run widget in plasmoidviewer from local package (MODE=panel|desktop|hidpi)"
@echo " make watch Start Development Mode with Hot Reload" @echo " make watch Start Development Mode with Hot Reload"
@echo " make cpp-build Build the C++ QML bridge module"
@echo " make cpp-clean Remove C++ bridge build artifacts"
@echo " make install Install/upgrade widget in local Plasma package store" @echo " make install Install/upgrade widget in local Plasma package store"
@echo " make uninstall Remove widget from local system" @echo " make uninstall Remove widget from local system"
@echo " make package Build .plasmoid file for distribution" @echo " make package Build .plasmoid file for distribution"
@echo " make clean Remove build artifacts" @echo " make clean Remove build artifacts"
@echo " make format Format QML, C++, JSON, YAML, and Markdown files" @echo " make format Format QML, JSON, YAML, and Markdown files"
@echo " make lint Run non-mutating lint and formatting checks" @echo " make lint Run non-mutating lint and formatting checks"
@echo " make hooks-install Configure Git to use versioned hooks from .githooks/" @echo " make hooks-install Configure Git to use versioned hooks from .githooks/"
@@ -35,28 +30,11 @@ hooks-install:
git config core.hooksPath .githooks git config core.hooksPath .githooks
@echo "Configured core.hooksPath=.githooks" @echo "Configured core.hooksPath=.githooks"
# C++ Bridge Build Targets
# Generate CMake build files for the C++ QML bridge.
.PHONY: cpp-configure
cpp-configure:
mkdir -p $(CPP_BUILD_DIR)
cd $(CPP_DIR) && cmake --preset clang-default
# Build the C++ QML bridge module binaries.
.PHONY: cpp-build
cpp-build: cpp-configure
cmake --build $(CPP_BUILD_DIR)
# Remove C++ bridge build output and copied module artifacts.
.PHONY: cpp-clean
cpp-clean:
rm -rf $(CPP_BUILD_DIR) $(CPP_MODULE_DIR)
# Testing Targets # Testing Targets
# Run plasmoidviewer from the local package directory. # Run plasmoidviewer from the local package directory.
# Optional MODE values: panel, desktop, hidpi (omit MODE for default). # Optional MODE values: panel, desktop, hidpi (omit MODE for default).
.PHONY: dev .PHONY: dev
dev: cpp-build dev:
@mode="$(MODE)"; \ @mode="$(MODE)"; \
case "$$mode" in \ case "$$mode" in \
""|default) \ ""|default) \
@@ -75,7 +53,7 @@ dev: cpp-build
# Development Tools # Development Tools
# Start file watcher for iterative development and reload workflow. # Start file watcher for iterative development and reload workflow.
.PHONY: watch .PHONY: watch
watch: cpp-build watch:
./scripts/watch.sh ./scripts/watch.sh
# Format all QML files in the package directory in-place. # Format all QML files in the package directory in-place.
@@ -84,12 +62,6 @@ format-qml:
@command -v qmlformat >/dev/null 2>&1 || { echo "Missing required tool: qmlformat"; exit 1; } @command -v qmlformat >/dev/null 2>&1 || { echo "Missing required tool: qmlformat"; exit 1; }
find $(PACKAGE_DIR) -name "*.qml" -type f -exec qmlformat -i {} + find $(PACKAGE_DIR) -name "*.qml" -type f -exec qmlformat -i {} +
# Format C/C++ source and header files with clang-format.
.PHONY: format-cpp
format-cpp:
@command -v clang-format >/dev/null 2>&1 || { echo "Missing required tool: clang-format"; exit 1; }
find $(CPP_DIR) -type f \( -name "*.c" -o -name "*.cc" -o -name "*.cpp" -o -name "*.h" -o -name "*.hh" -o -name "*.hpp" \) -exec clang-format -i {} +
# Format JSON/YAML/Markdown files with prettier. # Format JSON/YAML/Markdown files with prettier.
.PHONY: format-prettier .PHONY: format-prettier
format-prettier: format-prettier:
@@ -102,7 +74,7 @@ format-prettier:
# Run all formatting passes. # Run all formatting passes.
.PHONY: format .PHONY: format
format: format-qml format-cpp format-prettier format: format-qml format-prettier
# Linting # Linting
# Check QML formatting without changing files. # Check QML formatting without changing files.
@@ -119,16 +91,10 @@ lint-qml-format:
# Run semantic QML lint checks. # Run semantic QML lint checks.
.PHONY: lint-qml .PHONY: lint-qml
lint-qml: cpp-build lint-qml:
@command -v qmllint >/dev/null 2>&1 || { echo "Missing required tool: qmllint"; exit 1; } @command -v qmllint >/dev/null 2>&1 || { echo "Missing required tool: qmllint"; exit 1; }
find $(PACKAGE_DIR) -name "*.qml" -type f -print0 | xargs -0 -r qmllint find $(PACKAGE_DIR) -name "*.qml" -type f -print0 | xargs -0 -r qmllint
# Check C/C++ formatting without changing files.
.PHONY: lint-cpp
lint-cpp:
@command -v clang-format >/dev/null 2>&1 || { echo "Missing required tool: clang-format"; exit 1; }
find $(CPP_DIR) -type f \( -name "*.c" -o -name "*.cc" -o -name "*.cpp" -o -name "*.h" -o -name "*.hh" -o -name "*.hpp" \) -print0 | xargs -0 -r clang-format --dry-run --Werror
# Check JSON/YAML/Markdown formatting without changing files. # Check JSON/YAML/Markdown formatting without changing files.
.PHONY: lint-prettier .PHONY: lint-prettier
lint-prettier: lint-prettier:
@@ -141,12 +107,12 @@ lint-prettier:
# Run all lint and formatting checks. # Run all lint and formatting checks.
.PHONY: lint .PHONY: lint
lint: lint-qml-format lint-qml lint-cpp lint-prettier lint: lint-qml-format lint-qml lint-prettier
# Installation # Installation
# Install/upgrade widget in local Plasma package store. # Install/upgrade widget in local Plasma package store.
.PHONY: install .PHONY: install
install: cpp-build install:
@if kpackagetool6 --type Plasma/Applet --show $(WIDGET_ID) >/dev/null 2>&1; then \ @if kpackagetool6 --type Plasma/Applet --show $(WIDGET_ID) >/dev/null 2>&1; then \
echo "Upgrading existing package: $(WIDGET_ID)"; \ echo "Upgrading existing package: $(WIDGET_ID)"; \
kpackagetool6 --type Plasma/Applet --upgrade $(PACKAGE_DIR); \ kpackagetool6 --type Plasma/Applet --upgrade $(PACKAGE_DIR); \
@@ -157,7 +123,7 @@ install: cpp-build
# Upgrade an already installed widget package with local changes. # Upgrade an already installed widget package with local changes.
.PHONY: upgrade .PHONY: upgrade
upgrade: cpp-build upgrade:
kpackagetool6 --type Plasma/Applet --upgrade $(PACKAGE_DIR) kpackagetool6 --type Plasma/Applet --upgrade $(PACKAGE_DIR)
# Remove installed widget package from local system. # Remove installed widget package from local system.
@@ -173,7 +139,7 @@ uninstall:
# Packaging # Packaging
# Create distributable .plasmoid archive from current package contents. # Create distributable .plasmoid archive from current package contents.
.PHONY: package .PHONY: package
package: cpp-build package:
mkdir -p $(BUILD_DIR) mkdir -p $(BUILD_DIR)
rm -f $(BUILD_DIR)/$(PLASMOID_FILE) rm -f $(BUILD_DIR)/$(PLASMOID_FILE)
@@ -186,4 +152,4 @@ package: cpp-build
# Delete all generated build artifacts for a clean workspace. # Delete all generated build artifacts for a clean workspace.
.PHONY: clean .PHONY: clean
clean: clean:
rm -rf $(BUILD_DIR) $(CPP_MODULE_DIR) rm -rf $(BUILD_DIR)
+5 -1
View File
@@ -1,6 +1,6 @@
# katoclock # katoclock
Just a cat with a clock. Bootstrapped from my [kde-6-widget-starter](https://github.com/ruinivist/kde-6-widget-starter) Just a QML-only cat clock widget for KDE Plasma 6. Bootstrapped from my [kde-6-widget-starter](https://github.com/ruinivist/kde-6-widget-starter)
[Pling page](https://www.pling.com/p/2349620/) [Pling page](https://www.pling.com/p/2349620/)
@@ -10,3 +10,7 @@ Just a cat with a clock. Bootstrapped from my [kde-6-widget-starter](https://git
- clone the repo - clone the repo
- `make install` from repo root - `make install` from repo root
# Package
- `make package` builds a `.plasmoid` archive from the QML package and bundled image assets
-39
View File
@@ -1,39 +0,0 @@
cmake_minimum_required(VERSION 3.23)
project(cppbridge LANGUAGES CXX)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
find_package(Qt6 REQUIRED COMPONENTS Core Qml Quick)
find_package(OpenCV 4 REQUIRED COMPONENTS core imgcodecs imgproc)
qt_standard_project_setup(REQUIRES 6.5)
qt_add_qml_module(cppbridgeplugin
URI cppbridge
VERSION 1.0
OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/../package/contents/ui/cppbridge"
SOURCES
src/clockimageprovider.hpp
src/clockimageprovider.cpp
src/config.hpp
src/renderer.hpp
src/renderer.cpp
src/timebridge.h
src/timebridge.cpp
)
target_include_directories(cppbridgeplugin PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/src"
${OpenCV_INCLUDE_DIRS}
)
target_link_libraries(cppbridgeplugin PRIVATE
Qt6::Core
Qt6::Qml
Qt6::Quick
opencv_core
opencv_imgcodecs
opencv_imgproc
)
set_target_properties(cppbridgeplugin PROPERTIES
CXX_STANDARD 20
CXX_STANDARD_REQUIRED ON
)
-28
View File
@@ -1,28 +0,0 @@
{
"version": 6,
"cmakeMinimumRequired": {
"major": 3,
"minor": 23,
"patch": 0
},
"configurePresets": [
{
"name": "clang-default",
"displayName": "Clang Default",
"description": "Configure build/cpp with clang/clang++ and export compile commands.",
"generator": "Unix Makefiles",
"binaryDir": "${sourceDir}/../build/cpp",
"cacheVariables": {
"CMAKE_C_COMPILER": "clang",
"CMAKE_CXX_COMPILER": "clang++",
"CMAKE_EXPORT_COMPILE_COMMANDS": "ON"
}
}
],
"buildPresets": [
{
"name": "clang-default",
"configurePreset": "clang-default"
}
]
}
-32
View File
@@ -1,32 +0,0 @@
#include "clockimageprovider.hpp"
#include <QMutexLocker>
ClockImageProvider::ClockImageProvider()
: QQuickImageProvider(QQuickImageProvider::Image) {}
QImage ClockImageProvider::requestImage(const QString&, QSize* size,
const QSize& requestedSize) {
QImage frame;
{
QMutexLocker locker(&mutex_);
frame = image_;
}
if (size != nullptr) {
*size = frame.size();
}
if (!frame.isNull() && requestedSize.width() > 0 &&
requestedSize.height() > 0) {
return frame.scaled(requestedSize, Qt::KeepAspectRatio,
Qt::SmoothTransformation);
}
return frame;
}
void ClockImageProvider::setImage(const QImage& image) {
QMutexLocker locker(&mutex_);
image_ = image;
}
-19
View File
@@ -1,19 +0,0 @@
#pragma once
#include <QImage>
#include <QMutex>
#include <QQuickImageProvider>
class ClockImageProvider : public QQuickImageProvider {
public:
ClockImageProvider();
QImage requestImage(const QString& id, QSize* size,
const QSize& requestedSize) override;
void setImage(const QImage& image);
private:
QMutex mutex_;
QImage image_;
};
-21
View File
@@ -1,21 +0,0 @@
#pragma once
#include <opencv2/core.hpp>
// file full of magic numbers
// most are hand tuned based on the cat image I have
namespace Config {
const cv::Point2f CLOCK_CENTER(300.0f, 286.0f);
const double HAND_SCALE = 0.22;
const double HOUR_HAND_SCALE = HAND_SCALE * 0.7;
const double MINUTE_HAND_SCALE = HAND_SCALE * 0.7;
const cv::Point2f HOUR_HAND_PIVOT_FRACTION(0.126f, 0.502f);
const cv::Point2f MINUTE_HAND_PIVOT_FRACTION(0.101f, 0.488f);
// Asset convention: unrotated hand points right (+X).
// Clock convention: 12:00 points up (-Y), hence +90 degrees.
const double HAND_BASE_ANGLE_OFFSET = 90.0;
} // namespace Config
-112
View File
@@ -1,112 +0,0 @@
#include "renderer.hpp"
#include <iostream>
#include <opencv2/imgcodecs.hpp>
#include <opencv2/imgproc.hpp>
#include "config.hpp"
Renderer::Renderer() {}
bool Renderer::load_assets(const std::string& bg_path,
const std::string& hour_hand_path,
const std::string& minute_hand_path) {
background_ = cv::imread(bg_path, cv::IMREAD_UNCHANGED);
if (background_.empty()) {
std::cerr << "failed to load background: " << bg_path << std::endl;
return false;
}
if (background_.channels() != 4) {
std::cerr << "expected rgba png for background";
return false;
}
// Load hands with alpha channel.
hour_hand_ = cv::imread(hour_hand_path, cv::IMREAD_UNCHANGED);
if (hour_hand_.empty()) {
std::cerr << "failed to load hour hand: " << hour_hand_path
<< std::endl;
return false;
}
minute_hand_ = cv::imread(minute_hand_path, cv::IMREAD_UNCHANGED);
if (minute_hand_.empty()) {
std::cerr << "failed to load minute hand: " << minute_hand_path
<< std::endl;
return false;
}
if (hour_hand_.channels() != 4 || minute_hand_.channels() != 4) {
std::cerr << "expected rgba png for hands" << std::endl;
return false;
}
return true;
}
cv::Mat Renderer::render(double hour_angle, double minute_angle) {
auto clock_to_cv_rotation = [](double clock_angle) {
return Config::HAND_BASE_ANGLE_OFFSET - clock_angle;
};
cv::Mat warped_hour = transform_hand(
hour_hand_, clock_to_cv_rotation(hour_angle), Config::HOUR_HAND_SCALE,
Config::HOUR_HAND_PIVOT_FRACTION);
cv::Mat warped_minute = transform_hand(
minute_hand_, clock_to_cv_rotation(minute_angle),
Config::MINUTE_HAND_SCALE, Config::MINUTE_HAND_PIVOT_FRACTION);
cv::Mat keyframe = background_.clone();
// Layer order matters: hour underneath minute.
blend_overlay(keyframe, warped_hour);
blend_overlay(keyframe, warped_minute);
return keyframe;
}
cv::Mat Renderer::transform_hand(const cv::Mat& hand_sprite, double angle_deg,
double scale,
const cv::Point2f& pivot_fraction) {
cv::Mat oriented_sprite;
cv::flip(hand_sprite, oriented_sprite, 0);
if (scale <= 0.0) {
return cv::Mat::zeros(background_.size(), CV_8UC4);
}
cv::Point2f pivot(oriented_sprite.cols * pivot_fraction.x,
oriented_sprite.rows * pivot_fraction.y);
cv::Mat rot_mat = cv::getRotationMatrix2D(pivot, angle_deg, scale);
rot_mat.at<double>(0, 2) += Config::CLOCK_CENTER.x - pivot.x;
rot_mat.at<double>(1, 2) += Config::CLOCK_CENTER.y - pivot.y;
cv::Mat overlay = cv::Mat::zeros(background_.size(), CV_8UC4);
cv::warpAffine(oriented_sprite, overlay, rot_mat, background_.size(),
cv::INTER_CUBIC, cv::BORDER_CONSTANT,
cv::Scalar(0, 0, 0, 0));
return overlay;
}
void Renderer::blend_overlay(cv::Mat& bg, const cv::Mat& fg) {
// Blend hand RGB onto background RGB using hand alpha
for (int y = 0; y < fg.rows; ++y) {
const cv::Vec4b* fg_ptr = fg.ptr<cv::Vec4b>(y);
cv::Vec4b* bg_ptr = bg.ptr<cv::Vec4b>(y);
for (int x = 0; x < fg.cols; ++x) {
const cv::Vec4b& fg_pixel = fg_ptr[x];
const double alpha = fg_pixel[3] / 255.0;
if (alpha > 0.0) {
const cv::Vec4b bg_pixel = bg_ptr[x];
for (int c = 0; c < 3; ++c) {
bg_ptr[x][c] = static_cast<uchar>(
fg_pixel[c] * alpha + bg_pixel[c] * (1.0 - alpha));
}
}
}
}
}
-28
View File
@@ -1,28 +0,0 @@
#pragma once
#include <opencv2/core.hpp>
#include <string>
class Renderer {
public:
Renderer();
// Load assets from disk. Returns false if any fail to load.
bool load_assets(const std::string& bg_path,
const std::string& hour_hand_path,
const std::string& minute_hand_path);
// Composites the hands onto the background at the given angles (degrees).
// Returns the result image.
cv::Mat render(double hour_angle, double minute_angle);
private:
cv::Mat background_;
cv::Mat hour_hand_;
cv::Mat minute_hand_;
// Helpers
cv::Mat transform_hand(const cv::Mat& hand_sprite, double angle_deg,
double scale, const cv::Point2f& pivot_fraction);
void blend_overlay(cv::Mat& bg, const cv::Mat& fg);
};
-216
View File
@@ -1,216 +0,0 @@
#include "timebridge.h"
#include <QQmlEngine>
#include <QTime>
#include <QUuid>
#include <opencv2/imgproc.hpp>
#include "clockimageprovider.hpp"
#include "renderer.hpp"
namespace {
constexpr int kMillisPerMinute = 60 * 1000;
}
ClockBridge::ClockBridge(QObject* parent) : QObject(parent) {
updateTimer_.setSingleShot(true);
connect(&updateTimer_, &QTimer::timeout, this,
&ClockBridge::onUpdateTimerTimeout);
}
ClockBridge::~ClockBridge() {
updateTimer_.stop();
if (renderer_ != nullptr) {
delete renderer_;
renderer_ = nullptr;
}
}
QString ClockBridge::imageSource() const { return imageSource_; }
bool ClockBridge::ready() const { return ready_; }
QString ClockBridge::errorString() const { return errorString_; }
bool ClockBridge::initialize(const QUrl& bg, const QUrl& hour,
const QUrl& minute) {
updateTimer_.stop();
initialized_ = false;
setReady(false);
const QString bgPath = toLocalPath(bg);
const QString hourPath = toLocalPath(hour);
const QString minutePath = toLocalPath(minute);
if (bgPath.isEmpty() || hourPath.isEmpty() || minutePath.isEmpty()) {
setErrorString(QStringLiteral("Clock assets must be local file URLs."));
return false;
}
if (!ensureImageProvider()) {
return false;
}
if (renderer_ == nullptr) {
renderer_ = new Renderer();
}
if (!renderer_->load_assets(bgPath.toStdString(), hourPath.toStdString(),
minutePath.toStdString())) {
setErrorString(QStringLiteral("Failed to load clock assets."));
return false;
}
initialized_ = true;
if (!renderForCurrentTime()) {
initialized_ = false;
return false;
}
scheduleNextMinuteTick();
return true;
}
void ClockBridge::refreshNow() {
if (!initialized_) {
setErrorString(QStringLiteral("ClockBridge is not initialized."));
setReady(false);
return;
}
renderForCurrentTime();
scheduleNextMinuteTick();
}
void ClockBridge::onUpdateTimerTimeout() {
if (!initialized_) {
return;
}
renderForCurrentTime();
scheduleNextMinuteTick();
}
bool ClockBridge::ensureImageProvider() {
if (imageProvider_ != nullptr) {
return true;
}
QQmlEngine* engine = qmlEngine(this);
if (engine == nullptr) {
setErrorString(QStringLiteral("ClockBridge has no active QQmlEngine."));
return false;
}
providerId_ = QStringLiteral("catclock_%1")
.arg(QUuid::createUuid().toString(QUuid::Id128));
if (engine->imageProvider(providerId_) != nullptr) {
setErrorString(QStringLiteral("Image provider ID collision."));
return false;
}
imageProvider_ = new ClockImageProvider();
engine->addImageProvider(providerId_, imageProvider_);
return true;
}
bool ClockBridge::renderForCurrentTime() {
if (renderer_ == nullptr || imageProvider_ == nullptr) {
setErrorString(QStringLiteral("ClockBridge is not initialized."));
setReady(false);
return false;
}
const QTime now = QTime::currentTime();
const int minute = now.minute();
const int hour = now.hour();
const double minuteAngle = minute * 6.0;
const double hourAngle = (hour % 12) * 30.0 + minute * 0.5;
cv::Mat frame = renderer_->render(hourAngle, minuteAngle);
if (frame.empty()) {
setErrorString(QStringLiteral("Renderer returned an empty frame."));
setReady(false);
return false;
}
cv::Mat rgbaFrame;
cv::cvtColor(frame, rgbaFrame, cv::COLOR_BGRA2RGBA);
QImage image(rgbaFrame.data, rgbaFrame.cols, rgbaFrame.rows,
static_cast<int>(rgbaFrame.step), QImage::Format_RGBA8888);
imageProvider_->setImage(image.copy());
setErrorString(QString());
setReady(true);
bumpFrameRevision();
return true;
}
void ClockBridge::scheduleNextMinuteTick() {
if (!initialized_) {
return;
}
const QTime now = QTime::currentTime();
int delayMs = kMillisPerMinute - (now.second() * 1000 + now.msec());
if (delayMs <= 0 || delayMs > kMillisPerMinute) {
delayMs = kMillisPerMinute;
}
updateTimer_.start(delayMs);
}
void ClockBridge::setReady(bool value) {
if (ready_ == value) {
return;
}
ready_ = value;
emit readyChanged();
}
void ClockBridge::setErrorString(const QString& value) {
if (errorString_ == value) {
return;
}
errorString_ = value;
emit errorStringChanged();
}
QString ClockBridge::toLocalPath(const QUrl& url) const {
if (url.isEmpty()) {
return QString();
}
if (url.isLocalFile()) {
return url.toLocalFile();
}
if (url.scheme().isEmpty()) {
return url.toString();
}
return QString();
}
QString ClockBridge::buildImageSource() const {
if (providerId_.isEmpty()) {
return QString();
}
return QStringLiteral("image://%1/frame?rev=%2")
.arg(providerId_)
.arg(frameRevision_);
}
// to signal image inddeed changed even if the path is same
// we update revision
void ClockBridge::bumpFrameRevision() {
++frameRevision_;
const QString nextSource = buildImageSource();
if (imageSource_ == nextSource) {
return;
}
imageSource_ = nextSource;
emit imageSourceChanged();
}
-59
View File
@@ -1,59 +0,0 @@
#pragma once
#include <qqmlintegration.h>
#include <QObject>
#include <QString>
#include <QTimer>
#include <QUrl>
class ClockImageProvider;
class Renderer;
class ClockBridge : public QObject {
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(QString imageSource READ imageSource NOTIFY imageSourceChanged)
Q_PROPERTY(bool ready READ ready NOTIFY readyChanged)
Q_PROPERTY(QString errorString READ errorString NOTIFY errorStringChanged)
public:
explicit ClockBridge(QObject* parent = nullptr);
~ClockBridge() override;
QString imageSource() const;
bool ready() const;
QString errorString() const;
Q_INVOKABLE bool initialize(const QUrl& bg, const QUrl& hour,
const QUrl& minute);
Q_INVOKABLE void refreshNow();
signals:
void imageSourceChanged();
void readyChanged();
void errorStringChanged();
private slots:
void onUpdateTimerTimeout();
private:
bool ensureImageProvider();
bool renderForCurrentTime();
void scheduleNextMinuteTick();
void setReady(bool value);
void setErrorString(const QString& value);
QString toLocalPath(const QUrl& url) const;
QString buildImageSource() const;
void bumpFrameRevision();
Renderer* renderer_ = nullptr;
ClockImageProvider* imageProvider_ = nullptr;
QString providerId_;
QString imageSource_;
QString errorString_;
bool ready_ = false;
bool initialized_ = false;
int frameRevision_ = 0;
QTimer updateTimer_;
};
+97 -29
View File
@@ -1,56 +1,125 @@
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import "cppbridge" as CppBridge
import org.kde.kirigami as Kirigami import org.kde.kirigami as Kirigami
import org.kde.plasma.components as PlasmaComponents
import org.kde.plasma.core as PlasmaCore import org.kde.plasma.core as PlasmaCore
import org.kde.plasma.plasmoid import org.kde.plasma.plasmoid
PlasmoidItem { PlasmoidItem {
id: root id: root
// Widget setup readonly property real clockWidth: 421
readonly property real clockHeight: 600
readonly property real clockCenterX: 300
readonly property real clockCenterY: 286
readonly property real handScale: 0.154
property real hourAngle: 0
property real minuteAngle: 0
function refreshTime() {
const now = new Date();
const minute = now.getMinutes();
const hour = now.getHours();
root.minuteAngle = minute * 6;
root.hourAngle = (hour % 12) * 30 + minute * 0.5;
let nextTick = 60000 - (now.getSeconds() * 1000 + now.getMilliseconds());
if (nextTick <= 0 || nextTick > 60000)
nextTick = 60000;
minuteTimer.interval = nextTick;
minuteTimer.restart();
}
width: Kirigami.Units.gridUnit * 20 width: Kirigami.Units.gridUnit * 20
height: Kirigami.Units.gridUnit * 20 height: Kirigami.Units.gridUnit * 20
Plasmoid.backgroundHints: PlasmaCore.Types.NoBackground Plasmoid.backgroundHints: PlasmaCore.Types.NoBackground
toolTipMainText: "katoclock" toolTipMainText: "katoclock"
Component.onCompleted: { Component.onCompleted: refreshTime()
const initialized = clockBridge.initialize(Qt.resolvedUrl("assets/background.png"), Qt.resolvedUrl("assets/hand_hour.png"), Qt.resolvedUrl("assets/hand_minute.png"));
if (!initialized)
console.warn(clockBridge.errorString);
Timer {
id: minuteTimer
repeat: false
onTriggered: root.refreshTime()
} }
CppBridge.ClockBridge { component ClockHand: Item {
id: clockBridge required property url imageSource
required property real sourceWidth
required property real sourceHeight
required property real pivotFractionX
required property real pivotFractionY
required property real clockAngle
readonly property real pivotX: width * pivotFractionX
readonly property real pivotY: height * pivotFractionY
width: sourceWidth * root.handScale
height: sourceHeight * root.handScale
x: root.clockCenterX - pivotX
y: root.clockCenterY - pivotY
Image {
id: handImage
anchors.fill: parent
source: imageSource
smooth: true
mipmap: true
transform: Scale {
origin.x: handImage.width / 2
origin.y: handImage.height / 2
yScale: -1
}
}
transform: Rotation {
origin.x: pivotX
origin.y: pivotY
angle: clockAngle - 90
}
} }
component ClockImageView: Item { component ClockImageView: Item {
// asset image size -> 421x600, 1 is cropping artifact but I'll live with it implicitWidth: root.clockWidth
implicitWidth: 421 implicitHeight: root.clockHeight
implicitHeight: 600
Image { Item {
anchors.fill: parent width: root.clockWidth
visible: clockBridge.ready height: root.clockHeight
source: clockBridge.imageSource anchors.centerIn: parent
fillMode: Image.PreserveAspectFit scale: Math.min(parent.width / width, parent.height / height)
asynchronous: true
cache: false Image {
} anchors.fill: parent
source: Qt.resolvedUrl("assets/background.png")
smooth: true
mipmap: true
}
ClockHand {
imageSource: Qt.resolvedUrl("assets/hand_hour.png")
sourceWidth: 227
sourceHeight: 76
pivotFractionX: 0.126
pivotFractionY: 0.502
clockAngle: root.hourAngle
}
ClockHand {
imageSource: Qt.resolvedUrl("assets/hand_minute.png")
sourceWidth: 316
sourceHeight: 78
pivotFractionX: 0.101
pivotFractionY: 0.488
clockAngle: root.minuteAngle
}
PlasmaComponents.Label {
anchors.fill: parent
visible: !clockBridge.ready
text: clockBridge.errorString.length > 0 ? clockBridge.errorString : "Loading..."
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
wrapMode: Text.Wrap
} }
} }
// full representation (popup or desktop widget)
fullRepresentation: Item { fullRepresentation: Item {
Layout.minimumWidth: Kirigami.Units.gridUnit * 12 Layout.minimumWidth: Kirigami.Units.gridUnit * 12
Layout.minimumHeight: Kirigami.Units.gridUnit * 12 Layout.minimumHeight: Kirigami.Units.gridUnit * 12
@@ -64,7 +133,6 @@ PlasmoidItem {
} }
// compact representation (icon in panel)
compactRepresentation: Item { compactRepresentation: Item {
Layout.minimumWidth: Kirigami.Units.gridUnit * 6 Layout.minimumWidth: Kirigami.Units.gridUnit * 6
Layout.minimumHeight: Kirigami.Units.gridUnit * 6 Layout.minimumHeight: Kirigami.Units.gridUnit * 6