diff --git a/linux-wallpaperengine-controller/BarWidget.qml b/linux-wallpaperengine-controller/BarWidget.qml index 035420a05..41edec7f8 100644 --- a/linux-wallpaperengine-controller/BarWidget.qml +++ b/linux-wallpaperengine-controller/BarWidget.qml @@ -90,7 +90,7 @@ NIconButton { } else if (action === "start") { mainInstance?.reload(true); } else if (action === "settings") { - BarService.openPluginSettings(root.screen, pluginApi.manifest); + BarService.openPluginSettings(root.screen, pluginApi?.manifest); } } } diff --git a/linux-wallpaperengine-controller/Main.qml b/linux-wallpaperengine-controller/Main.qml index 38ecf1d89..eb8d3408e 100644 --- a/linux-wallpaperengine-controller/Main.qml +++ b/linux-wallpaperengine-controller/Main.qml @@ -2,7 +2,9 @@ import QtQuick import Quickshell import Quickshell.Io -import "helpers/ColorCacheHelpers.js" as ColorCacheHelpers +import "helpers/shared/ColorCacheHelpers.js" as ColorCacheHelpers +import "helpers/runtime/EngineHelpers.js" as EngineHelpers +import "helpers/runtime/WallpaperColorHelpers.js" as WallpaperColorHelpers import qs.Commons import qs.Services.UI @@ -11,6 +13,7 @@ import qs.Services.Theming Item { id: root + // Shared plugin API and runtime state. property var pluginApi: null property bool checkingEngine: true @@ -22,7 +25,7 @@ Item { property bool applyingWallpaperColors: false property string lastError: "" property string lastErrorDetails: "" - property string statusMessage: "" + property string lastRuntimeErrorKey: "" readonly property bool engineRunning: engineProcess.running || isApplying || pendingCommand.length > 0 property string lastScreenSetSignature: "" property bool scanningWallpapers: false @@ -32,11 +35,13 @@ Item { property var pendingWallpaperColorRequest: null property string pendingCachedWallpaperColorPath: "" property string pendingCachedWallpaperColorScreenName: "" + property bool pendingCachedWallpaperColorNotify: false property var pendingWallpaperColorReuseRequest: null property string wallpaperColorScreenName: "" property string wallpaperColorScaling: "fill" property string wallpaperColorRequestPath: "" property string wallpaperColorScreenshotPath: "" + property bool wallpaperColorNotify: false readonly property string activeColorMonitor: String(Settings.data.colorSchemes.monitorForColors || Quickshell.screens[0]?.name || "") readonly property bool wallpaperColorsEnabled: !!Settings.data.colorSchemes.useWallpaperColors readonly property bool wallpaperColorDarkMode: !!Settings.data.colorSchemes.darkMode @@ -47,13 +52,18 @@ Item { readonly property var cfg: pluginApi?.pluginSettings || ({}) readonly property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({}) - // Initialization and persistence helpers. + // Initialization. Component.onCompleted: { Logger.i("LWEController", "Main initialized"); + + // Clean up leftover processes from previous sessions. + startupCleanupProcess.running = true; + lastScreenSetSignature = currentScreenSetSignature(); - scheduleCachedWallpaperColorsForMonitor("startup"); + startupWallpaperColorResyncTimer.restart(); } + // Settings persistence and recovery snapshots. function ensureSettingsRoot() { if (!pluginApi) { return; @@ -84,6 +94,10 @@ Item { return JSON.parse(JSON.stringify(value || ({}))); } + function currentWallpaperPathForScreen(screenName) { + return normalizedPath(getScreenConfig(screenName).path || ""); + } + function hasAnyScreenPathFrom(sourceScreens) { const screens = sourceScreens || ({}); const keys = Object.keys(screens); @@ -131,7 +145,7 @@ Item { pluginApi.pluginSettings.runtimeRecoveryPending = false; pluginApi.saveSettings(); - Logger.i("LWEController", "Saved last-known-good layout", "reason=", reason); + Logger.d("LWEController", "Saved last-known-good layout", "reason=", reason); return true; } @@ -152,7 +166,7 @@ Item { pluginApi.pluginSettings.runtimeRecoveryPending = false; pluginApi.saveSettings(); - Logger.i("LWEController", "Restored last-known-good layout", "reason=", reason); + Logger.d("LWEController", "Restored last-known-good layout", "reason=", reason); return true; } @@ -217,16 +231,17 @@ Item { readonly property bool defaultNoFullscreenPause: cfg.defaultNoFullscreenPause ?? defaults.defaultNoFullscreenPause ?? false readonly property bool defaultFullscreenPauseOnlyActive: cfg.defaultFullscreenPauseOnlyActive ?? defaults.defaultFullscreenPauseOnlyActive ?? false readonly property bool defaultAutoApply: cfg.autoApplyOnStartup ?? defaults.autoApplyOnStartup ?? true - readonly property string assetsDir: cfg.assetsDir ?? defaults.assetsDir ?? "" + readonly property int wallpaperScanCacheMinutes: { - const value = Number(cfg.wallpaperScanCacheMinutes ?? defaults.wallpaperScanCacheMinutes ?? 5); + const value = Number(cfg.wallpaperScanCacheMinutes ?? defaults.wallpaperScanCacheMinutes ?? 10); if (isNaN(value)) { - return 5; + return 10; } return Math.max(0, Math.floor(value)); } - // Screen and wallpaper configuration accessors. + + // Screen topology, path normalization, and persisted wallpaper accessors. function normalizedPath(path) { return Settings.preprocessPath(String(path || "")); } @@ -239,10 +254,6 @@ Item { } function wallpaperScanCacheValid() { - if (scanningWallpapers) { - return true; - } - if (wallpaperScanCacheMinutes <= 0) { return false; } @@ -303,6 +314,7 @@ Item { return parts.length > 0 ? String(parts[parts.length - 1] || "") : ""; } + // Wallpaper property storage helpers. function cloneWallpaperProperties(source) { const cloned = {}; const raw = source || ({}); @@ -364,37 +376,22 @@ Item { } } + // Wallpaper color synchronization helpers. function currentWallpaperColorMode() { return Settings.data.colorSchemes.darkMode ? "dark" : "light"; } - function syncWallpaperColorSource(screenName, screenshotPath) { - const normalizedScreenName = String(screenName || "").trim(); - const normalizedScreenshotPath = String(screenshotPath || "").trim(); - if (normalizedScreenName.length === 0 || normalizedScreenshotPath.length === 0) { - return; - } - - WallpaperService.changeWallpaper(normalizedScreenshotPath, normalizedScreenName, "dark"); - WallpaperService.changeWallpaper(normalizedScreenshotPath, normalizedScreenName, "light"); - } - - function applyWallpaperColorsFromScreenshot(screenName, screenshotPath) { + function applyWallpaperColorsFromScreenshot(screenshotPath) { if (String(screenshotPath || "").trim().length === 0) { return; } - syncWallpaperColorSource(screenName, screenshotPath); TemplateProcessor.processWallpaperColors(screenshotPath, currentWallpaperColorMode()); } function screenshotPathForWallpaper(path, screenName = "") { - return ColorCacheHelpers.screenshotPathForWallpaper( - Settings.cacheDir, - pluginApi?.manifest?.id || pluginApi?.pluginId || "linux-wallpaperengine-controller", - wallpaperIdFromPath(path), - screenName - ); + const pluginId = pluginApi?.manifest?.id || pluginApi?.pluginId || "linux-wallpaperengine-controller"; + return ColorCacheHelpers.screenshotPathForWallpaper(Settings.cacheDir, pluginId, wallpaperIdFromPath(path), screenName); } function wallpaperColorScreenshotEntry(screenName) { @@ -413,56 +410,34 @@ Item { ); } - function startWallpaperColorCapture(wallpaperPath, targetScreenName, targetScaling) { + function startWallpaperColorCapture(wallpaperPath, targetScreenName, targetScaling, notify = false) { const screenshotPath = screenshotPathForWallpaper(wallpaperPath, targetScreenName); const pluginDir = pluginApi?.pluginDir || ""; const scriptPath = pluginDir + "/scripts/capture-wallpaper-colors.sh"; - const command = [ - "bash", + const wallpaperProperties = getWallpaperProperties(wallpaperPath); + const resolvedScaling = targetScaling.length > 0 ? targetScaling : "fill"; + const command = WallpaperColorHelpers.buildCaptureCommand( scriptPath, screenshotPath, - "linux-wallpaperengine" - ]; - const maybeAssetsDir = normalizedPath(assetsDir); - const wallpaperProperties = getWallpaperProperties(wallpaperPath); - - if (maybeAssetsDir.length > 0) { - command.push("--assets-dir"); - command.push(maybeAssetsDir); - } - - command.push("--fps"); - command.push(String(defaultFps)); - command.push("--clamp"); - command.push(String(defaultClamp || "clamp")); - command.push("--screen-root"); - command.push(targetScreenName); - command.push("--bg"); - command.push(wallpaperPath); - command.push("--scaling"); - command.push(targetScaling.length > 0 ? targetScaling : "fill"); - command.push("--screenshot"); - command.push(screenshotPath); - - for (const propertyKey of Object.keys(wallpaperProperties)) { - const propertyValue = wallpaperProperties[propertyKey]; - if (propertyValue === undefined || propertyValue === null || String(propertyKey || "").trim().length === 0) { - continue; - } - command.push("--set-property"); - command.push(String(propertyKey) + "=" + String(propertyValue)); - } + "", + defaultFps, + defaultClamp, + targetScreenName, + wallpaperPath, + resolvedScaling, + wallpaperProperties + ); applyingWallpaperColors = true; wallpaperColorRequestPath = wallpaperPath; wallpaperColorScreenshotPath = screenshotPath; wallpaperColorScreenName = targetScreenName; - wallpaperColorScaling = targetScaling.length > 0 ? targetScaling : "fill"; + wallpaperColorScaling = resolvedScaling; + wallpaperColorNotify = !!notify; wallpaperColorProcess.command = command; wallpaperColorProcess.running = true; Logger.i("LWEController", "Generating screenshot for wallpaper color extraction", "path=", wallpaperPath, "screen=", targetScreenName, "scaling=", wallpaperColorScaling, "output=", screenshotPath); - ToastService.showNotice(pluginApi?.tr("panel.title"), pluginApi?.tr("toast.wallpaperColorsGenerating"), "palette"); } function saveWallpaperColorScreenshot(screenName, screenshotPath, wallpaperPath, scaling) { @@ -471,16 +446,22 @@ Item { } ensureSettingsRoot(); - pluginApi.pluginSettings.wallpaperColorScreenshots[screenName] = { - "path": screenshotPath, - "wallpaperPath": wallpaperPath, - "scaling": scaling, - "updatedAt": Date.now() - }; + pluginApi.pluginSettings.wallpaperColorScreenshots[screenName] = WallpaperColorHelpers.buildScreenshotCacheEntry( + screenshotPath, + wallpaperPath, + scaling + ); pluginApi.saveSettings(); + + const pluginDir = pluginApi?.pluginDir || ""; + const scriptPath = pluginDir + "/scripts/update-noctalia-wallpapers-cache.sh"; + Quickshell.execDetached(["bash", scriptPath, screenName, screenshotPath]); } function scheduleCachedWallpaperColorsForMonitor(reason = "") { + pendingCachedWallpaperColorPath = ""; + pendingCachedWallpaperColorScreenName = ""; + if (!wallpaperColorsEnabled) { return; } @@ -490,39 +471,47 @@ Item { return; } - const entry = wallpaperColorScreenshotEntry(screenName); - const screenshotPath = normalizedPath(entry?.path || ""); - if (screenshotPath.length === 0) { - Logger.d("LWEController", "No cached wallpaper color screenshot for active monitor", "screen=", screenName, "reason=", reason); + const screenCfg = getScreenConfig(screenName); + const request = WallpaperColorHelpers.buildActiveMonitorSyncRequest(screenName, screenCfg, defaultScaling, normalizedPath); + if (!request) { + Logger.d("LWEController", "Skip wallpaper color sync: no configured wallpaper for active monitor", "screen=", screenName, "reason=", reason); return; } - pendingCachedWallpaperColorPath = screenshotPath; - pendingCachedWallpaperColorScreenName = screenName; - const pluginDir = pluginApi?.pluginDir || ""; - const scriptPath = pluginDir + "/scripts/check-file-exists.sh"; - cachedWallpaperColorSyncCheckProcess.command = ["bash", scriptPath, screenshotPath]; - cachedWallpaperColorSyncCheckProcess.running = true; - Logger.d("LWEController", "Scheduled cached wallpaper color sync", "screen=", screenName, "path=", screenshotPath, "reason=", reason); + Logger.d("LWEController", "Scheduling wallpaper color sync for current active monitor wallpaper", "screen=", request.screenName, "path=", request.path, "scaling=", request.scaling, "reason=", reason); + scheduleWallpaperColorsFromPath(request.path, request); } function applyWallpaperColorsFromPath(path, options = null) { - const wallpaperPath = normalizedPath(path); - const requestOptions = options || ({}); - const targetScreenName = String(requestOptions.screenName || Quickshell.screens[0]?.name || "").trim(); - const targetScaling = String(requestOptions.scaling || defaultScaling || "fill").trim(); + const request = WallpaperColorHelpers.normalizeWallpaperColorRequest( + path, + options, + defaultScaling, + Quickshell.screens[0]?.name || "", + normalizedPath + ); + const wallpaperPath = request.path; + const targetScreenName = request.screenName; + const targetScaling = request.scaling; + const notify = request.notify; if (!engineAvailable) { - ToastService.showWarning(pluginApi?.tr("panel.title"), pluginApi?.tr("toast.wallpaperColorsEngineUnavailable"), "alert-circle"); + if (notify) { + ToastService.showWarning(pluginApi?.tr("panel.title"), pluginApi?.tr("toast.wallpaperColorsEngineUnavailable"), "alert-circle"); + } return; } if (wallpaperPath.length === 0) { - ToastService.showWarning(pluginApi?.tr("panel.title"), pluginApi?.tr("toast.wallpaperColorsNoSelection"), "alert-circle"); + if (notify) { + ToastService.showWarning(pluginApi?.tr("panel.title"), pluginApi?.tr("toast.wallpaperColorsNoSelection"), "alert-circle"); + } return; } if (targetScreenName.length === 0) { - ToastService.showError(pluginApi?.tr("panel.title"), pluginApi?.tr("toast.wallpaperColorsFailed"), "alert-circle"); + if (notify) { + ToastService.showError(pluginApi?.tr("panel.title"), pluginApi?.tr("toast.wallpaperColorsFailed"), "alert-circle"); + } return; } @@ -535,37 +524,50 @@ Item { const cachedPath = normalizedPath(entry?.path || ""); const pluginDir = pluginApi?.pluginDir || ""; const scriptPath = pluginDir + "/scripts/check-file-exists.sh"; - pendingWallpaperColorReuseRequest = { - "wallpaperPath": wallpaperPath, - "screenName": targetScreenName, - "scaling": targetScaling, - "screenshotPath": cachedPath - }; + pendingWallpaperColorReuseRequest = WallpaperColorHelpers.buildReuseCheckRequest( + targetScreenName, + wallpaperPath, + targetScaling, + cachedPath, + notify + ); reusedWallpaperColorCheckProcess.command = ["bash", scriptPath, cachedPath]; reusedWallpaperColorCheckProcess.running = true; return; } - startWallpaperColorCapture(wallpaperPath, targetScreenName, targetScaling); + startWallpaperColorCapture(wallpaperPath, targetScreenName, targetScaling, notify); } function scheduleWallpaperColorsFromPath(path, options = null) { - const wallpaperPath = normalizedPath(path); - if (wallpaperPath.length === 0) { + const request = WallpaperColorHelpers.normalizeWallpaperColorRequest( + path, + options, + defaultScaling, + Quickshell.screens[0]?.name || "", + normalizedPath + ); + if (request.path.length === 0) { return; } - pendingWallpaperColorRequest = { - "path": wallpaperPath, - "screenName": String(options?.screenName || Quickshell.screens[0]?.name || ""), - "scaling": String(options?.scaling || defaultScaling || "fill") - }; + pendingCachedWallpaperColorPath = ""; + pendingCachedWallpaperColorScreenName = ""; + pendingCachedWallpaperColorNotify = false; + + pendingWallpaperColorRequest = request; wallpaperColorStartTimer.restart(); - Logger.d("LWEController", "Scheduled wallpaper color extraction", "path=", wallpaperPath, "screen=", pendingWallpaperColorRequest.screenName, "scaling=", pendingWallpaperColorRequest.scaling); + Logger.d("LWEController", "Scheduled wallpaper color extraction", "path=", pendingWallpaperColorRequest.path, "screen=", pendingWallpaperColorRequest.screenName, "scaling=", pendingWallpaperColorRequest.scaling); } + // Wallpaper source scanning and cache refresh. function refreshWallpaperCache(force = false, showToast = false) { const folderPath = Settings.preprocessPath(String(cfg.wallpapersFolder ?? defaults.wallpapersFolder ?? "")).trim(); + if (scanningWallpapers && !wallpaperScanProcess.running) { + Logger.w("LWEController", "Reset stale wallpaper scanning state before refresh"); + scanningWallpapers = false; + } + if (folderPath.length === 0) { cachedWallpaperItems = []; wallpapersFolderAccessible = false; @@ -578,7 +580,14 @@ Item { return; } + if (wallpaperScanProcess.running) { + Logger.d("LWEController", "Wallpaper scan already in progress"); + return; + } + if (!force && wallpaperScanCacheValid()) { + scanningWallpapers = false; + wallpaperScanShowToast = false; Logger.d("LWEController", "Wallpaper cache reused", "count=", cachedWallpaperItems.length, "ageMs=", Date.now() - lastWallpaperScanAt); return; } @@ -589,14 +598,12 @@ Item { Logger.i("LWEController", force ? "Refreshing wallpaper cache" : "Scanning wallpapers for cache", folderPath); scanningWallpapers = true; wallpaperScanShowToast = showToast; + // Mode is no longer passed as argument since we eliminated multi-mode. wallpaperScanProcess.command = ["bash", scriptPath, folderPath]; wallpaperScanProcess.running = true; - if (showToast) { - ToastService.showNotice(pluginApi?.tr("panel.title"), pluginApi?.tr("toast.refreshingWallpapers"), "refresh"); - } } - // Wallpaper application and persistence. + // Wallpaper application and persisted runtime option updates. function setScreenWallpaperWithOptions(screenName, path, options) { if (!pluginApi) { return; @@ -618,7 +625,7 @@ Item { pluginApi.pluginSettings.screens[screenName].scaling = resolvedScaling; } if (resolvedClamp.length > 0) { - pluginApi.pluginSettings.defaultClamp = resolvedClamp; + pluginApi.pluginSettings.screens[screenName].clamp = resolvedClamp; } if (options?.volume !== undefined) { @@ -656,7 +663,7 @@ Item { pluginApi.saveSettings(); - restartEngine(); + restartEngine(resolveRuntimeOverrides(options)); } function clearScreenWallpaper(screenName) { @@ -711,15 +718,14 @@ Item { if (resolvedScaling.length > 0) { pluginApi.pluginSettings.screens[screen.name].scaling = resolvedScaling; } + if (resolvedClamp.length > 0) { + pluginApi.pluginSettings.screens[screen.name].clamp = resolvedClamp; + } if (options?.customProperties !== undefined) { setWallpaperProperties(path, options.customProperties); } } - if (resolvedClamp.length > 0) { - pluginApi.pluginSettings.defaultClamp = resolvedClamp; - } - if (hasResolvedVolume) { pluginApi.pluginSettings.defaultVolume = resolvedVolume; } @@ -742,53 +748,16 @@ Item { clearLegacyRuntimeOptionsForAllScreens(); pluginApi.saveSettings(); - restartEngine(); + restartEngine(resolveRuntimeOverrides(options)); } - // Runtime error handling. + // Runtime error capture and recovery hints. function extractRuntimeError(stderrText) { - const text = (stderrText || "").trim(); - if (text.length === 0) { - return ""; - } - - const lower = text.toLowerCase(); - - if (lower.indexOf("cannot find a valid assets folder") !== -1) { - return pluginApi?.tr("main.error.assetsMissing"); - } - - if (lower.indexOf("at least one background id must be specified") !== -1) { - return pluginApi?.tr("main.error.noBackground"); - } - - if (lower.indexOf("opengl") !== -1 || lower.indexOf("glfw") !== -1) { - return pluginApi?.tr("main.error.opengl"); - } - - const lines = text.split(/\r?\n/) - .map(line => (line || "").trim()) - .filter(line => line.length > 0); - - if (lines.length === 0) { - return ""; - } - - let summary = lines[0]; - for (const line of lines) { - const normalized = line.toLowerCase(); - if (normalized.indexOf("error") !== -1 || normalized.indexOf("failed") !== -1) { - summary = line; - break; - } - } - - const maxLength = 220; - if (summary.length > maxLength) { - summary = summary.substring(0, maxLength) + "..."; - } - - return summary; + return EngineHelpers.extractRuntimeError(stderrText, { + assetsMissing: pluginApi?.tr("main.error.assetsMissing"), + noBackground: pluginApi?.tr("main.error.noBackground"), + opengl: pluginApi?.tr("main.error.opengl") + }); } function setRuntimeErrorFromStderr(stderrText) { @@ -807,6 +776,36 @@ Item { return true; } + function clearRuntimeErrorState() { + lastError = ""; + lastErrorDetails = ""; + lastRuntimeErrorKey = ""; + } + + function logCapturedRuntimeError(stage, exitCode = null, exitStatus = null) { + const summary = String(lastError || "").trim(); + const details = String(lastErrorDetails || "").trim(); + if (summary.length === 0 && details.length === 0) { + return false; + } + + const logKey = stage + "|" + summary + "|" + details; + if (logKey === lastRuntimeErrorKey) { + return true; + } + + lastRuntimeErrorKey = logKey; + Logger.e( + "LWEController", + "runtime-error", + "stage=", stage, + "exitCode=", exitCode === null ? "-" : exitCode, + "exitStatus=", exitStatus === null ? "-" : exitStatus, + "summary=", summary + ); + return true; + } + function markErrorAsRecovered() { const hintRaw = pluginApi?.tr("main.error.autoRecovered"); if (hintRaw === undefined || hintRaw === null) { @@ -826,101 +825,41 @@ Item { lastError = current + " (" + hint + ")"; } - // Command construction and engine lifecycle. - function buildCommand() { - const command = ["linux-wallpaperengine"]; - let firstPath = ""; - const appendedWallpaperIds = {}; - let runtimeOptions = { - volume: defaultVolume, - muted: defaultMuted, - audioReactiveEffects: defaultAudioReactiveEffects, - noAutomute: defaultNoAutomute, - disableMouse: defaultDisableMouse, - disableParallax: defaultDisableParallax - }; - - command.push("--fps"); - command.push(String(defaultFps)); - - const runtimeClamp = String(defaultClamp || "clamp").trim(); - if (runtimeClamp.length > 0) { - command.push("--clamp"); - command.push(runtimeClamp); - } - - if (runtimeOptions.muted) { - command.push("--silent"); - } else { - command.push("--volume"); - command.push(String(runtimeOptions.volume)); - } - - if (!runtimeOptions.audioReactiveEffects) { - command.push("--no-audio-processing"); - } - - if (runtimeOptions.noAutomute) { - command.push("--noautomute"); - } - - if (runtimeOptions.disableMouse) { - command.push("--disable-mouse"); - } - - if (runtimeOptions.disableParallax) { - command.push("--disable-parallax"); - } - - if (defaultNoFullscreenPause) { - command.push("--no-fullscreen-pause"); - } - - if (defaultFullscreenPauseOnlyActive) { - command.push("--fullscreen-pause-only-active"); - } - - const maybeAssetsDir = normalizedPath(assetsDir); - if (maybeAssetsDir.length > 0) { - command.push("--assets-dir"); - command.push(maybeAssetsDir); - } - - for (const screen of Quickshell.screens) { - const screenCfg = getScreenConfig(screen.name); - const path = normalizedPath(screenCfg.path); - if (!path || path.length === 0) { - continue; - } - - if (firstPath.length === 0) { - firstPath = path; - } - - command.push("--screen-root"); - command.push(screen.name); - command.push("--bg"); - command.push(path); - - command.push("--scaling"); - command.push(String(screenCfg.scaling)); - - const wallpaperId = wallpaperIdFromPath(path); - if (wallpaperId.length > 0 && !appendedWallpaperIds[wallpaperId]) { - const customProperties = getWallpaperProperties(path); - for (const propertyKey of Object.keys(customProperties)) { - const propertyValue = customProperties[propertyKey]; - if (propertyValue === undefined || propertyValue === null || String(propertyKey || "").trim().length === 0) { - continue; - } - command.push("--set-property"); - command.push(String(propertyKey) + "=" + String(propertyValue)); - } - appendedWallpaperIds[wallpaperId] = true; - } - } + // Engine command construction and lifecycle orchestration. + function buildCommand(runtimeOverrides = null) { + const overrides = runtimeOverrides || ({}); + return EngineHelpers.buildEngineCommand({ + defaultFps: defaultFps, + defaultClamp: overrides.defaultClamp ?? defaultClamp, + defaultVolume: overrides.defaultVolume ?? defaultVolume, + defaultMuted: overrides.defaultMuted ?? defaultMuted, + defaultAudioReactiveEffects: overrides.defaultAudioReactiveEffects ?? defaultAudioReactiveEffects, + defaultNoAutomute: overrides.defaultNoAutomute ?? defaultNoAutomute, + defaultDisableMouse: overrides.defaultDisableMouse ?? defaultDisableMouse, + defaultDisableParallax: overrides.defaultDisableParallax ?? defaultDisableParallax, + defaultNoFullscreenPause: defaultNoFullscreenPause, + defaultFullscreenPauseOnlyActive: defaultFullscreenPauseOnlyActive, + defaultScaling: defaultScaling, + screens: Quickshell.screens, + getScreenConfig: getScreenConfig, + normalizePath: normalizedPath, + wallpaperIdFromPath: wallpaperIdFromPath, + getWallpaperProperties: getWallpaperProperties + }); + } - return command; + function resolveRuntimeOverrides(options = null) { + const opts = options || ({}); + const resolvedVolumeRaw = Number(opts.volume); + return { + defaultClamp: String(opts.clamp || defaultClamp), + defaultVolume: isNaN(resolvedVolumeRaw) ? defaultVolume : Math.max(0, Math.min(100, Math.floor(resolvedVolumeRaw))), + defaultMuted: opts.muted === undefined ? defaultMuted : !!opts.muted, + defaultAudioReactiveEffects: opts.audioReactiveEffects === undefined ? defaultAudioReactiveEffects : !!opts.audioReactiveEffects, + defaultNoAutomute: opts.noAutomute === undefined ? defaultNoAutomute : !!opts.noAutomute, + defaultDisableMouse: opts.disableMouse === undefined ? defaultDisableMouse : !!opts.disableMouse, + defaultDisableParallax: opts.disableParallax === undefined ? defaultDisableParallax : !!opts.disableParallax + }; } function stopAll(showToast = false) { @@ -940,7 +879,6 @@ Item { } isApplying = false; - statusMessage = pluginApi?.tr("main.status.stopped"); if (showToast) { ToastService.showNotice(pluginApi?.tr("panel.title"), pluginApi?.tr("toast.stopped"), "player-stop"); } @@ -948,12 +886,12 @@ Item { function startEngineWithCommand(command) { if (!engineAvailable) { - Logger.w("LWEController", "Skip start: engine unavailable"); + Logger.d("LWEController", "Skip start: engine unavailable"); return; } if (!command || command.length <= 1) { - Logger.w("LWEController", "Skip start: empty command"); + Logger.d("LWEController", "Skip start: empty command"); stopAll(); return; } @@ -961,10 +899,8 @@ Item { Logger.d("LWEController", "Starting engine command", JSON.stringify(command)); if (!recoveryInProgress) { - lastError = ""; - lastErrorDetails = ""; + clearRuntimeErrorState(); } - statusMessage = pluginApi?.tr("main.status.starting"); isApplying = true; engineProcess.command = command; @@ -972,21 +908,21 @@ Item { stableRunTimer.restart(); } - function restartEngine() { + function restartEngine(runtimeOverrides = null) { if (!engineAvailable) { - Logger.w("LWEController", "Skip restart: engine unavailable"); + Logger.d("LWEController", "Skip restart: engine unavailable"); return; } if (!hasAnyConfiguredWallpaper()) { - Logger.i("LWEController", "Skip restart: no configured wallpaper; stopping engine"); + Logger.d("LWEController", "Skip restart: no configured wallpaper; stopping engine"); stopAll(); return; } - const command = buildCommand(); + const command = buildCommand(runtimeOverrides); if (!command || command.length <= 1) { - Logger.w("LWEController", "Restart resolved to empty command; stopping engine"); + Logger.d("LWEController", "Restart resolved to empty command; stopping engine"); stopAll(); return; } @@ -1009,10 +945,8 @@ Item { function reload(showToast = false) { if (!hasAnyConfiguredWallpaper()) { - lastError = ""; - lastErrorDetails = ""; - statusMessage = pluginApi?.tr("main.status.ready"); - Logger.i("LWEController", "Reload skipped: no configured wallpaper paths"); + clearRuntimeErrorState(); + Logger.d("LWEController", "Reload skipped: no configured wallpaper paths"); if (showToast) { ToastService.showWarning(pluginApi?.tr("panel.title"), pluginApi?.tr("toast.reloadSkippedNoWallpaper"), "alert-circle"); } @@ -1025,7 +959,7 @@ Item { } } - // External command and IPC integration. + // External processes. Process { id: wallpaperScanProcess @@ -1051,10 +985,14 @@ Item { const id = parts.length > 5 ? parts[5] : String(path || "").split("/").pop(); const type = parts.length > 6 ? parts[6] : "unknown"; const resolution = parts.length > 7 ? parts[7] : "unknown"; - const sizeMtime = parts.length > 8 ? parts[8] : "0:0"; + const hasEmbeddedAudio = parts.length > 8 ? parts[8] === "1" : false; + const hasAudioReactive = parts.length > 9 ? parts[9] === "1" : false; + const sizeMtime = parts.length > 10 ? parts[10] : "0:0"; const sizeParts = String(sizeMtime).split(":"); const bytes = sizeParts.length > 0 ? Number(sizeParts[0]) : 0; const mtime = sizeParts.length > 1 ? Number(sizeParts[1]) : 0; + const approved = parts.length > 11 ? parts[11] === "1" : false; + const description = parts.length > 12 ? String(parts[12] || "") : ""; if (path.length > 0) { parsed.push({ @@ -1063,11 +1001,15 @@ Item { thumb: thumb, motionPreview: motionPreview, dynamic: dynamic, + hasEmbeddedAudio: hasEmbeddedAudio, + hasAudioReactive: hasAudioReactive, id: id, type: type, resolution: resolution, bytes: bytes, - mtime: mtime + mtime: mtime, + approved: approved, + description: description }); } } @@ -1086,11 +1028,8 @@ Item { root.wallpaperScanShowToast = false; if (!root.wallpapersFolderAccessible) { - if (stderrText.length > 0) { - Logger.e("LWEController", "Wallpaper scan failed", "folder=", Settings.preprocessPath(String(cfg.wallpapersFolder ?? defaults.wallpapersFolder ?? "")), "exitCode=", exitCode, "stderr=", stderrText); - } else { - Logger.e("LWEController", "Wallpaper scan failed", "exitCode=", exitCode); - } + const msg = stderrText.length > 0 ? "Wallpaper scan failed, stderr=" + stderrText : "Wallpaper scan failed"; + Logger.e("LWEController", msg, "exitCode=", exitCode); } Logger.i("LWEController", "Wallpaper cache updated", "count=", parsed.length, "exitCode=", exitCode); @@ -1114,13 +1053,11 @@ Item { if (!root.engineAvailable) { root.lastError = root.pluginApi?.tr("main.error.notInstalled"); root.lastErrorDetails = ""; - root.statusMessage = root.pluginApi?.tr("main.status.unavailable"); + root.lastRuntimeErrorKey = ""; Logger.e("LWEController", "linux-wallpaperengine binary not found in PATH"); return; } - root.statusMessage = root.pluginApi?.tr("main.status.ready"); - root.refreshWallpaperCache(false, false); root.recoverPendingLayoutOnStartup(); @@ -1156,19 +1093,16 @@ Item { return; } - root.statusMessage = root.pluginApi?.tr("main.status.stopped"); return; } if (exitCode !== 0 || exitStatus !== Process.NormalExit) { if (root.setRuntimeErrorFromStderr(stderr.text)) { - Logger.e("LWEController", "Engine runtime error", root.lastError); + root.logCapturedRuntimeError("engine-exit", exitCode, exitStatus); } root.tryAutoRecoverFromRuntimeError("runtime-crash"); - root.statusMessage = root.pluginApi?.tr("main.status.crashed"); } else { root.recoveryInProgress = false; - root.statusMessage = root.pluginApi?.tr("main.status.stopped"); } } @@ -1180,9 +1114,7 @@ Item { return; } - if (root.setRuntimeErrorFromStderr(text)) { - Logger.w("LWEController", "Engine stderr", root.lastError); - } + root.setRuntimeErrorFromStderr(text); } } } @@ -1215,6 +1147,8 @@ Item { const requestPath = root.wallpaperColorRequestPath; const screenshotPath = root.wallpaperColorScreenshotPath; const screenName = root.wallpaperColorScreenName; + const appliedScaling = root.wallpaperColorScaling; + const notify = root.wallpaperColorNotify; const stderrText = String(stderr.text || "").trim(); root.applyingWallpaperColors = false; @@ -1222,27 +1156,54 @@ Item { root.wallpaperColorScreenshotPath = ""; root.wallpaperColorScreenName = ""; root.wallpaperColorScaling = "fill"; + root.wallpaperColorNotify = false; if (exitCode !== 0) { - Logger.w("LWEController", "Wallpaper screenshot generation failed", "path=", requestPath, "screen=", screenName, "exitCode=", exitCode, "stderr=", stderrText); - ToastService.showError(pluginApi?.tr("panel.title"), pluginApi?.tr("toast.wallpaperColorsFailed"), "alert-circle"); + const msg = stderrText.length > 0 ? "Wallpaper screenshot generation failed, stderr=" + stderrText : "Wallpaper screenshot generation failed"; + Logger.w("LWEController", msg, "path=", requestPath, "screen=", screenName, "exitCode=", exitCode); + if (notify) { + ToastService.showError(pluginApi?.tr("panel.title"), pluginApi?.tr("toast.wallpaperColorsFailed"), "alert-circle"); + } return; } - saveWallpaperColorScreenshot(screenName, screenshotPath, requestPath, root.wallpaperColorScaling); + saveWallpaperColorScreenshot(screenName, screenshotPath, requestPath, appliedScaling); if (wallpaperColorsEnabled && screenName === activeColorMonitor) { - root.applyWallpaperColorsFromScreenshot(screenName, screenshotPath); + root.applyWallpaperColorsFromScreenshot(screenshotPath); Logger.i("LWEController", "Wallpaper screenshot generated and applied for active color monitor", "path=", requestPath, "screen=", screenName, "screenshot=", screenshotPath); - ToastService.showNotice(pluginApi?.tr("panel.title"), pluginApi?.tr("toast.wallpaperColorsApplied"), "palette"); + if (notify) { + ToastService.showNotice(pluginApi?.tr("panel.title"), pluginApi?.tr("toast.wallpaperColorsApplied"), "palette"); + } return; } Logger.i("LWEController", "Wallpaper screenshot cached for color extraction", "path=", requestPath, "screen=", screenName, "screenshot=", screenshotPath); - ToastService.showNotice(pluginApi?.tr("panel.title"), pluginApi?.tr("toast.wallpaperColorsCached"), "palette"); + if (notify) { + ToastService.showNotice(pluginApi?.tr("panel.title"), pluginApi?.tr("toast.wallpaperColorsCached"), "palette"); + } } } + // Startup cleanup: kill any orphaned wallpaper engine processes from previous sessions. + Process { + id: startupCleanupProcess + running: false + + command: { + const pluginDir = root.pluginApi?.pluginDir || ""; + const scriptPath = pluginDir + "/scripts/force-stop-engine.sh"; + return ["bash", scriptPath]; + } + + onExited: function (exitCode) { + Logger.d("LWEController", "Startup cleanup finished", "exitCode=", exitCode); + } + + stdout: StdioCollector {} + stderr: StdioCollector {} + } + Process { id: reusedWallpaperColorCheckProcess running: false @@ -1258,16 +1219,18 @@ Item { Logger.i("LWEController", "Reusing cached wallpaper color screenshot", "path=", request.wallpaperPath, "screen=", request.screenName, "scaling=", request.scaling, "screenshot=", request.screenshotPath); if (root.wallpaperColorsEnabled && request.screenName === root.activeColorMonitor) { - root.applyWallpaperColorsFromScreenshot(request.screenName, request.screenshotPath); - ToastService.showNotice(pluginApi?.tr("panel.title"), pluginApi?.tr("toast.wallpaperColorsApplied"), "palette"); - } else { + root.applyWallpaperColorsFromScreenshot(request.screenshotPath); + if (request.notify) { + ToastService.showNotice(pluginApi?.tr("panel.title"), pluginApi?.tr("toast.wallpaperColorsApplied"), "palette"); + } + } else if (request.notify) { ToastService.showNotice(pluginApi?.tr("panel.title"), pluginApi?.tr("toast.wallpaperColorsCached"), "palette"); } return; } Logger.w("LWEController", "Cached wallpaper color screenshot missing; regenerating", "path=", request.wallpaperPath, "screen=", request.screenName, "scaling=", request.scaling, "screenshot=", request.screenshotPath); - root.startWallpaperColorCapture(request.wallpaperPath, request.screenName, request.scaling); + root.startWallpaperColorCapture(request.wallpaperPath, request.screenName, request.scaling, !!request.notify); } stdout: StdioCollector {} @@ -1281,14 +1244,17 @@ Item { onExited: function (exitCode) { const screenshotPath = root.pendingCachedWallpaperColorPath; const screenName = root.pendingCachedWallpaperColorScreenName; + const notify = root.pendingCachedWallpaperColorNotify; if (exitCode !== 0 || screenshotPath.length === 0) { Logger.w("LWEController", "Cached wallpaper color screenshot missing for active monitor", "screen=", screenName, "path=", screenshotPath, "exitCode=", exitCode); root.pendingCachedWallpaperColorPath = ""; root.pendingCachedWallpaperColorScreenName = ""; + root.pendingCachedWallpaperColorNotify = false; return; } + root.pendingCachedWallpaperColorNotify = notify; cachedWallpaperColorSyncTimer.restart(); } @@ -1296,6 +1262,7 @@ Item { stderr: StdioCollector {} } + // Timers and deferred follow-up work. Timer { id: wallpaperColorStartTimer interval: 1500 @@ -1313,20 +1280,33 @@ Item { Timer { id: cachedWallpaperColorSyncTimer interval: 250 + repeat: false onTriggered: { const screenshotPath = root.pendingCachedWallpaperColorPath; const screenName = root.pendingCachedWallpaperColorScreenName; root.pendingCachedWallpaperColorPath = ""; root.pendingCachedWallpaperColorScreenName = ""; + root.pendingCachedWallpaperColorNotify = false; if (screenshotPath.length === 0 || !root.wallpaperColorsEnabled) { return; } Logger.i("LWEController", "Applying cached wallpaper colors for active monitor", "screen=", screenName || root.activeColorMonitor, "path=", screenshotPath); - root.applyWallpaperColorsFromScreenshot(screenName || root.activeColorMonitor, screenshotPath); + root.applyWallpaperColorsFromScreenshot(screenshotPath); } } + Timer { + id: startupWallpaperColorResyncTimer + interval: 2200 + repeat: false + + onTriggered: { + root.scheduleCachedWallpaperColorsForMonitor("startup-final-resync"); + } + } + + // IPC entrypoints. IpcHandler { target: "plugin:linux-wallpaperengine-controller" @@ -1340,11 +1320,11 @@ Item { function apply(screenName: string, bgPath: string) { if (!screenName || !bgPath) { - Logger.w("LWEController", "IPC apply ignored due to invalid args", screenName, bgPath); + Logger.w("LWEController", "IPC apply ignored due to invalid args", "screenName=", screenName, "bgPath=", bgPath); return; } - Logger.i("LWEController", "IPC apply", screenName, bgPath); + Logger.i("LWEController", "IPC apply", "screenName=", screenName, "bgPath=", bgPath); root.setScreenWallpaper(screenName, bgPath); } @@ -1370,6 +1350,7 @@ Item { } } + // Shell event connections. Connections { target: Quickshell @@ -1383,6 +1364,7 @@ Item { onWallpaperColorDarkModeChanged: scheduleCachedWallpaperColorsForMonitor("dark-mode-changed") onWallpaperColorGenerationMethodChanged: scheduleCachedWallpaperColorsForMonitor("generation-method-changed") + // Stability and topology debounce timers. Timer { id: stableRunTimer interval: 2500 @@ -1417,4 +1399,39 @@ Item { root.restartEngine(); } } + + // Cleanup on shell shutdown. + Component.onDestruction: { + Logger.i("LWEController", "Shell shutting down, cleaning up wallpaper engine processes"); + + pendingCommand = []; + stopRequested = true; + + if (engineProcess.running) + engineProcess.running = false; + + if (wallpaperScanProcess.running) + wallpaperScanProcess.running = false; + if (wallpaperColorProcess.running) + wallpaperColorProcess.running = false; + if (reusedWallpaperColorCheckProcess.running) + reusedWallpaperColorCheckProcess.running = false; + if (cachedWallpaperColorSyncCheckProcess.running) + cachedWallpaperColorSyncCheckProcess.running = false; + + if (!forceStopProcess.running) + forceStopProcess.running = true; + + stableRunTimer.stop(); + screenTopologyRestartDebounce.stop(); + wallpaperColorStartTimer.stop(); + cachedWallpaperColorSyncTimer.stop(); + startupWallpaperColorResyncTimer.stop(); + + isApplying = false; + scanningWallpapers = false; + applyingWallpaperColors = false; + + Logger.i("LWEController", "Cleanup complete"); + } } diff --git a/linux-wallpaperengine-controller/Panel.qml b/linux-wallpaperengine-controller/Panel.qml index 4e8314f65..00e1da09d 100644 --- a/linux-wallpaperengine-controller/Panel.qml +++ b/linux-wallpaperengine-controller/Panel.qml @@ -1,7 +1,5 @@ import QtQuick -import QtQuick.Controls import QtQuick.Layouts -import QtMultimedia import Quickshell import Quickshell.Io @@ -9,9 +7,13 @@ import qs.Commons import qs.Services.UI import qs.Widgets -import "components" -import "helpers/WallpaperMetaHelpers.js" as WallpaperMetaHelpers -import "helpers/PropertyHelpers.js" as PropertyHelpers +import "components/panel" +import "components/wallpaper" +import "helpers/panel/BadgeHelpers.js" as BadgeHelpers +import "helpers/panel/WallpaperFilterHelpers.js" as WallpaperFilterHelpers +import "helpers/panel/WallpaperMetaHelpers.js" as WallpaperMetaHelpers +import "helpers/panel/WallpaperUiHelpers.js" as WallpaperUiHelpers +import "helpers/panel/PropertyHelpers.js" as PropertyHelpers Item { id: root @@ -29,8 +31,6 @@ Item { property real contentPreferredHeight: 860 * Style.uiScaleRatio readonly property bool allowAttach: true - readonly property bool panelAnchorHorizontalCenter: false - readonly property bool panelAnchorVerticalCenter: false // Panel state and current selection. readonly property string wallpapersFolder: cfg.wallpapersFolder ?? defaults.wallpapersFolder ?? "" @@ -38,6 +38,7 @@ Item { property string selectedScreenName: pluginApi?.panelOpenScreen?.name ?? "" property string selectedPath: "" property string pendingPath: "" + property string lastPersistedPath: "" property string selectedScaling: "fill" property string selectedClamp: "clamp" property int selectedVolume: 100 @@ -50,7 +51,6 @@ Item { readonly property bool scanningWallpapers: mainInstance?.scanningWallpapers ?? false property bool loadingWallpaperProperties: false property bool scanningCompatibility: false - property bool pendingCompatibilityScan: false readonly property bool folderAccessible: mainInstance?.wallpapersFolderAccessible ?? true property string searchText: "" @@ -59,24 +59,28 @@ Item { property string sortMode: "name" property bool sortAscending: true property int currentPage: 0 - property int pageSize: 24 + readonly property int pageSize: Math.max(1, Number(cfg.panelPageSize ?? defaults.panelPageSize ?? 24) || 24) readonly property bool singleScreenMode: Quickshell.screens.length <= 1 property bool applyAllDisplays: !singleScreenMode && root._applyAllDisplays property bool _applyAllDisplays: true + property bool sidebarVisible: true + readonly property var defaultBadgeOrder: BadgeHelpers.normalizedDefaultOrder(defaults.badgeOrder) + readonly property var defaultBadgeEnabled: BadgeHelpers.normalizedDefaultEnabled(defaults.badgeEnabled) + readonly property var badgeOrder: BadgeHelpers.normalizeBadgeOrder(cfg.badgeOrder, defaultBadgeOrder) + readonly property var badgeEnabled: BadgeHelpers.normalizeBadgeEnabled(cfg.badgeEnabled, defaultBadgeEnabled) + readonly property var visibleBadgeOrder: BadgeHelpers.filterVisibleBadgeOrder(badgeOrder, badgeEnabled, defaultBadgeEnabled) + readonly property bool showSidebarDescription: cfg.showSidebarDescription ?? defaults.showSidebarDescription ?? false property bool applyTargetExpanded: false property bool filterDropdownOpen: false - property bool resolutionDropdownOpen: false property bool sortDropdownOpen: false property bool errorDetailsExpanded: false property real filterDropdownX: 0 property real filterDropdownY: 0 property real filterDropdownWidth: 220 * Style.uiScaleRatio - property real resolutionDropdownX: 0 - property real resolutionDropdownY: 0 - property real resolutionDropdownWidth: 220 * Style.uiScaleRatio property real sortDropdownX: 0 property real sortDropdownY: 0 property real sortDropdownWidth: 220 * Style.uiScaleRatio + property bool panelInitialized: false // Data models and derived UI state. property var screenModel: [] @@ -88,44 +92,32 @@ Item { property var wallpaperPropertyValues: ({}) property string wallpaperPropertyError: "" property string wallpaperPropertyRequestPath: "" + readonly property var propertyTranslationApi: ({ + translatePropertyLabelKey: key => pluginApi?.tr(key), + createColor: (r, g, b, a) => Qt.rgba(r, g, b, a) + }) + readonly property var propertyEditorApi: ({ + propertyValueFor: definition => root.propertyValueFor(definition), + numberOr: (value, fallback) => PropertyHelpers.numberOr(value, fallback), + formatSliderValue: (value, step) => PropertyHelpers.formatSliderValue(value, step), + comboChoicesFor: definition => PropertyHelpers.comboChoicesFor(definition), + ensureColorValue: value => PropertyHelpers.ensureColorValue( + value, + (rawValue, type) => PropertyHelpers.parsePropertyValue(rawValue, type, root.propertyTranslationApi.createColor), + root.propertyTranslationApi.createColor + ), + resolvePropertyImageSource: rawValue => PropertyHelpers.resolvePropertyImageSource(rawValue, pendingPath), + serializePropertyValue: (value, type) => PropertyHelpers.serializePropertyValue(value, type), + setPropertyValue: (key, value) => root.setPropertyValue(key, value) + }) readonly property bool extraPropertiesEditorEnabled: cfg.enableExtraPropertiesEditor ?? defaults.enableExtraPropertiesEditor ?? true - readonly property string engineStatusBadgeText: { - if (mainInstance?.checkingEngine ?? false) { - return pluginApi?.tr("panel.statusChecking"); - } - if (!(mainInstance?.engineAvailable ?? false)) { - return pluginApi?.tr("panel.statusUnavailable"); - } - if (mainInstance?.engineRunning ?? false) { - return pluginApi?.tr("panel.statusRunning"); - } - if (mainInstance?.hasAnyConfiguredWallpaper && mainInstance.hasAnyConfiguredWallpaper()) { - return pluginApi?.tr("panel.statusReady"); - } - return pluginApi?.tr("panel.statusStopped"); - } - readonly property color engineStatusBadgeFg: { - if (mainInstance?.checkingEngine ?? false) { - return Color.mSecondary; - } - if (!(mainInstance?.engineAvailable ?? false)) { - return Color.mError; - } - if (mainInstance?.engineRunning ?? false) { - return Color.mPrimary; - } - if (mainInstance?.hasAnyConfiguredWallpaper && mainInstance.hasAnyConfiguredWallpaper()) { - return Color.mTertiary; - } - return Color.mOnSurfaceVariant; - } - readonly property color engineStatusBadgeBg: Qt.alpha(engineStatusBadgeFg, 0.16) readonly property int pageCount: Math.max(1, Math.ceil(visibleWallpapers.length / Math.max(pageSize, 1))) readonly property bool paginationVisible: visibleWallpapers.length > pageSize readonly property int currentPageDisplay: visibleWallpapers.length === 0 ? 0 : currentPage + 1 readonly property int currentPageStartIndex: visibleWallpapers.length === 0 ? 0 : currentPage * pageSize + 1 readonly property int currentPageEndIndex: Math.min((currentPage + 1) * pageSize, visibleWallpapers.length) - readonly property var selectedWallpaperData: { + + function getSelectedWallpaperData() { const target = String(pendingPath || ""); if (target.length === 0) { return null; @@ -139,29 +131,20 @@ Item { } // Basic file and metadata helpers. - function basename(path) { - return WallpaperMetaHelpers.basename(path); - } - - function workshopUrlForWallpaper(item) { - return WallpaperMetaHelpers.workshopUrlForWallpaper(item); + function isVideoMotion(path) { + return WallpaperMetaHelpers.isVideoMotion(path); } - function fileExt(path) { - return WallpaperMetaHelpers.fileExt(path); + function typeLabel(value) { + return WallpaperUiHelpers.typeLabel(value, key => pluginApi?.tr(key)); } - function isVideoMotion(path) { - return WallpaperMetaHelpers.isVideoMotion(path); + function typeBadgeIcon(value) { + return WallpaperUiHelpers.typeBadgeIcon(value); } - function typeLabel(value) { - const key = String(value || "all").toLowerCase(); - if (key === "scene") return pluginApi?.tr("panel.typeScene"); - if (key === "video") return pluginApi?.tr("panel.typeVideo"); - if (key === "web") return pluginApi?.tr("panel.typeWeb"); - if (key === "application") return pluginApi?.tr("panel.typeApplication"); - return pluginApi?.tr("panel.filterAll"); + function dynamicBadgeIcon(isDynamic) { + return WallpaperUiHelpers.dynamicBadgeIcon(isDynamic); } function formatBytes(bytesValue) { @@ -169,10 +152,7 @@ Item { } function sortLabel(value) { - if (value === "date") return pluginApi?.tr("panel.sortDateAdded"); - if (value === "size") return pluginApi?.tr("panel.sortSize"); - if (value === "recent") return pluginApi?.tr("panel.sortRecent"); - return pluginApi?.tr("panel.sortName"); + return WallpaperUiHelpers.sortLabel(value, key => pluginApi?.tr(key)); } // Resolution helpers for badges and filtering. @@ -189,39 +169,7 @@ Item { } function resolutionFilterLabel(value) { - if (value === "8k") return pluginApi?.tr("panel.filterRes8k"); - if (value === "4k") return pluginApi?.tr("panel.filterRes4k"); - if (value === "unknown") return pluginApi?.tr("panel.filterResUnknown"); - return pluginApi?.tr("panel.filterResAll"); - } - - // Extra property parsing and normalization helpers. - function stripHtml(rawText) { - return PropertyHelpers.stripHtml(rawText); - } - - function cleanedPropertyLabel(rawText, fallbackKey) { - return PropertyHelpers.cleanedPropertyLabel(rawText, fallbackKey, key => pluginApi?.tr(key)); - } - - function normalizePropertyLabel(value) { - return PropertyHelpers.normalizePropertyLabel(value, key => pluginApi?.tr(key)); - } - - function isNoisePropertyKey(value) { - return PropertyHelpers.isNoisePropertyKey(value); - } - - function isNoisePropertyLabel(value) { - return PropertyHelpers.isNoisePropertyLabel(value); - } - - function parsePropertyValue(rawValue, type) { - return PropertyHelpers.parsePropertyValue(rawValue, type, (r, g, b, a) => Qt.rgba(r, g, b, a)); - } - - function serializePropertyValue(value, type) { - return PropertyHelpers.serializePropertyValue(value, type); + return WallpaperUiHelpers.resolutionFilterLabel(value, key => pluginApi?.tr(key)); } // Extra property value accessors. @@ -237,26 +185,6 @@ Item { return definition.defaultValue; } - function comboChoicesFor(definition) { - return PropertyHelpers.comboChoicesFor(definition); - } - - function ensureColorValue(value) { - return PropertyHelpers.ensureColorValue( - value, - (rawValue, type) => parsePropertyValue(rawValue, type), - (r, g, b, a) => Qt.rgba(r, g, b, a) - ); - } - - function numberOr(value, fallback) { - return PropertyHelpers.numberOr(value, fallback); - } - - function formatSliderValue(value, step) { - return PropertyHelpers.formatSliderValue(value, step); - } - function setPropertyValue(key, value) { const current = wallpaperPropertyValues || ({}); const next = Object.assign({}, current); @@ -266,134 +194,7 @@ Item { // Property loading and compatibility scan actions. function parseWallpaperPropertiesOutput(rawText) { - const lines = String(rawText || "").split(/\r?\n/); - const definitions = []; - let current = null; - let parsingValues = false; - - function commitCurrent() { - if (!current) { - return; - } - if (["boolean", "slider", "combo", "textinput", "color", "text"].indexOf(current.type) === -1) { - current = null; - parsingValues = false; - return; - } - current.label = cleanedPropertyLabel(current.label, current.key); - if (current.type === "text") { - if (current.label.length === 0 || isNoisePropertyLabel(current.label)) { - current = null; - parsingValues = false; - return; - } - definitions.push({ - key: current.key, - type: "text", - label: current.label, - defaultValue: "" - }); - current = null; - parsingValues = false; - return; - } - if (isNoisePropertyKey(current.key) || isNoisePropertyLabel(current.label)) { - current = null; - parsingValues = false; - return; - } - definitions.push(current); - current = null; - parsingValues = false; - } - - for (const rawLine of lines) { - const line = String(rawLine || ""); - const trimmed = line.trim(); - if (trimmed.length === 0) { - commitCurrent(); - continue; - } - - if (trimmed.indexOf("Unknown object type found:") === 0 - || trimmed.indexOf("ScriptEngine [evaluate]:") === 0 - || trimmed.indexOf("Text objects are not supported yet") === 0 - || trimmed.indexOf("Applying override value for ") === 0) { - continue; - } - - const headerMatch = trimmed.match(/^([^\s].*?)\s+-\s+(slider|boolean|combo|textinput|color|text|scene texture)$/i); - if (headerMatch) { - commitCurrent(); - current = { - key: headerMatch[1].trim(), - type: headerMatch[2].toLowerCase(), - label: undefined, - min: undefined, - max: undefined, - step: undefined, - defaultValue: "", - choices: [] - }; - parsingValues = false; - continue; - } - - if (!current) { - continue; - } - - if (trimmed.indexOf("Text:") === 0) { - current.label = trimmed.substring(5).trim(); - parsingValues = false; - continue; - } - if (trimmed.indexOf("Min:") === 0) { - const parsed = Number(trimmed.substring(4).trim()); - current.min = isNaN(parsed) ? undefined : parsed; - parsingValues = false; - continue; - } - if (trimmed.indexOf("Max:") === 0) { - const parsed = Number(trimmed.substring(4).trim()); - current.max = isNaN(parsed) ? undefined : parsed; - parsingValues = false; - continue; - } - if (trimmed.indexOf("Step:") === 0) { - const parsed = Number(trimmed.substring(5).trim()); - current.step = isNaN(parsed) ? undefined : parsed; - parsingValues = false; - continue; - } - if (trimmed.indexOf("Value:") === 0) { - current.defaultValue = parsePropertyValue(trimmed.substring(6).trim(), current.type); - parsingValues = false; - continue; - } - if (trimmed === "Values:") { - parsingValues = true; - continue; - } - - if (parsingValues && current.type === "combo") { - const valueMatch = trimmed.match(/^(.*?)\s*=\s*(.*)$/); - if (valueMatch) { - const choiceKey = valueMatch[1].trim(); - const choiceName = valueMatch[2].trim(); - current.choices.push({ - key: choiceKey, - name: choiceName, - label: choiceName, - value: choiceKey, - text: choiceName - }); - } - } - } - - commitCurrent(); - return definitions; + return PropertyHelpers.parseWallpaperPropertiesOutput(rawText, root.propertyTranslationApi); } function loadWallpaperProperties(path) { @@ -414,31 +215,88 @@ Item { } function setWallpaperPropertyLoadFailed(path, failed) { + const currentState = propertyCompatibilityStateForPath(path); + if (failed) { + setWallpaperPropertyCompatibilityState(path, "failed"); + return; + } + if (currentState === "failed" || currentState.length === 0) { + setWallpaperPropertyCompatibilityState(path, ""); + } + } + + function setWallpaperPropertyCompatibilityState(path, state) { const wallpaperPath = String(path || "").trim(); if (wallpaperPath.length === 0) { return; } + const normalizedState = String(state || "").trim(); const nextState = Object.assign({}, wallpaperPropertyLoadFailedByPath); - if (failed) { - nextState[wallpaperPath] = true; + if (normalizedState.length > 0) { + nextState[wallpaperPath] = normalizedState; } else { delete nextState[wallpaperPath]; } wallpaperPropertyLoadFailedByPath = nextState; } + function propertyCompatibilityStateForPath(path) { + const key = String(path || "").trim(); + const raw = wallpaperPropertyLoadFailedByPath || ({}); + const value = raw[key]; + if (value === true) { + return "failed"; + } + return String(value || "").trim(); + } + + function propertyCompatibilityBadgeIconForPath(path) { + const state = propertyCompatibilityStateForPath(path); + if (state === "limited") { + return "alert-circle"; + } + if (state === "failed") { + return "alert-triangle"; + } + return ""; + } + + function propertyCompatibilityBadgeTextForPath(path) { + const state = propertyCompatibilityStateForPath(path); + if (state === "limited") { + return pluginApi?.tr("panel.propertiesLimitedBadge"); + } + if (state === "failed") { + return pluginApi?.tr("panel.propertiesFailedBadge"); + } + return ""; + } + + function propertyCompatibilityBadgeColorForPath(path) { + const state = propertyCompatibilityStateForPath(path); + if (state === "limited") { + return Color.mSecondary; + } + if (state === "failed") { + return Color.mError; + } + return Color.mOnSurfaceVariant; + } + + function propertyCompatibilityBadgeBackgroundForPath(path) { + return Qt.alpha(propertyCompatibilityBadgeColorForPath(path), 0.16); + } + function startCompatibilityScan() { const folderPath = String(resolvedWallpapersFolder || "").trim(); if (folderPath.length === 0 || !(mainInstance?.engineAvailable ?? false)) { - pendingCompatibilityScan = false; return; } const pluginDir = pluginApi?.pluginDir || ""; const scriptPath = pluginDir + "/scripts/scan-properties-compatibility.sh"; - pendingCompatibilityScan = false; scanningCompatibility = true; compatibilityScanProcess.command = ["bash", scriptPath, folderPath]; compatibilityScanProcess.running = true; @@ -457,29 +315,40 @@ Item { const parts = line.split("\t"); const path = String(parts[0] || "").trim(); - const failed = String(parts[1] || "0").trim() === "1"; + const statusCode = String(parts[1] || "0").trim(); if (path.length === 0) { continue; } totalCount += 1; - if (failed) { - nextState[path] = true; + if (statusCode === "1") { + nextState[path] = "failed"; + } else if (statusCode === "2") { + nextState[path] = "limited"; } } wallpaperPropertyLoadFailedByPath = nextState; + let limitedCount = 0; + let failedCount = 0; + for (const value of Object.values(nextState)) { + if (value === "limited") { + limitedCount += 1; + } else if (value === "failed") { + failedCount += 1; + } + } return { totalCount: totalCount, - failedCount: Object.keys(nextState).length + failedCount: failedCount, + limitedCount: limitedCount }; } // Dropdown state helpers. function closeDropdowns() { filterDropdownOpen = false; - resolutionDropdownOpen = false; sortDropdownOpen = false; } @@ -487,7 +356,6 @@ Item { filterDropdownX = x; filterDropdownY = y; filterDropdownWidth = width; - resolutionDropdownOpen = false; sortDropdownOpen = false; filterDropdownOpen = true; } @@ -497,28 +365,13 @@ Item { sortDropdownY = y; sortDropdownWidth = width; filterDropdownOpen = false; - resolutionDropdownOpen = false; sortDropdownOpen = true; } - function openResolutionDropdown(x, y, width) { - resolutionDropdownX = x; - resolutionDropdownY = y; - resolutionDropdownWidth = width; - filterDropdownOpen = false; - sortDropdownOpen = false; - resolutionDropdownOpen = true; - } - function applyFilterAction(action) { if (String(action).indexOf("type:") === 0) { selectedType = String(action).substring(5); - } - closeDropdowns(); - } - - function applyResolutionFilterAction(action) { - if (String(action).indexOf("res:") === 0) { + } else if (String(action).indexOf("res:") === 0) { selectedResolution = String(action).substring(4); } closeDropdowns(); @@ -540,25 +393,23 @@ Item { } const remembered = String(pluginApi?.pluginSettings?.panelLastSelectedPath || "").trim(); + root.lastPersistedPath = remembered; if (remembered.length > 0) { pendingPath = remembered; } } function persistPanelMemory(flushToDisk = false) { - if (!pluginApi) { - return; - } - - const current = String(pluginApi?.pluginSettings?.panelLastSelectedPath || ""); const next = String(pendingPath || ""); - if (current === next) { + if (root.lastPersistedPath === next) { return; } - - pluginApi.pluginSettings.panelLastSelectedPath = next; - if (flushToDisk) { - pluginApi.saveSettings(); + root.lastPersistedPath = next; + if (pluginApi) { + pluginApi.pluginSettings.panelLastSelectedPath = next; + if (flushToDisk) { + pluginApi.saveSettings(); + } } } @@ -577,8 +428,6 @@ Item { } function syncSelectionOptionsFromScreen() { - syncGlobalRuntimeOptions(); - const fallbackScreenName = root.singleScreenMode ? (Quickshell.screens[0]?.name || selectedScreenName) : selectedScreenName; if (root.singleScreenMode && selectedScreenName.length === 0 && fallbackScreenName.length > 0) { selectedScreenName = fallbackScreenName; @@ -593,6 +442,11 @@ Item { selectedScaling = String(screenCfg.scaling || defaults.defaultScaling || "fill"); } + function resetSelectionOptionsFromCurrentConfig() { + syncGlobalRuntimeOptions(); + syncSelectionOptionsFromScreen(); + } + // Wallpaper application and list state refresh. function applyPendingSelection() { const path = String(pendingPath || "").trim(); @@ -606,7 +460,8 @@ Item { : (root.singleScreenMode ? (Quickshell.screens[0]?.name || "") : (selectedScreenName || Quickshell.screens[0]?.name || "")); const colorApplyOptions = { "screenName": colorApplyScreen, - "scaling": selectedScaling + "scaling": selectedScaling, + "notify": true }; const options = { "scaling": selectedScaling, "clamp": selectedClamp }; @@ -619,10 +474,10 @@ Item { const customProperties = {}; for (const definition of wallpaperPropertyDefinitions) { const propertyKey = String(definition?.key || ""); - if (propertyKey.length === 0) { + if (propertyKey.length === 0 || !PropertyHelpers.isWritablePropertyType(definition?.type)) { continue; } - customProperties[propertyKey] = serializePropertyValue(propertyValueFor(definition), definition.type); + customProperties[propertyKey] = propertyEditorApi.serializePropertyValue(propertyEditorApi.propertyValueFor(definition), definition.type); } options.customProperties = customProperties; selectedPath = path; @@ -653,53 +508,26 @@ Item { function refreshVisibleWallpapers() { const query = String(searchText || "").trim().toLowerCase(); - let items = wallpaperItems.slice(); - - if (selectedType !== "all") { - items = items.filter(item => String(item.type || "unknown").toLowerCase() === selectedType); - } - - if (selectedResolution !== "all") { - items = items.filter(item => resolutionFilterKey(item.resolution) === selectedResolution); - } - - if (query.length > 0) { - items = items.filter(item => { - return String(item.name || "").toLowerCase().indexOf(query) >= 0 - || String(item.id || "").toLowerCase().indexOf(query) >= 0; - }); - } - - if (sortMode === "date") { - items.sort((a, b) => Number(a.mtime || 0) - Number(b.mtime || 0)); - } else if (sortMode === "size") { - items.sort((a, b) => Number(a.bytes || 0) - Number(b.bytes || 0)); - } else if (sortMode === "recent") { - items.sort((a, b) => Number(b.mtime || 0) - Number(a.mtime || 0)); - } else { - items.sort((a, b) => String(a.name || "").localeCompare(String(b.name || ""))); - } - - if (!sortAscending) { - items.reverse(); - } - - visibleWallpapers = items; + visibleWallpapers = WallpaperFilterHelpers.filteredAndSortedWallpapers(wallpaperItems, { + query: query, + selectedType: selectedType, + selectedResolution: selectedResolution, + sortMode: sortMode, + sortAscending: sortAscending, + resolutionFilterKey: resolutionFilterKey + }); Logger.d("LWEController", "Visible wallpapers refreshed", "count=", visibleWallpapers.length, "type=", selectedType, "resolution=", selectedResolution, "sort=", sortMode, "ascending=", sortAscending, "query=", query); } function refreshPagedWallpapers() { - const safePageSize = Math.max(1, Number(pageSize) || 1); - const totalPages = Math.max(1, Math.ceil(visibleWallpapers.length / safePageSize)); - const nextPage = Math.max(0, Math.min(currentPage, totalPages - 1)); + const pageState = WallpaperFilterHelpers.pagedWallpapers(visibleWallpapers, currentPage, pageSize); - if (nextPage !== currentPage) { - currentPage = nextPage; + if (pageState.page !== currentPage) { + currentPage = pageState.page; return; } - const startIndex = nextPage * safePageSize; - pagedWallpapers = visibleWallpapers.slice(startIndex, startIndex + safePageSize); + pagedWallpapers = pageState.items; } function resetPagination() { @@ -765,6 +593,7 @@ Item { return; } pendingPath = path; + sidebarVisible = true; } // Reactive state updates. @@ -776,8 +605,7 @@ Item { onCurrentPageChanged: refreshPagedWallpapers() onPageSizeChanged: refreshPagedWallpapers() onSearchTextChanged: { - refreshVisibleWallpapers(); - resetPagination(); + searchDebounceTimer.restart(); } onSelectedTypeChanged: { refreshVisibleWallpapers(); @@ -795,13 +623,12 @@ Item { refreshVisibleWallpapers(); resetPagination(); } - onSelectedScreenNameChanged: syncSelectionOptionsFromScreen() onPendingPathChanged: { persistPanelMemory(); - loadWallpaperProperties(pendingPath); + propertiesLoadTimer.restart(); } onWallpapersFolderChanged: { - if (!root.pluginApi) { + if (!root.pluginApi || !root.panelInitialized) { return; } mainInstance?.refreshWallpaperCache(true, false); @@ -811,26 +638,24 @@ Item { Logger.i("LWEController", "Panel opened", "screen=", selectedScreenName); rebuildScreenModel(); loadPanelMemory(); - syncSelectionOptionsFromScreen(); + resetSelectionOptionsFromCurrentConfig(); mainInstance?.refreshWallpaperCache(false, false); - loadWallpaperProperties(pendingPath); + panelInitialized = true; + propertiesLoadTimer.restart(); } Component.onDestruction: { persistPanelMemory(true); + if (pluginApi) { + pluginApi.pluginSettings.applyWallpaperColorsOnApply = root.applyWallpaperColorsOnApply; + pluginApi.saveSettings(); + } } // Keep dropdowns aligned with panel width changes. onWidthChanged: { - if (filterDropdownOpen) { - openFilterDropdown(filterDropdownX, filterDropdownY, filterDropdownWidth); - } - if (resolutionDropdownOpen) { - openResolutionDropdown(resolutionDropdownX, resolutionDropdownY, resolutionDropdownWidth); - } - if (sortDropdownOpen) { - openSortDropdown(sortDropdownX, sortDropdownY, sortDropdownWidth); - } + if (filterDropdownOpen) openFilterDropdown(filterDropdownX, filterDropdownY, filterDropdownWidth); + if (sortDropdownOpen) openSortDropdown(sortDropdownX, sortDropdownY, sortDropdownWidth); } // Main instance state hooks. @@ -859,11 +684,7 @@ Item { pluginApi: root.pluginApi mainInstance: root.mainInstance positionTarget: root - engineStatusBadgeText: root.engineStatusBadgeText - engineStatusBadgeFg: root.engineStatusBadgeFg - engineStatusBadgeBg: root.engineStatusBadgeBg scanningCompatibility: root.scanningCompatibility - pendingCompatibilityScan: root.pendingCompatibilityScan searchText: root.searchText selectedType: root.selectedType selectedResolution: root.selectedResolution @@ -872,8 +693,7 @@ Item { typeLabel: root.typeLabel resolutionFilterLabel: root.resolutionFilterLabel sortLabel: root.sortLabel - resolutionButtonWidth: 172 * Style.uiScaleRatio - filterButtonWidth: 172 * Style.uiScaleRatio + filterButtonWidth: 180 * Style.uiScaleRatio sortButtonWidth: 172 * Style.uiScaleRatio onCompatibilityQuickCheckRequested: root.startCompatibilityScan() onReloadRequested: { @@ -893,22 +713,8 @@ Item { pluginApi.togglePanel(screen); } } - onCloseRequested: { - const screen = pluginApi?.panelOpenScreen; - if (pluginApi) { - pluginApi.togglePanel(screen); - } - } - onPendingCompatibilityScanRequested: value => root.pendingCompatibilityScan = value onSearchTextUpdateRequested: text => root.searchText = text onClearSearchRequested: root.searchText = "" - onResolutionDropdownToggleRequested: (x, y, width) => { - if (resolutionDropdownOpen) { - root.closeDropdowns(); - } else { - root.openResolutionDropdown(x, y, width); - } - } onFilterDropdownToggleRequested: (x, y, width) => { if (filterDropdownOpen) { root.closeDropdowns(); @@ -954,10 +760,9 @@ Item { RowLayout { Layout.fillWidth: true Layout.fillHeight: true - Layout.topMargin: Style.marginXS spacing: Style.marginM - WallpaperGridSection { + GridSection { pluginApi: root.pluginApi mainInstance: root.mainInstance wallpapers: root.pagedWallpapers @@ -966,7 +771,10 @@ Item { scanningWallpapers: root.scanningWallpapers wallpaperItemsCount: root.wallpaperItems.length visibleWallpaperCount: root.visibleWallpapers.length - propertyLoadFailedByPath: root.wallpaperPropertyLoadFailedByPath + propertyCompatibilityBadgeIconForPath: root.propertyCompatibilityBadgeIconForPath + propertyCompatibilityBadgeTextForPath: root.propertyCompatibilityBadgeTextForPath + propertyCompatibilityBadgeColorForPath: root.propertyCompatibilityBadgeColorForPath + propertyCompatibilityBadgeBackgroundForPath: root.propertyCompatibilityBadgeBackgroundForPath currentPage: root.currentPage pageCount: root.pageCount currentPageDisplay: root.currentPageDisplay @@ -976,17 +784,23 @@ Item { resolutionBadgeIcon: root.resolutionBadgeIcon resolutionBadgeLabel: root.resolutionBadgeLabel typeLabel: root.typeLabel + typeBadgeIcon: root.typeBadgeIcon + dynamicBadgeIcon: root.dynamicBadgeIcon + badgeOrder: root.visibleBadgeOrder isVideoMotion: root.isVideoMotion onWallpaperActivated: path => root.applyPath(path) onPreviousPageRequested: root.goToPreviousPage() onNextPageRequested: root.goToNextPage() } - WallpaperSidebar { + Sidebar { pluginApi: root.pluginApi mainInstance: root.mainInstance - selectedWallpaperData: root.selectedWallpaperData - propertyLoadFailedByPath: root.wallpaperPropertyLoadFailedByPath + selectedWallpaperData: root.getSelectedWallpaperData() + propertyCompatibilityBadgeIconForPath: root.propertyCompatibilityBadgeIconForPath + propertyCompatibilityBadgeTextForPath: root.propertyCompatibilityBadgeTextForPath + propertyCompatibilityBadgeColorForPath: root.propertyCompatibilityBadgeColorForPath + propertyCompatibilityBadgeBackgroundForPath: root.propertyCompatibilityBadgeBackgroundForPath singleScreenMode: root.singleScreenMode applyAllDisplays: root.applyAllDisplays applyTargetExpanded: root.applyTargetExpanded @@ -1008,16 +822,13 @@ Item { resolutionBadgeIcon: root.resolutionBadgeIcon resolutionBadgeLabel: root.resolutionBadgeLabel typeLabel: root.typeLabel + typeBadgeIcon: root.typeBadgeIcon + dynamicBadgeIcon: root.dynamicBadgeIcon + badgeOrder: root.visibleBadgeOrder + showDescription: root.showSidebarDescription isVideoMotion: root.isVideoMotion formatBytes: root.formatBytes - workshopUrlForWallpaper: root.workshopUrlForWallpaper - propertyValueFor: root.propertyValueFor - numberOr: root.numberOr - formatSliderValue: root.formatSliderValue - comboChoicesFor: root.comboChoicesFor - ensureColorValue: root.ensureColorValue - serializePropertyValue: root.serializePropertyValue - setPropertyValue: root.setPropertyValue + propertyEditorApi: root.propertyEditorApi onApplyRequested: root.applyPendingSelection() onApplyAllDisplaysRequested: value => root._applyAllDisplays = value onApplyTargetExpandedRequested: value => root.applyTargetExpanded = value @@ -1031,22 +842,9 @@ Item { onSelectedDisableParallaxRequested: value => root.selectedDisableParallax = value onApplyWallpaperColorsOnApplyRequested: value => { root.applyWallpaperColorsOnApply = value; - if (pluginApi) { - pluginApi.pluginSettings.applyWallpaperColorsOnApply = value; - pluginApi.saveSettings(); - } - } - onWorkshopLinkRequested: workshopUrl => { - if (workshopUrl.length === 0) { - return; - } - - const screen = pluginApi?.panelOpenScreen; - if (pluginApi) { - pluginApi.togglePanel(screen); - } - Qt.openUrlExternally(workshopUrl); } + sidebarVisible: root.sidebarVisible + onSidebarVisibleRequested: value => root.sidebarVisible = value } } @@ -1071,23 +869,17 @@ Item { } } } - } - } PanelDropdowns { pluginApi: root.pluginApi - resolutionDropdownOpen: root.resolutionDropdownOpen filterDropdownOpen: root.filterDropdownOpen sortDropdownOpen: root.sortDropdownOpen selectedResolution: root.selectedResolution selectedType: root.selectedType sortMode: root.sortMode sortAscending: root.sortAscending - resolutionDropdownX: root.resolutionDropdownX - resolutionDropdownY: root.resolutionDropdownY - resolutionDropdownWidth: root.resolutionDropdownWidth filterDropdownX: root.filterDropdownX filterDropdownY: root.filterDropdownY filterDropdownWidth: root.filterDropdownWidth @@ -1095,7 +887,6 @@ Item { sortDropdownY: root.sortDropdownY sortDropdownWidth: root.sortDropdownWidth onCloseRequested: root.closeDropdowns() - onResolutionActionTriggered: action => root.applyResolutionFilterAction(action) onFilterActionTriggered: action => root.applyFilterAction(action) onSortActionTriggered: action => root.applySortAction(action) } @@ -1130,7 +921,7 @@ Item { root.wallpaperPropertyValues = ({}); root.setWallpaperPropertyLoadFailed(requestPath, true); root.wallpaperPropertyError = pluginApi?.tr("panel.propertiesLoadFailed"); - Logger.w("LWEController", "Wallpaper properties load failed", "path=", requestPath, "exitCode=", exitCode, "stderr=", wallpaperPropertyStderr.text); + Logger.w("LWEController", "Wallpaper properties load failed", "path=", requestPath, "exitCode=", exitCode); return; } @@ -1139,7 +930,7 @@ Item { root.wallpaperPropertyDefinitions = definitions; for (const definition of definitions) { if (definition.type === "combo") { - Logger.d("LWEController", "Combo property parsed", "key=", definition.key, "choices=", JSON.stringify(root.comboChoicesFor(definition))); + Logger.d("LWEController", "Combo property parsed", "key=", definition.key, "choices=", JSON.stringify(root.propertyEditorApi.comboChoicesFor(definition))); } } @@ -1148,7 +939,7 @@ Item { for (const definition of definitions) { const propertyKey = String(definition.key || ""); if (savedProperties[propertyKey] !== undefined) { - nextValues[propertyKey] = root.parsePropertyValue(savedProperties[propertyKey], definition.type); + nextValues[propertyKey] = PropertyHelpers.parsePropertyValue(savedProperties[propertyKey], definition.type, root.propertyTranslationApi.createColor); } else { nextValues[propertyKey] = definition.defaultValue; } @@ -1177,11 +968,10 @@ Item { const stderrText = String(compatibilityScanStderr.text || "").trim(); if (exitCode !== 0) { - if (stderrText.length > 0) { - Logger.w("LWEController", "Compatibility scan failed", "exitCode=", exitCode, "stderr=", stderrText); - } else { - Logger.w("LWEController", "Compatibility scan failed", "exitCode=", exitCode); - } + const msg = stderrText.length > 0 + ? "Compatibility scan failed" + ", stderr=" + stderrText + : "Compatibility scan failed"; + Logger.w("LWEController", msg, "exitCode=", exitCode); return; } @@ -1191,11 +981,29 @@ Item { pluginApi?.tr("panel.title"), pluginApi?.tr("panel.compatibilityQuickCheckFinished", { total: result.totalCount, - failed: result.failedCount + failed: result.failedCount, + limited: result.limitedCount }), - result.failedCount > 0 ? "alert-triangle" : "check" + result.failedCount > 0 ? "alert-triangle" : (result.limitedCount > 0 ? "alert-circle" : "check") ); } } + Timer { + id: searchDebounceTimer + interval: 250 + onTriggered: { + refreshVisibleWallpapers(); + resetPagination(); + } + } + + Timer { + id: propertiesLoadTimer + interval: 500 + onTriggered: { + loadWallpaperProperties(pendingPath); + } + } + } diff --git a/linux-wallpaperengine-controller/README.md b/linux-wallpaperengine-controller/README.md index 259eddbb5..1c3f7de29 100644 --- a/linux-wallpaperengine-controller/README.md +++ b/linux-wallpaperengine-controller/README.md @@ -5,9 +5,9 @@ A Noctalia plugin that provides a Wallpaper-Engine wallpaper selector powered by ## Features - Bar widget with quick access to the wallpaper selector panel -- Panel with wallpaper search by name or workshop ID, type filter, resolution filter, sorting, and pagination +- Panel with wallpaper search by name or ID, type filter, resolution filter, sorting, and pagination - Apply wallpapers to all displays or select a specific display target -- Sidebar preview with wallpaper badges for resolution, type, dynamic/static state, and possible compatibility issues, plus a clickable workshop ID +- Sidebar preview with wallpaper badges for resolution, type, dynamic/static state, and possible compatibility issues - Runtime controls for scaling, clamp mode, volume, mute, audio reactive effects, mouse input, and parallax - Optional `Sync wallpaper colors` flow that generates per-display color screenshots and applies Noctalia wallpaper colors for the configured source monitor - Settings resource tools to view and clear color image cache (while preserving cache entries for currently online displays) diff --git a/linux-wallpaperengine-controller/Settings.qml b/linux-wallpaperengine-controller/Settings.qml index df8162fea..c0f3bdb1d 100644 --- a/linux-wallpaperengine-controller/Settings.qml +++ b/linux-wallpaperengine-controller/Settings.qml @@ -3,7 +3,8 @@ import QtQuick.Layouts import Quickshell import Quickshell.Io -import "helpers/ColorCacheHelpers.js" as ColorCacheHelpers +import "helpers/shared/ColorCacheHelpers.js" as ColorCacheHelpers +import "helpers/panel/BadgeHelpers.js" as BadgeHelpers import qs.Commons import qs.Widgets @@ -11,13 +12,16 @@ import qs.Widgets ColumnLayout { id: root + width: parent ? parent.width : 640 * Style.uiScaleRatio + implicitHeight: 720 * Style.uiScaleRatio + Layout.fillWidth: true + property var pluginApi: null readonly property var cfg: pluginApi?.pluginSettings || ({}) readonly property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({}) property string editWallpapersFolder: cfg.wallpapersFolder ?? defaults.wallpapersFolder ?? "" - property string editAssetsDir: cfg.assetsDir ?? defaults.assetsDir ?? "" property string editIconColor: cfg.iconColor ?? defaults.iconColor ?? "none" property bool editEnableExtraPropertiesEditor: cfg.enableExtraPropertiesEditor ?? defaults.enableExtraPropertiesEditor ?? true property string editDefaultScaling: cfg.defaultScaling ?? defaults.defaultScaling ?? "fill" @@ -32,11 +36,17 @@ ColumnLayout { property bool editDefaultNoFullscreenPause: cfg.defaultNoFullscreenPause ?? defaults.defaultNoFullscreenPause ?? false property bool editDefaultFullscreenPauseOnlyActive: cfg.defaultFullscreenPauseOnlyActive ?? defaults.defaultFullscreenPauseOnlyActive ?? false property bool editAutoApplyOnStartup: cfg.autoApplyOnStartup ?? defaults.autoApplyOnStartup ?? true - property int editWallpaperScanCacheMinutes: cfg.wallpaperScanCacheMinutes ?? defaults.wallpaperScanCacheMinutes ?? 5 + property bool editShowSidebarDescription: cfg.showSidebarDescription ?? defaults.showSidebarDescription ?? false + property int editWallpaperScanCacheMinutes: cfg.wallpaperScanCacheMinutes ?? defaults.wallpaperScanCacheMinutes ?? 10 + readonly property var defaultBadgeOrder: BadgeHelpers.normalizedDefaultOrder(defaults.badgeOrder) + readonly property var defaultBadgeEnabled: BadgeHelpers.normalizedDefaultEnabled(defaults.badgeEnabled) + property var editBadgeOrder: BadgeHelpers.normalizeBadgeOrder(cfg.badgeOrder, defaultBadgeOrder) + property var editBadgeEnabled: BadgeHelpers.normalizeBadgeEnabled(cfg.badgeEnabled, defaultBadgeEnabled) property bool scanning: false property bool refreshingCacheSize: false property bool clearingCache: false property string cacheSizeLabel: pluginApi?.tr("settings.cache.sizeUnknown") + readonly property string pluginCacheDir: ColorCacheHelpers.pluginCacheDir( Settings.cacheDir, pluginApi?.manifest?.id || pluginApi?.pluginId || "linux-wallpaperengine-controller" @@ -44,6 +54,33 @@ ColumnLayout { spacing: Style.marginL + function badgeLabel(key) { + return BadgeHelpers.settingsBadgeLabel(key, badgeKey => pluginApi?.tr(badgeKey)); + } + + function badgeIcon(key) { + return BadgeHelpers.settingsBadgeIcon(key); + } + + function setBadgeEnabled(key, enabled) { + const normalizedKey = String(key || "").trim(); + const next = Object.assign({}, root.editBadgeEnabled || ({})); + next[normalizedKey] = enabled; + root.editBadgeEnabled = next; + } + + function moveBadge(fromIndex, toIndex) { + if (fromIndex < 0 || toIndex < 0 || fromIndex >= editBadgeOrder.length || toIndex >= editBadgeOrder.length || fromIndex === toIndex) { + return; + } + + const next = editBadgeOrder.slice(); + const moved = next[fromIndex]; + next.splice(fromIndex, 1); + next.splice(toIndex, 0, moved); + editBadgeOrder = next; + } + function refreshCacheSize() { if (root.refreshingCacheSize) { return; @@ -71,302 +108,635 @@ ColumnLayout { Component.onCompleted: refreshCacheSize() - NBox { + NTabBar { + id: tabBar Layout.fillWidth: true - implicitHeight: interfaceSection.implicitHeight + Style.marginL * 2 - - ColumnLayout { - id: interfaceSection - anchors.fill: parent - anchors.margins: Style.marginL - spacing: Style.marginM + distributeEvenly: true + currentIndex: tabView.currentIndex - NText { - Layout.fillWidth: true - text: pluginApi?.tr("settings.category.interfaceTitle") - color: Color.mOnSurface - font.weight: Font.Bold - } + NTabButton { + text: pluginApi?.tr("settings.category.interfaceTitle") + tabIndex: 0 + checked: tabBar.currentIndex === 0 + } - NColorChoice { - Layout.fillWidth: true - label: pluginApi?.tr("settings.iconColor.label") - description: pluginApi?.tr("settings.iconColor.description") - currentKey: root.editIconColor - onSelected: key => root.editIconColor = key - } + NTabButton { + text: pluginApi?.tr("settings.category.compatibilityTitle") + tabIndex: 1 + checked: tabBar.currentIndex === 1 + } - NToggle { - Layout.fillWidth: true - label: pluginApi?.tr("settings.enableExtraPropertiesEditor.label") - description: pluginApi?.tr("settings.enableExtraPropertiesEditor.description") - checked: root.editEnableExtraPropertiesEditor - onToggled: checked => root.editEnableExtraPropertiesEditor = checked - } + NTabButton { + text: pluginApi?.tr("settings.defaults.title") + tabIndex: 2 + checked: tabBar.currentIndex === 2 } } - NBox { + NTabView { + id: tabView Layout.fillWidth: true - implicitHeight: resourcesSection.implicitHeight + Style.marginL * 2 + Layout.fillHeight: true + Layout.preferredHeight: 640 * Style.uiScaleRatio + Layout.minimumHeight: 640 * Style.uiScaleRatio + currentIndex: tabBar.currentIndex + + NScrollView { + id: interfaceScroll + Layout.fillWidth: true + Layout.fillHeight: true + contentWidth: availableWidth + showScrollbarWhenScrollable: true + gradientColor: "transparent" + + ColumnLayout { + width: interfaceScroll.availableWidth + Layout.fillWidth: true + spacing: Style.marginL - ColumnLayout { - id: resourcesSection - anchors.fill: parent - anchors.margins: Style.marginL - spacing: Style.marginM + ColumnLayout { + id: interfaceSection + Layout.fillWidth: true + spacing: Style.marginM + + NColorChoice { + Layout.fillWidth: true + label: pluginApi?.tr("settings.iconColor.label") + description: pluginApi?.tr("settings.iconColor.description") + currentKey: root.editIconColor + onSelected: key => root.editIconColor = key + } - NText { - Layout.fillWidth: true - text: pluginApi?.tr("settings.category.compatibilityTitle") - color: Color.mOnSurface - font.weight: Font.Bold - } + NToggle { + Layout.fillWidth: true + label: pluginApi?.tr("settings.enableExtraPropertiesEditor.label") + description: pluginApi?.tr("settings.enableExtraPropertiesEditor.description") + checked: root.editEnableExtraPropertiesEditor + onToggled: checked => root.editEnableExtraPropertiesEditor = checked + } - NTextInput { - Layout.fillWidth: true - label: pluginApi?.tr("settings.wallpapersFolder.label") - description: pluginApi?.tr("settings.wallpapersFolder.description") - placeholderText: pluginApi?.tr("settings.wallpapersFolder.placeholder") - text: root.editWallpapersFolder - onTextChanged: root.editWallpapersFolder = text - } + NToggle { + Layout.fillWidth: true + label: pluginApi?.tr("settings.showSidebarDescription.label") + description: pluginApi?.tr("settings.showSidebarDescription.description") + checked: root.editShowSidebarDescription + onToggled: checked => root.editShowSidebarDescription = checked + } - NButton { - Layout.fillWidth: true - text: pluginApi?.tr("settings.wallpapersFolder.scan") - icon: root.scanning ? "loader" : "search" - enabled: !root.scanning - onClicked: { - root.scanning = true; - scanProcess.running = true; - } - } + NText { + Layout.fillWidth: true + text: pluginApi?.tr("settings.badges.title") + color: Color.mOnSurface + font.weight: Font.Bold + } - NTextInput { - Layout.fillWidth: true - label: pluginApi?.tr("settings.assetsDir.label") - description: pluginApi?.tr("settings.assetsDir.description") - placeholderText: pluginApi?.tr("settings.assetsDir.placeholder") - text: root.editAssetsDir - onTextChanged: root.editAssetsDir = text - } + NText { + Layout.fillWidth: true + text: pluginApi?.tr("settings.badges.description") + color: Color.mOnSurfaceVariant + wrapMode: Text.Wrap + } - NSpinBox { - Layout.fillWidth: true - label: pluginApi?.tr("settings.wallpaperScanCacheMinutes.label") - description: pluginApi?.tr("settings.wallpaperScanCacheMinutes.description") - from: 0 - to: 1440 - stepSize: 1 - value: root.editWallpaperScanCacheMinutes - suffix: pluginApi?.tr("settings.units.minutes") - onValueChanged: if (value !== root.editWallpaperScanCacheMinutes) root.editWallpaperScanCacheMinutes = value + Item { + id: badgeEditorContainer + Layout.fillWidth: true + implicitHeight: badgeEditorColumn.implicitHeight + + property int draggedIndex: -1 + property int dropTargetIndex: -1 + property bool dragStarted: false + property bool potentialDrag: false + property point startPos: Qt.point(0, 0) + readonly property real dragThreshold: 8 + + function cardRect(i) { + const card = badgeCardRepeater.itemAt(i); + if (!card) return Qt.rect(0, 0, 0, 0); + const mapped = card.mapToItem(badgeEditorContainer, 0, 0); + return Qt.rect(mapped.x, mapped.y, card.width, card.height); + } + + function computeDropIndex(mouseY) { + let best = draggedIndex; + let bestDist = Infinity; + const count = root.editBadgeOrder.length; + + for (let i = 0; i < count; ++i) { + if (i === draggedIndex) continue; + const rect = cardRect(i); + if (rect.height <= 0) continue; + const centerY = rect.y + rect.height / 2; + const dist = Math.abs(mouseY - centerY); + if (dist < bestDist) { + bestDist = dist; + best = mouseY < centerY ? i : i + 1; + } + } + + if (best > draggedIndex) best = best - 1; + return Math.max(0, Math.min(count - 1, best)); + } + + function updateDropIndicator() { + const count = root.editBadgeOrder.length; + if (!dragStarted || dropTargetIndex < 0 || count <= 0) return; + const refIndex = Math.min(dropTargetIndex, count - 1); + const refCard = badgeCardRepeater.itemAt(refIndex); + if (!refCard) return; + const mapped = refCard.mapToItem(badgeEditorContainer, 0, 0); + dropIndicator.width = Math.max(0, refCard.width - Style.marginL * 2); + dropIndicator.x = mapped.x + Style.marginL; + dropIndicator.y = dropTargetIndex <= draggedIndex + ? mapped.y - Style.marginXS + : mapped.y + refCard.height - dropIndicator.height + Style.marginXS; + } + + function resetDrag() { + draggedIndex = -1; + dropTargetIndex = -1; + dragStarted = false; + potentialDrag = false; + dragGhost.visible = false; + } + + Rectangle { + id: dropIndicator + width: 0 + height: 3 + radius: Style.radiusXS + color: Color.mPrimary + visible: badgeEditorContainer.dragStarted && badgeEditorContainer.dropTargetIndex !== -1 + z: 10 + } + + Rectangle { + id: dragGhost + width: Math.min(badgeEditorContainer.width, ghostRow.implicitWidth + Style.marginL * 2) + height: ghostRow.implicitHeight + Style.marginM * 2 + radius: Style.radiusM + color: Color.mPrimary + border.width: Style.borderS + border.color: Qt.alpha(Color.mOnPrimary, 0.18) + opacity: 0.9 + visible: false + z: 20 + + RowLayout { + id: ghostRow + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginM + + NIcon { + icon: "grip-vertical" + pointSize: Style.fontSizeS + color: Qt.alpha(Color.mOnPrimary, 0.75) + } + + NIcon { + icon: root.badgeIcon(modelData) + pointSize: Style.fontSizeM + color: Color.mOnPrimary + } + + NText { + id: ghostText + Layout.fillWidth: true + text: "" + color: Color.mOnPrimary + font.weight: Font.Medium + elide: Text.ElideRight + } + } + } + + ColumnLayout { + id: badgeEditorColumn + width: parent.width + spacing: Style.marginS + + Repeater { + id: badgeCardRepeater + model: root.editBadgeOrder + + Rectangle { + required property int index + required property var modelData + Layout.fillWidth: true + width: badgeEditorColumn.width + height: badgeCardRow.implicitHeight + Style.marginS * 2 + radius: Style.radiusM + color: Qt.alpha(Color.mSurfaceVariant, 0.6) + border.width: Style.borderS + border.color: badgeDragHandleMouseArea.containsMouse + ? Qt.alpha(Color.mPrimary, 0.55) + : Qt.alpha(Color.mOutline, 0.35) + opacity: badgeEditorContainer.draggedIndex === index && badgeEditorContainer.dragStarted ? 0.3 : 1.0 + + RowLayout { + id: badgeCardRow + anchors.fill: parent + anchors.margins: Style.marginS + spacing: Style.marginS + + Item { + Layout.preferredWidth: 24 * Style.uiScaleRatio + Layout.preferredHeight: 34 * Style.uiScaleRatio + + NIcon { + anchors.centerIn: parent + icon: "grip-vertical" + pointSize: Style.fontSizeS + color: badgeDragHandleMouseArea.containsMouse || badgeEditorContainer.draggedIndex === index + ? Color.mPrimary + : Color.mOnSurfaceVariant + } + + MouseArea { + id: badgeDragHandleMouseArea + anchors.fill: parent + acceptedButtons: Qt.LeftButton + cursorShape: badgeEditorContainer.dragStarted && badgeEditorContainer.draggedIndex === index + ? Qt.ClosedHandCursor + : Qt.OpenHandCursor + hoverEnabled: true + preventStealing: true + + onPressed: mouse => { + const mapped = badgeDragHandleMouseArea.mapToItem(badgeEditorContainer, mouse.x, mouse.y); + badgeEditorContainer.startPos = Qt.point(mapped.x, mapped.y); + badgeEditorContainer.draggedIndex = index; + badgeEditorContainer.dropTargetIndex = index; + badgeEditorContainer.dragStarted = false; + badgeEditorContainer.potentialDrag = true; + mouse.accepted = true; + } + + onPositionChanged: mouse => { + if (!badgeEditorContainer.potentialDrag || badgeEditorContainer.draggedIndex !== index) return; + const mapped = badgeDragHandleMouseArea.mapToItem(badgeEditorContainer, mouse.x, mouse.y); + const dx = mapped.x - badgeEditorContainer.startPos.x; + const dy = mapped.y - badgeEditorContainer.startPos.y; + const dist = Math.sqrt(dx * dx + dy * dy); + if (!badgeEditorContainer.dragStarted && dist > badgeEditorContainer.dragThreshold) { + badgeEditorContainer.dragStarted = true; + ghostText.text = root.badgeLabel(modelData); + dragGhost.visible = true; + } + if (badgeEditorContainer.dragStarted) { + dragGhost.x = mapped.x - dragGhost.width / 2; + dragGhost.y = mapped.y - dragGhost.height / 2; + badgeEditorContainer.dropTargetIndex = badgeEditorContainer.computeDropIndex(mapped.y); + badgeEditorContainer.updateDropIndicator(); + } + } + + onReleased: mouse => { + if (badgeEditorContainer.dragStarted) { + const from = badgeEditorContainer.draggedIndex; + const to = badgeEditorContainer.dropTargetIndex; + if (to !== -1 && to !== from) { + root.moveBadge(from, to); + } + } + badgeEditorContainer.resetDrag(); + mouse.accepted = true; + } + + onCanceled: badgeEditorContainer.resetDrag() + } + } + + Rectangle { + Layout.preferredWidth: 34 * Style.uiScaleRatio + Layout.preferredHeight: 34 * Style.uiScaleRatio + radius: Style.radiusS + color: Qt.alpha(Color.mPrimary, 0.12) + + NIcon { + anchors.centerIn: parent + icon: root.badgeIcon(modelData) + pointSize: Style.fontSizeM + color: Color.mPrimary + } + } + + NText { + Layout.fillWidth: true + text: root.badgeLabel(modelData) + color: Color.mOnSurface + font.weight: Font.Medium + elide: Text.ElideRight + } + + NIconButton { + icon: "chevron-up" + tooltipText: pluginApi?.tr("settings.badges.moveUp") + enabled: index > 0 + onClicked: root.moveBadge(index, index - 1) + } + + NIconButton { + icon: "chevron-down" + tooltipText: pluginApi?.tr("settings.badges.moveDown") + enabled: index < root.editBadgeOrder.length - 1 + onClicked: root.moveBadge(index, index + 1) + } + + NToggle { + Layout.alignment: Qt.AlignVCenter + checked: !!root.editBadgeEnabled[String(modelData || "")] + onToggled: checked => root.setBadgeEnabled(modelData, checked) + } + } + } + } + } + } + } } + } - NText { - Layout.fillWidth: true - text: pluginApi?.tr("settings.cache.currentSize", { size: root.cacheSizeLabel }) - color: Color.mOnSurfaceVariant - wrapMode: Text.Wrap - } + NScrollView { + id: resourcesScroll + Layout.fillWidth: true + Layout.fillHeight: true + contentWidth: availableWidth + showScrollbarWhenScrollable: true + gradientColor: "transparent" - RowLayout { + ColumnLayout { + width: resourcesScroll.availableWidth Layout.fillWidth: true - spacing: Style.marginS + spacing: Style.marginL + + NText { + Layout.fillWidth: true + text: pluginApi?.tr("settings.category.compatibilityTitle") + color: Color.mOnSurface + font.weight: Font.Bold + font.pointSize: Style.fontSizeL + } - NButton { + NText { Layout.fillWidth: true - text: pluginApi?.tr("settings.cache.refresh") - icon: root.refreshingCacheSize ? "loader" : "refresh" - enabled: !root.refreshingCacheSize && !root.clearingCache - onClicked: root.refreshCacheSize() + text: pluginApi?.tr("settings.resourcesIntro") + color: Color.mOnSurfaceVariant + wrapMode: Text.Wrap } - NButton { + ColumnLayout { + id: resourcesSection Layout.fillWidth: true - text: pluginApi?.tr("settings.cache.clear") - icon: root.clearingCache ? "loader" : "trash" - enabled: !root.clearingCache && !root.refreshingCacheSize - onClicked: { - root.clearingCache = true; - clearCacheProcess.command = root.clearCacheCommand(); - clearCacheProcess.running = true; + spacing: Style.marginM + + NTextInput { + Layout.fillWidth: true + label: pluginApi?.tr("settings.wallpapersFolder.label") + description: pluginApi?.tr("settings.wallpapersFolder.description") + placeholderText: pluginApi?.tr("settings.wallpapersFolder.placeholder") + text: root.editWallpapersFolder + onTextChanged: root.editWallpapersFolder = text + } + + NButton { + Layout.fillWidth: true + text: pluginApi?.tr("settings.wallpapersFolder.scan") + icon: root.scanning ? "loader" : "search" + enabled: !root.scanning + onClicked: { + root.scanning = true; + scanProcess.running = true; + } + } + + NSpinBox { + Layout.fillWidth: true + label: pluginApi?.tr("settings.wallpaperScanCacheMinutes.label") + description: pluginApi?.tr("settings.wallpaperScanCacheMinutes.description") + from: 0 + to: 1440 + stepSize: 1 + value: root.editWallpaperScanCacheMinutes + suffix: pluginApi?.tr("settings.units.minutes") + onValueChanged: if (value !== root.editWallpaperScanCacheMinutes) root.editWallpaperScanCacheMinutes = value + } + + NText { + Layout.fillWidth: true + text: pluginApi?.tr("settings.cache.currentSize", { size: root.cacheSizeLabel }) + color: Color.mOnSurfaceVariant + wrapMode: Text.Wrap + } + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS + + NButton { + Layout.fillWidth: true + text: pluginApi?.tr("settings.cache.refresh") + icon: root.refreshingCacheSize ? "loader" : "refresh" + enabled: !root.refreshingCacheSize && !root.clearingCache + onClicked: root.refreshCacheSize() + } + + NButton { + Layout.fillWidth: true + text: pluginApi?.tr("settings.cache.clear") + icon: root.clearingCache ? "loader" : "trash" + enabled: !root.clearingCache && !root.refreshingCacheSize + onClicked: { + root.clearingCache = true; + clearCacheProcess.command = root.clearCacheCommand(); + clearCacheProcess.running = true; + } + } } } } } - } - NBox { - Layout.fillWidth: true - implicitHeight: defaultsSection.implicitHeight + Style.marginL * 2 + NScrollView { + id: defaultsScroll + Layout.fillWidth: true + Layout.fillHeight: true + contentWidth: availableWidth + showScrollbarWhenScrollable: true + gradientColor: "transparent" - ColumnLayout { - id: defaultsSection - anchors.fill: parent - anchors.margins: Style.marginL - spacing: Style.marginM - - NText { + ColumnLayout { + width: defaultsScroll.availableWidth Layout.fillWidth: true - text: pluginApi?.tr("settings.defaults.title") - color: Color.mOnSurface - font.weight: Font.Bold - } + spacing: Style.marginL - NText { - Layout.fillWidth: true - text: pluginApi?.tr("settings.defaults.description") - color: Color.mOnSurfaceVariant - wrapMode: Text.Wrap - } + NText { + Layout.fillWidth: true + text: pluginApi?.tr("settings.defaults.title") + color: Color.mOnSurface + font.weight: Font.Bold + font.pointSize: Style.fontSizeL + } - NText { - Layout.fillWidth: true - text: pluginApi?.tr("settings.category.performanceTitle") - color: Color.mOnSurface - font.weight: Font.Bold - } + NText { + Layout.fillWidth: true + text: pluginApi?.tr("settings.defaults.description") + color: Color.mOnSurfaceVariant + wrapMode: Text.Wrap + } - NSpinBox { - id: defaultFpsSpinBox - Layout.fillWidth: true - label: pluginApi?.tr("settings.defaultFps.label") - description: pluginApi?.tr("settings.defaultFps.description") - from: 1 - to: 240 - stepSize: 1 - value: root.editDefaultFps - suffix: pluginApi?.tr("settings.units.fps") - onValueChanged: if (value !== root.editDefaultFps) root.editDefaultFps = value - } + ColumnLayout { + id: defaultsSection + Layout.fillWidth: true + spacing: Style.marginM - NToggle { - Layout.fillWidth: true - label: pluginApi?.tr("settings.defaultNoFullscreenPause.label") - description: pluginApi?.tr("settings.defaultNoFullscreenPause.description") - checked: root.editDefaultNoFullscreenPause - onToggled: checked => root.editDefaultNoFullscreenPause = checked - } + NText { + Layout.fillWidth: true + text: pluginApi?.tr("settings.category.performanceTitle") + color: Color.mOnSurface + font.weight: Font.Bold + } - NToggle { - Layout.fillWidth: true - label: pluginApi?.tr("settings.defaultFullscreenPauseOnlyActive.label") - description: pluginApi?.tr("settings.defaultFullscreenPauseOnlyActive.description") - checked: root.editDefaultFullscreenPauseOnlyActive - onToggled: checked => root.editDefaultFullscreenPauseOnlyActive = checked - } + NSpinBox { + id: defaultFpsSpinBox + Layout.fillWidth: true + label: pluginApi?.tr("settings.defaultFps.label") + description: pluginApi?.tr("settings.defaultFps.description") + from: 1 + to: 240 + stepSize: 1 + value: root.editDefaultFps + suffix: pluginApi?.tr("settings.units.fps") + onValueChanged: if (value !== root.editDefaultFps) root.editDefaultFps = value + } - NToggle { - Layout.fillWidth: true - label: pluginApi?.tr("settings.autoApplyOnStartup.label") - description: pluginApi?.tr("settings.autoApplyOnStartup.description") - checked: root.editAutoApplyOnStartup - onToggled: checked => root.editAutoApplyOnStartup = checked - } + NToggle { + Layout.fillWidth: true + label: pluginApi?.tr("settings.defaultNoFullscreenPause.label") + description: pluginApi?.tr("settings.defaultNoFullscreenPause.description") + checked: root.editDefaultNoFullscreenPause + onToggled: checked => root.editDefaultNoFullscreenPause = checked + } - NDivider { - Layout.fillWidth: true - } + NToggle { + Layout.fillWidth: true + label: pluginApi?.tr("settings.defaultFullscreenPauseOnlyActive.label") + description: pluginApi?.tr("settings.defaultFullscreenPauseOnlyActive.description") + checked: root.editDefaultFullscreenPauseOnlyActive + onToggled: checked => root.editDefaultFullscreenPauseOnlyActive = checked + } - NText { - Layout.fillWidth: true - text: pluginApi?.tr("settings.category.audioTitle") - color: Color.mOnSurface - font.weight: Font.Bold - } + NToggle { + Layout.fillWidth: true + label: pluginApi?.tr("settings.autoApplyOnStartup.label") + description: pluginApi?.tr("settings.autoApplyOnStartup.description") + checked: root.editAutoApplyOnStartup + onToggled: checked => root.editAutoApplyOnStartup = checked + } - NToggle { - Layout.fillWidth: true - label: pluginApi?.tr("settings.defaultMuted.label") - description: pluginApi?.tr("settings.defaultMuted.description") - checked: root.editDefaultMuted - onToggled: checked => root.editDefaultMuted = checked - } + NDivider { + Layout.fillWidth: true + } - NSpinBox { - id: defaultVolumeSpinBox - Layout.fillWidth: true - label: pluginApi?.tr("settings.defaultVolume.label") - description: pluginApi?.tr("settings.defaultVolume.description") - from: 0 - to: 100 - stepSize: 1 - suffix: pluginApi?.tr("settings.units.percent") - value: root.editDefaultVolume - enabled: !root.editDefaultMuted - onValueChanged: if (value !== root.editDefaultVolume) root.editDefaultVolume = value - } + NText { + Layout.fillWidth: true + text: pluginApi?.tr("settings.category.audioTitle") + color: Color.mOnSurface + font.weight: Font.Bold + } - NToggle { - Layout.fillWidth: true - label: pluginApi?.tr("settings.defaultAudioReactiveEffects.label") - description: pluginApi?.tr("settings.defaultAudioReactiveEffects.description") - checked: root.editDefaultAudioReactiveEffects - onToggled: checked => root.editDefaultAudioReactiveEffects = checked - } + NToggle { + Layout.fillWidth: true + label: pluginApi?.tr("settings.defaultMuted.label") + description: pluginApi?.tr("settings.defaultMuted.description") + checked: root.editDefaultMuted + onToggled: checked => root.editDefaultMuted = checked + } - NToggle { - Layout.fillWidth: true - label: pluginApi?.tr("settings.defaultNoAutomute.label") - description: pluginApi?.tr("settings.defaultNoAutomute.description") - checked: root.editDefaultNoAutomute - onToggled: checked => root.editDefaultNoAutomute = checked - } + NSpinBox { + id: defaultVolumeSpinBox + Layout.fillWidth: true + label: pluginApi?.tr("settings.defaultVolume.label") + description: pluginApi?.tr("settings.defaultVolume.description") + from: 0 + to: 100 + stepSize: 1 + suffix: pluginApi?.tr("settings.units.percent") + value: root.editDefaultVolume + enabled: !root.editDefaultMuted + onValueChanged: if (value !== root.editDefaultVolume) root.editDefaultVolume = value + } - NDivider { - Layout.fillWidth: true - } + NToggle { + Layout.fillWidth: true + label: pluginApi?.tr("settings.defaultAudioReactiveEffects.label") + description: pluginApi?.tr("settings.defaultAudioReactiveEffects.description") + checked: root.editDefaultAudioReactiveEffects + onToggled: checked => root.editDefaultAudioReactiveEffects = checked + } - NText { - Layout.fillWidth: true - text: pluginApi?.tr("settings.category.displayTitle") - color: Color.mOnSurface - font.weight: Font.Bold - } + NToggle { + Layout.fillWidth: true + label: pluginApi?.tr("settings.defaultNoAutomute.label") + description: pluginApi?.tr("settings.defaultNoAutomute.description") + checked: root.editDefaultNoAutomute + onToggled: checked => root.editDefaultNoAutomute = checked + } - NComboBox { - Layout.fillWidth: true - label: pluginApi?.tr("settings.defaultScaling.label") - description: pluginApi?.tr("settings.defaultScaling.description") - model: [ - { "key": "fill", "name": pluginApi?.tr("panel.scalingFill") }, - { "key": "fit", "name": pluginApi?.tr("panel.scalingFit") }, - { "key": "stretch", "name": pluginApi?.tr("panel.scalingStretch") }, - { "key": "default", "name": pluginApi?.tr("panel.scalingDefault") } - ] - currentKey: root.editDefaultScaling - onSelected: key => root.editDefaultScaling = key - } + NDivider { + Layout.fillWidth: true + } - NComboBox { - Layout.fillWidth: true - label: pluginApi?.tr("settings.defaultClamp.label") - description: pluginApi?.tr("settings.defaultClamp.description") - model: [ - { "key": "clamp", "name": pluginApi?.tr("panel.clampClamp") }, - { "key": "border", "name": pluginApi?.tr("panel.clampBorder") }, - { "key": "repeat", "name": pluginApi?.tr("panel.clampRepeat") } - ] - currentKey: root.editDefaultClamp - onSelected: key => root.editDefaultClamp = key - } + NText { + Layout.fillWidth: true + text: pluginApi?.tr("settings.category.displayTitle") + color: Color.mOnSurface + font.weight: Font.Bold + } - NToggle { - Layout.fillWidth: true - label: pluginApi?.tr("settings.defaultDisableMouse.label") - description: pluginApi?.tr("settings.defaultDisableMouse.description") - checked: root.editDefaultDisableMouse - onToggled: checked => root.editDefaultDisableMouse = checked - } + NComboBox { + Layout.fillWidth: true + label: pluginApi?.tr("settings.defaultScaling.label") + description: pluginApi?.tr("settings.defaultScaling.description") + model: [ + { "key": "fill", "name": pluginApi?.tr("panel.scalingFill") }, + { "key": "fit", "name": pluginApi?.tr("panel.scalingFit") }, + { "key": "stretch", "name": pluginApi?.tr("panel.scalingStretch") }, + { "key": "default", "name": pluginApi?.tr("panel.scalingDefault") } + ] + currentKey: root.editDefaultScaling + onSelected: key => root.editDefaultScaling = key + } - NToggle { - Layout.fillWidth: true - label: pluginApi?.tr("settings.defaultDisableParallax.label") - description: pluginApi?.tr("settings.defaultDisableParallax.description") - checked: root.editDefaultDisableParallax - onToggled: checked => root.editDefaultDisableParallax = checked + NComboBox { + Layout.fillWidth: true + label: pluginApi?.tr("settings.defaultClamp.label") + description: pluginApi?.tr("settings.defaultClamp.description") + model: [ + { "key": "clamp", "name": pluginApi?.tr("panel.clampClamp") }, + { "key": "border", "name": pluginApi?.tr("panel.clampBorder") }, + { "key": "repeat", "name": pluginApi?.tr("panel.clampRepeat") } + ] + currentKey: root.editDefaultClamp + onSelected: key => root.editDefaultClamp = key + } + + NToggle { + Layout.fillWidth: true + label: pluginApi?.tr("settings.defaultDisableMouse.label") + description: pluginApi?.tr("settings.defaultDisableMouse.description") + checked: root.editDefaultDisableMouse + onToggled: checked => root.editDefaultDisableMouse = checked + } + + NToggle { + Layout.fillWidth: true + label: pluginApi?.tr("settings.defaultDisableParallax.label") + description: pluginApi?.tr("settings.defaultDisableParallax.description") + checked: root.editDefaultDisableParallax + onToggled: checked => root.editDefaultDisableParallax = checked + } + } } } } @@ -382,7 +752,6 @@ ColumnLayout { } pluginApi.pluginSettings.wallpapersFolder = root.editWallpapersFolder; - pluginApi.pluginSettings.assetsDir = root.editAssetsDir; pluginApi.pluginSettings.iconColor = root.editIconColor; pluginApi.pluginSettings.enableExtraPropertiesEditor = root.editEnableExtraPropertiesEditor; pluginApi.pluginSettings.defaultScaling = root.editDefaultScaling; @@ -397,13 +766,14 @@ ColumnLayout { pluginApi.pluginSettings.defaultNoFullscreenPause = root.editDefaultNoFullscreenPause; pluginApi.pluginSettings.defaultFullscreenPauseOnlyActive = root.editDefaultFullscreenPauseOnlyActive; pluginApi.pluginSettings.autoApplyOnStartup = root.editAutoApplyOnStartup; + pluginApi.pluginSettings.showSidebarDescription = root.editShowSidebarDescription; pluginApi.pluginSettings.wallpaperScanCacheMinutes = root.editWallpaperScanCacheMinutes; + pluginApi.pluginSettings.badgeOrder = BadgeHelpers.normalizeBadgeOrder(root.editBadgeOrder, root.defaultBadgeOrder); + pluginApi.pluginSettings.badgeEnabled = BadgeHelpers.normalizeBadgeEnabled(root.editBadgeEnabled, root.defaultBadgeEnabled); pluginApi.saveSettings(); - Logger.d("LWEController", "Settings saved", "wallpapersFolder=", root.editWallpapersFolder, "assetsDir=", root.editAssetsDir, "defaultScaling=", root.editDefaultScaling, "defaultClamp=", root.editDefaultClamp, "defaultFps=", defaultFpsSpinBox.value, "defaultVolume=", defaultVolumeSpinBox.value, "defaultMuted=", root.editDefaultMuted, "defaultAudioReactiveEffects=", root.editDefaultAudioReactiveEffects, "defaultNoAutomute=", root.editDefaultNoAutomute, "defaultDisableMouse=", root.editDefaultDisableMouse, "defaultDisableParallax=", root.editDefaultDisableParallax, "defaultNoFullscreenPause=", root.editDefaultNoFullscreenPause, "defaultFullscreenPauseOnlyActive=", root.editDefaultFullscreenPauseOnlyActive, "autoApplyOnStartup=", root.editAutoApplyOnStartup, "wallpaperScanCacheMinutes=", root.editWallpaperScanCacheMinutes); if (pluginApi.mainInstance) { - Logger.d("LWEController", "Refreshing wallpaper cache and reloading engine after settings save"); pluginApi.mainInstance.refreshWallpaperCache(true, false); if (pluginApi.mainInstance.hasAnyConfiguredWallpaper()) { pluginApi.mainInstance.reload(); @@ -436,6 +806,7 @@ ColumnLayout { Process { id: cacheSizeProcess running: false + command: { const pluginDir = root.pluginApi?.pluginDir || ""; const scriptPath = pluginDir + "/scripts/get-cache-size-bytes.sh"; diff --git a/linux-wallpaperengine-controller/components/WallpaperGridSection.qml b/linux-wallpaperengine-controller/components/WallpaperGridSection.qml deleted file mode 100644 index c67c1da02..000000000 --- a/linux-wallpaperengine-controller/components/WallpaperGridSection.qml +++ /dev/null @@ -1,324 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import QtMultimedia - -import qs.Commons -import qs.Widgets - -ColumnLayout { - id: root - - property var pluginApi: null - property var mainInstance: null - property var wallpapers: [] - property string pendingPath: "" - property string selectedPath: "" - property bool scanningWallpapers: false - property int wallpaperItemsCount: 0 - property int visibleWallpaperCount: 0 - property var propertyLoadFailedByPath: ({}) - property int currentPage: 0 - property int pageCount: 1 - property int currentPageDisplay: 0 - property int currentPageStartIndex: 0 - property int currentPageEndIndex: 0 - property bool paginationVisible: false - property var resolutionBadgeIcon: null - property var resolutionBadgeLabel: null - property var typeLabel: null - property var isVideoMotion: null - - signal wallpaperActivated(string path) - signal previousPageRequested() - signal nextPageRequested() - - Layout.fillWidth: true - Layout.fillHeight: true - spacing: Style.marginS - - NGridView { - id: gridView - Layout.fillWidth: true - Layout.fillHeight: true - property real minCardWidth: 244 * Style.uiScaleRatio - property real cardGap: Style.marginS - property int columnCount: Math.max(1, Math.floor((availableWidth + cardGap) / (minCardWidth + cardGap))) - cellWidth: (availableWidth - ((columnCount - 1) * cardGap)) / columnCount - cellHeight: 208 * Style.uiScaleRatio - boundsBehavior: Flickable.StopAtBounds - clip: true - - model: root.wallpapers - - delegate: Rectangle { - id: tileCard - required property var modelData - width: gridView.cellWidth - height: gridView.cellHeight - radius: Style.radiusL - color: Qt.alpha(Color.mSurfaceVariant, 0.42) - border.width: root.pendingPath === modelData.path ? 2 : (root.selectedPath === modelData.path ? 1 : 0) - border.color: root.pendingPath === modelData.path ? Color.mPrimary : Qt.alpha(Color.mOutline, 0.35) - clip: true - - ColumnLayout { - anchors.fill: parent - anchors.margins: Style.marginS - spacing: Style.marginXS - - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 136 * Style.uiScaleRatio - radius: Style.radiusM - color: Color.mSurfaceVariant - clip: true - - Image { - anchors.fill: parent - visible: modelData.thumb && modelData.thumb.length > 0 - source: visible ? ("file://" + modelData.thumb) : "" - fillMode: Image.PreserveAspectCrop - cache: false - } - - Loader { - anchors.fill: parent - active: modelData.motionPreview && modelData.motionPreview.length > 0 - sourceComponent: root.isVideoMotion && root.isVideoMotion(modelData.motionPreview) ? motionVideoComponent : motionAnimatedComponent - } - - Component { - id: motionAnimatedComponent - - AnimatedImage { - anchors.fill: parent - source: "file://" + modelData.motionPreview - fillMode: Image.PreserveAspectCrop - cache: false - playing: true - } - } - - Component { - id: motionVideoComponent - - Video { - anchors.fill: parent - autoPlay: true - loops: MediaPlayer.Infinite - muted: true - fillMode: VideoOutput.PreserveAspectCrop - source: "file://" + modelData.motionPreview - } - } - - NIcon { - anchors.centerIn: parent - visible: (!modelData.thumb || modelData.thumb.length === 0) && (!modelData.motionPreview || modelData.motionPreview.length === 0) - icon: "photo" - pointSize: Style.fontSizeXL - color: Color.mOnSurfaceVariant - } - } - - RowLayout { - Layout.fillWidth: true - spacing: Style.marginXS - - NText { - Layout.fillWidth: true - text: modelData.name - color: Color.mOnSurface - font.weight: Font.Medium - elide: Text.ElideRight - } - - NIcon { - visible: root.selectedPath === modelData.path - icon: "check" - pointSize: Style.fontSizeL - color: Color.mPrimary - } - } - - Flow { - Layout.fillWidth: true - spacing: Style.marginXS - - Rectangle { - color: Qt.alpha(Color.mSecondary, 0.18) - radius: Style.radiusXS - implicitWidth: typeBadgeText.implicitWidth + Style.marginS * 2 - implicitHeight: typeBadgeText.implicitHeight + Style.marginXS * 2 - - NText { - id: typeBadgeText - anchors.centerIn: parent - text: root.typeLabel ? root.typeLabel(modelData.type) : "" - color: Color.mSecondary - font.pointSize: Style.fontSizeXS - font.weight: Font.Medium - } - } - - Rectangle { - color: modelData.dynamic ? Qt.alpha(Color.mTertiary, 0.18) : Qt.alpha(Color.mOutline, 0.18) - radius: Style.radiusXS - implicitWidth: motionBadgeText.implicitWidth + Style.marginS * 2 - implicitHeight: motionBadgeText.implicitHeight + Style.marginXS * 2 - - NText { - id: motionBadgeText - anchors.centerIn: parent - text: modelData.dynamic - ? pluginApi?.tr("panel.dynamicBadge") - : pluginApi?.tr("panel.staticBadge") - color: modelData.dynamic ? Color.mTertiary : Color.mOnSurfaceVariant - font.pointSize: Style.fontSizeXS - font.weight: Font.Medium - } - } - - Rectangle { - visible: root.resolutionBadgeIcon && root.resolutionBadgeIcon(modelData.resolution).length > 0 - color: Qt.alpha(Color.mSurfaceVariant, 0.24) - radius: Style.radiusXS - implicitWidth: resolutionBadgeRow.implicitWidth + Style.marginS * 2 - implicitHeight: resolutionBadgeRow.implicitHeight + Style.marginXS * 2 - - RowLayout { - id: resolutionBadgeRow - anchors.centerIn: parent - spacing: Style.marginXS - - NIcon { - icon: root.resolutionBadgeIcon ? root.resolutionBadgeIcon(modelData.resolution) : "" - pointSize: Style.fontSizeM - color: Color.mOnSurfaceVariant - } - - NText { - text: root.resolutionBadgeLabel ? root.resolutionBadgeLabel(modelData.resolution) : "" - color: Color.mOnSurfaceVariant - font.pointSize: Style.fontSizeXS - font.weight: Font.Medium - } - } - } - - Rectangle { - visible: root.propertyLoadFailedByPath[String(modelData.path || "")] === true - color: Qt.alpha(Color.mError, 0.16) - radius: Style.radiusXS - implicitWidth: propertyFailedBadgeRow.implicitWidth + Style.marginS * 2 - implicitHeight: propertyFailedBadgeRow.implicitHeight + Style.marginXS * 2 - - RowLayout { - id: propertyFailedBadgeRow - anchors.centerIn: parent - spacing: Style.marginXS - - NIcon { - icon: "alert-triangle" - pointSize: Style.fontSizeM - color: Color.mError - } - - NText { - text: pluginApi?.tr("panel.propertiesFailedBadge") - color: Color.mError - font.pointSize: Style.fontSizeXS - font.weight: Font.Medium - } - } - } - } - } - - MouseArea { - anchors.fill: parent - enabled: root.mainInstance?.engineAvailable ?? false - hoverEnabled: true - onClicked: root.wallpaperActivated(modelData.path) - } - } - - Rectangle { - visible: root.wallpapers.length === 0 && !root.scanningWallpapers - anchors.centerIn: parent - color: "transparent" - width: 300 * Style.uiScaleRatio - height: 140 * Style.uiScaleRatio - - ColumnLayout { - anchors.centerIn: parent - spacing: Style.marginS - - NIcon { - Layout.alignment: Qt.AlignHCenter - icon: "photo" - pointSize: Style.fontSizeXL - color: Color.mOnSurfaceVariant - } - - NText { - text: root.wallpaperItemsCount === 0 - ? pluginApi?.tr("panel.emptyAll") - : pluginApi?.tr("panel.emptyFiltered") - color: Color.mOnSurfaceVariant - } - } - } - } - - Rectangle { - Layout.fillWidth: true - visible: root.paginationVisible - implicitHeight: paginationRow.implicitHeight + Style.marginS * 2 - radius: Style.radiusM - color: Qt.alpha(Color.mSurfaceVariant, 0.35) - border.width: Style.borderS - border.color: Qt.alpha(Color.mOutline, 0.3) - - RowLayout { - id: paginationRow - anchors.fill: parent - anchors.margins: Style.marginS - spacing: Style.marginS - - NButton { - text: pluginApi?.tr("panel.prevPage") - icon: "chevron-left" - enabled: root.currentPage > 0 - onClicked: root.previousPageRequested() - } - - NText { - text: pluginApi?.tr("panel.pageSummary", { - current: root.currentPageDisplay, - total: root.pageCount - }) - color: Color.mOnSurface - font.weight: Font.Medium - } - - NText { - text: pluginApi?.tr("panel.pageRange", { - start: root.currentPageStartIndex, - end: root.currentPageEndIndex, - total: root.visibleWallpaperCount - }) - color: Color.mOnSurfaceVariant - } - - Item { Layout.fillWidth: true } - - NButton { - text: pluginApi?.tr("panel.nextPage") - icon: "chevron-right" - enabled: root.currentPage < root.pageCount - 1 - onClicked: root.nextPageRequested() - } - } - } -} diff --git a/linux-wallpaperengine-controller/components/WallpaperPreviewCard.qml b/linux-wallpaperengine-controller/components/WallpaperPreviewCard.qml deleted file mode 100644 index 2faa16912..000000000 --- a/linux-wallpaperengine-controller/components/WallpaperPreviewCard.qml +++ /dev/null @@ -1,252 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import QtMultimedia - -import qs.Commons -import qs.Widgets - -ColumnLayout { - id: root - - property var pluginApi: null - property var selectedWallpaperData: null - property var propertyLoadFailedByPath: ({}) - property var resolutionBadgeIcon: null - property var resolutionBadgeLabel: null - property var typeLabel: null - property var isVideoMotion: null - property var formatBytes: null - property var workshopUrlForWallpaper: null - - signal workshopLinkRequested(string url) - - Layout.fillWidth: true - spacing: Style.marginS - - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 180 * Style.uiScaleRatio - radius: Style.radiusM - color: Color.mSurfaceVariant - clip: true - - Image { - anchors.fill: parent - visible: root.selectedWallpaperData && (!root.selectedWallpaperData.motionPreview || root.selectedWallpaperData.motionPreview.length === 0) && root.selectedWallpaperData.thumb && root.selectedWallpaperData.thumb.length > 0 - source: visible ? ("file://" + root.selectedWallpaperData.thumb) : "" - fillMode: Image.PreserveAspectCrop - cache: false - } - - AnimatedImage { - anchors.fill: parent - visible: root.selectedWallpaperData && root.selectedWallpaperData.motionPreview && root.selectedWallpaperData.motionPreview.length > 0 && !(root.isVideoMotion && root.isVideoMotion(root.selectedWallpaperData.motionPreview)) - source: visible ? ("file://" + root.selectedWallpaperData.motionPreview) : "" - fillMode: Image.PreserveAspectCrop - cache: false - playing: visible - } - - Video { - anchors.fill: parent - visible: root.selectedWallpaperData && root.selectedWallpaperData.motionPreview && root.selectedWallpaperData.motionPreview.length > 0 && root.isVideoMotion && root.isVideoMotion(root.selectedWallpaperData.motionPreview) - autoPlay: true - loops: MediaPlayer.Infinite - muted: true - fillMode: VideoOutput.PreserveAspectCrop - source: visible ? ("file://" + root.selectedWallpaperData.motionPreview) : "" - } - } - - NText { - Layout.fillWidth: true - text: root.selectedWallpaperData ? root.selectedWallpaperData.name : "" - color: Color.mOnSurface - font.weight: Font.Bold - elide: Text.ElideRight - } - - Flow { - Layout.fillWidth: true - spacing: Style.marginXS - - Rectangle { - visible: root.selectedWallpaperData && root.resolutionBadgeLabel && root.resolutionBadgeLabel(root.selectedWallpaperData.resolution).length > 0 - color: Qt.alpha(Color.mSurfaceVariant, 0.24) - radius: Style.radiusXS - implicitWidth: sidebarResolutionBadgeRow.implicitWidth + Style.marginS * 2 - implicitHeight: sidebarResolutionBadgeRow.implicitHeight + Style.marginXS * 2 - - RowLayout { - id: sidebarResolutionBadgeRow - anchors.centerIn: parent - spacing: Style.marginXS - - NIcon { - icon: root.selectedWallpaperData && root.resolutionBadgeIcon ? root.resolutionBadgeIcon(root.selectedWallpaperData.resolution) : "" - pointSize: Style.fontSizeM - color: Color.mOnSurfaceVariant - } - - NText { - text: root.selectedWallpaperData && root.resolutionBadgeLabel ? root.resolutionBadgeLabel(root.selectedWallpaperData.resolution) : "" - color: Color.mOnSurfaceVariant - font.pointSize: Style.fontSizeXS - font.weight: Font.Medium - } - } - } - - Rectangle { - color: Qt.alpha(Color.mSecondary, 0.18) - radius: Style.radiusXS - implicitWidth: sidebarTypeBadgeText.implicitWidth + Style.marginS * 2 - implicitHeight: sidebarTypeBadgeText.implicitHeight + Style.marginXS * 2 - - NText { - id: sidebarTypeBadgeText - anchors.centerIn: parent - text: root.selectedWallpaperData && root.typeLabel ? root.typeLabel(root.selectedWallpaperData.type) : "" - color: Color.mSecondary - font.pointSize: Style.fontSizeXS - font.weight: Font.Medium - } - } - - Rectangle { - color: root.selectedWallpaperData && root.selectedWallpaperData.dynamic - ? Qt.alpha(Color.mTertiary, 0.18) - : Qt.alpha(Color.mOutline, 0.18) - radius: Style.radiusXS - implicitWidth: sidebarMotionBadgeText.implicitWidth + Style.marginS * 2 - implicitHeight: sidebarMotionBadgeText.implicitHeight + Style.marginXS * 2 - - NText { - id: sidebarMotionBadgeText - anchors.centerIn: parent - text: root.selectedWallpaperData - ? (root.selectedWallpaperData.dynamic - ? pluginApi?.tr("panel.dynamicBadge") - : pluginApi?.tr("panel.staticBadge")) - : "" - color: root.selectedWallpaperData && root.selectedWallpaperData.dynamic ? Color.mTertiary : Color.mOnSurfaceVariant - font.pointSize: Style.fontSizeXS - font.weight: Font.Medium - } - } - - Rectangle { - visible: root.propertyLoadFailedByPath[String(root.selectedWallpaperData?.path || "")] === true - color: Qt.alpha(Color.mError, 0.16) - radius: Style.radiusXS - implicitWidth: sidebarPropertyFailedBadgeRow.implicitWidth + Style.marginS * 2 - implicitHeight: sidebarPropertyFailedBadgeRow.implicitHeight + Style.marginXS * 2 - - RowLayout { - id: sidebarPropertyFailedBadgeRow - anchors.centerIn: parent - spacing: Style.marginXS - - NIcon { - icon: "alert-triangle" - pointSize: Style.fontSizeM - color: Color.mError - } - - NText { - text: pluginApi?.tr("panel.propertiesFailedBadge") - color: Color.mError - font.pointSize: Style.fontSizeXS - font.weight: Font.Medium - } - } - } - } - - GridLayout { - Layout.fillWidth: true - Layout.topMargin: Style.marginM - columns: 2 - columnSpacing: Style.marginM - rowSpacing: Style.marginS - - NText { - text: pluginApi?.tr("panel.infoType") - color: Color.mOnSurfaceVariant - } - - NText { - Layout.fillWidth: true - text: root.selectedWallpaperData && root.typeLabel ? root.typeLabel(root.selectedWallpaperData.type) : "" - color: Color.mOnSurface - horizontalAlignment: Text.AlignRight - wrapMode: Text.Wrap - } - - NText { - text: pluginApi?.tr("panel.infoId") - color: Color.mOnSurfaceVariant - } - - Rectangle { - color: "transparent" - Layout.fillWidth: true - implicitHeight: idValueText.implicitHeight - - NText { - id: idValueText - anchors.left: parent.left - anchors.right: parent.right - text: root.selectedWallpaperData ? root.selectedWallpaperData.id : "" - color: idLinkArea.containsMouse ? Color.mPrimary : Color.mOnSurface - horizontalAlignment: Text.AlignRight - elide: Text.ElideMiddle - } - - MouseArea { - id: idLinkArea - anchors.fill: parent - hoverEnabled: true - enabled: root.workshopUrlForWallpaper && root.workshopUrlForWallpaper(root.selectedWallpaperData).length > 0 - cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor - onClicked: { - const workshopUrl = root.workshopUrlForWallpaper ? root.workshopUrlForWallpaper(root.selectedWallpaperData) : ""; - if (workshopUrl.length === 0) { - return; - } - root.workshopLinkRequested(workshopUrl); - } - } - } - - NText { - text: pluginApi?.tr("panel.infoResolution") - color: Color.mOnSurfaceVariant - } - - NText { - Layout.fillWidth: true - text: root.selectedWallpaperData - ? (String(root.selectedWallpaperData.resolution || "unknown") === "unknown" - ? pluginApi?.tr("panel.resolutionUnknown") - : root.selectedWallpaperData.resolution) - : "" - color: Color.mOnSurface - horizontalAlignment: Text.AlignRight - wrapMode: Text.Wrap - } - - NText { - text: pluginApi?.tr("panel.infoSize") - color: Color.mOnSurfaceVariant - } - - NText { - Layout.fillWidth: true - text: root.selectedWallpaperData && root.formatBytes ? root.formatBytes(root.selectedWallpaperData.bytes) : "" - color: Color.mOnSurface - horizontalAlignment: Text.AlignRight - wrapMode: Text.Wrap - } - } -} diff --git a/linux-wallpaperengine-controller/components/WallpaperSidebar.qml b/linux-wallpaperengine-controller/components/WallpaperSidebar.qml deleted file mode 100644 index 5ecacbb98..000000000 --- a/linux-wallpaperengine-controller/components/WallpaperSidebar.qml +++ /dev/null @@ -1,149 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import QtMultimedia - -import qs.Commons -import qs.Widgets - -import "." - -ColumnLayout { - id: root - - property var pluginApi: null - property var mainInstance: null - property var selectedWallpaperData: null - property var propertyLoadFailedByPath: ({}) - property bool singleScreenMode: true - property bool applyAllDisplays: true - property bool applyTargetExpanded: false - property var screenModel: [] - property string selectedScreenName: "" - property string selectedScaling: "fill" - property string selectedClamp: "clamp" - property int selectedVolume: 100 - property bool selectedMuted: true - property bool selectedAudioReactiveEffects: true - property bool selectedDisableMouse: false - property bool selectedDisableParallax: false - property bool applyWallpaperColorsOnApply: false - property bool applyingWallpaperColors: false - property bool extraPropertiesEditorEnabled: true - property bool loadingWallpaperProperties: false - property string wallpaperPropertyError: "" - property var wallpaperPropertyDefinitions: [] - property var resolutionBadgeIcon: null - property var resolutionBadgeLabel: null - property var typeLabel: null - property var isVideoMotion: null - property var formatBytes: null - property var workshopUrlForWallpaper: null - property var propertyValueFor: null - property var numberOr: null - property var formatSliderValue: null - property var comboChoicesFor: null - property var ensureColorValue: null - property var serializePropertyValue: null - property var setPropertyValue: null - - signal applyRequested() - signal applyAllDisplaysRequested(bool value) - signal applyTargetExpandedRequested(bool value) - signal selectedScreenNameRequested(string value) - signal selectedScalingRequested(string value) - signal selectedClampRequested(string value) - signal selectedVolumeRequested(int value) - signal selectedMutedRequested(bool value) - signal selectedAudioReactiveEffectsRequested(bool value) - signal selectedDisableMouseRequested(bool value) - signal selectedDisableParallaxRequested(bool value) - signal workshopLinkRequested(string url) - signal applyWallpaperColorsOnApplyRequested(bool value) - - Layout.preferredWidth: 340 * Style.uiScaleRatio - Layout.maximumWidth: 340 * Style.uiScaleRatio - Layout.fillWidth: false - Layout.fillHeight: true - visible: root.selectedWallpaperData !== null - spacing: 0 - - Rectangle { - Layout.fillWidth: true - Layout.fillHeight: true - radius: Style.radiusL - color: Qt.alpha(Color.mSurfaceVariant, 0.35) - border.width: Style.borderS - border.color: Qt.alpha(Color.mOutline, 0.35) - clip: true - - NScrollView { - id: sidebarScrollView - anchors.fill: parent - anchors.margins: Style.marginM - showScrollbarWhenScrollable: true - gradientColor: "transparent" - - ColumnLayout { - width: sidebarScrollView.availableWidth - spacing: Style.marginS - - WallpaperPreviewCard { - pluginApi: root.pluginApi - selectedWallpaperData: root.selectedWallpaperData - propertyLoadFailedByPath: root.propertyLoadFailedByPath - resolutionBadgeIcon: root.resolutionBadgeIcon - resolutionBadgeLabel: root.resolutionBadgeLabel - typeLabel: root.typeLabel - isVideoMotion: root.isVideoMotion - formatBytes: root.formatBytes - workshopUrlForWallpaper: root.workshopUrlForWallpaper - onWorkshopLinkRequested: workshopUrl => root.workshopLinkRequested(workshopUrl) - } - - WallpaperApplyControls { - pluginApi: root.pluginApi - mainInstance: root.mainInstance - selectedWallpaperData: root.selectedWallpaperData - singleScreenMode: root.singleScreenMode - applyAllDisplays: root.applyAllDisplays - applyTargetExpanded: root.applyTargetExpanded - screenModel: root.screenModel - selectedScreenName: root.selectedScreenName - selectedScaling: root.selectedScaling - selectedClamp: root.selectedClamp - selectedVolume: root.selectedVolume - selectedMuted: root.selectedMuted - selectedAudioReactiveEffects: root.selectedAudioReactiveEffects - selectedDisableMouse: root.selectedDisableMouse - selectedDisableParallax: root.selectedDisableParallax - applyWallpaperColorsOnApply: root.applyWallpaperColorsOnApply - applyingWallpaperColors: root.applyingWallpaperColors - extraPropertiesEditorEnabled: root.extraPropertiesEditorEnabled - loadingWallpaperProperties: root.loadingWallpaperProperties - wallpaperPropertyError: root.wallpaperPropertyError - wallpaperPropertyDefinitions: root.wallpaperPropertyDefinitions - propertyValueFor: root.propertyValueFor - numberOr: root.numberOr - formatSliderValue: root.formatSliderValue - comboChoicesFor: root.comboChoicesFor - ensureColorValue: root.ensureColorValue - serializePropertyValue: root.serializePropertyValue - setPropertyValue: root.setPropertyValue - onApplyRequested: root.applyRequested() - onApplyAllDisplaysRequested: value => root.applyAllDisplaysRequested(value) - onApplyTargetExpandedRequested: value => root.applyTargetExpandedRequested(value) - onSelectedScreenNameRequested: value => root.selectedScreenNameRequested(value) - onSelectedScalingRequested: value => root.selectedScalingRequested(value) - onSelectedClampRequested: value => root.selectedClampRequested(value) - onSelectedVolumeRequested: value => root.selectedVolumeRequested(value) - onSelectedMutedRequested: value => root.selectedMutedRequested(value) - onSelectedAudioReactiveEffectsRequested: value => root.selectedAudioReactiveEffectsRequested(value) - onSelectedDisableMouseRequested: value => root.selectedDisableMouseRequested(value) - onSelectedDisableParallaxRequested: value => root.selectedDisableParallaxRequested(value) - onApplyWallpaperColorsOnApplyRequested: value => root.applyWallpaperColorsOnApplyRequested(value) - } - } - } - } -} diff --git a/linux-wallpaperengine-controller/components/PanelDropdowns.qml b/linux-wallpaperengine-controller/components/panel/PanelDropdowns.qml similarity index 71% rename from linux-wallpaperengine-controller/components/PanelDropdowns.qml rename to linux-wallpaperengine-controller/components/panel/PanelDropdowns.qml index 75a58e2f0..fd07adf8a 100644 --- a/linux-wallpaperengine-controller/components/PanelDropdowns.qml +++ b/linux-wallpaperengine-controller/components/panel/PanelDropdowns.qml @@ -7,16 +7,12 @@ Item { id: root property var pluginApi: null - property bool resolutionDropdownOpen: false property bool filterDropdownOpen: false property bool sortDropdownOpen: false property string selectedResolution: "all" property string selectedType: "all" property string sortMode: "name" property bool sortAscending: true - property real resolutionDropdownX: 0 - property real resolutionDropdownY: 0 - property real resolutionDropdownWidth: 220 * Style.uiScaleRatio property real filterDropdownX: 0 property real filterDropdownY: 0 property real filterDropdownWidth: 220 * Style.uiScaleRatio @@ -25,7 +21,6 @@ Item { property real sortDropdownWidth: 220 * Style.uiScaleRatio signal closeRequested() - signal resolutionActionTriggered(string action) signal filterActionTriggered(string action) signal sortActionTriggered(string action) @@ -33,70 +28,18 @@ Item { MouseArea { anchors.fill: parent - visible: root.filterDropdownOpen || root.resolutionDropdownOpen || root.sortDropdownOpen + visible: root.filterDropdownOpen || root.sortDropdownOpen z: 900 acceptedButtons: Qt.LeftButton | Qt.RightButton onClicked: root.closeRequested() } - Rectangle { - visible: root.resolutionDropdownOpen - x: root.resolutionDropdownX - y: root.resolutionDropdownY - width: root.resolutionDropdownWidth - height: Math.min(210 * Style.uiScaleRatio, resolutionList.contentHeight + 2 * Style.marginS) - radius: Style.radiusL - color: Qt.alpha(Color.mSurface, 0.96) - border.width: Style.borderS - border.color: Qt.alpha(Color.mOutline, 0.45) - z: 901 - - NListView { - id: resolutionList - anchors.fill: parent - anchors.margins: Style.marginS - clip: true - spacing: Style.marginXS - model: [ - { "label": pluginApi?.tr("panel.filterResAll"), "action": "res:all", "selected": root.selectedResolution === "all" }, - { "label": pluginApi?.tr("panel.filterRes4k"), "action": "res:4k", "selected": root.selectedResolution === "4k" }, - { "label": pluginApi?.tr("panel.filterRes8k"), "action": "res:8k", "selected": root.selectedResolution === "8k" }, - { "label": pluginApi?.tr("panel.filterResUnknown"), "action": "res:unknown", "selected": root.selectedResolution === "unknown" } - ] - - delegate: Rectangle { - required property var modelData - width: resolutionList.availableWidth - height: 34 * Style.uiScaleRatio - radius: Style.radiusM - color: modelData.selected ? Qt.alpha(Color.mPrimary, 0.22) : "transparent" - border.width: modelData.selected ? 1 : 0 - border.color: Qt.alpha(Color.mPrimary, 0.45) - - NText { - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - anchors.leftMargin: Style.marginS - text: modelData.label - color: modelData.selected ? Color.mPrimary : Color.mOnSurface - font.weight: modelData.selected ? Font.Medium : Font.Normal - } - - MouseArea { - anchors.fill: parent - hoverEnabled: true - onClicked: root.resolutionActionTriggered(modelData.action) - } - } - } - } - Rectangle { visible: root.filterDropdownOpen x: root.filterDropdownX y: root.filterDropdownY width: root.filterDropdownWidth - height: Math.min(244 * Style.uiScaleRatio, filterList.contentHeight + 2 * Style.marginS) + height: Math.min(320 * Style.uiScaleRatio, filterList.contentHeight + 2 * Style.marginS) radius: Style.radiusL color: Qt.alpha(Color.mSurface, 0.96) border.width: Style.borderS @@ -114,19 +57,34 @@ Item { { "label": pluginApi?.tr("panel.filterTypeScene"), "action": "type:scene", "selected": root.selectedType === "scene" }, { "label": pluginApi?.tr("panel.filterTypeVideo"), "action": "type:video", "selected": root.selectedType === "video" }, { "label": pluginApi?.tr("panel.filterTypeWeb"), "action": "type:web", "selected": root.selectedType === "web" }, - { "label": pluginApi?.tr("panel.filterTypeApplication"), "action": "type:application", "selected": root.selectedType === "application" } + { "label": pluginApi?.tr("panel.filterTypeApplication"), "action": "type:application", "selected": root.selectedType === "application" }, + { "label": "", "action": "", "selected": false, "separator": true }, + { "label": pluginApi?.tr("panel.filterResAll"), "action": "res:all", "selected": root.selectedResolution === "all" }, + { "label": pluginApi?.tr("panel.filterRes4k"), "action": "res:4k", "selected": root.selectedResolution === "4k" }, + { "label": pluginApi?.tr("panel.filterResUnknown"), "action": "res:unknown", "selected": root.selectedResolution === "unknown" } ] delegate: Rectangle { required property var modelData + readonly property bool isSeparator: !!modelData.separator width: filterList.availableWidth - height: 34 * Style.uiScaleRatio + height: isSeparator ? (8 * Style.uiScaleRatio) : (34 * Style.uiScaleRatio) radius: Style.radiusM - color: modelData.selected ? Qt.alpha(Color.mPrimary, 0.22) : "transparent" - border.width: modelData.selected ? 1 : 0 + color: isSeparator ? "transparent" : (modelData.selected ? Qt.alpha(Color.mPrimary, 0.22) : "transparent") + border.width: isSeparator ? 0 : (modelData.selected ? 1 : 0) border.color: Qt.alpha(Color.mPrimary, 0.45) + Rectangle { + visible: isSeparator + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.right: parent.right + height: 0 + color: "transparent" + } + NText { + visible: !isSeparator anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left anchors.leftMargin: Style.marginS @@ -136,6 +94,7 @@ Item { } MouseArea { + visible: !isSeparator anchors.fill: parent hoverEnabled: true onClicked: root.filterActionTriggered(modelData.action) diff --git a/linux-wallpaperengine-controller/components/PanelHeader.qml b/linux-wallpaperengine-controller/components/panel/PanelHeader.qml similarity index 50% rename from linux-wallpaperengine-controller/components/PanelHeader.qml rename to linux-wallpaperengine-controller/components/panel/PanelHeader.qml index 893792df7..d36f699bc 100644 --- a/linux-wallpaperengine-controller/components/PanelHeader.qml +++ b/linux-wallpaperengine-controller/components/panel/PanelHeader.qml @@ -10,11 +10,7 @@ Rectangle { property var pluginApi: null property var mainInstance: null property Item positionTarget: null - property string engineStatusBadgeText: "" - property color engineStatusBadgeFg: Color.mOnSurfaceVariant - property color engineStatusBadgeBg: Qt.alpha(engineStatusBadgeFg, 0.16) property bool scanningCompatibility: false - property bool pendingCompatibilityScan: false property string searchText: "" property string selectedType: "all" property string selectedResolution: "all" @@ -23,7 +19,6 @@ Rectangle { property var typeLabel: null property var resolutionFilterLabel: null property var sortLabel: null - property real resolutionButtonWidth: 220 * Style.uiScaleRatio property real filterButtonWidth: 220 * Style.uiScaleRatio property real sortButtonWidth: 220 * Style.uiScaleRatio @@ -31,14 +26,11 @@ Rectangle { signal reloadRequested() signal toggleRunRequested() signal settingsRequested() - signal closeRequested() - signal pendingCompatibilityScanRequested(bool value) signal searchTextUpdateRequested(string text) signal clearSearchRequested() - signal resolutionDropdownToggleRequested(real x, real y, real width) + signal filterDropdownToggleRequested(real x, real y, real width) signal sortDropdownToggleRequested(real x, real y, real width) - function mapButtonGeometry(item) { if (!item || !root.positionTarget) { return { x: 0, y: 0, width: 0 }; @@ -66,124 +58,14 @@ Rectangle { anchors.margins: Style.marginS spacing: Style.marginS - RowLayout { - Layout.fillWidth: true - - NIcon { - icon: "wallpaper-selector" - pointSize: Style.fontSizeL - color: Color.mOnSurface - } - - NText { - text: pluginApi?.tr("panel.title") - font.pointSize: Style.fontSizeL - font.weight: Font.Bold - color: Color.mOnSurface - } - - Rectangle { - radius: Style.radiusXS - color: root.engineStatusBadgeBg - implicitWidth: statusBadgeText.implicitWidth + Style.marginS * 2 - implicitHeight: statusBadgeText.implicitHeight + Style.marginXS * 2 - - NText { - id: statusBadgeText - anchors.centerIn: parent - text: root.engineStatusBadgeText - color: root.engineStatusBadgeFg - font.pointSize: Style.fontSizeXS - font.weight: Font.Medium - } - } - - Item { Layout.fillWidth: true } - - NIconButton { - enabled: (mainInstance?.engineAvailable ?? false) && !root.scanningCompatibility - icon: root.scanningCompatibility ? "loader" : "shield-search" - colorFg: Color.mOnSurface - tooltipText: root.scanningCompatibility - ? pluginApi?.tr("panel.compatibilityQuickCheckRunning") - : pluginApi?.tr("panel.compatibilityQuickCheck") - onClicked: { - if (!root.scanningCompatibility) { - root.pendingCompatibilityScanRequested(true) - } - } - } - - NIconButton { - enabled: !(mainInstance?.scanningWallpapers ?? false) - icon: "refresh" - colorFg: Color.mOnSurface - tooltipText: pluginApi?.tr("panel.refreshWallpapers") - onClicked: root.reloadRequested() - } - - NIconButton { - enabled: mainInstance?.engineAvailable ?? false - icon: mainInstance?.engineRunning ? "player-stop" : "player-play" - colorFg: Color.mOnSurface - tooltipText: mainInstance?.engineRunning ? pluginApi?.tr("panel.stop") : pluginApi?.tr("panel.start") - onClicked: root.toggleRunRequested() - } - - NIconButton { - icon: "settings" - colorFg: Color.mOnSurface - tooltipText: pluginApi?.tr("menu.settings") - onClicked: root.settingsRequested() - } - - NIconButton { - icon: "x" - colorFg: Color.mOnSurface - tooltipText: pluginApi?.tr("panel.closePanel") - onClicked: root.closeRequested() - } - } - - NBox { - visible: root.pendingCompatibilityScan - Layout.fillWidth: true - Layout.preferredHeight: compatibilityConfirmRow.implicitHeight + Style.marginM * 2 - - RowLayout { - id: compatibilityConfirmRow - anchors.fill: parent - anchors.margins: Style.marginM - spacing: Style.marginM - - NText { - Layout.fillWidth: true - text: pluginApi?.tr("panel.compatibilityQuickCheckConfirm") - pointSize: Style.fontSizeS - color: Color.mOnSurface - wrapMode: Text.WordWrap - } - - NButton { - text: pluginApi?.tr("panel.confirm") - enabled: !root.scanningCompatibility - onClicked: root.compatibilityQuickCheckRequested() - } - - NButton { - text: pluginApi?.tr("panel.cancel") - enabled: !root.scanningCompatibility - onClicked: root.pendingCompatibilityScanRequested(false) - } - } - } - RowLayout { Layout.fillWidth: true Layout.preferredHeight: 48 * Style.uiScaleRatio + spacing: Style.marginXS NTextInput { Layout.fillWidth: true + Layout.minimumWidth: 260 * Style.uiScaleRatio placeholderText: pluginApi?.tr("panel.searchPlaceholder") text: root.searchText onTextChanged: root.searchTextUpdateRequested(text) @@ -198,9 +80,9 @@ Rectangle { } Rectangle { - id: resolutionButton - Layout.preferredWidth: root.resolutionButtonWidth - Layout.maximumWidth: root.resolutionButtonWidth + id: filterButton + Layout.preferredWidth: root.filterButtonWidth + Layout.maximumWidth: root.filterButtonWidth Layout.preferredHeight: 42 * Style.uiScaleRatio radius: Style.radiusL color: Qt.alpha(Color.mSurfaceVariant, 0.42) @@ -214,14 +96,16 @@ Rectangle { spacing: Style.marginXXS NIcon { - icon: "badge-hd" + icon: "adjustments-horizontal" pointSize: Style.fontSizeM color: Color.mOnSurface } NText { Layout.fillWidth: true - text: root.resolutionFilterLabel ? root.resolutionFilterLabel(root.selectedResolution) : "" + text: pluginApi?.tr("panel.filterButtonSummary", { + type: root.typeLabel ? root.typeLabel(root.selectedType) : "" + }) color: Color.mOnSurface elide: Text.ElideRight } @@ -236,16 +120,16 @@ Rectangle { MouseArea { anchors.fill: parent onClicked: { - const geometry = root.mapButtonGeometry(resolutionButton); - root.resolutionDropdownToggleRequested(geometry.x, geometry.y, geometry.width); + const geometry = root.mapButtonGeometry(filterButton); + root.filterDropdownToggleRequested(geometry.x, geometry.y, geometry.width); } } } Rectangle { - id: filterButton - Layout.preferredWidth: root.filterButtonWidth - Layout.maximumWidth: root.filterButtonWidth + id: sortButton + Layout.preferredWidth: root.sortButtonWidth + Layout.maximumWidth: root.sortButtonWidth Layout.preferredHeight: 42 * Style.uiScaleRatio radius: Style.radiusL color: Qt.alpha(Color.mSurfaceVariant, 0.42) @@ -259,14 +143,17 @@ Rectangle { spacing: Style.marginXXS NIcon { - icon: "adjustments-horizontal" + icon: "arrows-sort" pointSize: Style.fontSizeM color: Color.mOnSurface } NText { Layout.fillWidth: true - text: pluginApi?.tr("panel.filterButtonSummary", { type: root.typeLabel ? root.typeLabel(root.selectedType) : "" }) + text: pluginApi?.tr("panel.sortButtonSummary", { + direction: root.sortAscending ? "↑" : "↓", + sort: root.sortLabel ? root.sortLabel(root.sortMode) : "" + }) color: Color.mOnSurface elide: Text.ElideRight } @@ -281,59 +168,65 @@ Rectangle { MouseArea { anchors.fill: parent onClicked: { - const geometry = root.mapButtonGeometry(filterButton); - root.filterDropdownToggleRequested(geometry.x, geometry.y, geometry.width); + const geometry = root.mapButtonGeometry(sortButton); + root.sortDropdownToggleRequested(geometry.x, geometry.y, geometry.width); } } } + Item { + Layout.fillWidth: true + Layout.minimumWidth: 0 + } + Rectangle { - id: sortButton - Layout.preferredWidth: root.sortButtonWidth - Layout.maximumWidth: root.sortButtonWidth - Layout.preferredHeight: 42 * Style.uiScaleRatio radius: Style.radiusL - color: Qt.alpha(Color.mSurfaceVariant, 0.42) + color: Qt.alpha(Color.mSurfaceVariant, 0.34) border.width: Style.borderS - border.color: Qt.alpha(Color.mOutline, 0.45) + border.color: Qt.alpha(Color.mOutline, 0.35) + implicitWidth: actionButtons.implicitWidth + Style.marginS * 2 + implicitHeight: actionButtons.implicitHeight + Style.marginXS * 2 RowLayout { + id: actionButtons anchors.fill: parent - anchors.leftMargin: Style.marginS - anchors.rightMargin: Style.marginS + anchors.margins: Style.marginXS spacing: Style.marginXXS - NIcon { - icon: "arrows-sort" - pointSize: Style.fontSizeM - color: Color.mOnSurface + NIconButton { + enabled: !(mainInstance?.scanningWallpapers ?? false) + icon: "refresh" + colorFg: Color.mOnSurface + tooltipText: pluginApi?.tr("panel.refreshWallpapers") + onClicked: root.reloadRequested() } - NText { - Layout.fillWidth: true - text: pluginApi?.tr("panel.sortButtonSummary", { - direction: root.sortAscending ? "↑" : "↓", - sort: root.sortLabel ? root.sortLabel(root.sortMode) : "" - }) - color: Color.mOnSurface - elide: Text.ElideRight + NIconButton { + visible: (mainInstance?.engineAvailable ?? false) + enabled: !root.scanningCompatibility + icon: "shield-search" + colorFg: Color.mOnSurface + tooltipText: pluginApi?.tr("panel.compatibilityQuickCheck") + onClicked: root.compatibilityQuickCheckRequested() } - NIcon { - icon: "chevron-down" - pointSize: Style.fontSizeM - color: Color.mOnSurfaceVariant + NIconButton { + enabled: mainInstance?.engineAvailable ?? false + icon: mainInstance?.engineRunning ? "player-stop" : "player-play" + colorFg: Color.mOnSurface + tooltipText: mainInstance?.engineRunning ? pluginApi?.tr("panel.stop") : pluginApi?.tr("panel.start") + onClicked: root.toggleRunRequested() } - } - MouseArea { - anchors.fill: parent - onClicked: { - const geometry = root.mapButtonGeometry(sortButton); - root.sortDropdownToggleRequested(geometry.x, geometry.y, geometry.width); + NIconButton { + icon: "settings" + colorFg: Color.mOnSurface + tooltipText: pluginApi?.tr("menu.settings") + onClicked: root.settingsRequested() } } } } + } } diff --git a/linux-wallpaperengine-controller/components/RuntimeErrorBanner.qml b/linux-wallpaperengine-controller/components/panel/RuntimeErrorBanner.qml similarity index 71% rename from linux-wallpaperengine-controller/components/RuntimeErrorBanner.qml rename to linux-wallpaperengine-controller/components/panel/RuntimeErrorBanner.qml index 8c3002081..f88ac1c16 100644 --- a/linux-wallpaperengine-controller/components/RuntimeErrorBanner.qml +++ b/linux-wallpaperengine-controller/components/panel/RuntimeErrorBanner.qml @@ -1,7 +1,9 @@ import QtQuick import QtQuick.Layouts +import Quickshell import qs.Commons +import qs.Services.UI import qs.Widgets Rectangle { @@ -14,14 +16,23 @@ Rectangle { signal errorDetailsExpandedRequested(bool value) signal dismissRequested() + function copiedErrorText() { + const summary = String(mainInstance?.lastError || "").trim(); + const details = String(mainInstance?.lastErrorDetails || "").trim(); + if (details.length === 0) { + return summary; + } + return summary + "\n\n" + details; + } + visible: !!(mainInstance?.lastError && mainInstance.lastError.length > 0) Layout.fillWidth: true implicitHeight: errorBannerContent.implicitHeight + Style.marginS * 2 Layout.preferredHeight: implicitHeight radius: Style.radiusM - color: Color.mSurface + color: Qt.alpha(Color.mError, 0.08) border.width: Style.borderS - border.color: Qt.alpha(Color.mOutline, 0.2) + border.color: Qt.alpha(Color.mError, 0.32) ColumnLayout { id: errorBannerContent @@ -44,12 +55,25 @@ Rectangle { NText { text: pluginApi?.tr("panel.errorBannerTitle") - color: Color.mOnSurface + color: Color.mError font.weight: Font.Bold } Item { Layout.fillWidth: true } + NIconButton { + icon: "copy" + tooltipText: pluginApi?.tr("panel.errorCopy") + onClicked: { + const text = root.copiedErrorText(); + if (text.length === 0) { + return; + } + Quickshell.clipboardText = text; + ToastService.showNotice(pluginApi?.tr("panel.title"), pluginApi?.tr("panel.errorCopied"), "copy"); + } + } + NButton { text: root.errorDetailsExpanded ? pluginApi?.tr("panel.errorHideDetails") @@ -70,8 +94,7 @@ Rectangle { text: mainInstance?.lastError ?? "" color: Color.mOnSurface wrapMode: Text.WordWrap - maximumLineCount: 2 - elide: Text.ElideRight + font.weight: Font.Medium } Rectangle { @@ -79,9 +102,9 @@ Rectangle { Layout.fillWidth: true Layout.preferredHeight: 136 * Style.uiScaleRatio radius: Style.radiusS - color: Qt.alpha(Color.mSurfaceVariant, 0.35) + color: Qt.alpha(Color.mSurface, 0.55) border.width: Style.borderS - border.color: Qt.alpha(Color.mOutline, 0.25) + border.color: Qt.alpha(Color.mError, 0.18) NScrollView { anchors.fill: parent diff --git a/linux-wallpaperengine-controller/components/shared/CardGridSection.qml b/linux-wallpaperengine-controller/components/shared/CardGridSection.qml new file mode 100644 index 000000000..ca80742e8 --- /dev/null +++ b/linux-wallpaperengine-controller/components/shared/CardGridSection.qml @@ -0,0 +1,125 @@ +import QtQuick +import QtQuick.Layouts + +import qs.Commons +import qs.Widgets + +ColumnLayout { + id: root + + property var pluginApi: null + property var items: [] + property Component cardDelegate: null + property real minCardWidth: 244 * Style.uiScaleRatio + property real cardGap: Style.marginS + property real cellHeight: 208 * Style.uiScaleRatio + property bool showEmptyState: items.length === 0 + property string emptyIcon: "photo" + property string emptyText: "" + property bool paginationVisible: false + property int currentPage: 0 + property int pageCount: 1 + property int currentPageDisplay: 0 + property int currentPageStartIndex: 0 + property int currentPageEndIndex: 0 + property int totalVisibleCount: 0 + + signal previousPageRequested() + signal nextPageRequested() + + Layout.fillWidth: true + Layout.fillHeight: true + spacing: Style.marginS + + NGridView { + id: gridView + Layout.fillWidth: true + Layout.fillHeight: true + property int columnCount: Math.max(1, Math.floor((availableWidth + root.cardGap) / (root.minCardWidth + root.cardGap))) + cellWidth: (availableWidth - ((columnCount - 1) * root.cardGap)) / columnCount + cellHeight: root.cellHeight + boundsBehavior: Flickable.StopAtBounds + clip: true + + model: root.items + delegate: root.cardDelegate + + Rectangle { + visible: root.showEmptyState + anchors.centerIn: parent + color: "transparent" + width: 320 * Style.uiScaleRatio + height: 140 * Style.uiScaleRatio + + ColumnLayout { + anchors.centerIn: parent + spacing: Style.marginS + + NIcon { + Layout.alignment: Qt.AlignHCenter + icon: root.emptyIcon + pointSize: Style.fontSizeXL + color: Color.mOnSurfaceVariant + } + + NText { + text: root.emptyText + color: Color.mOnSurfaceVariant + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + } + } + } + } + + Rectangle { + Layout.fillWidth: true + visible: root.paginationVisible + implicitHeight: paginationRow.implicitHeight + Style.marginS * 2 + radius: Style.radiusM + color: Qt.alpha(Color.mSurface, 0.78) + border.width: Style.borderS + border.color: Qt.alpha(Color.mOutline, 0.3) + + RowLayout { + id: paginationRow + anchors.fill: parent + anchors.margins: Style.marginS + spacing: Style.marginS + + NButton { + text: pluginApi?.tr("panel.prevPage") + icon: "chevron-left" + enabled: root.currentPage > 0 + onClicked: root.previousPageRequested() + } + + NText { + text: pluginApi?.tr("panel.pageSummary", { + current: root.currentPageDisplay, + total: root.pageCount + }) + color: Color.mOnSurface + font.weight: Font.Medium + } + + NText { + text: pluginApi?.tr("panel.pageRange", { + start: root.currentPageStartIndex, + end: root.currentPageEndIndex, + total: root.totalVisibleCount + }) + color: Color.mOnSurfaceVariant + } + + Item { Layout.fillWidth: true } + + NButton { + text: pluginApi?.tr("panel.nextPage") + icon: "chevron-right" + enabled: root.currentPage < root.pageCount - 1 + onClicked: root.nextPageRequested() + } + } + } +} diff --git a/linux-wallpaperengine-controller/components/shared/SidebarPanel.qml b/linux-wallpaperengine-controller/components/shared/SidebarPanel.qml new file mode 100644 index 000000000..1e5b49d81 --- /dev/null +++ b/linux-wallpaperengine-controller/components/shared/SidebarPanel.qml @@ -0,0 +1,71 @@ +import QtQuick +import QtQuick.Layouts + +import qs.Commons +import qs.Widgets + +ColumnLayout { + id: root + + default property alias content: contentColumn.data + property alias footerContent: footerRow.data + + property bool panelVisible: true + property real panelWidth: 340 * Style.uiScaleRatio + + Layout.preferredWidth: panelWidth + Layout.maximumWidth: panelWidth + Layout.fillWidth: false + Layout.fillHeight: true + Layout.alignment: Qt.AlignTop + visible: panelVisible + spacing: 0 + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + radius: Style.radiusL + color: Qt.alpha(Color.mSurfaceVariant, 0.35) + border.width: Style.borderS + border.color: Qt.alpha(Color.mOutline, 0.35) + clip: true + + NScrollView { + id: sidebarScrollView + anchors.fill: parent + anchors.margins: Style.marginM + anchors.bottomMargin: Style.marginM + 56 * Style.uiScaleRatio + contentWidth: availableWidth + showScrollbarWhenScrollable: true + gradientColor: "transparent" + + ColumnLayout { + id: contentColumn + width: sidebarScrollView.availableWidth + spacing: Style.marginS + } + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: footerRow.implicitHeight > 0 ? footerRow.implicitHeight + Style.marginM * 2 : 0 + color: Qt.rgba(0, 0, 0, 0) + gradient: Gradient { + orientation: Gradient.Vertical + GradientStop { position: 0.0; color: Qt.rgba(0, 0, 0, 0) } + GradientStop { position: 1.0; color: Qt.alpha(Color.mSurface, 0.32) } + } + + RowLayout { + id: footerRow + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: Style.marginM + spacing: Style.marginS + } + } + } +} diff --git a/linux-wallpaperengine-controller/components/WallpaperApplyControls.qml b/linux-wallpaperengine-controller/components/wallpaper/ApplyControls.qml similarity index 86% rename from linux-wallpaperengine-controller/components/WallpaperApplyControls.qml rename to linux-wallpaperengine-controller/components/wallpaper/ApplyControls.qml index 5e89242b0..b1dc04e09 100644 --- a/linux-wallpaperengine-controller/components/WallpaperApplyControls.qml +++ b/linux-wallpaperengine-controller/components/wallpaper/ApplyControls.qml @@ -2,20 +2,27 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import "." + import qs.Commons import qs.Widgets ColumnLayout { id: root + // Shared context. property var pluginApi: null property var mainInstance: null property var selectedWallpaperData: null + + // Apply target selection. property bool singleScreenMode: true property bool applyAllDisplays: true property bool applyTargetExpanded: false property var screenModel: [] property string selectedScreenName: "" + + // Per-apply runtime options. property string selectedScaling: "fill" property string selectedClamp: "clamp" property int selectedVolume: 100 @@ -25,18 +32,16 @@ ColumnLayout { property bool selectedDisableParallax: false property bool applyWallpaperColorsOnApply: false property bool applyingWallpaperColors: false + property bool showApplyButton: true + + // Extra wallpaper property editor state. property bool extraPropertiesEditorEnabled: true property bool loadingWallpaperProperties: false property string wallpaperPropertyError: "" property var wallpaperPropertyDefinitions: [] - property var propertyValueFor: null - property var numberOr: null - property var formatSliderValue: null - property var comboChoicesFor: null - property var ensureColorValue: null - property var serializePropertyValue: null - property var setPropertyValue: null + property var propertyEditorApi: null + // Upstream actions. signal applyRequested() signal applyAllDisplaysRequested(bool value) signal applyTargetExpandedRequested(bool value) @@ -50,28 +55,21 @@ ColumnLayout { signal selectedDisableParallaxRequested(bool value) signal applyWallpaperColorsOnApplyRequested(bool value) - readonly property string applyButtonText: { - if (root.singleScreenMode) { - return pluginApi?.tr("panel.confirmApply"); - } - - if (root.applyAllDisplays) { - return pluginApi?.tr("panel.applyAllDisplays"); - } - - return pluginApi?.tr("panel.applySingleDisplay", { screen: root.selectedScreenName }); - } - Layout.fillWidth: true spacing: Style.marginS + // Primary apply action and display target chooser. RowLayout { Layout.fillWidth: true + visible: root.showApplyButton || (!root.singleScreenMode) spacing: Style.marginS NButton { + visible: root.showApplyButton Layout.fillWidth: true - text: root.applyButtonText + text: root.applyAllDisplays + ? pluginApi?.tr("panel.applyAllDisplays") + : pluginApi?.tr("panel.applySingleDisplay", { screen: root.selectedScreenName }) icon: "check" enabled: (root.mainInstance?.engineAvailable ?? false) && !!root.selectedWallpaperData onClicked: root.applyRequested() @@ -80,10 +78,12 @@ ColumnLayout { NIconButton { Layout.preferredWidth: 42 * Style.uiScaleRatio Layout.preferredHeight: 42 * Style.uiScaleRatio - visible: !root.singleScreenMode + visible: root.showApplyButton && !root.singleScreenMode enabled: root.mainInstance?.engineAvailable ?? false - icon: root.applyTargetExpanded ? "chevron-up" : "chevron-down" - tooltipText: pluginApi?.tr("panel.applyTarget") + icon: "device-desktop" + tooltipText: root.applyAllDisplays + ? pluginApi?.tr("panel.targetAllDisplays") + : pluginApi?.tr("panel.targetSingleDisplay", { screen: root.selectedScreenName }) onClicked: root.applyTargetExpandedRequested(!root.applyTargetExpanded) } } @@ -236,6 +236,7 @@ ColumnLayout { onToggled: checked => root.applyWallpaperColorsOnApplyRequested(checked) } + // Optional extra property editor for wallpapers that expose editable values. ColumnLayout { Layout.fillWidth: true visible: root.extraPropertiesEditorEnabled @@ -247,18 +248,12 @@ ColumnLayout { Layout.bottomMargin: Style.marginM } - WallpaperPropertiesEditor { + PropertiesEditor { pluginApi: root.pluginApi loadingWallpaperProperties: root.loadingWallpaperProperties wallpaperPropertyError: root.wallpaperPropertyError wallpaperPropertyDefinitions: root.wallpaperPropertyDefinitions - propertyValueFor: root.propertyValueFor - numberOr: root.numberOr - formatSliderValue: root.formatSliderValue - comboChoicesFor: root.comboChoicesFor - ensureColorValue: root.ensureColorValue - serializePropertyValue: root.serializePropertyValue - setPropertyValue: root.setPropertyValue + propertyEditorApi: root.propertyEditorApi } } } diff --git a/linux-wallpaperengine-controller/components/wallpaper/GridSection.qml b/linux-wallpaperengine-controller/components/wallpaper/GridSection.qml new file mode 100644 index 000000000..910f6b8cc --- /dev/null +++ b/linux-wallpaperengine-controller/components/wallpaper/GridSection.qml @@ -0,0 +1,411 @@ +import QtQuick +import QtQuick.Layouts +import QtMultimedia + +import "../shared" + +import qs.Commons +import qs.Widgets + +ColumnLayout { + id: root + + property var pluginApi: null + property var mainInstance: null + property var wallpapers: [] + property string pendingPath: "" + property string selectedPath: "" + property bool scanningWallpapers: false + property int wallpaperItemsCount: 0 + property int visibleWallpaperCount: 0 + property int currentPage: 0 + property int pageCount: 1 + property int currentPageDisplay: 0 + property int currentPageStartIndex: 0 + property int currentPageEndIndex: 0 + property bool paginationVisible: false + property var propertyCompatibilityBadgeIconForPath: null + property var propertyCompatibilityBadgeTextForPath: null + property var propertyCompatibilityBadgeColorForPath: null + property var propertyCompatibilityBadgeBackgroundForPath: null + property var resolutionBadgeIcon: null + property var resolutionBadgeLabel: null + property var typeLabel: null + property var typeBadgeIcon: null + property var dynamicBadgeIcon: null + property var badgeOrder: [] + property var isVideoMotion: null + + function showBadge(key, modelData) { + const order = root.badgeOrder || []; + if (order.indexOf(key) < 0) { + return false; + } + + if (key === "type") { + return root.typeLabel && root.typeLabel(modelData.type).length > 0; + } + if (key === "dynamic") { + return true; + } + if (key === "music") { + return !!modelData.hasEmbeddedAudio; + } + if (key === "reactive") { + return !!modelData.hasAudioReactive; + } + if (key === "approved") { + return !!modelData.approved; + } + if (key === "resolution") { + return root.resolutionBadgeIcon && root.resolutionBadgeIcon(modelData.resolution).length > 0; + } + if (key === "compatibility") { + return root.propertyCompatibilityBadgeTextForPath + && root.propertyCompatibilityBadgeTextForPath(String(modelData.path || "")).length > 0; + } + + return false; + } + + signal wallpaperActivated(string path) + signal previousPageRequested() + signal nextPageRequested() + + Layout.fillWidth: true + Layout.fillHeight: true + spacing: Style.marginS + + Component { + id: wallpaperCardDelegate + +Rectangle { + id: tileCard + required property var modelData + readonly property var wallpaperData: modelData + readonly property bool isPending: root.pendingPath === modelData.path + readonly property bool isSelected: root.selectedPath === modelData.path + readonly property bool isMotion: modelData.motionPreview && modelData.motionPreview.length > 0 + readonly property bool isVideoMotion: root.isVideoMotion && root.isVideoMotion(modelData.motionPreview) + width: GridView.view.cellWidth + height: GridView.view.cellHeight + radius: Style.radiusL + color: Qt.alpha(Color.mSurface, 0.82) + border.width: isPending ? 2 : (isSelected ? 1 : 0) + border.color: isPending ? Color.mPrimary : Qt.alpha(Color.mOutline, 0.45) + clip: true + + ColumnLayout { + anchors.fill: parent + anchors.margins: Style.marginS + spacing: Style.marginXS + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 136 * Style.uiScaleRatio + radius: Style.radiusM + color: Color.mSurfaceVariant + clip: true + + Image { + anchors.fill: parent + visible: modelData.thumb && modelData.thumb.length > 0 + source: visible ? ("file://" + modelData.thumb) : "" + fillMode: Image.PreserveAspectCrop + cache: false + } + + Loader { + anchors.fill: parent + active: tileCard.isMotion + sourceComponent: tileCard.isVideoMotion ? motionVideoComponent : motionAnimatedComponent + } + + Component { + id: motionAnimatedComponent + + AnimatedImage { + anchors.fill: parent + source: "file://" + modelData.motionPreview + fillMode: Image.PreserveAspectCrop + cache: false + playing: true + } + } + + Component { + id: motionVideoComponent + + Video { + anchors.fill: parent + autoPlay: true + loops: MediaPlayer.Infinite + muted: true + fillMode: VideoOutput.PreserveAspectCrop + source: "file://" + modelData.motionPreview + } + } + + NIcon { + anchors.centerIn: parent + visible: (!modelData.thumb || modelData.thumb.length === 0) && (!modelData.motionPreview || modelData.motionPreview.length === 0) + icon: "photo" + pointSize: Style.fontSizeXL + color: Color.mOnSurfaceVariant + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginXS + + NText { + Layout.fillWidth: true + text: modelData.name + color: Color.mOnSurface + elide: Text.ElideRight + font.weight: Font.Medium + } + + NIcon { + visible: root.selectedPath === modelData.path + icon: "check" + pointSize: Style.fontSizeL + color: Color.mPrimary + } + } + + Item { + id: badgeContainer + Layout.fillWidth: true + Layout.preferredHeight: badgeFlow.implicitHeight + readonly property var visibleBadgeKeys: { + const ordered = root.badgeOrder || []; + const result = []; + for (const key of ordered) { + if (root.showBadge(String(key || ""), modelData)) { + result.push(String(key || "")); + } + } + return result; + } + + Flow { + id: badgeFlow + width: parent.width + spacing: Style.marginXS + + Repeater { + model: badgeContainer.visibleBadgeKeys + + Loader { + required property var modelData + sourceComponent: { + const key = String(modelData || ""); + if (key === "type") return typeBadgeComponent; + if (key === "dynamic") return dynamicBadgeComponent; + if (key === "music") return musicBadgeComponent; + if (key === "reactive") return reactiveBadgeComponent; + if (key === "approved") return approvedBadgeComponent; + if (key === "resolution") return resolutionBadgeComponent; + if (key === "compatibility") return compatibilityBadgeComponent; + return null; + } + } + } + } + + Component { + id: typeBadgeComponent + + Rectangle { + color: Qt.alpha(Color.mSecondary, 0.18) + radius: Style.radiusXS + implicitWidth: typeBadgeRow.implicitWidth + Style.marginS * 2 + implicitHeight: typeBadgeText.implicitHeight + Style.marginXS * 2 + + RowLayout { + id: typeBadgeRow + anchors.centerIn: parent + spacing: Style.marginXS + + NIcon { + icon: root.typeBadgeIcon ? root.typeBadgeIcon(tileCard.wallpaperData.type) : "category" + pointSize: Style.fontSizeM + color: Color.mSecondary + } + + NText { + id: typeBadgeText + text: root.typeLabel ? root.typeLabel(tileCard.wallpaperData.type) : "" + color: Color.mSecondary + font.pointSize: Style.fontSizeXS + font.weight: Font.Medium + } + } + } + } + + Component { + id: dynamicBadgeComponent + + Rectangle { + color: Qt.alpha(Color.mTertiary, 0.18) + radius: Style.radiusXS + implicitWidth: motionBadgeIcon.implicitWidth + Style.marginXS * 2 + implicitHeight: motionBadgeIcon.implicitHeight + Style.marginXS * 2 + + NIcon { + id: motionBadgeIcon + anchors.centerIn: parent + icon: root.dynamicBadgeIcon ? root.dynamicBadgeIcon(!!tileCard.wallpaperData.dynamic) : (tileCard.wallpaperData.dynamic ? "player-play" : "player-stop") + pointSize: Style.fontSizeM + color: tileCard.wallpaperData.dynamic ? Color.mTertiary : Color.mOnSurfaceVariant + } + } + } + + Component { + id: musicBadgeComponent + + Rectangle { + color: Qt.alpha(Color.mPrimary, 0.16) + radius: Style.radiusXS + implicitWidth: musicBadgeIcon.implicitWidth + Style.marginXS * 2 + implicitHeight: musicBadgeIcon.implicitHeight + Style.marginXS * 2 + + NIcon { + id: musicBadgeIcon + anchors.centerIn: parent + icon: "volume" + pointSize: Style.fontSizeM + color: Color.mPrimary + } + } + } + + Component { + id: reactiveBadgeComponent + + Rectangle { + color: Qt.alpha(Color.mSecondary, 0.16) + radius: Style.radiusXS + implicitWidth: reactiveBadgeIcon.implicitWidth + Style.marginXS * 2 + implicitHeight: reactiveBadgeIcon.implicitHeight + Style.marginXS * 2 + + NIcon { + id: reactiveBadgeIcon + anchors.centerIn: parent + icon: "wave-sine" + pointSize: Style.fontSizeM + color: Color.mSecondary + } + } + } + + Component { + id: approvedBadgeComponent + + Rectangle { + color: Qt.alpha(Color.mPrimary, 0.16) + radius: Style.radiusXS + implicitWidth: approvedBadgeIcon.implicitWidth + Style.marginXS * 2 + implicitHeight: approvedBadgeIcon.implicitHeight + Style.marginXS * 2 + + NIcon { + id: approvedBadgeIcon + anchors.centerIn: parent + icon: "rosette-discount-check" + pointSize: Style.fontSizeM + color: Color.mPrimary + } + } + } + + Component { + id: resolutionBadgeComponent + + Rectangle { + color: Qt.alpha(Color.mSurfaceVariant, 0.24) + radius: Style.radiusXS + implicitWidth: resolutionBadgeRow.implicitWidth + Style.marginS * 2 + implicitHeight: resolutionBadgeRow.implicitHeight + Style.marginXS * 2 + + RowLayout { + id: resolutionBadgeRow + anchors.centerIn: parent + spacing: Style.marginXS + + NIcon { + icon: "aspect-ratio" + pointSize: Style.fontSizeM + color: Color.mOnSurfaceVariant + } + + NText { + text: root.resolutionBadgeLabel ? root.resolutionBadgeLabel(tileCard.wallpaperData.resolution) : "" + color: Color.mOnSurfaceVariant + font.pointSize: Style.fontSizeXS + font.weight: Font.Medium + } + } + } + } + + Component { + id: compatibilityBadgeComponent + + Rectangle { + readonly property string compatibilityPath: String(tileCard.wallpaperData.path || "") + readonly property color compatibilityColor: root.propertyCompatibilityBadgeColorForPath ? root.propertyCompatibilityBadgeColorForPath(compatibilityPath) : Color.mError + color: root.propertyCompatibilityBadgeBackgroundForPath + ? root.propertyCompatibilityBadgeBackgroundForPath(compatibilityPath) + : Qt.alpha(compatibilityColor, 0.16) + radius: Style.radiusXS + implicitWidth: compatibilityBadgeIcon.implicitWidth + Style.marginXS * 2 + implicitHeight: compatibilityBadgeIcon.implicitHeight + Style.marginXS * 2 + + NIcon { + id: compatibilityBadgeIcon + anchors.centerIn: parent + icon: "settings-cog" + pointSize: Style.fontSizeM + color: parent.compatibilityColor + } + } + } + } + } + + MouseArea { + anchors.fill: parent + enabled: root.mainInstance?.engineAvailable ?? false + hoverEnabled: true + onClicked: root.wallpaperActivated(modelData.path) + } + } + } + + CardGridSection { + Layout.fillWidth: true + Layout.fillHeight: true + pluginApi: root.pluginApi + items: root.wallpapers + cardDelegate: wallpaperCardDelegate + cellHeight: 208 * Style.uiScaleRatio + showEmptyState: root.wallpapers.length === 0 && !root.scanningWallpapers + emptyIcon: "photo" + emptyText: root.wallpaperItemsCount === 0 + ? pluginApi?.tr("panel.emptyAll") + : pluginApi?.tr("panel.emptyFiltered") + paginationVisible: root.paginationVisible + currentPage: root.currentPage + pageCount: root.pageCount + currentPageDisplay: root.currentPageDisplay + currentPageStartIndex: root.currentPageStartIndex + currentPageEndIndex: root.currentPageEndIndex + totalVisibleCount: root.visibleWallpaperCount + onPreviousPageRequested: root.previousPageRequested() + onNextPageRequested: root.nextPageRequested() + } +} diff --git a/linux-wallpaperengine-controller/components/wallpaper/PreviewCard.qml b/linux-wallpaperengine-controller/components/wallpaper/PreviewCard.qml new file mode 100644 index 000000000..61a4a9122 --- /dev/null +++ b/linux-wallpaperengine-controller/components/wallpaper/PreviewCard.qml @@ -0,0 +1,567 @@ +import QtQuick +import QtQuick.Layouts +import QtMultimedia + +import "../../helpers/panel/DescriptionHelpers.js" as DescriptionHelpers + +import qs.Commons +import qs.Widgets + +ColumnLayout { + id: root + + property var pluginApi: null + property var selectedWallpaperData: null + property var propertyCompatibilityBadgeIconForPath: null + property var propertyCompatibilityBadgeTextForPath: null + property var propertyCompatibilityBadgeColorForPath: null + property var propertyCompatibilityBadgeBackgroundForPath: null + property var resolutionBadgeIcon: null + property var resolutionBadgeLabel: null + property var typeLabel: null + property var typeBadgeIcon: null + property var dynamicBadgeIcon: null + property var badgeOrder: [] + property var isVideoMotion: null + property var formatBytes: null + property bool showDescription: true + readonly property var visibleBadgeKeys: { + const ordered = root.badgeOrder || []; + const result = []; + for (const key of ordered) { + if (root.badgeVisible(String(key || ""))) { + result.push(String(key || "")); + } + } + return result; + } + property bool descriptionExpanded: false + property bool descriptionShouldCollapse: false + readonly property int collapsedDescriptionLineCount: 6 + readonly property real collapsedDescriptionHeight: descriptionFontMetrics.height * root.collapsedDescriptionLineCount + readonly property string selectedDescriptionMarkup: { + return DescriptionHelpers.toRichDescription(root.selectedWallpaperData?.description || ""); + } + + function evaluateDescriptionCollapse() { + if (root.selectedDescriptionMarkup.length === 0) { + root.descriptionShouldCollapse = false; + return; + } + + const measuredLinesRaw = Number(descriptionMeasureText.lineCount || 0); + const measuredHeight = Number(descriptionMeasureText.contentHeight || 0); + const lineHeight = Math.max(1, Number(descriptionFontMetrics.height || 1)); + const measuredLines = measuredLinesRaw > 0 ? measuredLinesRaw : Math.ceil(measuredHeight / lineHeight); + const rawDescription = String(root.selectedWallpaperData?.description || ""); + const normalizedRaw = rawDescription + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n") + .replace(/\\n/g, "\n"); + const hardBreakCount = normalizedRaw.split(/\n+/).length; + const longTextHint = normalizedRaw.length > 900; + + if (measuredLinesRaw > 0 || measuredHeight > 0) { + root.descriptionShouldCollapse = measuredLines > root.collapsedDescriptionLineCount; + return; + } + + root.descriptionShouldCollapse = hardBreakCount > root.collapsedDescriptionLineCount + 1 || longTextHint; + } + + function badgeVisible(key) { + if (!root.selectedWallpaperData) { + return false; + } + if (key === "resolution") { + return root.resolutionBadgeLabel && root.resolutionBadgeLabel(root.selectedWallpaperData.resolution).length > 0; + } + if (key === "type") { + return root.typeLabel && root.typeLabel(root.selectedWallpaperData.type).length > 0; + } + if (key === "dynamic") { + return true; + } + if (key === "music") { + return !!root.selectedWallpaperData.hasEmbeddedAudio; + } + if (key === "reactive") { + return !!root.selectedWallpaperData.hasAudioReactive; + } + if (key === "approved") { + return !!root.selectedWallpaperData.approved; + } + if (key === "compatibility") { + const path = String(root.selectedWallpaperData.path || ""); + return root.propertyCompatibilityBadgeTextForPath && root.propertyCompatibilityBadgeTextForPath(path).length > 0; + } + return false; + } + + Layout.fillWidth: true + spacing: Style.marginS + + onSelectedWallpaperDataChanged: { + descriptionExpanded = false; + descriptionCollapseEvalTimer.restart(); + } + onSelectedDescriptionMarkupChanged: descriptionCollapseEvalTimer.restart() + onWidthChanged: descriptionCollapseEvalTimer.restart() + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 180 * Style.uiScaleRatio + radius: Style.radiusM + color: Color.mSurfaceVariant + clip: true + + Image { + anchors.fill: parent + visible: root.selectedWallpaperData && (!root.selectedWallpaperData.motionPreview || root.selectedWallpaperData.motionPreview.length === 0) && root.selectedWallpaperData.thumb && root.selectedWallpaperData.thumb.length > 0 + source: visible ? ("file://" + root.selectedWallpaperData.thumb) : "" + fillMode: Image.PreserveAspectCrop + cache: false + } + + AnimatedImage { + anchors.fill: parent + visible: root.selectedWallpaperData && root.selectedWallpaperData.motionPreview && root.selectedWallpaperData.motionPreview.length > 0 && !(root.isVideoMotion && root.isVideoMotion(root.selectedWallpaperData.motionPreview)) + source: visible ? ("file://" + root.selectedWallpaperData.motionPreview) : "" + fillMode: Image.PreserveAspectCrop + cache: false + playing: visible + } + + Video { + anchors.fill: parent + visible: root.selectedWallpaperData && root.selectedWallpaperData.motionPreview && root.selectedWallpaperData.motionPreview.length > 0 && root.isVideoMotion && root.isVideoMotion(root.selectedWallpaperData.motionPreview) + autoPlay: true + loops: MediaPlayer.Infinite + muted: true + fillMode: VideoOutput.PreserveAspectCrop + source: visible ? ("file://" + root.selectedWallpaperData.motionPreview) : "" + } + } + + NText { + Layout.fillWidth: true + text: root.selectedWallpaperData ? root.selectedWallpaperData.name : "" + color: Color.mOnSurface + font.weight: Font.Bold + elide: Text.ElideRight + } + + Item { + Layout.fillWidth: true + Layout.preferredHeight: badgeFlow.implicitHeight + + Flow { + id: badgeFlow + width: parent.width + spacing: Style.marginS + + Repeater { + model: root.visibleBadgeKeys + + Loader { + required property var modelData + sourceComponent: { + const key = String(modelData || ""); + if (key === "resolution") return resolutionBadgeComponent; + if (key === "type") return typeBadgeComponent; + if (key === "dynamic") return motionBadgeComponent; + if (key === "music") return musicBadgeComponent; + if (key === "reactive") return reactiveBadgeComponent; + if (key === "approved") return approvedBadgeComponent; + if (key === "compatibility") return compatibilityBadgeComponent; + return null; + } + } + } + + Component { + id: resolutionBadgeComponent + + Rectangle { + color: Qt.alpha(Color.mSurfaceVariant, 0.24) + radius: Style.radiusS + width: resolutionBadgeContent.implicitWidth + Style.marginS * 2 + height: resolutionBadgeContent.implicitHeight + Style.marginXS * 2 + + RowLayout { + id: resolutionBadgeContent + anchors.centerIn: parent + spacing: Style.marginXS + + NIcon { + icon: "aspect-ratio" + pointSize: Style.fontSizeM + color: Color.mOnSurfaceVariant + } + + NText { + text: root.resolutionBadgeLabel ? root.resolutionBadgeLabel(root.selectedWallpaperData.resolution) : "" + color: Color.mOnSurfaceVariant + font.pointSize: Style.fontSizeXS + font.weight: Font.Medium + elide: Text.ElideRight + } + } + } + } + + Component { + id: typeBadgeComponent + + Rectangle { + color: Qt.alpha(Color.mSecondary, 0.1) + radius: Style.radiusS + width: typeBadgeContent.implicitWidth + Style.marginS * 2 + height: typeBadgeContent.implicitHeight + Style.marginXS * 2 + + RowLayout { + id: typeBadgeContent + anchors.centerIn: parent + spacing: Style.marginXS + + NIcon { + icon: root.typeBadgeIcon ? root.typeBadgeIcon(root.selectedWallpaperData.type) : "category" + pointSize: Style.fontSizeM + color: Color.mSecondary + } + + NText { + text: root.typeLabel ? root.typeLabel(root.selectedWallpaperData.type) : "" + color: Color.mSecondary + font.pointSize: Style.fontSizeXS + font.weight: Font.Medium + elide: Text.ElideRight + } + } + } + } + + Component { + id: motionBadgeComponent + + Rectangle { + color: root.selectedWallpaperData.dynamic + ? Qt.alpha(Color.mTertiary, 0.1) + : Qt.alpha(Color.mOutline, 0.1) + radius: Style.radiusS + width: motionBadgeContent.implicitWidth + Style.marginS * 2 + height: motionBadgeContent.implicitHeight + Style.marginXS * 2 + + RowLayout { + id: motionBadgeContent + anchors.centerIn: parent + spacing: Style.marginXS + + NIcon { + icon: root.dynamicBadgeIcon + ? root.dynamicBadgeIcon(!!root.selectedWallpaperData.dynamic) + : (root.selectedWallpaperData.dynamic ? "player-play" : "player-stop") + pointSize: Style.fontSizeM + color: root.selectedWallpaperData.dynamic ? Color.mTertiary : Color.mOnSurfaceVariant + } + + NText { + text: root.selectedWallpaperData.dynamic + ? pluginApi?.tr("panel.dynamicBadge") + : pluginApi?.tr("panel.staticBadge") + color: root.selectedWallpaperData.dynamic ? Color.mTertiary : Color.mOnSurfaceVariant + font.pointSize: Style.fontSizeXS + font.weight: Font.Medium + elide: Text.ElideRight + } + } + } + } + + Component { + id: musicBadgeComponent + + Rectangle { + color: Qt.alpha(Color.mPrimary, 0.1) + radius: Style.radiusS + width: audioBadgeContent.implicitWidth + Style.marginS * 2 + height: audioBadgeContent.implicitHeight + Style.marginXS * 2 + + RowLayout { + id: audioBadgeContent + anchors.centerIn: parent + spacing: Style.marginXS + + NIcon { + icon: "volume" + pointSize: Style.fontSizeM + color: Color.mPrimary + } + + NText { + text: pluginApi?.tr("panel.musicBadge") + color: Color.mPrimary + font.pointSize: Style.fontSizeXS + font.weight: Font.Medium + elide: Text.ElideRight + } + } + } + } + + Component { + id: reactiveBadgeComponent + + Rectangle { + color: Qt.alpha(Color.mSecondary, 0.1) + radius: Style.radiusS + width: reactiveBadgeContent.implicitWidth + Style.marginS * 2 + height: reactiveBadgeContent.implicitHeight + Style.marginXS * 2 + + RowLayout { + id: reactiveBadgeContent + anchors.centerIn: parent + spacing: Style.marginXS + + NIcon { + icon: "wave-sine" + pointSize: Style.fontSizeM + color: Color.mSecondary + } + + NText { + text: pluginApi?.tr("panel.reactiveBadge") + color: Color.mSecondary + font.pointSize: Style.fontSizeXS + font.weight: Font.Medium + elide: Text.ElideRight + } + } + } + } + + Component { + id: approvedBadgeComponent + + Rectangle { + color: Qt.alpha(Color.mPrimary, 0.1) + radius: Style.radiusS + width: approvedBadgeContent.implicitWidth + Style.marginS * 2 + height: approvedBadgeContent.implicitHeight + Style.marginXS * 2 + + RowLayout { + id: approvedBadgeContent + anchors.centerIn: parent + spacing: Style.marginXS + + NIcon { + icon: "rosette-discount-check" + pointSize: Style.fontSizeM + color: Color.mPrimary + } + + NText { + text: pluginApi?.tr("panel.approvedBadge") + color: Color.mPrimary + font.pointSize: Style.fontSizeXS + font.weight: Font.Medium + elide: Text.ElideRight + } + } + } + } + + Component { + id: compatibilityBadgeComponent + + Rectangle { + id: compatibilityBadge + readonly property string compatibilityPath: String(root.selectedWallpaperData?.path || "") + readonly property string compatibilityLabel: root.propertyCompatibilityBadgeTextForPath ? root.propertyCompatibilityBadgeTextForPath(compatibilityPath) : "" + readonly property color compatibilityColor: root.propertyCompatibilityBadgeColorForPath ? root.propertyCompatibilityBadgeColorForPath(compatibilityPath) : Color.mError + readonly property string compatibilityIcon: root.propertyCompatibilityBadgeIconForPath ? root.propertyCompatibilityBadgeIconForPath(compatibilityPath) : "alert-triangle" + color: root.propertyCompatibilityBadgeBackgroundForPath + ? root.propertyCompatibilityBadgeBackgroundForPath(compatibilityPath) + : Qt.alpha(compatibilityColor, 0.1) + radius: Style.radiusS + width: compatBadgeContent.implicitWidth + Style.marginS * 2 + height: compatBadgeContent.implicitHeight + Style.marginXS * 2 + + RowLayout { + id: compatBadgeContent + anchors.centerIn: parent + spacing: Style.marginXS + + NIcon { + icon: "settings-cog" + pointSize: Style.fontSizeM + color: compatibilityBadge.compatibilityColor + } + + NText { + text: compatibilityBadge.compatibilityLabel + color: compatibilityBadge.compatibilityColor + font.pointSize: Style.fontSizeXS + font.weight: Font.Medium + elide: Text.ElideRight + } + } + } + } + } + } + + RowLayout { + Layout.fillWidth: true + Layout.topMargin: Style.marginM + + NText { + text: pluginApi?.tr("panel.infoType") + color: Color.mOnSurfaceVariant + } + + Item { Layout.fillWidth: true } + + NText { + text: root.selectedWallpaperData && root.typeLabel ? root.typeLabel(root.selectedWallpaperData.type) : "" + color: Color.mOnSurface + } + } + + RowLayout { + Layout.fillWidth: true + + NText { + text: pluginApi?.tr("panel.infoId") + color: Color.mOnSurfaceVariant + } + + Item { Layout.fillWidth: true } + + NText { + text: root.selectedWallpaperData ? root.selectedWallpaperData.id : "" + color: Color.mOnSurface + elide: Text.ElideMiddle + } + } + + RowLayout { + Layout.fillWidth: true + + NText { + text: pluginApi?.tr("panel.infoResolution") + color: Color.mOnSurfaceVariant + } + + Item { Layout.fillWidth: true } + + NText { + text: root.selectedWallpaperData + ? (String(root.selectedWallpaperData.resolution || "unknown") === "unknown" + ? pluginApi?.tr("panel.resolutionUnknown") + : root.selectedWallpaperData.resolution) + : "" + color: Color.mOnSurface + } + } + + RowLayout { + Layout.fillWidth: true + + NText { + text: pluginApi?.tr("panel.infoSize") + color: Color.mOnSurfaceVariant + } + + Item { Layout.fillWidth: true } + + NText { + text: root.selectedWallpaperData && root.formatBytes ? root.formatBytes(root.selectedWallpaperData.bytes) : "" + color: Color.mOnSurface + } + } + + ColumnLayout { + Layout.fillWidth: true + visible: root.showDescription && root.selectedDescriptionMarkup.length > 0 + spacing: Style.marginXS + + FontMetrics { + id: descriptionFontMetrics + font: descriptionText.font + } + + NText { + Layout.fillWidth: true + text: pluginApi?.tr("panel.infoDescription") + color: Color.mOnSurfaceVariant + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: { + if (!root.descriptionShouldCollapse || root.descriptionExpanded) { + return descriptionText.implicitHeight + Style.marginS * 2; + } + return root.collapsedDescriptionHeight + Style.marginS * 2; + } + radius: Style.radiusM + color: Qt.alpha(Color.mSurfaceVariant, 0.2) + border.width: Style.borderS + border.color: Qt.alpha(Color.mOutline, 0.22) + clip: true + + NText { + id: descriptionMeasureText + visible: false + width: descriptionText.width + text: root.selectedDescriptionMarkup + textFormat: Text.RichText + wrapMode: Text.Wrap + font: descriptionText.font + onContentHeightChanged: descriptionCollapseEvalTimer.restart() + } + + NText { + id: descriptionText + anchors.fill: parent + anchors.margins: Style.marginS + text: root.selectedDescriptionMarkup + textFormat: Text.RichText + color: Color.mOnSurfaceVariant + wrapMode: Text.Wrap + linkColor: Color.mPrimary + onLinkActivated: url => Qt.openUrlExternally(url) + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 36 * Style.uiScaleRatio + visible: root.descriptionShouldCollapse && !root.descriptionExpanded + color: Qt.rgba( + Color.mSurfaceVariant.r, + Color.mSurfaceVariant.g, + Color.mSurfaceVariant.b, + 0.28 + ) + } + } + + NButton { + Layout.fillWidth: true + visible: root.descriptionShouldCollapse || root.descriptionExpanded + text: root.descriptionExpanded ? pluginApi?.tr("panel.showLess") : pluginApi?.tr("panel.showMore") + icon: root.descriptionExpanded ? "chevron-up" : "chevron-down" + onClicked: root.descriptionExpanded = !root.descriptionExpanded + } + } + + Timer { + id: descriptionCollapseEvalTimer + interval: 40 + repeat: false + onTriggered: root.evaluateDescriptionCollapse() + } +} diff --git a/linux-wallpaperengine-controller/components/WallpaperPropertiesEditor.qml b/linux-wallpaperengine-controller/components/wallpaper/PropertiesEditor.qml similarity index 53% rename from linux-wallpaperengine-controller/components/WallpaperPropertiesEditor.qml rename to linux-wallpaperengine-controller/components/wallpaper/PropertiesEditor.qml index e7a6d9635..535d1e430 100644 --- a/linux-wallpaperengine-controller/components/WallpaperPropertiesEditor.qml +++ b/linux-wallpaperengine-controller/components/wallpaper/PropertiesEditor.qml @@ -11,13 +11,7 @@ ColumnLayout { property bool loadingWallpaperProperties: false property string wallpaperPropertyError: "" property var wallpaperPropertyDefinitions: [] - property var propertyValueFor: null - property var numberOr: null - property var formatSliderValue: null - property var comboChoicesFor: null - property var ensureColorValue: null - property var serializePropertyValue: null - property var setPropertyValue: null + property var propertyEditorApi: null Layout.fillWidth: true spacing: Style.marginS @@ -70,15 +64,18 @@ ColumnLayout { Layout.fillWidth: true spacing: Style.marginXS - property bool boolValue: !!(root.propertyValueFor ? root.propertyValueFor(modelData) : false) - property real sliderValue: root.numberOr ? root.numberOr(root.propertyValueFor ? root.propertyValueFor(modelData) : 0, 0) : 0 - property string comboValue: String(root.propertyValueFor ? root.propertyValueFor(modelData) : "") - property string textValue: String(root.propertyValueFor ? root.propertyValueFor(modelData) : "") + property bool boolValue: !!(root.propertyEditorApi?.propertyValueFor ? root.propertyEditorApi.propertyValueFor(modelData) : false) + property real sliderValue: root.propertyEditorApi?.numberOr ? root.propertyEditorApi.numberOr(root.propertyEditorApi?.propertyValueFor ? root.propertyEditorApi.propertyValueFor(modelData) : 0, 0) : 0 + property string comboValue: String(root.propertyEditorApi?.propertyValueFor ? root.propertyEditorApi.propertyValueFor(modelData) : "") + property string textValue: String(root.propertyEditorApi?.propertyValueFor ? root.propertyEditorApi.propertyValueFor(modelData) : "") + property string imageValue: modelData.type === "image" + ? String(modelData.imageSource || "") + : "" property color colorValue: Qt.rgba(1, 1, 1, 1) Component.onCompleted: { - if (modelData.type === "color" && root.ensureColorValue && root.propertyValueFor) { - propertyEditor.colorValue = root.ensureColorValue(root.propertyValueFor(modelData)); + if (modelData.type === "color" && root.propertyEditorApi?.ensureColorValue && root.propertyEditorApi?.propertyValueFor) { + propertyEditor.colorValue = root.propertyEditorApi.ensureColorValue(root.propertyEditorApi.propertyValueFor(modelData)); } } @@ -93,8 +90,8 @@ ColumnLayout { return; } propertyEditor.boolValue = checked; - if (root.setPropertyValue) { - root.setPropertyValue(modelData.key, checked); + if (root.propertyEditorApi?.setPropertyValue) { + root.propertyEditorApi.setPropertyValue(modelData.key, checked); } } } @@ -104,18 +101,18 @@ ColumnLayout { Layout.preferredHeight: visible ? implicitHeight : 0 visible: modelData.type === "slider" label: modelData.label - from: root.numberOr ? root.numberOr(modelData.min, 0) : 0 - to: root.numberOr ? root.numberOr(modelData.max, 100) : 100 - stepSize: Math.max(root.numberOr ? root.numberOr(modelData.step, 1) : 1, 0.001) + from: root.propertyEditorApi?.numberOr ? root.propertyEditorApi.numberOr(modelData.min, 0) : 0 + to: root.propertyEditorApi?.numberOr ? root.propertyEditorApi.numberOr(modelData.max, 100) : 100 + stepSize: Math.max(root.propertyEditorApi?.numberOr ? root.propertyEditorApi.numberOr(modelData.step, 1) : 1, 0.001) value: propertyEditor.sliderValue - text: root.formatSliderValue ? root.formatSliderValue(propertyEditor.sliderValue, modelData.step) : String(propertyEditor.sliderValue) + text: root.propertyEditorApi?.formatSliderValue ? root.propertyEditorApi.formatSliderValue(propertyEditor.sliderValue, modelData.step) : String(propertyEditor.sliderValue) onMoved: value => { if (value === propertyEditor.sliderValue) { return; } propertyEditor.sliderValue = value; - if (root.setPropertyValue) { - root.setPropertyValue(modelData.key, value); + if (root.propertyEditorApi?.setPropertyValue) { + root.propertyEditorApi.setPropertyValue(modelData.key, value); } } } @@ -125,7 +122,7 @@ ColumnLayout { Layout.preferredHeight: visible ? implicitHeight : 0 visible: modelData.type === "combo" label: modelData.label - model: root.comboChoicesFor ? root.comboChoicesFor(modelData) : [] + model: root.propertyEditorApi?.comboChoicesFor ? root.propertyEditorApi.comboChoicesFor(modelData) : [] currentKey: propertyEditor.comboValue onSelected: key => { const normalizedKey = String(key); @@ -133,8 +130,8 @@ ColumnLayout { return; } propertyEditor.comboValue = normalizedKey; - if (root.setPropertyValue) { - root.setPropertyValue(modelData.key, normalizedKey); + if (root.propertyEditorApi?.setPropertyValue) { + root.propertyEditorApi.setPropertyValue(modelData.key, normalizedKey); } } } @@ -151,8 +148,8 @@ ColumnLayout { return; } propertyEditor.textValue = nextText; - if (root.setPropertyValue) { - root.setPropertyValue(modelData.key, nextText); + if (root.propertyEditorApi?.setPropertyValue) { + root.propertyEditorApi.setPropertyValue(modelData.key, nextText); } } onAccepted: { @@ -161,8 +158,8 @@ ColumnLayout { return; } propertyEditor.textValue = nextText; - if (root.setPropertyValue) { - root.setPropertyValue(modelData.key, nextText); + if (root.propertyEditorApi?.setPropertyValue) { + root.propertyEditorApi.setPropertyValue(modelData.key, nextText); } } } @@ -180,6 +177,63 @@ ColumnLayout { bottomPadding: Style.marginXS } + ColumnLayout { + Layout.fillWidth: true + Layout.preferredHeight: visible ? implicitHeight : 0 + visible: modelData.type === "image" + spacing: Style.marginXS + + NText { + Layout.fillWidth: true + visible: String(modelData.label || "").trim().length > 0 + text: modelData.label + color: Color.mOnSurface + font.pointSize: Style.fontSizeM + wrapMode: Text.Wrap + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 160 * Style.uiScaleRatio + radius: Style.radiusM + color: Qt.alpha(Color.mSurfaceVariant, 0.35) + border.width: Style.borderS + border.color: Qt.alpha(Color.mOutline, 0.35) + clip: true + + Image { + id: sceneTexturePreview + anchors.fill: parent + anchors.margins: Style.marginXS + source: parent.parent.visible && root.propertyEditorApi?.resolvePropertyImageSource + ? root.propertyEditorApi.resolvePropertyImageSource(propertyEditor.imageValue) + : "" + fillMode: Image.PreserveAspectFit + asynchronous: true + cache: false + } + + NText { + anchors.centerIn: parent + visible: sceneTexturePreview.status === Image.Error || sceneTexturePreview.source.length === 0 + text: propertyEditor.imageValue + color: Color.mOnSurfaceVariant + wrapMode: Text.WrapAnywhere + width: parent.width - Style.marginM * 2 + horizontalAlignment: Text.AlignHCenter + } + } + + NText { + Layout.fillWidth: true + visible: sceneTexturePreview.status === Image.Error || sceneTexturePreview.source.length === 0 + text: propertyEditor.imageValue + color: Color.mOnSurfaceVariant + font.pointSize: Style.fontSizeS + wrapMode: Text.WrapAnywhere + } + } + ColumnLayout { Layout.fillWidth: true Layout.preferredHeight: visible ? implicitHeight : 0 @@ -210,15 +264,15 @@ ColumnLayout { selectedColor: propertyEditor.colorValue onColorSelected: color => { propertyEditor.colorValue = color; - if (root.setPropertyValue) { - root.setPropertyValue(modelData.key, color); + if (root.propertyEditorApi?.setPropertyValue) { + root.propertyEditorApi.setPropertyValue(modelData.key, color); } } } NText { Layout.fillWidth: true - text: root.serializePropertyValue ? root.serializePropertyValue(propertyEditor.colorValue, "color") : "" + text: root.propertyEditorApi?.serializePropertyValue ? root.propertyEditorApi.serializePropertyValue(propertyEditor.colorValue, "color") : "" color: Color.mOnSurfaceVariant font.pointSize: Style.fontSizeS } diff --git a/linux-wallpaperengine-controller/components/wallpaper/Sidebar.qml b/linux-wallpaperengine-controller/components/wallpaper/Sidebar.qml new file mode 100644 index 000000000..59a2cbee8 --- /dev/null +++ b/linux-wallpaperengine-controller/components/wallpaper/Sidebar.qml @@ -0,0 +1,140 @@ +import QtQuick +import QtQuick.Layouts + +import qs.Commons +import qs.Widgets + +import "." +import "../shared" + +SidebarPanel { + id: root + + // Shared context. + property var pluginApi: null + property var mainInstance: null + + // Current wallpaper preview state. + property var selectedWallpaperData: null + property var propertyCompatibilityBadgeIconForPath: null + property var propertyCompatibilityBadgeTextForPath: null + property var propertyCompatibilityBadgeColorForPath: null + property var propertyCompatibilityBadgeBackgroundForPath: null + property var resolutionBadgeIcon: null + property var resolutionBadgeLabel: null + property var typeLabel: null + property var typeBadgeIcon: null + property var dynamicBadgeIcon: null + property var badgeOrder: [] + property var isVideoMotion: null + property var formatBytes: null + property bool showDescription: true + + // Apply target selection. + property bool singleScreenMode: true + property bool applyAllDisplays: true + property bool applyTargetExpanded: false + property var screenModel: [] + property string selectedScreenName: "" + + // Per-apply runtime options. + property string selectedScaling: "fill" + property string selectedClamp: "clamp" + property int selectedVolume: 100 + property bool selectedMuted: true + property bool selectedAudioReactiveEffects: true + property bool selectedDisableMouse: false + property bool selectedDisableParallax: false + property bool applyWallpaperColorsOnApply: false + property bool applyingWallpaperColors: false + + // Extra wallpaper property editor state. + property bool extraPropertiesEditorEnabled: true + property bool loadingWallpaperProperties: false + property string wallpaperPropertyError: "" + property var wallpaperPropertyDefinitions: [] + property var propertyEditorApi: null + + // Upstream actions. + signal applyRequested() + signal applyAllDisplaysRequested(bool value) + signal applyTargetExpandedRequested(bool value) + signal selectedScreenNameRequested(string value) + signal selectedScalingRequested(string value) + signal selectedClampRequested(string value) + signal selectedVolumeRequested(int value) + signal selectedMutedRequested(bool value) + signal selectedAudioReactiveEffectsRequested(bool value) + signal selectedDisableMouseRequested(bool value) + signal selectedDisableParallaxRequested(bool value) + signal applyWallpaperColorsOnApplyRequested(bool value) + signal sidebarVisibleRequested(bool value) + property bool sidebarVisible: true + + panelVisible: root.selectedWallpaperData !== null && root.sidebarVisible + + // Preview content. + PreviewCard { + pluginApi: root.pluginApi + selectedWallpaperData: root.selectedWallpaperData + propertyCompatibilityBadgeIconForPath: root.propertyCompatibilityBadgeIconForPath + propertyCompatibilityBadgeTextForPath: root.propertyCompatibilityBadgeTextForPath + propertyCompatibilityBadgeColorForPath: root.propertyCompatibilityBadgeColorForPath + propertyCompatibilityBadgeBackgroundForPath: root.propertyCompatibilityBadgeBackgroundForPath + resolutionBadgeIcon: root.resolutionBadgeIcon + resolutionBadgeLabel: root.resolutionBadgeLabel + typeLabel: root.typeLabel + typeBadgeIcon: root.typeBadgeIcon + dynamicBadgeIcon: root.dynamicBadgeIcon + badgeOrder: root.badgeOrder + isVideoMotion: root.isVideoMotion + formatBytes: root.formatBytes + showDescription: root.showDescription + } + + // Apply controls and property editing. + ApplyControls { + pluginApi: root.pluginApi + mainInstance: root.mainInstance + selectedWallpaperData: root.selectedWallpaperData + singleScreenMode: root.singleScreenMode + applyAllDisplays: root.applyAllDisplays + applyTargetExpanded: root.applyTargetExpanded + screenModel: root.screenModel + selectedScreenName: root.selectedScreenName + selectedScaling: root.selectedScaling + selectedClamp: root.selectedClamp + selectedVolume: root.selectedVolume + selectedMuted: root.selectedMuted + selectedAudioReactiveEffects: root.selectedAudioReactiveEffects + selectedDisableMouse: root.selectedDisableMouse + selectedDisableParallax: root.selectedDisableParallax + applyWallpaperColorsOnApply: root.applyWallpaperColorsOnApply + applyingWallpaperColors: root.applyingWallpaperColors + extraPropertiesEditorEnabled: root.extraPropertiesEditorEnabled + loadingWallpaperProperties: root.loadingWallpaperProperties + wallpaperPropertyError: root.wallpaperPropertyError + wallpaperPropertyDefinitions: root.wallpaperPropertyDefinitions + propertyEditorApi: root.propertyEditorApi + onApplyRequested: root.applyRequested() + onApplyAllDisplaysRequested: value => root.applyAllDisplaysRequested(value) + onApplyTargetExpandedRequested: value => root.applyTargetExpandedRequested(value) + onSelectedScreenNameRequested: value => root.selectedScreenNameRequested(value) + onSelectedScalingRequested: value => root.selectedScalingRequested(value) + onSelectedClampRequested: value => root.selectedClampRequested(value) + onSelectedVolumeRequested: value => root.selectedVolumeRequested(value) + onSelectedMutedRequested: value => root.selectedMutedRequested(value) + onSelectedAudioReactiveEffectsRequested: value => root.selectedAudioReactiveEffectsRequested(value) + onSelectedDisableMouseRequested: value => root.selectedDisableMouseRequested(value) + onSelectedDisableParallaxRequested: value => root.selectedDisableParallaxRequested(value) + onApplyWallpaperColorsOnApplyRequested: value => root.applyWallpaperColorsOnApplyRequested(value) + } + + footerContent: NButton { + Layout.fillWidth: true + text: pluginApi?.tr("panel.closeSidebar") + icon: "layout-sidebar-right-collapse" + onClicked: root.sidebarVisibleRequested(false) + } + +} diff --git a/linux-wallpaperengine-controller/helpers/PropertyHelpers.js b/linux-wallpaperengine-controller/helpers/PropertyHelpers.js deleted file mode 100644 index a976c69ca..000000000 --- a/linux-wallpaperengine-controller/helpers/PropertyHelpers.js +++ /dev/null @@ -1,178 +0,0 @@ -.pragma library - -function stripHtml(rawText) { - return String(rawText || "") - .replace(/<[^>]*>/g, " ") - .replace(/ ?/gi, " ") - .replace(/&/gi, "&") - .replace(/</gi, "<") - .replace(/>/gi, ">") - .replace(/\s+/g, " ") - .trim(); -} - -function normalizePropertyLabel(value, translatePropertyLabelKey) { - const raw = String(value || "").trim(); - if (raw.length === 0) { - return ""; - } - - const looksLikeKey = /^[a-z0-9_]+$/i.test(raw) && raw.indexOf("_") >= 0; - if (!looksLikeKey) { - return raw; - } - - const normalizedKey = raw - .replace(/^ui_browse_properties_/i, "") - .replace(/^ui_/i, "") - .replace(/^properties_/i, ""); - - if (normalizedKey.toLowerCase() === "scheme_color" && translatePropertyLabelKey) { - return translatePropertyLabelKey("panel.propertyLabelThemeColor"); - } - - return normalizedKey - .split("_") - .filter(part => part.length > 0) - .map(part => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) - .join(" "); -} - -function cleanedPropertyLabel(rawText, fallbackKey, translatePropertyLabelKey) { - const stripped = stripHtml(rawText) - .replace(/^[\-–—•·*_#\s]+/, "") - .replace(/^[^\p{L}\p{N}]+/u, "") - .trim(); - if (stripped.length > 0) { - return normalizePropertyLabel(stripped, translatePropertyLabelKey); - } - return normalizePropertyLabel(String(fallbackKey || ""), translatePropertyLabelKey); -} - -function isNoisePropertyKey(value) { - const key = String(value || "").toLowerCase().trim(); - if (key.length === 0) { - return true; - } - return key.indexOf("imgsrc") === 0 - || key.indexOf("brahref") === 0 - || key.indexOf("centerbrahref") === 0 - || key.indexOf("bigweixin") === 0 - || key.indexOf("viewer_4") >= 0 - || key.indexOf("photogz") >= 0 - || key.indexOf("mqpic") >= 0 - || key.indexOf("width") >= 0 && key.indexOf("height") >= 0; -} - -function isNoisePropertyLabel(value) { - const label = String(value || "").toLowerCase().trim(); - if (label.length === 0) { - return true; - } - return label.indexOf("imgsrc") >= 0 - || label.indexOf("photogz") >= 0 - || label.indexOf("mqpic") >= 0 - || label.indexOf("viewer_4") >= 0; -} - -function comboChoicesFor(definition) { - const rawChoices = definition && definition.choices || []; - const normalized = []; - for (let i = 0; i < rawChoices.length; i++) { - const choice = rawChoices[i]; - const key = String(choice && (choice.key ?? choice.value) || "").trim(); - const name = String(choice && (choice.name ?? choice.label ?? choice.text) || key).trim(); - if (key.length === 0) { - continue; - } - normalized.push({ key: key, name: name.length > 0 ? name : key }); - } - return normalized; -} - -function numberOr(value, fallback) { - const parsed = Number(value); - return isNaN(parsed) ? fallback : parsed; -} - -function formatSliderValue(value, step) { - const numericValue = numberOr(value, 0); - const numericStep = Math.max(numberOr(step, 1), 0.001); - let decimals = 0; - if (numericStep < 1) { - const stepText = String(numericStep); - if (stepText.indexOf("e-") >= 0) { - decimals = Number(stepText.split("e-")[1]) || 0; - } else if (stepText.indexOf(".") >= 0) { - decimals = stepText.split(".")[1].length; - } - } - return numericValue.toFixed(Math.min(decimals, 6)); -} - -function parsePropertyValue(rawValue, type, createColor) { - const trimmed = String(rawValue || "").trim(); - if (type === "boolean") { - return trimmed === "1"; - } - if (type === "slider") { - const parsed = Number(trimmed); - return isNaN(parsed) ? 0 : parsed; - } - if (type === "combo") { - return String(trimmed); - } - if (type === "textinput") { - return trimmed.replace(/^"|"$/g, ""); - } - if (type === "color") { - const parts = trimmed.split(",").map(part => Number(String(part).trim())); - if (parts.length >= 3 && parts.every(part => !isNaN(part))) { - const maxChannel = Math.max(parts[0], parts[1], parts[2]); - if (createColor) { - if (maxChannel > 1) { - return createColor(parts[0] / 255, parts[1] / 255, parts[2] / 255, 1); - } - return createColor(parts[0], parts[1], parts[2], 1); - } - } - return createColor ? createColor(1, 1, 1, 1) : trimmed; - } - return trimmed; -} - -function serializePropertyValue(value, type) { - if (type === "boolean") { - return value ? "1" : "0"; - } - if (type === "slider") { - return String(value); - } - if (type === "combo") { - return String(value); - } - if (type === "textinput") { - return String(value); - } - if (type === "color") { - const color = value; - const r = Math.round((color && color.r !== undefined ? color.r : 1) * 255); - const g = Math.round((color && color.g !== undefined ? color.g : 1) * 255); - const b = Math.round((color && color.b !== undefined ? color.b : 1) * 255); - return String(r) + "," + String(g) + "," + String(b); - } - return String(value); -} - -function ensureColorValue(value, parseColorValue, createColor) { - if (value === undefined || value === null || value === "") { - return createColor ? createColor(1, 1, 1, 1) : value; - } - if (typeof value === "string") { - return parseColorValue ? parseColorValue(value, "color") : value; - } - if (value.r !== undefined && value.g !== undefined && value.b !== undefined) { - return createColor ? createColor(value.r, value.g, value.b, value.a !== undefined ? value.a : 1) : value; - } - return createColor ? createColor(1, 1, 1, 1) : value; -} diff --git a/linux-wallpaperengine-controller/helpers/panel/BadgeHelpers.js b/linux-wallpaperengine-controller/helpers/panel/BadgeHelpers.js new file mode 100644 index 000000000..e1739a764 --- /dev/null +++ b/linux-wallpaperengine-controller/helpers/panel/BadgeHelpers.js @@ -0,0 +1,91 @@ +.pragma library + +var allowedBadgeKeys = ["type", "dynamic", "music", "reactive", "approved", "resolution", "compatibility"]; + +function normalizedDefaultOrder(rawOrder) { + const input = Array.isArray(rawOrder) ? rawOrder : allowedBadgeKeys; + const output = []; + for (const raw of input) { + const key = String(raw || "").trim(); + if (allowedBadgeKeys.indexOf(key) >= 0 && output.indexOf(key) < 0) { + output.push(key); + } + } + for (const key of allowedBadgeKeys) { + if (output.indexOf(key) < 0) { + output.push(key); + } + } + return output; +} + +function normalizedDefaultEnabled(rawState) { + const source = rawState && typeof rawState === "object" ? rawState : ({}); + const output = {}; + for (const key of allowedBadgeKeys) { + output[key] = source[key] ?? true; + } + return output; +} + +function normalizeBadgeOrder(rawOrder, fallbackOrder) { + return normalizedDefaultOrder(Array.isArray(rawOrder) ? rawOrder : fallbackOrder); +} + +function normalizeBadgeEnabled(rawState, fallbackState) { + const fallback = normalizedDefaultEnabled(fallbackState); + const source = rawState && typeof rawState === "object" ? rawState : fallback; + const output = {}; + for (const key of allowedBadgeKeys) { + output[key] = source[key] ?? fallback[key] ?? true; + } + return output; +} + +function filterVisibleBadgeOrder(order, enabledState, fallbackState) { + const normalizedOrder = normalizeBadgeOrder(order, allowedBadgeKeys); + const normalizedEnabled = normalizeBadgeEnabled(enabledState, fallbackState); + const output = []; + for (const key of normalizedOrder) { + if (normalizedEnabled[key]) { + output.push(key); + } + } + return output; +} + +function settingsBadgeLabel(key, tr) { + const value = String(key || ""); + if (value === "type") return tr ? tr("settings.badges.items.type") : value; + if (value === "dynamic") return tr ? tr("settings.badges.items.dynamic") : value; + if (value === "music") return tr ? tr("settings.badges.items.music") : value; + if (value === "reactive") return tr ? tr("settings.badges.items.reactive") : value; + if (value === "approved") return tr ? tr("settings.badges.items.approved") : value; + if (value === "resolution") return tr ? tr("settings.badges.items.resolution") : value; + if (value === "compatibility") return tr ? tr("settings.badges.items.compatibility") : value; + return value; +} + +function settingsBadgeIcon(key) { + const value = String(key || ""); + if (value === "type") return "apps"; + if (value === "dynamic") return "player-play"; + if (value === "music") return "volume"; + if (value === "reactive") return "wave-sine"; + if (value === "approved") return "rosette-discount-check"; + if (value === "resolution") return "aspect-ratio"; + if (value === "compatibility") return "settings-cog"; + return "tag"; +} + +function settingsBadgeDescription(key, tr) { + const value = String(key || ""); + if (value === "type") return tr ? tr("settings.badges.details.type") : ""; + if (value === "dynamic") return tr ? tr("settings.badges.details.dynamic") : ""; + if (value === "music") return tr ? tr("settings.badges.details.music") : ""; + if (value === "reactive") return tr ? tr("settings.badges.details.reactive") : ""; + if (value === "approved") return tr ? tr("settings.badges.details.approved") : ""; + if (value === "resolution") return tr ? tr("settings.badges.details.resolution") : ""; + if (value === "compatibility") return tr ? tr("settings.badges.details.compatibility") : ""; + return ""; +} diff --git a/linux-wallpaperengine-controller/helpers/panel/DescriptionHelpers.js b/linux-wallpaperengine-controller/helpers/panel/DescriptionHelpers.js new file mode 100644 index 000000000..3f1c05ccf --- /dev/null +++ b/linux-wallpaperengine-controller/helpers/panel/DescriptionHelpers.js @@ -0,0 +1,158 @@ +.pragma library + +function escapeHtml(text) { + return String(text || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\"/g, """) + .replace(/'/g, "'"); +} + +function decodeEntities(text) { + return String(text || "") + .replace(/&/gi, "&") + .replace(/"/gi, "\"") + .replace(/'/gi, "'") + .replace(/</gi, "<") + .replace(/>/gi, ">"); +} + +function escapeAttr(text) { + return escapeHtml(String(text || "")).replace(/\n/g, ""); +} + +function normalizeEscapes(rawText) { + let text = String(rawText || ""); + for (let i = 0; i < 4; i++) { + const next = text + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n") + .replace(/\\r\\n/g, "\n") + .replace(/\\n/g, "\n") + .replace(/\\r/g, "\n") + .replace(/\\t/g, " ") + .replace(/\\\//g, "/") + .replace(/\\\"/g, "\"") + .replace(/\\\\/g, "\\"); + + if (next === text) { + break; + } + text = next; + } + return text; +} + +function sanitizeUrl(urlText) { + const raw = decodeEntities(String(urlText || "")).trim(); + if (raw.length === 0) { + return ""; + } + + if (/^(https?:\/\/|steam:\/\/|mailto:)/i.test(raw)) { + return raw; + } + + if (/^www\./i.test(raw)) { + return "https://" + raw; + } + + return ""; +} + +function linkHtml(url, label) { + const safeUrl = sanitizeUrl(url); + if (safeUrl.length === 0) { + return label; + } + return '' + label + ""; +} + +function linkifyPlainUrls(htmlText) { + const parts = String(htmlText || "").split(/(<[^>]+>)/g); + for (let i = 0; i < parts.length; i++) { + const chunk = parts[i]; + if (chunk.length === 0 || chunk.charAt(0) === "<") { + continue; + } + + parts[i] = chunk.replace(/(^|[\s(>])((?:https?:\/\/|www\.)[^\s<]+)/gi, function (_match, prefix, url) { + const link = linkHtml(url, escapeHtml(url)); + if (link === escapeHtml(url)) { + return prefix + escapeHtml(url); + } + return prefix + link; + }); + } + return parts.join(""); +} + +function toRichDescription(rawText) { + const normalized = normalizeEscapes(rawText).trim(); + if (normalized.length === 0) { + return ""; + } + + let text = escapeHtml(normalized); + const blocks = []; + + function stash(blockHtml) { + const token = "@@BLOCK_" + String(blocks.length) + "@@"; + blocks.push(blockHtml); + return token; + } + + text = text.replace(/\[code\]([\s\S]*?)\[\/code\]/gi, function (_m, body) { + return stash('
' + body + "");
+ });
+
+ text = text.replace(/\[quote(?:=[^\]]+)?\]([\s\S]*?)\[\/quote\]/gi, function (_m, body) {
+ return stash('' + body + ""); + }); + + text = text.replace(/\[img\]([\s\S]*?)\[\/img\]/gi, function (_m, url) { + const label = "[image]"; + const linked = linkHtml(url, escapeHtml(label)); + return linked === escapeHtml(label) ? escapeHtml(label) : linked; + }); + + text = text.replace(/\[url=([^\]]+)\]([\s\S]*?)\[\/url\]/gi, function (_m, url, body) { + const inner = String(body || ""); + const linked = linkHtml(url, inner); + return linked === inner ? inner : linked; + }); + + text = text.replace(/\[url\]([\s\S]*?)\[\/url\]/gi, function (_m, url) { + const label = escapeHtml(decodeEntities(String(url || ""))); + const linked = linkHtml(url, label); + return linked === label ? label : linked; + }); + + text = text.replace(/\[b\]([\s\S]*?)\[\/b\]/gi, "$1"); + text = text.replace(/\[i\]([\s\S]*?)\[\/i\]/gi, "$1"); + text = text.replace(/\[u\]([\s\S]*?)\[\/u\]/gi, "$1"); + text = text.replace(/\[s\]([\s\S]*?)\[\/s\]/gi, "