Make flow ribbons themeable

This commit is contained in:
luisangelsm
2026-03-21 20:45:23 +01:00
parent 5fbd5bc185
commit f65fd08e65
13 changed files with 201 additions and 32 deletions

View File

@ -24,6 +24,7 @@ void ComicFlowWidget::applyTheme(const Theme &theme)
{
setBackgroundColor(theme.comicFlow.backgroundColor);
setTextColor(theme.comicFlow.textColor);
flow->setRibbonImages(theme.comicFlow.readPixmap.toImage(), theme.comicFlow.readingPixmap.toImage());
}
void ComicFlowWidget::setBackgroundColor(const QColor &color)

View File

@ -78,8 +78,8 @@
<file>../images/notCover.png</file>
<file>../images/library_dialogs/openLibrary.svg</file>
<file>../images/metadata_dialog/previousCoverPage.svg</file>
<file>../images/readingRibbon.png</file>
<file>../images/readRibbon.png</file>
<file>../images/readingRibbon.svg</file>
<file>../images/readRibbon.svg</file>
<file>../images/metadata_dialog/resetCover.svg</file>
<file>../images/search_result.svg</file>
<file>../images/serverConfigBackground.svg</file>

View File

@ -1,6 +1,9 @@
{
"comicFlow": {
"backgroundColor": "#000000",
"readMainColor": "#db4725",
"readTickColor": "#8a2c17",
"readingColor": "#e6b90f",
"textColor": "#4c4c4c"
},
"comicsViewTable": {

View File

@ -1,6 +1,9 @@
{
"comicFlow": {
"backgroundColor": "#111111",
"readMainColor": "#db4725",
"readTickColor": "#8a2c17",
"readingColor": "#e6b90f",
"textColor": "#888888"
},
"comicsViewTable": {

View File

@ -1,6 +1,9 @@
{
"comicFlow": {
"backgroundColor": "#dcdcdc",
"readMainColor": "#db4725",
"readTickColor": "#8a2c17",
"readingColor": "#e6b90f",
"textColor": "#303030"
},
"comicsViewTable": {

View File

@ -92,9 +92,11 @@ struct MetadataScraperDialogThemeTemplates {
QSize rowIconSize = QSize(8, 7);
};
struct ComicFlowColors {
struct ComicFlowTheme {
QColor backgroundColor;
QColor textColor;
QPixmap readPixmap;
QPixmap readingPixmap;
};
struct ComicsViewTableThemeTemplates {
@ -484,7 +486,7 @@ struct Theme {
ThemeMeta meta;
QJsonObject sourceJson;
ComicFlowColors comicFlow;
ComicFlowTheme comicFlow;
MetadataScraperDialogTheme metadataScraperDialog;
HelpAboutDialogTheme helpAboutDialog;
WhatsNewDialogTheme whatsNewDialog;

View File

@ -330,10 +330,18 @@ struct WhatsNewDialogParams {
QColor headerDecorationColor;
};
struct ComicFlowParams {
QColor backgroundColor;
QColor textColor;
QColor readMainColor; // Main ribbon color for read state (#f0f in readRibbon.svg)
QColor readTickColor; // Tick color for read state (#0ff in readRibbon.svg)
QColor readingColor; // Main ribbon color for reading state (#f0f in readingRibbon.svg)
};
struct ThemeParams {
ThemeMeta meta;
ComicFlowColors comicFlowColors;
ComicFlowParams comicFlowParams;
MetadataScraperDialogParams metadataScraperDialogParams;
HelpAboutDialogTheme helpAboutDialogParams;
EmptyContainerParams emptyContainerParams;
@ -361,10 +369,22 @@ Theme makeTheme(const ThemeParams &params)
Theme theme;
// Comic Flow
const auto &cf = params.comicFlowColors;
const auto &cf = params.comicFlowParams;
theme.comicFlow.backgroundColor = cf.backgroundColor;
theme.comicFlow.textColor = cf.textColor;
{
const qreal dpr = qApp->devicePixelRatio();
// readRibbon: #f0f (main) + #0ff (tick)
theme.comicFlow.readPixmap = renderSvgToPixmap(
recoloredSvgToThemeFile(":/images/readRibbon.svg", cf.readMainColor, cf.readTickColor, params.meta.id),
100, 136, dpr);
// readingRibbon: #f0f (main)
theme.comicFlow.readingPixmap = renderSvgToPixmap(
recoloredSvgToThemeFile(":/images/readingRibbon.svg", cf.readingColor, params.meta.id),
100, 136, dpr);
}
// MetadataScraperDialog
const auto &msd = params.metadataScraperDialogParams;
const auto &t = msd.t;
@ -918,8 +938,11 @@ Theme makeTheme(const QJsonObject &json)
if (json.contains("comicFlow")) {
const auto o = json["comicFlow"].toObject();
p.comicFlowColors.backgroundColor = colorFromJson(o, "backgroundColor", p.comicFlowColors.backgroundColor);
p.comicFlowColors.textColor = colorFromJson(o, "textColor", p.comicFlowColors.textColor);
p.comicFlowParams.backgroundColor = colorFromJson(o, "backgroundColor", p.comicFlowParams.backgroundColor);
p.comicFlowParams.textColor = colorFromJson(o, "textColor", p.comicFlowParams.textColor);
p.comicFlowParams.readMainColor = colorFromJson(o, "readMainColor", p.comicFlowParams.readMainColor);
p.comicFlowParams.readTickColor = colorFromJson(o, "readTickColor", p.comicFlowParams.readTickColor);
p.comicFlowParams.readingColor = colorFromJson(o, "readingColor", p.comicFlowParams.readingColor);
}
if (json.contains("metadataScraperDialog")) {

View File

@ -138,28 +138,8 @@ void YACReaderFlow3D::initialize(QRhiCommandBuffer *cb)
qDebug() << "YACReaderFlow3D: Created defaultTexture" << defaultImage.size();
}
#ifdef YACREADER_LIBRARY
// Initialize mark textures
if (!scene.markTexture) {
QImage markImage = QImage(":/images/readRibbon.png").convertToFormat(QImage::Format_RGBA8888);
if (!markImage.isNull()) {
scene.markTexture.reset(m_rhi->newTexture(QRhiTexture::RGBA8, markImage.size(), 1, QRhiTexture::MipMapped | QRhiTexture::UsedWithGenerateMips));
scene.markTexture->create();
getResourceBatch()->uploadTexture(scene.markTexture.get(), markImage);
getResourceBatch()->generateMips(scene.markTexture.get());
}
}
if (!scene.readingTexture) {
QImage readingImage = QImage(":/images/readingRibbon.png").convertToFormat(QImage::Format_RGBA8888);
if (!readingImage.isNull()) {
scene.readingTexture.reset(m_rhi->newTexture(QRhiTexture::RGBA8, readingImage.size(), 1, QRhiTexture::MipMapped | QRhiTexture::UsedWithGenerateMips));
scene.readingTexture->create();
getResourceBatch()->uploadTexture(scene.readingTexture.get(), readingImage);
getResourceBatch()->generateMips(scene.readingTexture.get());
}
}
#endif
if (ribbonTexturesDirty || (!readRibbonImage.isNull() && !scene.markTexture) || (!readingRibbonImage.isNull() && !scene.readingTexture))
syncRibbonTextures(getResourceBatch());
// Create vertex buffer (quad geometry)
if (!scene.vertexBuffer) {
@ -373,9 +353,35 @@ void YACReaderFlow3D::ensurePipeline()
void YACReaderFlow3D::render(QRhiCommandBuffer *cb)
{
if (!m_rhi || numObjects == 0)
if (!m_rhi)
return;
QRhiResourceUpdateBatch *batch = scene.resourceUpdates;
scene.resourceUpdates = nullptr;
auto ensureBatch = [this, &batch]() {
if (!batch)
batch = m_rhi->nextResourceUpdateBatch();
return batch;
};
auto deferBatch = [this, &batch]() {
if (batch)
scene.resourceUpdates = batch;
};
#ifdef YACREADER_LIBRARY
if (ribbonTexturesDirty || (!readRibbonImage.isNull() && !scene.markTexture) || (!readingRibbonImage.isNull() && !scene.readingTexture))
syncRibbonTextures(ensureBatch());
#endif
// Even without draw calls, pending uploads still have to reach the GPU.
// Otherwise recreated ribbon textures would exist but never receive pixels.
if (numObjects == 0) {
if (batch)
cb->resourceUpdate(batch);
return;
}
const QSize outputSize = renderTarget()->pixelSize();
const QColor clearColor = backgroundColor;
@ -552,6 +558,7 @@ void YACReaderFlow3D::render(QRhiCommandBuffer *cb)
ensureUniformBufferCapacity(draws.size());
if (!scene.uniformBuffer) {
deferBatch();
qWarning() << "YACReaderFlow3D: No uniform buffer available for rendering";
return;
}
@ -560,6 +567,7 @@ void YACReaderFlow3D::render(QRhiCommandBuffer *cb)
ensurePipeline();
if (!scene.pipeline) {
deferBatch();
qWarning() << "YACReaderFlow3D: No pipeline available for rendering";
return;
}
@ -569,6 +577,7 @@ void YACReaderFlow3D::render(QRhiCommandBuffer *cb)
if (!scene.instanceBuffer || scene.instanceBuffer->size() < requiredInstanceSize) {
scene.instanceBuffer.reset(m_rhi->newBuffer(QRhiBuffer::Dynamic, QRhiBuffer::VertexBuffer, requiredInstanceSize));
if (!scene.instanceBuffer->create()) {
deferBatch();
qWarning() << "YACReaderFlow3D: Failed to create instance buffer of size" << requiredInstanceSize;
return;
}
@ -576,7 +585,7 @@ void YACReaderFlow3D::render(QRhiCommandBuffer *cb)
// === PHASE 1: PREPARE (BEFORE PASS) ===
// Update ALL uniform and instance data for ALL draws in one batch
QRhiResourceUpdateBatch *batch = m_rhi->nextResourceUpdateBatch();
batch = ensureBatch();
// Process pending texture uploads
if (!pendingTextureUploads.isEmpty()) {
@ -603,6 +612,7 @@ void YACReaderFlow3D::render(QRhiCommandBuffer *cb)
// === PHASE 2: RENDER (DURING PASS) ===
cb->beginPass(renderTarget(), clearColor, { 1.0f, 0 }, batch);
batch = nullptr;
cb->setGraphicsPipeline(scene.pipeline.get());
cb->setViewport(QRhiViewport(0, 0, outputSize.width(), outputSize.height()));
@ -762,6 +772,65 @@ void YACReaderFlow3D::executeDrawWithOffset(QRhiCommandBuffer *cb, QRhiTexture *
cb->draw(6);
}
void YACReaderFlow3D::removeCachedShaderBindings(QRhiTexture *texture)
{
if (!texture)
return;
auto it = scene.shaderBindingsCache.find(texture);
if (it != scene.shaderBindingsCache.end()) {
delete it.value();
scene.shaderBindingsCache.erase(it);
}
}
void YACReaderFlow3D::syncRibbonTextures(QRhiResourceUpdateBatch *batch)
{
#ifndef YACREADER_LIBRARY
Q_UNUSED(batch);
ribbonTexturesDirty = false;
#else
if (!m_rhi || !batch)
return;
bool allTexturesReady = true;
auto syncTexture = [this, batch, &allTexturesReady](std::unique_ptr<QRhiTexture> &texture, const QImage &image, const char *name) {
if (image.isNull()) {
if (texture) {
removeCachedShaderBindings(texture.get());
texture.reset();
}
return;
}
if (texture && !ribbonTexturesDirty)
return;
if (texture) {
removeCachedShaderBindings(texture.get());
texture.reset();
}
std::unique_ptr<QRhiTexture> newTexture(m_rhi->newTexture(QRhiTexture::RGBA8, image.size(), 1, QRhiTexture::MipMapped | QRhiTexture::UsedWithGenerateMips));
if (!newTexture->create()) {
qWarning() << "YACReaderFlow3D: Failed to create" << name << "ribbon texture";
allTexturesReady = false;
return;
}
batch->uploadTexture(newTexture.get(), image);
batch->generateMips(newTexture.get());
texture = std::move(newTexture);
};
syncTexture(scene.markTexture, readRibbonImage, "read");
syncTexture(scene.readingTexture, readingRibbonImage, "reading");
ribbonTexturesDirty = !allTexturesReady;
#endif
}
void YACReaderFlow3D::releaseResources()
{
scene.reset();
@ -1278,6 +1347,19 @@ void YACReaderFlow3D::setTextColor(const QColor &color)
update();
}
void YACReaderFlow3D::setRibbonImages(const QImage &readImage, const QImage &readingImage)
{
readRibbonImage = readImage.convertToFormat(QImage::Format_RGBA8888);
readingRibbonImage = readingImage.convertToFormat(QImage::Format_RGBA8888);
#ifdef YACREADER_LIBRARY
ribbonTexturesDirty = true;
#else
ribbonTexturesDirty = false;
#endif
update();
}
// Event handlers
void YACReaderFlow3D::wheelEvent(QWheelEvent *event)
{

View File

@ -179,6 +179,10 @@ protected:
QColor backgroundColor;
QColor textColor;
QImage readRibbonImage;
QImage readingRibbonImage;
bool ribbonTexturesDirty = false;
/*** System info ***/
float viewRotate;
@ -202,6 +206,8 @@ protected:
// Helper methods
QRhiTexture *createTextureFromImage(QRhiCommandBuffer *cb, const QImage &image);
void removeCachedShaderBindings(QRhiTexture *texture);
void syncRibbonTextures(QRhiResourceUpdateBatch *batch);
void updateUniformBuffer(QRhiCommandBuffer *cb, const UniformData &data);
void prepareMarkInstanceData(const YACReader3DImageRHI &image, QVector<float> &data);
void ensureUniformBufferCapacity(int requiredSlots);
@ -260,6 +266,9 @@ public slots:
void setBackgroundColor(const QColor &color);
void setTextColor(const QColor &color);
// Ribbon image setters (for themed SVG rasterized images)
void setRibbonImages(const QImage &readImage, const QImage &readingImage);
virtual void updateImageData() = 0;
void reset();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

24
images/readRibbon.svg Normal file
View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 100 136">
<defs>
<style>
.cls-1 {
fill: #0ff;
}
.cls-2 {
fill: #f0f;
filter: url(#drop-shadow-1);
}
</style>
<filter id="drop-shadow-1" x="0" y="-1" width="100" height="134" filterUnits="userSpaceOnUse">
<feOffset dx="0" dy="1"/>
<feGaussianBlur result="blur" stdDeviation="2"/>
<feFlood flood-color="#000" flood-opacity=".33"/>
<feComposite in2="blur" operator="in"/>
<feComposite in="SourceGraphic"/>
</filter>
</defs>
<path class="cls-2" d="M90.69,125.14l-38.73-33.45c-1.13-.97-2.8-.97-3.92,0L9.31,125.14c-1.3,1.12-3.31.2-3.31-1.51V8c0-2.21,1.79-4,4-4h80c2.21,0,4,1.79,4,4v115.63c0,1.71-2.01,2.63-3.31,1.51Z"/>
<polygon class="cls-1" points="34.29 52 40 46.29 46 52.29 61 37.29 66.71 43 46 63.71 34.29 52"/>
</svg>

After

Width:  |  Height:  |  Size: 988 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

19
images/readingRibbon.svg Normal file
View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 100 136">
<defs>
<style>
.cls-1 {
fill: #f0f;
filter: url(#drop-shadow-2);
}
</style>
<filter id="drop-shadow-2" x="0" y="-1" width="100" height="134" filterUnits="userSpaceOnUse">
<feOffset dx="0" dy="1"/>
<feGaussianBlur result="blur" stdDeviation="2"/>
<feFlood flood-color="#000" flood-opacity=".33"/>
<feComposite in2="blur" operator="in"/>
<feComposite in="SourceGraphic"/>
</filter>
</defs>
<path class="cls-1" d="M90.69,125.14l-38.73-33.45c-1.13-.97-2.8-.97-3.92,0L9.31,125.14c-1.3,1.12-3.31.2-3.31-1.51V8c0-2.21,1.79-4,4-4h80c2.21,0,4,1.79,4,4v115.63c0,1.71-2.01,2.63-3.31,1.51Z"/>
</svg>

After

Width:  |  Height:  |  Size: 845 B