Author SHA1 Message Date
AramJonghu b1ef587dd2 fix(ci): cpp clang-tidy now properly reads non-build files only
C++ / fmt (pull_request) Successful in 6s
JS/TS / fmt & lint (pull_request) Successful in 16s
Python / fmt & lint (pull_request) Successful in 48s
C++ / build & lint (pull_request) Failing after 2m33s
Python / test & nuitka build (pull_request) Failing after 1m38s
Rust / build, clippy & fmt (pull_request) Failing after 4m41s
2026-10-25 16:01:40 +01:00
AramJonghu 9ad94f1e3e feat(makefile): added no nuitka option for cpp-compile task 2026-10-25 16:01:40 +01:00
AramJonghu 856c78c380 chore(gitignore): ordering known files and directories 2026-10-25 16:01:40 +01:00
AramJonghu 0c4e8e805a chore(gitignore): nuitka compile files ignored and categorized 2026-10-25 16:01:40 +01:00
AramJonghu 18c474669b fix(clang-tidy): make cpp-lint has proper clang-tidy check 2026-10-25 16:01:40 +01:00
AramJonghu 5eb84285a4 chore(Dockerfile): add future dependencies 2026-10-25 16:01:40 +01:00
zachandAramJonghu 02fb847268 Merge branch 'main' into feat/task-runner-ci 2026-10-25 16:01:40 +01:00
AramJonghu a96211a5b5 feat(makefile): added qmlformat + install -> compile in cpp step 2026-10-25 16:01:40 +01:00
AramJonghu cbb108a6dd feat(makefile): C++, Python, and Rust checks included (incomplete) 2026-10-25 16:01:40 +01:00
AramJonghu 9f9d09bc5b feat(makefile): makefile build & install + progress on cpp and py 2026-10-25 16:01:40 +01:00
AramJonghu a2326dc641 format(clang): make cpp-fmt result 2026-10-25 16:01:39 +01:00
AramJonghu b4f0239e2a ci: rework Dockerfile image + buildx dep added 2026-10-25 16:01:39 +01:00
AramJonghu fd938d4e47 refactor(workflows): combined steps where it makes sense 2026-10-25 16:01:39 +01:00
AramJonghu c5dfb306dd fix(ci): missing dependency wayland-scanner 2026-10-25 16:01:39 +01:00
zach 200a6a5d54 fix: suppress private headers cmake warning 2026-10-25 02:07:38 +02:00
zach 2d2231745c fix: remove layer from settings when fading 2026-10-25 01:49:53 +02:00
28 changed files with 282 additions and 1477 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
uses: actions/checkout@v4
- name: Install Docker CLI
run: apk add --no-cache docker-cli
run: apk add --no-cache docker-cli docker-cli-buildx
- name: Login to registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.aramjonghu.dev --username aramjonghu --password-stdin
+5 -21
View File
@@ -20,28 +20,10 @@ jobs:
-exec clang-format -i --style=file {} +
git diff --exit-code && echo "clang-format: passed"
build:
build & lint:
runs-on: alpine
container:
image: git.aramjonghu.dev/aramjonghu/zshell-ci:latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Configure
run: cmake -B build -G Ninja -DENABLE_MODULES=plugin -DCMAKE_BUILD_TYPE=Release
- name: Build
run: ninja -C build
clang-tidy:
runs-on: alpine
container:
image: git.aramjonghu.dev/aramjonghu/zshell-ci:latest
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -58,5 +40,7 @@ jobs:
run: |
sed -i 's/-mno-direct-extern-access //g' build/compile_commands.json
set -o pipefail
run-clang-tidy -p build -j $(nproc) -header-filter="Plugins/.*" 2>&1 | \
grep -v 'Suppressed\|non-user\|non-system\|unknown argument.*mno-direct\|Found compiler error\|warnings generated\.\|^Use -header-filter'
run-clang-tidy -p build -j "$(nproc)" \
-source-filter="^$(pwd)/Plugins/" \
-header-filter=".*Plugins/.*" \
-removed-arg=-mno-direct-extern-access
+1 -15
View File
@@ -4,7 +4,7 @@ on:
pull_request:
jobs:
fmt:
fmt & lint:
runs-on: alpine
container: node:26-alpine
@@ -24,20 +24,6 @@ jobs:
else
echo "No JS/TS files found"
fi
lint:
runs-on: alpine
container: node:26-alpine
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install tools
run: |
apk add --no-cache \
git
- name: ESLint
run: |
if [ -n "$(find . \( -iname "*.js" -o -iname "*.jsx" -o -iname "*.ts" -o -iname "*.tsx" -o -iname "*.mjs" -o -iname "*.cjs" \) -print -quit)" ]; then
+3 -3
View File
@@ -7,7 +7,7 @@ on:
pull_request:
jobs:
static:
fmt & lint:
runs-on: debian
container: node:26-trixie-slim
@@ -33,7 +33,7 @@ jobs:
. .venv/bin/activate
ruff check .
verify:
test & nuitka build:
if: always()
runs-on: debian
container: node:26-trixie-slim
@@ -64,4 +64,4 @@ jobs:
- name: Nuitka module check
run: |
. .venv/bin/activate
nuitka --module --include-package=zshell cli/src/zshell/
nuitka --module --include-package=zshell cli/src/zshell/
+21 -86
View File
@@ -3,8 +3,9 @@ name: Rust
on:
pull_request:
# jobs can be separated if we integrate tests
jobs:
build:
build, clippy & fmt:
runs-on: alpine
container: node:26-alpine
env:
@@ -34,7 +35,9 @@ jobs:
apk add --no-cache \
git \
cargo \
rust
rust \
rust-clippy \
rustfmt
- name: Cargo check
run: |
@@ -50,90 +53,6 @@ jobs:
else
echo "No Rust project found"
fi
fmt:
runs-on: alpine
container: node:26-alpine
env:
CARGO_HOME: ${{ github.workspace }}/.cargo
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Cache cargo packages
uses: actions/cache@v4
env:
cache-name: cache-cargo-packages
with:
path: |
.cargo/registry
.cargo/git
target
key: rust-fmt-${{ runner.os }}-${{ env.cache-name }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
rust-${{ runner.os }}-build-${{ env.cache-name }}-
rust-${{ runner.os }}-build-
rust-
- name: Install tools
run: |
apk add --no-cache \
git \
cargo \
rust \
rustfmt
- name: Format check
run: |
if [ -n "$(find . -name "Cargo.toml" -print -quit)" ]; then
status=0
for manifest in $(find . -name "Cargo.toml"); do
cargo fmt --manifest-path "$manifest" --check && \
echo "$manifest: formatting OK" || \
{ echo "$manifest: needs formatting"; status=1; }
done
exit $status
elif [ -n "$(find . -name "*.rs" -print -quit)" ]; then
echo "Rust files found but no Cargo.toml"
exit 1
else
echo "No Rust project found"
fi
clippy:
runs-on: alpine
container: node:26-alpine
env:
CARGO_HOME: ${{ github.workspace }}/.cargo
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Cache cargo packages
uses: actions/cache@v4
env:
cache-name: cache-cargo-packages
with:
path: |
.cargo/registry
.cargo/git
target
key: rust-clippy-${{ runner.os }}-${{ env.cache-name }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
rust-${{ runner.os }}-build-${{ env.cache-name }}-
rust-${{ runner.os }}-build-
rust-
- name: Install tools
run: |
apk add --no-cache \
git \
cargo \
rust \
rust-clippy
- name: Clippy
run: |
if [ -n "$(find . -name "Cargo.toml" -print -quit)" ]; then
@@ -150,3 +69,19 @@ jobs:
else
echo "No Rust project found"
fi
- name: Format check
run: |
if [ -n "$(find . -name "Cargo.toml" -print -quit)" ]; then
status=0
for manifest in $(find . -name "Cargo.toml"); do
cargo fmt --manifest-path "$manifest" --check && \
echo "$manifest: formatting OK" || \
{ echo "$manifest: needs formatting"; status=1; }
done
exit $status
elif [ -n "$(find . -name "*.rs" -print -quit)" ]; then
echo "Rust files found but no Cargo.toml"
exit 1
else
echo "No Rust project found"
fi
+22 -10
View File
@@ -1,23 +1,35 @@
**/__pycache__/
./result/
.pyre/
.cache/
.venv/
.qmlls.ini
build/
compile_commands.json
testpython
pkg/
*.tar.*
uv.lock
.qtcreator/
dist/
**/target/
**/test-plugins/
**/Charts/
network-dev/
**/zshell.build/
**/zshell.dist/
# Qml
.qtcreator/
.qmlls.ini
# CMake compile artifacts
.cache/
compile_commands.json
build/
# Python
uv.lock
testpython
.venv/
**/__pycache__/
# Nuitka compile
**/zshell.pyi
**/zshell.cpython-*
**/zshell.build/
# Agents stuff
.opencode/
run-agent.sh
AGENTS.md
+1
View File
@@ -50,6 +50,7 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib")
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
set(QT_NO_PRIVATE_MODULE_WARNING ON)
set(ENABLE_MODULES "plugin;shell;cli;m3shapes" CACHE STRING "Modules to build/install")
set(INSTALL_LIBDIR "${CMAKE_INSTALL_LIBDIR}/ZShell" CACHE STRING "Library install dir")
+1 -26
View File
@@ -18,8 +18,6 @@ Searcher {
property string actualCurrent: WallpaperPath.currentWallpaperPath
property alias crops: adapter.monitorCrops
readonly property string current: showPreview ? previewPath : actualCurrent
readonly property string currentThumbnail: thumbnailFor(current)
readonly property bool currentIsVideo: isVideoPath(current)
property alias monitorCrops: monitorCrops
property bool pendingPreviewClear
property bool previewColorLock
@@ -34,29 +32,6 @@ Searcher {
return category;
}
readonly property var videoExts: ["mp4", "mkv", "webm", "avi", "mov", "wmv", "flv", "m4v", "mpg", "mpeg", "3gp", "ogv", "ts", "vob"]
function isVideoPath(path: string): bool {
if (!path)
return false;
const dot = path.lastIndexOf(".");
if (dot < 0)
return false;
return videoExts.includes(path.slice(dot + 1).toLowerCase());
}
function thumbnailFor(path: string): string {
if (!isVideoPath(path))
return path;
for (const entry of root.list) {
if (entry.path === path && entry.thumbnailPath)
return entry.thumbnailPath;
}
return path;
}
function getCrop(screen: string): var {
return root.crops[screen];
}
@@ -150,7 +125,7 @@ Searcher {
FileSystemModel {
id: wallpapers
filter: FileSystemModel.Media
filter: FileSystemModel.Images
path: Config.general.wallpaperPath
recursive: true
}
+6 -58
View File
@@ -1,5 +1,3 @@
pragma ComponentBehavior: Bound
import Quickshell
import QtQuick
import ZShell.Config
@@ -11,7 +9,7 @@ Item {
id: root
required property bool isCurrent
required property var modelData
required property var modelData // FileSystemEntry-like {path, relativePath}
required property real tileHeight
required property real tileWidth
required property ScreenState screenState
@@ -31,75 +29,25 @@ Item {
radius: Tokens.rounding.small
y: Tokens.padding.large
Loader {
active: opacity > 0
MaterialIcon {
anchors.centerIn: parent
opacity: img.status === Image.Ready ? 0 : 1
Behavior on opacity {
Anim {}
}
sourceComponent: CustomRect {
color: Colors.palette.m3primaryContainer
implicitHeight: loadingIndicator.implicitSize + Tokens.padding.large * 2
implicitWidth: loadingIndicator.implicitSize + Tokens.padding.large * 2
radius: Tokens.rounding.full
LoadingIndicator {
id: loadingIndicator
anchors.centerIn: parent
containsIcon: true
implicitSize: Math.min(image.width, image.height) * 0.3
}
}
color: Colors.tPalette.m3outline
fontStyle: Tokens.font.icon.builders.extraLarge.scale(2).weight(Font.DemiBold).build()
text: "image"
}
CachingImage {
id: img
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
cache: true
fillMode: Image.PreserveAspectCrop
height: root.tileHeight
path: root.modelData ? root.modelData.thumbnailPath : ""
path: root.modelData ? root.modelData.path : ""
smooth: root.isCurrent
sourceSize.height: root.tileHeight
sourceSize.width: root.tileHeight * (16 / 9)
width: root.tileHeight * (16 / 9)
opacity: status === Image.Ready ? 1 : 0
Behavior on opacity {
Anim {
type: Anim.SlowEffects
}
}
Loader {
opacity: root.modelData.isVideo && img.status === Image.Ready ? 1 : 0
active: opacity > 0
anchors.centerIn: parent
Behavior on opacity {
Anim {
type: Anim.SlowEffects
}
}
sourceComponent: CustomRect {
implicitWidth: videoIcon.implicitWidth + Tokens.padding.extraLarge * 2
implicitHeight: implicitWidth
radius: Tokens.rounding.full
color: Colors.tPalette.m3surface
MaterialIcon {
id: videoIcon
anchors.centerIn: parent
text: "video_library"
fontStyle: Tokens.font.icon.extraLarge
}
}
}
}
StateLayer {
+3 -27
View File
@@ -39,7 +39,8 @@ Item {
opacity: img.status === Image.Ready ? 0 : 1
Behavior on opacity {
Anim {}
Anim {
}
}
sourceComponent: CustomRect {
color: Colors.palette.m3primaryContainer
@@ -63,43 +64,18 @@ Item {
anchors.fill: parent
asynchronous: true
fillMode: Image.PreserveAspectCrop
opacity: status === Image.Ready ? 1 : 0
retainWhileLoading: true
sourceSize: {
const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1;
return Qt.size(width * dpr, height * dpr);
}
opacity: status === Image.Ready ? 1 : 0
Behavior on opacity {
Anim {
type: Anim.SlowEffects
}
}
Loader {
opacity: root.modelData.isVideo && img.status === Image.Ready ? 1 : 0
active: opacity > 0
anchors.centerIn: parent
Behavior on opacity {
Anim {
type: Anim.SlowEffects
}
}
sourceComponent: CustomRect {
implicitWidth: videoIcon.implicitWidth + Tokens.padding.extraLarge * 2
implicitHeight: implicitWidth
radius: Tokens.rounding.full
color: Colors.tPalette.m3surface
MaterialIcon {
id: videoIcon
anchors.centerIn: parent
text: "video_library"
fontStyle: Tokens.font.icon.extraLarge
}
}
}
}
}
}
+8 -36
View File
@@ -13,9 +13,7 @@ import qs.Services
Item {
id: wrapper
readonly property bool isVideo: Wallpapers.currentIsVideo
readonly property var currentScreen: screens.length > selectedScreenIndex ? screens[selectedScreenIndex] : null
readonly property real maxZoom: 5.0
property int buttonRowHeight: Tokens.padding.large * 3
readonly property bool changesMade: {
cropRevision;
@@ -27,9 +25,9 @@ Item {
return Math.abs(r.x - t.x) > 0.5 || Math.abs(r.y - t.y) > 0.5 || Math.abs(r.zoom - t.zoom) > 0.001;
}
property int buttonRowHeight: Tokens.padding.large * 3
property int cropRevision: 0
readonly property var currentScreen: screens.length > selectedScreenIndex ? screens[selectedScreenIndex] : null
readonly property real maxZoom: 5.0
property var screens: []
property int selectedScreenIndex: 0
property bool shouldBeActive: true
@@ -129,7 +127,7 @@ Item {
anchors.rightMargin: Tokens.padding.extraLarge * 2
anchors.top: parent.top
active: wrapper.screens.length > 1 && !wrapper.isVideo
active: wrapper.screens.length > 1
sourceComponent: ButtonRow {
id: screenSelector
@@ -165,7 +163,6 @@ Item {
anchors.left: parent.left
anchors.right: parent.right
implicitHeight: 30
enabled: !wrapper.isVideo
CustomSlider {
id: zoomSlider
@@ -277,7 +274,7 @@ Item {
opacity: status === Image.Ready ? 1 : 0
retainWhileLoading: true
smooth: true
source: Wallpapers.currentThumbnail
source: Wallpapers.current
sourceSize.height: {
const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1;
return imageSlot.height * dpr;
@@ -288,37 +285,12 @@ Item {
type: Anim.SlowEffects
}
}
Loader {
opacity: wrapper.isVideo && scaledImg.status === Image.Ready ? 1 : 0
active: opacity > 0
anchors.centerIn: parent
Behavior on opacity {
Anim {
type: Anim.SlowEffects
}
}
sourceComponent: CustomRect {
implicitWidth: videoIcon.implicitWidth + Tokens.padding.extraLarge * 2
implicitHeight: implicitWidth
radius: Tokens.rounding.full
color: Colors.tPalette.m3surface
MaterialIcon {
id: videoIcon
anchors.centerIn: parent
text: "video_library"
fontStyle: Tokens.font.icon.extraLarge
}
}
}
}
Loader {
id: cropRectLoader
active: scaledImg.status === Image.Ready && !wrapper.isVideo
active: scaledImg.status === Image.Ready
sourceComponent: Component {
CustomRect {
@@ -361,6 +333,7 @@ Item {
y = t.y;
}
// Where the rect should be according to saved data (same rules as a restore).
function savedTarget() {
const data = Wallpapers.getCrop(wrapper.currentScreen.name);
const hasCrop = data && (Math.abs(data.x) > 0.001 || Math.abs(data.y) > 0.001 || Math.abs(data.width - 1.0) > 0.001 || Math.abs(data.height - 1.0) > 0.001);
@@ -463,10 +436,9 @@ Item {
}
anchors.fill: parent
cursorShape: wrapper.isVideo ? Qt.ArrowCursor : pressed ? Qt.ClosedHandCursor : insideCropRect(mouseX, mouseY) ? Qt.OpenHandCursor : Qt.PointingHandCursor
cursorShape: pressed ? Qt.ClosedHandCursor : insideCropRect(mouseX, mouseY) ? Qt.OpenHandCursor : Qt.PointingHandCursor
hoverEnabled: true
preventStealing: true
enabled: !wrapper.isVideo
onPositionChanged: mouse => {
if (pressed) {
-1
View File
@@ -38,7 +38,6 @@ Item {
id: container
anchors.fill: parent
layer.enabled: opacity < 1
objectName: "PageContainer"
Component.onCompleted: root.loadPage(root.sState.currentPageIdx)
@@ -50,12 +50,11 @@ PageBase {
}
WallItem {
id: wallItem
required property FileSystemEntry modelData
enabled: modelData
opacity: modelData ? 1 : 0
source: String(modelData?.thumbnailPath ?? "")
source: String(modelData?.path ?? "")
onClicked: {
Wallpapers.setWallpaper(modelData.path);
-1
View File
@@ -20,7 +20,6 @@ pkg_check_modules(WAYLAND_CLIENT REQUIRED IMPORTED_TARGET wayland-client)
pkg_check_modules(EGL REQUIRED IMPORTED_TARGET egl)
pkg_check_modules(WAYLAND_EGL REQUIRED IMPORTED_TARGET wayland-egl)
pkg_check_modules(GL REQUIRED IMPORTED_TARGET gl)
pkg_check_modules(MPV REQUIRED IMPORTED_TARGET mpv)
pkg_check_modules(Qalculate IMPORTED_TARGET libqalculate REQUIRED)
pkg_check_modules(Pipewire IMPORTED_TARGET libpipewire-0.3 REQUIRED)
pkg_check_modules(Aubio IMPORTED_TARGET aubio REQUIRED)
+1 -1
View File
@@ -218,7 +218,7 @@ void BlobInvertedRect::outerCornerRadii(float out[4]) const {
const auto base = std::min(static_cast<float>(m_outerRadius), maxR);
out[0] = std::min(
m_outerTopRightRadius >= 0 ? static_cast<float>(m_outerTopRightRadius)
: base,
: base,
maxR);
out[1] = std::min(
m_outerBottomRightRadius >= 0
+6 -2
View File
@@ -60,11 +60,15 @@ class BlobInvertedRect : public BlobShape {
void setOuterRadius(qreal v);
// Per-corner outer radii. -1 falls back to outerRadius (mirrors BlobRect).
[[nodiscard]] qreal outerTopLeftRadius() const { return m_outerTopLeftRadius; }
[[nodiscard]] qreal outerTopLeftRadius() const {
return m_outerTopLeftRadius;
}
void setOuterTopLeftRadius(qreal v);
[[nodiscard]] qreal outerTopRightRadius() const { return m_outerTopRightRadius; }
[[nodiscard]] qreal outerTopRightRadius() const {
return m_outerTopRightRadius;
}
void setOuterTopRightRadius(qreal v);
+1 -2
View File
@@ -240,8 +240,7 @@ void BlobShape::updatePolish() {
m_cachedInvertedRadius = 0;
memset(m_cachedInvertedOuter, 0, sizeof(m_cachedInvertedOuter));
memset(m_cachedInvertedInner, 0, sizeof(m_cachedInvertedInner));
memset(
m_cachedInvertedOuterRadii, 0, sizeof(m_cachedInvertedOuterRadii));
memset(m_cachedInvertedOuterRadii, 0, sizeof(m_cachedInvertedOuterRadii));
auto* inv = m_group->invertedRect();
if (inv) {
-1
View File
@@ -39,5 +39,4 @@ qml_module(ZShell-internal
PkgConfig::EGL
PkgConfig::WAYLAND_EGL
PkgConfig::GL
PkgConfig::MPV
)
+57 -637
View File
@@ -7,26 +7,15 @@
#include <GL/gl.h>
#include <GL/glext.h>
#include <mpv/client.h>
#include <mpv/render_gl.h>
#include <clocale>
#include <fcntl.h>
#include <poll.h>
#include <unistd.h>
#include <QDebug>
#include <QFileInfo>
#include <QGuiApplication>
#include <QImageReader>
#include <QScreen>
#include <QCryptographicHash>
#include <QDir>
#include <QFile>
#include <QProcess>
#include <qguiapplication_platform.h>
#include <private/qwaylandscreen_p.h>
@@ -40,6 +29,7 @@ namespace {
const char* kVertexShader = R"(
#version 300 es
void main() {
// The corners are (-1,-1), (3,-1) and (-1,3), which cover the whole buffer.
vec2 corner = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0);
}
@@ -51,36 +41,16 @@ precision highp float;
uniform sampler2D source;
uniform vec2 size;
uniform vec4 crop;
uniform float alpha;
out vec4 outColor;
void main() {
// The pixels are stored top-down, so the fragment coordinate is flipped.
vec2 p = vec2(gl_FragCoord.x, size.y - gl_FragCoord.y) / size;
vec4 c = texture(source, crop.xy + p * crop.zw);
outColor = vec4(c.rgb, c.a * alpha);
outColor = texture(source, crop.xy + p * crop.zw);
}
)";
constexpr int kWaitTimeoutMs = 20;
constexpr int kFadeDurationMs = 400;
const QStringList kVideoExtensions = {
"mp4",
"mkv",
"webm",
"avi",
"mov",
"wmv",
"flv",
"m4v",
"mpg",
"mpeg",
"3gp",
"ogv",
"ts",
"vob",
};
void logShader(uint32_t object, bool program) {
int ok = 0;
if (program)
@@ -124,16 +94,6 @@ WallpaperService::~WallpaperService() {
if (m_thread.joinable()) m_thread.join();
if (m_mpvRender) {
mpv_render_context_free(m_mpvRender);
m_mpvRender = nullptr;
}
if (m_mpv) {
mpv_terminate_destroy(m_mpv);
m_mpv = nullptr;
}
for (auto* surface : m_surfaces)
delete surface;
m_surfaces.clear();
@@ -142,8 +102,6 @@ WallpaperService::~WallpaperService() {
if (eglMakeCurrent(
m_eglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, m_context)) {
if (m_texture) glDeleteTextures(1, &m_texture);
if (m_previousTexture) glDeleteTextures(1, &m_previousTexture);
if (m_blackTex) glDeleteTextures(1, &m_blackTex);
if (m_program) glDeleteProgram(m_program);
}
@@ -169,210 +127,22 @@ QString WallpaperService::path() const {
}
void WallpaperService::setPath(const QString& path) {
if (path.isEmpty() || path == this->path()) return;
if (isVideoPath(path) && needsTranscode(path)) {
const QString dst = transcodeTarget(path);
if (QFile::exists(dst)) {
applyPath(dst);
return;
}
applyPath(path);
transcodeVideo(path, dst);
return;
}
applyPath(path);
}
void WallpaperService::applyPath(const QString& path) {
const bool video = isVideoPath(path);
if (m_path == path) return;
{
std::lock_guard<std::recursive_mutex> lock(m_mutex);
m_path = path;
const bool wasVideo = m_isVideo && m_mpv != nullptr;
const bool wasImage = !m_image.isNull() && m_texture != 0;
m_previousVideo = wasVideo && !video;
if (wasImage) {
m_previousImage = m_image;
m_previousCrops = m_crops;
m_previousTexture = 0;
} else {
m_previousImage = QImage();
}
m_isVideo = video;
m_fading = true;
m_fadeClockStarted = false;
m_forceDecode = true;
m_imageChanged = true;
m_videoReload = true;
}
emit pathChanged();
start();
queueAll();
}
bool WallpaperService::isHardwareCodec(const QString& codec) {
static const QStringList hardware{
"av1",
"hevc",
"h264",
"vp9",
"vp8",
"vc1",
"mpeg2",
"mpeg2video",
"mpeg4",
"mjpeg",
};
return hardware.contains(codec);
}
QString WallpaperService::codecName(const QString& path) const {
QProcess probe;
probe.start(
"ffprobe",
{
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=codec_name",
"-of",
"default=nw=1:nk=1",
path,
});
if (!probe.waitForFinished(2000) || probe.exitCode() != 0) {
qWarning() << "WallpaperService: ffprobe failed for" << path << "(exit"
<< probe.exitCode() << ")";
return {};
}
const QString codec =
QString::fromUtf8(probe.readAllStandardOutput()).trimmed().toLower();
qWarning() << "WallpaperService: codec of" << path << "is"
<< (codec.isEmpty() ? "<empty>" : codec);
return codec;
}
QString WallpaperService::cacheDir() const {
return QDir::homePath() + "/.cache/zshell/wallpaper-videos";
}
QString WallpaperService::transcodeTarget(const QString& path) const {
const QFileInfo info(path);
const QByteArray key =
path.toUtf8() + '|' + QByteArray::number(info.size()) + '|' +
QByteArray::number(info.lastModified().toMSecsSinceEpoch());
const QString hash = QString::fromUtf8(
QCryptographicHash::hash(key, QCryptographicHash::Sha1).toHex());
return cacheDir() + '/' + hash + ".av1.mkv";
}
bool WallpaperService::needsTranscode(const QString& path) const {
if (path.startsWith(cacheDir())) return false;
const QString codec = codecName(path);
if (codec.isEmpty()) return false;
return !isHardwareCodec(codec);
}
void WallpaperService::transcodeVideo(const QString& src, const QString& dst) {
QDir().mkpath(cacheDir());
if (m_transcode) {
m_transcode->kill();
m_transcode->deleteLater();
m_transcode = nullptr;
}
m_transcode = new QProcess(this);
auto* proc = m_transcode;
connect(proc, &QProcess::readyReadStandardError, proc, [proc]() {
qWarning().noquote()
<< "ffmpeg:" << QString::fromUtf8(proc->readAllStandardError());
});
connect(
proc,
&QProcess::finished,
this,
[this, proc, dst](int code, QProcess::ExitStatus status) {
const bool ok = code == 0 && status == QProcess::NormalExit &&
QFile::exists(dst);
qWarning() << "WallpaperService: transcode finished, exit" << code
<< "status" << int(status) << "exists"
<< QFile::exists(dst) << "->" << dst;
if (proc == m_transcode) m_transcode = nullptr;
proc->deleteLater();
if (ok) applyPath(dst);
});
qWarning() << "WallpaperService: transcoding" << src << "->" << dst;
proc->setProgram("nice");
proc->setArguments({
"-n",
"19",
"ffmpeg",
"-hide_banner",
"-loglevel",
"error",
"-y",
"-threads",
"2",
"-i",
src,
"-map",
"0:v:0",
"-vf",
"scale=in_range=pc:out_range=tv:out_color_matrix=bt709",
"-colorspace",
"bt709",
"-color_primaries",
"bt709",
"-color_trc",
"bt709",
"-color_range",
"tv",
"-c:v",
"libsvtav1",
"-preset",
"6",
"-crf",
"20",
"-pix_fmt",
"yuv420p10le",
"-an",
dst,
});
proc->start();
}
void WallpaperService::addScreen(const QString& screen) {
{
std::lock_guard<std::recursive_mutex> lock(m_mutex);
@@ -442,178 +212,6 @@ void WallpaperService::redraw() {
queueAll();
}
bool WallpaperService::isVideoPath(const QString& path) {
return kVideoExtensions.contains(QFileInfo(path).suffix().toLower());
}
void WallpaperService::startVideo(const QString& path) {
if (path.isEmpty()) return;
m_videoReady.store(false, std::memory_order_relaxed);
m_videoLoaded.store(false, std::memory_order_relaxed);
if (!m_mpv) {
std::setlocale(LC_NUMERIC, "C");
m_mpv = mpv_create();
if (!m_mpv) {
qWarning() << "WallpaperService: could not create mpv handle";
return;
}
mpv_set_option_string(m_mpv, "config", "no");
mpv_set_option_string(m_mpv, "vo", "libmpv");
mpv_set_option_string(m_mpv, "audio", "no");
mpv_set_option_string(m_mpv, "loop", "inf");
mpv_set_option_string(m_mpv, "hwdec", "auto-copy");
mpv_set_option_string(m_mpv, "video-sync", "display");
mpv_set_option_string(m_mpv, "framedrop", "decoder");
mpv_set_option_string(m_mpv, "background-color", "#00000000");
if (mpv_initialize(m_mpv) < 0) {
qWarning() << "WallpaperService: mpv_initialize failed";
mpv_terminate_destroy(m_mpv);
m_mpv = nullptr;
return;
}
}
const QByteArray local = path.toUtf8();
initMpvRender();
const char* loadCmd[]{"loadfile", local.constData(), nullptr};
if (mpv_command(m_mpv, loadCmd) < 0)
qWarning() << "WallpaperService: mpv could not load" << path;
m_mpvFrameReady.store(true, std::memory_order_relaxed);
}
void WallpaperService::pumpMpvEvents() {
if (!m_mpv) return;
mpv_event* event = nullptr;
while ((event = mpv_wait_event(m_mpv, 0)) != nullptr &&
event->event_id != MPV_EVENT_NONE) {
if (event->event_id == MPV_EVENT_LOG_MESSAGE) {
auto* data = static_cast<mpv_event_log_message*>(event->data);
if (data && data->text)
qWarning().noquote() << "mpv:" << data->prefix << data->text;
}
if (event->event_id == MPV_EVENT_FILE_LOADED) {
m_videoLoaded.store(true, std::memory_order_relaxed);
}
if (event->event_id == MPV_EVENT_END_FILE) {
m_videoLoaded.store(false, std::memory_order_relaxed);
m_videoReady.store(false, std::memory_order_relaxed);
auto* data = static_cast<mpv_event_end_file*>(event->data);
if (data && data->error != MPV_END_FILE_REASON_EOF)
qWarning() << "WallpaperService: mpv ended the file, error"
<< data->error;
}
}
}
void WallpaperService::stopVideo() {
teardownMpv();
}
void WallpaperService::teardownMpv() {
if (m_mpvRender) {
mpv_render_context_free(m_mpvRender);
m_mpvRender = nullptr;
}
if (m_mpv) {
mpv_terminate_destroy(m_mpv);
m_mpv = nullptr;
}
m_mpvFrameReady.store(false, std::memory_order_relaxed);
m_videoReady.store(false, std::memory_order_relaxed);
m_videoLoaded.store(false, std::memory_order_relaxed);
}
void* WallpaperService::getMpvProcAddress(void* ctx, const char* name) {
(void)ctx;
if (strcmp(name, "glFenceSync") == 0)
return reinterpret_cast<void*>(
+[](GLenum, GLbitfield) -> GLsync { return nullptr; });
return reinterpret_cast<void*>(eglGetProcAddress(name));
}
void WallpaperService::mpvUpdate(void* ctx) {
auto* self = static_cast<WallpaperService*>(ctx);
self->m_mpvFrameReady.store(true, std::memory_order_relaxed);
if (self->m_videoLoaded.load(std::memory_order_relaxed))
self->m_videoReady.store(true, std::memory_order_relaxed);
self->wake();
}
void WallpaperService::initMpvRender() {
if (m_mpvRender || !m_mpv) return;
mpv_opengl_init_params gl_init{getMpvProcAddress, nullptr};
mpv_render_param params[]{
{MPV_RENDER_PARAM_API_TYPE,
const_cast<char*>(MPV_RENDER_API_TYPE_OPENGL)},
{MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, &gl_init},
{MPV_RENDER_PARAM_INVALID, nullptr},
};
if (mpv_render_context_create(&m_mpvRender, m_mpv, params) < 0) {
qWarning() << "WallpaperService: could not create mpv render context";
m_mpvRender = nullptr;
return;
}
mpv_render_context_set_update_callback(m_mpvRender, mpvUpdate, this);
}
void WallpaperService::renderMpvToSurface(const QSize& size) {
if (!m_mpvRender) initMpvRender();
if (!m_mpvRender) return;
glViewport(0, 0, size.width(), size.height());
mpv_opengl_fbo fbo{0, size.width(), size.height(), GL_RGBA};
int flip = 1;
int block = 0;
mpv_render_param params[]{
{MPV_RENDER_PARAM_OPENGL_FBO, &fbo},
{MPV_RENDER_PARAM_FLIP_Y, &flip},
{MPV_RENDER_PARAM_BLOCK_FOR_TARGET_TIME, &block},
{MPV_RENDER_PARAM_INVALID, nullptr},
};
mpv_render_context_render(m_mpvRender, params);
}
void WallpaperService::createBlackTexture(uint32_t& texture) {
if (texture != 0) return;
const unsigned char pixel[4] = {0, 0, 0, 255};
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexImage2D(
GL_TEXTURE_2D, 0, GL_RGBA, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixel);
}
void WallpaperService::start() {
{
std::lock_guard<std::recursive_mutex> lock(m_mutex);
@@ -662,7 +260,11 @@ void WallpaperService::wake() {
}
void WallpaperService::queue(WallpaperSurface* surface) {
surface->markDirty();
{
std::lock_guard<std::recursive_mutex> lock(m_mutex);
m_pending.push_back(surface);
}
wake();
}
@@ -671,7 +273,7 @@ void WallpaperService::queueAll() {
std::lock_guard<std::recursive_mutex> lock(m_mutex);
for (auto* surface : m_surfaces)
surface->markDirty();
m_pending.push_back(surface);
}
wake();
@@ -721,50 +323,13 @@ void WallpaperService::present() {
ensureDecodeSize();
QList<WallpaperSurface*> surfaces;
bool fading = false;
bool videoReload = false;
QString path;
bool isVideo = false;
std::vector<WallpaperSurface*> pending;
{
std::lock_guard<std::recursive_mutex> lock(m_mutex);
if (m_videoReload) {
m_videoReload = false;
videoReload = true;
path = m_path;
isVideo = m_isVideo;
}
fading = m_fading;
const bool newVideoFrame =
m_isVideo &&
m_mpvFrameReady.exchange(false, std::memory_order_relaxed);
if (fading || newVideoFrame)
for (auto* surface : m_surfaces)
surface->markDirty();
surfaces = m_surfaces;
pending.swap(m_pending);
}
if (m_context == EGL_NO_CONTEXT) return;
eglMakeCurrent(m_eglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, m_context);
if (videoReload) {
if (isVideo)
startVideo(path);
else if (m_mpv && !m_previousVideo)
teardownMpv();
}
const bool videoActive = (m_isVideo || m_previousVideo) && m_mpv != nullptr;
if (videoActive) pumpMpvEvents();
for (auto* surface : surfaces)
for (auto* surface : pending)
drawSurface(surface);
}
@@ -929,8 +494,7 @@ uint32_t WallpaperService::createProgram() {
logShader(program, true);
if (glGetUniformLocation(program, "size") < 0 ||
glGetUniformLocation(program, "crop") < 0 ||
glGetUniformLocation(program, "alpha") < 0) {
glGetUniformLocation(program, "crop") < 0) {
qWarning() << "WallpaperService: the program does not have the "
"uniforms it needs";
glDeleteProgram(program);
@@ -939,23 +503,22 @@ uint32_t WallpaperService::createProgram() {
m_sizeUniform = glGetUniformLocation(program, "size");
m_cropUniform = glGetUniformLocation(program, "crop");
m_alphaUniform = glGetUniformLocation(program, "alpha");
glDeleteShader(vertex);
glDeleteShader(fragment);
return program;
}
void WallpaperService::uploadTexture(uint32_t& texture, const QImage& image) {
if (image.isNull()) return;
void WallpaperService::uploadImage() {
if (m_image.isNull()) return;
if (texture == 0) {
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
if (m_texture == 0) {
glGenTextures(1, &m_texture);
glBindTexture(GL_TEXTURE_2D, m_texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
} else {
glBindTexture(GL_TEXTURE_2D, texture);
glBindTexture(GL_TEXTURE_2D, m_texture);
}
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
@@ -963,35 +526,12 @@ void WallpaperService::uploadTexture(uint32_t& texture, const QImage& image) {
GL_TEXTURE_2D,
0,
GL_RGBA8,
image.width(),
image.height(),
m_image.width(),
m_image.height(),
0,
GL_RGBA,
GL_UNSIGNED_BYTE,
image.constBits());
}
void WallpaperService::uploadImage() {
uploadTexture(m_texture, m_image);
}
void WallpaperService::drawTexture(
uint32_t texture,
const QRectF& rect,
float sizeW,
float sizeH,
float alpha) {
glBindTexture(GL_TEXTURE_2D, texture);
glUniform2f(GLint(m_sizeUniform), sizeW, sizeH);
glUniform4f(
GLint(m_cropUniform),
GLfloat(rect.x()),
GLfloat(rect.y()),
GLfloat(rect.width()),
GLfloat(rect.height()));
glUniform1f(GLint(m_alphaUniform), alpha);
glDrawArrays(GL_TRIANGLES, 0, 3);
m_image.constBits());
}
bool WallpaperService::decode(const QString& path) {
@@ -1024,8 +564,6 @@ bool WallpaperService::ensureDecodeSize() {
{
std::lock_guard<std::recursive_mutex> lock(m_mutex);
if (m_isVideo) return false;
largest = largestBufferSize();
if (largest.isEmpty()) return false;
@@ -1073,10 +611,7 @@ QSize WallpaperService::largestBufferSize() {
}
QRectF WallpaperService::sourceRect(
const QVariantMap& crops,
const QString& screen,
const QSize& size,
const QSize& image) const {
const QVariantMap& crops, const QString& screen, const QSize& size) const {
QVariantMap crop = cropFor(crops, screen);
QRectF rect(
@@ -1085,10 +620,10 @@ QRectF WallpaperService::sourceRect(
crop.value("width", 1.0).toDouble(),
crop.value("height", 1.0).toDouble());
if (size.isEmpty() || rect.isEmpty() || image.isEmpty()) return rect;
if (size.isEmpty() || rect.isEmpty() || m_image.isNull()) return rect;
qreal imageWidth = qreal(image.width());
qreal imageHeight = qreal(image.height());
qreal imageWidth = qreal(m_image.width());
qreal imageHeight = qreal(m_image.height());
QRectF pixels(
rect.x() * imageWidth,
@@ -1117,8 +652,6 @@ QRectF WallpaperService::sourceRect(
}
void WallpaperService::drawSurface(WallpaperSurface* surface) {
if (!surface->needsDraw() || !surface->frameReady()) return;
EGLSurface target = surface->eglSurface();
if (m_context == EGL_NO_CONTEXT || target == EGL_NO_SURFACE ||
surface->bufferSize().isEmpty())
@@ -1129,8 +662,9 @@ void WallpaperService::drawSurface(WallpaperSurface* surface) {
return;
}
eglSwapInterval(m_eglDisplay, 0);
// Release the context when this scope ends (see FrameService::drawSurface).
// Holding it across the draw loop's sleep serializes the two raw-EGL clients on
// NVIDIA, which makes eglSwapBuffers fail and starves other processes' contexts.
struct ContextRelease {
EGLDisplay display;
~ContextRelease() {
@@ -1141,6 +675,7 @@ void WallpaperService::drawSurface(WallpaperSurface* surface) {
QVariantMap crops;
QSize size;
bool upload = false;
{
std::lock_guard<std::recursive_mutex> lock(m_mutex);
@@ -1148,153 +683,38 @@ void WallpaperService::drawSurface(WallpaperSurface* surface) {
crops = m_crops;
size = surface->bufferSize();
if (size.isEmpty()) return;
if (m_program == 0) m_program = createProgram();
if (m_program == 0) return;
const bool videoNow = m_isVideo;
if (videoNow && !m_videoReady.load(std::memory_order_relaxed)) return;
const bool videoAsBackground =
(videoNow || (m_previousVideo && m_fading)) && m_mpv != nullptr;
if (videoAsBackground) {
if (!m_mpvRender) initMpvRender();
if (!m_mpvRender) return;
glDisable(GL_BLEND);
glViewport(0, 0, size.width(), size.height());
renderMpvToSurface(size);
} else {
glUseProgram(m_program);
glViewport(0, 0, size.width(), size.height());
glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
glClear(GL_COLOR_BUFFER_BIT);
}
if (!videoNow) {
const bool upload = m_imageChanged;
m_imageChanged = false;
if (m_texture == 0 || upload) uploadImage();
if (m_texture == 0 || m_image.isNull()) return;
}
glUseProgram(m_program);
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
if (m_fading && !m_fadeClockStarted) {
m_fadeClockStarted = true;
m_fadeTimer.start();
}
bool fade = false;
bool fadeDone = false;
qreal progress = 1.0;
if (m_fading) {
progress = qreal(m_fadeTimer.elapsed()) / qreal(kFadeDurationMs);
if (progress >= 1.0)
fadeDone = true;
else
fade = true;
}
const float w = float(size.width());
const float h = float(size.height());
if (videoNow) {
if (fade && !m_previousImage.isNull()) {
if (m_previousTexture == 0)
uploadTexture(m_previousTexture, m_previousImage);
if (m_previousTexture != 0) {
const QRectF previousRect = sourceRect(
m_previousCrops,
surface->screen(),
size,
m_previousImage.size());
drawTexture(
m_previousTexture,
previousRect,
w,
h,
1.0f - float(progress));
}
} else if (fade && !m_firstFadeDone) {
if (m_blackTex == 0) createBlackTexture(m_blackTex);
if (m_blackTex != 0)
drawTexture(
m_blackTex,
QRectF(0.0, 0.0, 1.0, 1.0),
w,
h,
1.0f - float(progress));
}
} else {
const QRectF rect =
sourceRect(crops, surface->screen(), size, m_image.size());
if (fade) {
if (!m_previousVideo && !m_previousImage.isNull() &&
m_previousTexture == 0) {
uploadTexture(m_previousTexture, m_previousImage);
}
if (!m_previousVideo && m_previousTexture != 0) {
const QRectF previousRect = sourceRect(
m_previousCrops,
surface->screen(),
size,
m_previousImage.size());
drawTexture(m_previousTexture, previousRect, w, h, 1.0f);
}
drawTexture(m_texture, rect, w, h, float(progress));
} else {
drawTexture(m_texture, rect, w, h, 1.0f);
}
}
if (fadeDone) {
m_fading = false;
m_firstFadeDone = true;
if (m_previousVideo) {
teardownMpv();
m_previousVideo = false;
}
if (m_previousTexture != 0) {
glDeleteTextures(1, &m_previousTexture);
m_previousTexture = 0;
}
m_previousImage = QImage();
}
upload = m_imageChanged;
m_imageChanged = false;
}
surface->requestFrame();
if (size.isEmpty()) return;
if (!eglSwapBuffers(m_eglDisplay, target)) {
if (m_program == 0) m_program = createProgram();
if (m_texture == 0 || upload) uploadImage();
if (m_program == 0 || m_texture == 0 || m_image.isNull()) return;
glUseProgram(m_program);
glBindTexture(GL_TEXTURE_2D, m_texture);
glUniform2f(
GLint(m_sizeUniform), GLfloat(size.width()), GLfloat(size.height()));
QRectF rect = sourceRect(crops, surface->screen(), size);
glUniform4f(
GLint(m_cropUniform),
GLfloat(rect.x()),
GLfloat(rect.y()),
GLfloat(rect.width()),
GLfloat(rect.height()));
glViewport(0, 0, size.width(), size.height());
glDrawArrays(GL_TRIANGLES, 0, 3);
if (!eglSwapBuffers(m_eglDisplay, target))
qWarning()
<< "WallpaperService: eglSwapBuffers failed, eglGetError = 0x"
<< QString::number(unsigned(eglGetError()), 16);
return;
}
if ((m_isVideo || m_previousVideo) && m_mpvRender)
mpv_render_context_report_swap(m_mpvRender);
surface->endDraw();
}
void WallpaperService::addSurface(const QString& screen) {
+2 -62
View File
@@ -9,7 +9,6 @@
#include <QImage>
#include <QList>
#include <QElapsedTimer>
#include <QRectF>
#include <QSize>
#include <QString>
@@ -17,18 +16,12 @@
#include <QVariantMap>
#include <QtQml/qqml.h>
#include <qtmetamacros.h>
#include <atomic>
#include <qwayland-wlr-layer-shell-unstable-v1.h>
#include <wayland-client-protocol.h>
#include "wallpapersurface.hpp"
struct mpv_handle;
struct mpv_render_context;
class QProcess;
namespace ZShell::internal {
class WallpaperSurface;
@@ -64,29 +57,6 @@ class WallpaperService : public QObject {
private:
friend class WallpaperSurface;
static bool isVideoPath(const QString& path);
static bool isHardwareCodec(const QString& codec);
[[nodiscard]] QString codecName(const QString& path) const;
[[nodiscard]] QString cacheDir() const;
[[nodiscard]] QString transcodeTarget(const QString& path) const;
[[nodiscard]] bool needsTranscode(const QString& path) const;
void transcodeVideo(const QString& src, const QString& dst);
void applyPath(const QString& path);
void startVideo(const QString& path);
void stopVideo();
void teardownMpv();
void initMpvRender();
void renderMpvToSurface(const QSize& size);
void pumpMpvEvents();
static void* getMpvProcAddress(void* ctx, const char* name);
static void mpvUpdate(void* ctx);
void bindGlobals();
static void registryGlobal(
void* data,
@@ -100,16 +70,7 @@ class WallpaperService : public QObject {
[[nodiscard]] bool setupGl();
[[nodiscard]] uint32_t createProgram();
void uploadTexture(uint32_t& texture, const QImage& image);
void uploadImage();
void createBlackTexture(uint32_t& texture);
void drawTexture(
uint32_t texture,
const QRectF& rect,
float sizeW,
float sizeH,
float alpha);
bool decode(const QString& path);
@@ -134,8 +95,7 @@ class WallpaperService : public QObject {
[[nodiscard]] QRectF sourceRect(
const QVariantMap& crops,
const QString& screen,
const QSize& size,
const QSize& image) const;
const QSize& size) const;
void start();
@@ -148,18 +108,6 @@ class WallpaperService : public QObject {
QImage m_image;
bool m_isVideo = false;
bool m_videoReload = false;
bool m_previousVideo = false;
mpv_handle* m_mpv = nullptr;
mpv_render_context* m_mpvRender = nullptr;
uint32_t m_blackTex = 0;
std::atomic<bool> m_mpvFrameReady{false};
std::atomic<bool> m_videoReady{false};
std::atomic<bool> m_videoLoaded{false};
QProcess* m_transcode = nullptr;
QSize m_sourceSize;
QSize m_decodeSize;
@@ -177,20 +125,12 @@ class WallpaperService : public QObject {
uint32_t m_program = 0;
int m_sizeUniform = -1;
int m_cropUniform = -1;
int m_alphaUniform = -1;
QImage m_previousImage;
QVariantMap m_previousCrops;
uint32_t m_previousTexture = 0;
bool m_fading = false;
bool m_fadeClockStarted = false;
bool m_firstFadeDone = false;
QElapsedTimer m_fadeTimer;
QList<WallpaperSurface*> m_surfaces;
std::thread m_thread;
mutable std::recursive_mutex m_mutex;
std::vector<WallpaperSurface*> m_pending;
wl_event_queue* m_queue = nullptr;
int m_wake[2] = {-1, -1};
+2 -45
View File
@@ -53,11 +53,6 @@ WallpaperSurface::WallpaperSurface(
}
WallpaperSurface::~WallpaperSurface() {
if (m_frameCallback) {
wl_callback_destroy(m_frameCallback);
m_frameCallback = nullptr;
}
if (m_eglSurface != EGL_NO_SURFACE)
eglDestroySurface(m_service->m_eglDisplay, m_eglSurface);
@@ -73,45 +68,8 @@ WallpaperSurface::~WallpaperSurface() {
if (m_surface) wl_surface_destroy(m_surface);
}
void WallpaperSurface::markDirty() {
m_dirty.store(true, std::memory_order_relaxed);
}
bool WallpaperSurface::needsDraw() const {
return m_dirty.load(std::memory_order_relaxed);
}
bool WallpaperSurface::frameReady() const {
return m_frameReady.load(std::memory_order_relaxed);
}
void WallpaperSurface::requestFrame() {
if (!m_surface || m_frameCallback) return;
m_frameCallback = wl_surface_frame(m_surface);
static const struct wl_callback_listener listener = {
[](void* data, struct wl_callback*, uint32_t) {
static_cast<WallpaperSurface*>(data)->onFrameDone();
},
};
wl_callback_add_listener(m_frameCallback, &listener, this);
}
void WallpaperSurface::onFrameDone() {
if (m_frameCallback) {
wl_callback_destroy(m_frameCallback);
m_frameCallback = nullptr;
}
m_frameReady.store(true, std::memory_order_relaxed);
m_service->wake();
}
void WallpaperSurface::endDraw() {
m_dirty.store(false, std::memory_order_relaxed);
m_frameReady.store(false, std::memory_order_relaxed);
void WallpaperSurface::redraw() {
m_service->queue(this);
}
void WallpaperSurface::zwlr_layer_surface_v1_configure(
@@ -183,7 +141,6 @@ void WallpaperSurface::updateBuffer() {
if (QtWayland::wp_viewport::isInitialized())
set_destination(m_size.width(), m_size.height());
requestFrame();
m_service->queue(this);
}
+1 -16
View File
@@ -3,8 +3,6 @@
#include <EGL/egl.h>
#include <wayland-egl.h>
#include <atomic>
#include <QSize>
#include <QString>
@@ -34,14 +32,7 @@ class WallpaperSurface : public QtWayland::zwlr_layer_surface_v1,
[[nodiscard]] const QSize& bufferSize() const { return m_bufferSize; }
[[nodiscard]] EGLSurface eglSurface() const { return m_eglSurface; }
void markDirty();
[[nodiscard]] bool needsDraw() const;
[[nodiscard]] bool frameReady() const;
void requestFrame();
void endDraw();
void redraw();
private:
void zwlr_layer_surface_v1_configure(
@@ -51,8 +42,6 @@ class WallpaperSurface : public QtWayland::zwlr_layer_surface_v1,
void updateBuffer();
void onFrameDone();
WallpaperService* m_service;
QString m_screen;
wl_surface* m_surface;
@@ -64,10 +53,6 @@ class WallpaperSurface : public QtWayland::zwlr_layer_surface_v1,
qreal m_scale = 1.0;
bool configured = false;
std::atomic<bool> m_dirty{false};
std::atomic<bool> m_frameReady{true};
struct wl_callback* m_frameCallback = nullptr;
};
} // namespace ZShell::internal
+6 -233
View File
@@ -1,183 +1,17 @@
#include "filesystemmodel.hpp"
#include <qcoreapplication.h>
#include <qdatetime.h>
#include <qdir.h>
#include <qdiriterator.h>
#include <qfile.h>
#include <qfileinfo.h>
#include <qfuturewatcher.h>
#include <qimage.h>
#include <qpointer.h>
#include <qprocess.h>
#include <qregularexpression.h>
#include <qtemporarydir.h>
#include <qtconcurrentrun.h>
#include <algorithm>
namespace ZShell::models {
const QStringList kVideoExtensions = {
"mp4",
"mkv",
"webm",
"avi",
"mov",
"wmv",
"flv",
"m4v",
"mpg",
"mpeg",
"3gp",
"ogv",
"ts",
"vob",
};
bool hasVideoExtension(const QString& path) {
return kVideoExtensions.contains(QFileInfo(path).suffix().toLower());
}
QString videoThumbnailPath(const QString& path) {
const QFileInfo info(path);
QString key = QStringLiteral("%1_%2_%3")
.arg(info.baseName())
.arg(info.size())
.arg(info.lastModified().toMSecsSinceEpoch() / 1000);
key.replace(QRegularExpression("[^A-Za-z0-9._-]"), "_");
return QDir::homePath() + "/.cache/zshell/imagecache/video-previews/" +
key + "_preview.jpg";
}
bool generateVideoThumbnail(const QString& src, const QString& cachePath) {
if (QFile::exists(cachePath)) {
return true;
}
const QString scale = QStringLiteral(
"scale=1920:1080:force_original_aspect_ratio=decrease,"
"scale=trunc(iw/2)*2:trunc(ih/2)*2");
auto runProcess = [](const QString& program, const QStringList& args) {
QProcess process;
process.start(program, args);
process.waitForFinished(15000);
return qMakePair(process.exitCode(), process.readAllStandardOutput());
};
double duration = 0.0;
const auto probe = runProcess(
"ffprobe",
{
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=nw=1:nk=1",
src,
});
if (probe.first == 0) {
duration = QString::fromUtf8(probe.second).trimmed().toDouble();
}
const int samples = duration > 0 ? std::min(10, std::max(4, int(duration)))
: 8;
QTemporaryDir tmpDir;
if (!tmpDir.isValid()) {
return false;
}
int bestIndex = -1;
double bestChroma = -1.0;
double bestLuma = -1.0;
for (int i = 0; i < samples; ++i) {
const double frac = duration > 0 ? (i + 0.5) / samples : 0.0;
const QString seek = duration > 0
? QString::number(frac * duration, 'f', 3)
: QStringLiteral("0");
const QString framePath =
tmpDir.filePath(QStringLiteral("frame_%1.jpg").arg(i));
runProcess(
"ffmpeg",
{
"-hide_banner",
"-loglevel",
"error",
"-ss",
seek,
"-i",
src,
"-frames:v",
"1",
"-vf",
scale,
"-pix_fmt",
"yuvj420p",
"-q:v",
"2",
"-y",
framePath,
});
QImage frame(framePath);
if (frame.isNull()) {
continue;
}
frame = frame.convertToFormat(QImage::Format_RGB888);
double chroma = 0.0;
double luma = 0.0;
int count = 0;
for (int y = 0; y < frame.height(); ++y) {
const auto* line = frame.constScanLine(y);
for (int x = 0; x < frame.width(); ++x) {
const int r = line[x * 3];
const int g = line[x * 3 + 1];
const int b = line[x * 3 + 2];
chroma += std::max({r, g, b}) - std::min({r, g, b});
luma += r + g + b;
++count;
}
}
const int n = std::max(1, count);
chroma /= n;
luma /= 3.0 * n;
if (chroma > bestChroma + 0.5 ||
(chroma > bestChroma - 0.5 && luma > bestLuma)) {
bestChroma = chroma;
bestLuma = luma;
bestIndex = i;
}
}
if (bestIndex < 0) {
return false;
}
QDir().mkpath(QFileInfo(cachePath).absolutePath());
return QFile::copy(
tmpDir.filePath(QStringLiteral("frame_%1.jpg").arg(bestIndex)),
cachePath);
}
FileSystemEntry::FileSystemEntry(
const QString& path, const QString& relativePath, QObject* parent)
: QObject(parent)
, m_fileInfo(path)
, m_path(path)
, m_relativePath(relativePath)
, m_thumbnailPath(path)
, m_isImageInitialised(false)
, m_mimeTypeInitialised(false) {}
@@ -222,23 +56,6 @@ bool FileSystemEntry::isImage() const {
return m_isImage;
}
bool FileSystemEntry::isVideo() const {
return hasVideoExtension(m_path);
}
QString FileSystemEntry::thumbnailPath() const {
return m_thumbnailPath;
}
void FileSystemEntry::setThumbnailPath(const QString& path) {
if (m_thumbnailPath == path) {
return;
}
m_thumbnailPath = path;
emit thumbnailPathChanged();
}
QString FileSystemEntry::mimeType() const {
if (!m_mimeTypeInitialised) {
const QMimeDatabase db;
@@ -501,20 +318,11 @@ void FileSystemModel::updateEntriesForDir(const QString& dir) {
std::optional<QDirIterator> iter;
if (filter == Images || filter == Videos || filter == Media) {
if (filter == Images) {
QStringList extraNameFilters = nameFilters;
if (filter == Images || filter == Media) {
const auto formats = QImageReader::supportedImageFormats();
for (const auto& format : formats) {
extraNameFilters << "*." + format;
}
}
if (filter == Videos || filter == Media) {
for (const auto& ext : kVideoExtensions) {
extraNameFilters << "*." + ext;
}
const auto formats = QImageReader::supportedImageFormats();
for (const auto& format : formats) {
extraNameFilters << "*." + format;
}
QDir::Filters filters = QDir::Files;
@@ -558,13 +366,6 @@ void FileSystemModel::updateEntriesForDir(const QString& dir) {
if (!reader.canRead()) {
continue;
}
} else if (filter == Media) {
if (!hasVideoExtension(path)) {
QImageReader reader(path);
if (!reader.canRead()) {
continue;
}
}
}
newPaths.insert(path);
@@ -608,30 +409,6 @@ void FileSystemModel::updateEntriesForDir(const QString& dir) {
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks)
}
void FileSystemModel::startThumbnailGeneration(FileSystemEntry* entry) {
const QPointer<FileSystemEntry> guard(entry);
const QString source = entry->path();
const QString cachePath = videoThumbnailPath(source);
const auto future = QtConcurrent::run([source, cachePath]() {
return generateVideoThumbnail(source, cachePath);
});
const auto watcher = new QFutureWatcher<bool>(this);
connect(
watcher,
&QFutureWatcher<bool>::finished,
this,
[guard, cachePath, watcher]() {
if (guard && watcher->result()) {
guard->setThumbnailPath(cachePath);
}
watcher->deleteLater();
});
watcher->setFuture(future);
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks)
}
void FileSystemModel::applyChanges(
const QSet<QString>& removedPaths, const QSet<QString>& addedPaths) {
QList<int> removedIndices;
@@ -673,12 +450,8 @@ void FileSystemModel::applyChanges(
// Create new entries
QList<FileSystemEntry*> newEntries;
for (const auto& path : addedPaths) {
auto* entry =
new FileSystemEntry(path, m_dir.relativeFilePath(path), this);
if (entry->isVideo()) {
startThumbnailGeneration(entry);
}
newEntries << entry;
newEntries << new FileSystemEntry(
path, m_dir.relativeFilePath(path), this);
}
std::sort(
newEntries.begin(),
+1 -11
View File
@@ -28,9 +28,6 @@ class FileSystemEntry : public QObject {
Q_PROPERTY(qint64 size READ size CONSTANT)
Q_PROPERTY(bool isDir READ isDir CONSTANT)
Q_PROPERTY(bool isImage READ isImage CONSTANT)
Q_PROPERTY(bool isVideo READ isVideo CONSTANT)
Q_PROPERTY(
QString thumbnailPath READ thumbnailPath NOTIFY thumbnailPathChanged)
Q_PROPERTY(QString mimeType READ mimeType CONSTANT)
public:
@@ -48,24 +45,18 @@ class FileSystemEntry : public QObject {
[[nodiscard]] qint64 size() const;
[[nodiscard]] bool isDir() const;
[[nodiscard]] bool isImage() const;
[[nodiscard]] bool isVideo() const;
[[nodiscard]] QString thumbnailPath() const;
[[nodiscard]] QString mimeType() const;
void setThumbnailPath(const QString& path);
void updateRelativePath(const QDir& dir);
signals:
void relativePathChanged();
void thumbnailPathChanged();
private:
const QFileInfo m_fileInfo;
const QString m_path;
QString m_relativePath;
QString m_thumbnailPath;
mutable bool m_isImage;
mutable bool m_isImageInitialised;
@@ -100,7 +91,7 @@ class FileSystemModel : public QAbstractListModel {
NOTIFY entriesChanged)
public:
enum Filter { NoFilter, Images, Videos, Media, Files, Dirs };
enum Filter { NoFilter, Images, Files, Dirs };
Q_ENUM(Filter)
explicit FileSystemModel(QObject* parent = nullptr);
@@ -165,7 +156,6 @@ class FileSystemModel : public QAbstractListModel {
void updateEntriesForDir(const QString& dir);
void applyChanges(
const QSet<QString>& removedPaths, const QSet<QString>& addedPaths);
void startThumbnailGeneration(FileSystemEntry* entry);
[[nodiscard]] bool compareEntries(
const FileSystemEntry* a, const FileSystemEntry* b) const;
};
+25 -53
View File
@@ -1,56 +1,28 @@
FROM archlinux:latest
RUN pacman -Syyu --noconfirm
RUN pacman -Syu --noconfirm \
&& pacman -S --needed --noconfirm \
base-devel cmake ninja clang lld nodejs tree-sitter \
jkqtplotter cmark-gfm git wayland-protocols ffmpeg mpv \
qt6-base qt6-declarative qt6-shadertools qt6-5compat \
qt6-canvaspainter qt6-wayland qt5-wayland \
libqalculate pipewire aubio cava lm_sensors glib2 \
&& pacman -Scc --noconfirm
# Build toolchain
RUN pacman -S --noconfirm \
base-devel \
cmake \
ninja \
clang \
lld \
nodejs \
tree-sitter \
jkqtplotter \
cmark-gfm \
git
# Qt6
RUN pacman -S --noconfirm \
qt6-base \
qt6-declarative \
qt6-shadertools \
qt6-5compat \
qt6-canvaspainter
# Project-specific dependencies
RUN pacman -S --noconfirm \
libqalculate \
pipewire \
aubio \
cava \
lm_sensors \
glib2
# AUR: libcava (provides development headers for cava)
RUN pacman -Syu --noconfirm && \
pacman -S --needed --noconfirm \
sudo \
meson \
autoconf-archive \
python-tqdm && \
useradd -m builder && \
echo "builder ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
RUN for i in 1 2 3 4 5; do \
rm -rf /tmp/libcava && \
git clone --depth 1 https://aur.archlinux.org/libcava.git /tmp/libcava && \
break || sleep 5; \
done && \
test -d /tmp/libcava/.git && \
chown -R builder /tmp/libcava
RUN su builder -c "cd /tmp/libcava && makepkg -si --noconfirm" && \
rm -rf /tmp/libcava && \
userdel -r builder && \
sed -i '/^builder/d' /etc/sudoers
RUN pacman -Syu --noconfirm \
&& pacman -S --needed --noconfirm \
sudo meson autoconf-archive python-tqdm \
&& useradd -m builder \
&& printf '%s\n' 'builder ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/builder \
&& chmod 0440 /etc/sudoers.d/builder \
&& for i in 1 2 3 4 5; do \
rm -rf /tmp/libcava; \
if git clone --depth 1 https://aur.archlinux.org/libcava.git /tmp/libcava; then break; fi; \
sleep 5; \
done \
&& test -d /tmp/libcava/.git \
&& chown -R builder:builder /tmp/libcava \
&& su builder -c "cd /tmp/libcava && makepkg -si --noconfirm" \
&& rm -rf /tmp/libcava /etc/sudoers.d/builder \
&& userdel -r builder \
&& pacman -Scc --noconfirm
-125
View File
@@ -5,7 +5,6 @@ import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any
@@ -32,23 +31,6 @@ from zshell.utils.schemepalettes import (
app = typer.Typer()
VIDEO_EXTENSIONS = {
".mp4",
".mkv",
".webm",
".avi",
".mov",
".wmv",
".flv",
".m4v",
".mpg",
".mpeg",
".3gp",
".ogv",
".ts",
".vob",
}
def _complete_scheme_name(incomplete):
schemes = [
@@ -369,109 +351,6 @@ def generate(
safe_key = re.sub(r"[^A-Za-z0-9._-]", "_", key)
return thumb_dir / f"{safe_key}_thumbnail.jpg"
def is_video(path: Path) -> bool:
return path.suffix.lower() in VIDEO_EXTENSIONS
def video_duration(path: Path) -> float:
try:
out = subprocess.run(
[
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=nw=1:nk=1",
str(path),
],
capture_output=True,
text=True,
check=False,
)
return float(out.stdout.strip())
except (ValueError, OSError):
return 0.0
def video_frame_chroma(path: Path) -> tuple[float, float]:
"""Return (mean chroma, mean luminance) of a frame.
Mean chroma (mean of max-min across RGB channels) is ~0 for black,
white and grey frames, and rises with saturation - so it identifies a
"good", colorful frame and rejects black or fade frames.
"""
with Image.open(path) as im:
im = im.convert("RGB")
pixels = list(im.getdata())
chroma = 0
luma = 0
for r, g, b in pixels:
chroma += max(r, g, b) - min(r, g, b)
luma += r + g + b
n = max(1, len(pixels))
return chroma / n, luma / (3 * n)
def generate_video_thumbnail(
video_path: Path, cache_path: Path, size=(128, 128)
) -> None:
w, h = size
duration = video_duration(video_path)
samples = 8 if duration <= 0 else min(10, max(4, int(duration)))
tmp_dir = Path(tempfile.mkdtemp(prefix="zshell-vthumb-"))
try:
best: Path | None = None
best_key: tuple[float, float] = (-1.0, -1.0)
for i in range(samples):
frac = (i + 0.5) / samples if duration > 0 else 0.0
seek = f"{frac * duration:.3f}" if duration > 0 else "0"
frame = tmp_dir / f"frame_{i}.jpg"
subprocess.run(
[
"ffmpeg",
"-hide_banner",
"-loglevel",
"error",
"-ss",
seek,
"-i",
str(video_path),
"-frames:v",
"1",
"-vf",
f"scale={w}:{h}",
"-pix_fmt",
"yuvj420p",
"-q:v",
"2",
"-y",
str(frame),
],
check=False,
)
if not frame.exists():
continue
key = video_frame_chroma(frame)
if key > best_key:
best_key = key
best = frame
if best is None:
raise RuntimeError(
f"Could not extract a frame from {video_path}"
)
shutil.copy(best, cache_path)
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
def generate_thumbnail(
image_path: Path, thumb_dir: Path, size=(128, 128)
) -> Path:
@@ -481,10 +360,6 @@ def generate(
if cache_path.exists():
return cache_path
if is_video(image_path):
generate_video_thumbnail(image_path, cache_path, size)
return cache_path
image = Image.open(image_path)
image.draft("RGB", size)
image = image.convert("RGB")
+1 -2
View File
@@ -15,8 +15,7 @@ qt_logging_rules = (
"scene=false;"
"quickshell.service.sni.watcher=false;"
"default.warning=false;"
"default.info=false;"
"qt.sql.qsqldatabase"
"default.info=false"
)
+106
View File
@@ -0,0 +1,106 @@
BUILD ?= cmake -B build -G Ninja
NONUITKA ?= cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DENABLE_MODULES="plugin;shell;m3shapes"
NINJA ?= ninja -C build
COMPILE ?= $(BUILD) && $(NINJA)
INSTALL ?= $(BUILD) && $(NINJA) && sudo ninja -C build install
CPPFMT ?= find . \( -name '*.cpp' -o -name '*.h' -o -name '*.hpp' \) -not -path './build/*' -exec clang-format -i --style=file {} +
CPPLINT ?= run-clang-tidy -p build -j "$$(nproc)" -source-filter="^$$(pwd)/Plugins/" -header-filter=".*Plugins/.*" -removed-arg=-mno-direct-extern-access
NUITKA ?= nuitka --module --include-package=zshell cli/src/zshell/ --output-dir=cli/
.DEFAULT_GOAL := help
# string is misaligned to align when run in terminal
help:
@echo "build cmake builds into build directory"
@echo "install cmake builds, compiles and installs into filesystem"
@echo "compile cmake builds and compiles C++ code"
@echo "cpp-fmt format C++ code with clang-format"
@echo "cpp-lint lint check C++ code with clang-tidy"
@echo "cpp-compile similar to make compile, but excludes Nuitka from compiling python"
@echo "py-fmt format Python code with ruff"
@echo "py-lint lint check Python code with ruff"
@echo "py-check lsp and typecheck all Python code using Basedpyright"
@echo "py-test run all Python tests using Pytest"
@echo "py-compile compiles Python code using Nuitka"
@echo "rs-compile compiles Rust code"
@echo "rs-format formats Rust code with Rustfmt"
@echo "rs-lint lint check Rust code with Clippy"
@echo "rs-test runs all Rust tests"
@echo "qml-fmt format QML code with qmlformat"
@echo "check-cpp runs all C++ checks"
@echo "check-py runs all Python checks"
@echo "check-rs runs all Rust checks"
build:
$(BUILD)
install:
$(INSTALL)
compile:
$(COMPILE)
# C++
cpp-fmt:
$(CPPFMT)
cpp-lint:
$(CPPLINT)
cpp-compile:
$(NONUITKA)
# Python
py-fmt:
ruff format --check
py-lint:
ruff check ./cli
py-check:
basedpyright
py-test:
python -m pytest ./cli/tests/ -v
# Want to use gcc, clang or zig C compiler instead? Output directory?
py-compile:
$(NUITKA)
# Rust
rs-compile:
cd ./zshell-img-tools/ && cargo run
rs-fmt:
cd ./zshell-img-tools/ && cargo fmt && echo "formatted!"
rs-lint:
cd ./zshell-img-tools/ && cargo clippy
rs-test:
cd ./zshell-img-tools/ && cargo test
# Qml
qml-fmt:
find -name '*.qml' -exec /usr/lib/qt6/bin/qmlformat -i {} +
# Full checks
check-cpp:
$(MAKE) cpp-fmt
$(MAKE) cpp-lint
$(MAKE) compile
check-rs:
$(MAKE) rs-fmt
$(MAKE) rs-lint
$(MAKE) rs-test
$(MAKE) rs-compile
check-py:
$(MAKE) py-fmt
$(MAKE) py-lint
$(MAKE) py-test
$(MAKE) py-check
$(MAKE) py-compile