pragma Singleton import QtQuick import Quickshell import Quickshell.Io import qs.Commons import qs.Services.System import qs.Services.Theming import qs.Services.UI Singleton { id: root // Signal emitted when color generation completes successfully (for wallpaper-based theming) signal colorsGenerated readonly property string dynamicConfigPath: Settings.cacheDir + "theming.dynamic.toml" readonly property string templateProcessorScript: Quickshell.shellDir + "/Scripts/python/src/theming/template-processor.py" // Debounce state for wallpaper processing property var pendingWallpaperRequest: null property var pendingPredefinedRequest: null readonly property var schemeTypes: [ { "key": "tonal-spot", "name": "M3-Tonal Spot" // Do not translate }, { "key": "content", "name": "M3-Content" // Do not translate }, { "key": "fruit-salad", "name": "M3-Fruit Salad" // Do not translate }, { "key": "rainbow", "name": "M3-Rainbow" // Do not translate }, { "key": "monochrome", "name": "M3-Monochrome" // Do not translate }, { "key": "vibrant", "name": I18n.tr("common.vibrant") }, { "key": "faithful", "name": I18n.tr("common.faithful") }, { "key": "dysfunctional", "name": I18n.tr("common.dysfunctional") }, { "key": "muted", "name": I18n.tr("common.color-muted") }, ] // Check if a template is enabled in the activeTemplates array function isTemplateEnabled(templateId) { const activeTemplates = Settings.data.templates.activeTemplates; if (!activeTemplates) return false; for (let i = 0; i < activeTemplates.length; i++) { if (activeTemplates[i].id === templateId && activeTemplates[i].enabled) { return true; } } return false; } function escapeTomlString(value) { if (!value) return ""; return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); } /** * Process wallpaper colors using internal themer * Dual-path architecture (wallpaper generation) * Uses debouncing to prevent spawning multiple processes when spamming wallpaper changes */ function processWallpaperColors(wallpaperPath, mode) { Logger.d("TemplateProcessor", `processWallpaperColors called: path=${wallpaperPath}, mode=${mode}`); pendingWallpaperRequest = { wallpaperPath: wallpaperPath, mode: mode }; pendingPredefinedRequest = null; debounceTimer.restart(); } function executeWallpaperColors(wallpaperPath, mode) { Logger.d("TemplateProcessor", `executeWallpaperColors: path=${wallpaperPath}, mode=${mode}`); const content = buildThemeConfig(); if (!content && !Settings.data.templates.enableUserTheming) { Logger.d("TemplateProcessor", "executeWallpaperColors: no config content and no user theming, aborting"); return; } const script = buildGenerationScript(content, wallpaperPath, mode); generateProcess.command = ["sh", "-c", script]; generateProcess.running = true; } readonly property string schemeJsonPath: Settings.cacheDir + "predefined-scheme.json" readonly property string predefinedConfigPath: Settings.cacheDir + "theming.predefined.toml" /** * Process predefined color scheme using Python template processor * Uses --scheme flag to expand 14-color scheme to full 48-color palette * Uses debouncing to prevent spawning multiple processes when spamming scheme changes */ function processPredefinedScheme(schemeData, mode, wallpaperPath) { pendingPredefinedRequest = { schemeData: schemeData, mode: mode, wallpaperPath: wallpaperPath || "" }; pendingWallpaperRequest = null; debounceTimer.restart(); } function executePredefinedScheme(schemeData, mode, wallpaperPath) { // 1. Build TOML config for application templates (including terminals) const tomlContent = buildPredefinedTemplateConfig(mode); if (!tomlContent && !Settings.data.templates.enableUserTheming) { Logger.d("TemplateProcessor", "No application templates enabled for predefined scheme"); return; } // 3. Build script to write files and run Python const schemeJsonPathEsc = schemeJsonPath.replace(/'/g, "'\\''"); let script = ""; // Write scheme JSON (needed by both built-in and user templates) const schemeDelimiter = "SCHEME_JSON_EOF_" + Math.random().toString(36).substr(2, 9); script += `cat > '${schemeJsonPathEsc}' << '${schemeDelimiter}'\n`; script += JSON.stringify(schemeData, null, 2) + "\n"; script += `${schemeDelimiter}\n`; // Run built-in template processor only if there are templates configured if (tomlContent) { const configPathEsc = predefinedConfigPath.replace(/'/g, "'\\''"); const tomlDelimiter = "TOML_CONFIG_EOF_" + Math.random().toString(36).substr(2, 9); // Write TOML config script += `cat > '${configPathEsc}' << '${tomlDelimiter}'\n`; script += tomlContent + "\n"; script += `${tomlDelimiter}\n`; // Run Python template processor with --scheme flag // Don't pass --mode so templates get both dark and light colors (e.g., zed.json needs both) // Pass --default-mode so "default" in templates resolves to the current theme mode // Pass wallpaper as positional arg so image_path is available in templates (no extraction occurs when --scheme is used) const wpArg = wallpaperPath ? `'${wallpaperPath.replace(/'/g, "'\\''")}'` : ""; script += `python3 "${templateProcessorScript}" ${wpArg} --scheme '${schemeJsonPathEsc}' --config '${configPathEsc}' --default-mode ${mode}\n`; } // Add user templates if enabled script += buildUserTemplateCommandForPredefined(schemeData, mode, wallpaperPath); generateProcess.command = ["sh", "-c", script]; generateProcess.running = true; } /** * Build TOML config for predefined scheme templates (excludes terminal themes) */ function buildPredefinedTemplateConfig(mode) { var lines = []; const homeDir = Quickshell.env("HOME"); // Add terminal templates TemplateRegistry.terminals.forEach(terminal => { if (isTemplateEnabled(terminal.id)) { lines.push(`\n[templates.${terminal.id}]`); lines.push(`input_path = "${Quickshell.shellDir}/Assets/Templates/${terminal.predefinedTemplatePath}"`); const outputPath = terminal.outputPath.replace("~", homeDir); lines.push(`output_path = "${outputPath}"`); const postHookEsc = escapeTomlString(terminal.postHook); lines.push(`post_hook = "${postHookEsc}"`); } }); addApplicationTheming(lines, mode); if (lines.length > 0) { return ["[config]"].concat(lines).join("\n") + "\n"; } return ""; } // ================================================================================ // WALLPAPER-BASED GENERATION // ================================================================================ function buildThemeConfig() { var lines = []; var mode = Settings.data.colorSchemes.darkMode ? "dark" : "light"; if (Settings.data.colorSchemes.useWallpaperColors) { addWallpaperTheming(lines, mode); } addApplicationTheming(lines, mode); if (lines.length > 0) { return ["[config]"].concat(lines).join("\n") + "\n"; } return ""; } function addWallpaperTheming(lines, mode) { const homeDir = Quickshell.env("HOME"); // Noctalia colors JSON lines.push("[templates.noctalia]"); lines.push('input_path = "' + Quickshell.shellDir + '/Assets/Templates/noctalia.json"'); lines.push('output_path = "' + Settings.configDir + 'colors.json"'); // Terminal templates TemplateRegistry.terminals.forEach(terminal => { if (isTemplateEnabled(terminal.id)) { lines.push(`\n[templates.${terminal.id}]`); lines.push(`input_path = "${Quickshell.shellDir}/Assets/Templates/${terminal.templatePath}"`); const outputPath = terminal.outputPath.replace("~", homeDir); lines.push(`output_path = "${outputPath}"`); const postHookEsc = escapeTomlString(terminal.postHook); lines.push(`post_hook = "${postHookEsc}"`); } }); } function addApplicationTheming(lines, mode) { const homeDir = Quickshell.env("HOME"); TemplateRegistry.applications.forEach(app => { if (app.id === "discord") { // Handle Discord clients specially - multiple CSS themes if (isTemplateEnabled("discord")) { const inputs = Array.isArray(app.input) ? app.input : [app.input]; inputs.forEach((inputFile, idx) => { // Derive theme suffix from input filename: discord-midnight.css → midnight const themeSuffix = inputFile.replace(/^discord-/, "").replace(/\.css$/, ""); app.clients.forEach(client => { if (isDiscordClientEnabled(client.name)) { lines.push(`\n[templates.discord_${themeSuffix}_${client.name}]`); lines.push(`input_path = "${Quickshell.shellDir}/Assets/Templates/${inputFile}"`); // First input uses legacy name for backward compatibility const outputFile = idx === 0 ? "noctalia.theme.css" : `noctalia-${themeSuffix}.theme.css`; const outputPath = client.path.replace("~", homeDir) + `/themes/${outputFile}`; lines.push(`output_path = "${outputPath}"`); } }); }); } } else if (app.id === "code") { // Handle Code clients specially if (isTemplateEnabled("code")) { app.clients.forEach(client => { // Check if this specific client is detected var resolvedPaths = TemplateRegistry.resolvedCodeClientPaths(client.name); if (isCodeClientEnabled(client.name) && resolvedPaths.length > 0) { resolvedPaths.forEach((resolvedPath, pathIndex) => { var suffix = resolvedPaths.length > 1 ? `_${pathIndex}` : ""; lines.push(`\n[templates.code_${client.name}${suffix}]`); lines.push(`input_path = "${Quickshell.shellDir}/Assets/Templates/${app.input}"`); lines.push(`output_path = "${resolvedPath}"`); }); } }); } } else if (app.id === "emacs") { if (isTemplateEnabled("emacs")) { ProgramCheckerService.availableEmacsClients.forEach(client => { lines.push(`\n[templates.emacs_${client.name}]`); lines.push(`input_path = "${Quickshell.shellDir}/Assets/Templates/${app.input}"`); const expandedPath = client.path.replace("~", homeDir) + "/themes/noctalia-theme.el"; lines.push(`output_path = "${expandedPath}"`); if (app.postProcess) { const postHook = escapeTomlString(app.postProcess(mode)); lines.push(`post_hook = "${postHook}"`); } }); } } else { // Handle regular apps if (isTemplateEnabled(app.id)) { app.outputs.forEach((output, idx) => { lines.push(`\n[templates.${app.id}_${idx}]`); const inputFile = output.input || app.input; lines.push(`input_path = "${Quickshell.shellDir}/Assets/Templates/${inputFile}"`); const outputPath = output.path.replace("~", homeDir); lines.push(`output_path = "${outputPath}"`); if (app.postProcess) { const postHook = escapeTomlString(app.postProcess(mode)); lines.push(`post_hook = "${postHook}"`); } }); } } }); } function isDiscordClientEnabled(clientName) { // Check ProgramCheckerService to see if client is detected for (var i = 0; i < ProgramCheckerService.availableDiscordClients.length; i++) { if (ProgramCheckerService.availableDiscordClients[i].name === clientName) { return true; } } return false; } function isCodeClientEnabled(clientName) { // Check ProgramCheckerService to see if client is detected for (var i = 0; i < ProgramCheckerService.availableCodeClients.length; i++) { if (ProgramCheckerService.availableCodeClients[i].name === clientName) { return true; } } return false; } // Get scheme type, defaulting to tonal-spot if not a recognized value function getSchemeType() { const method = Settings.data.colorSchemes.generationMethod; const validKeys = root.schemeTypes.map(scheme => scheme.key); return validKeys.includes(method) ? method : "tonal-spot"; } function buildGenerationScript(content, wallpaper, mode) { const pathEsc = dynamicConfigPath.replace(/'/g, "'\\''"); const wpDelimiter = "WALLPAPER_PATH_EOF_" + Math.random().toString(36).substr(2, 9); // Use heredoc for wallpaper path to avoid all escaping issues let script = `NOCTALIA_WP_PATH=$(cat << '${wpDelimiter}'\n${wallpaper}\n${wpDelimiter}\n)\n`; // Run built-in template processor only if there are templates configured if (content) { const delimiter = "THEME_CONFIG_EOF_" + Math.random().toString(36).substr(2, 9); script += `cat > '${pathEsc}' << '${delimiter}'\n${content}\n${delimiter}\n`; // Use template-processor.py (Python implementation) // Don't pass --mode so templates get both dark and light colors (e.g., zed.json needs both) // Pass --default-mode so "default" in templates resolves to the current theme mode const schemeType = getSchemeType(); script += `python3 "${templateProcessorScript}" "$NOCTALIA_WP_PATH" --scheme-type ${schemeType} --config '${pathEsc}' --default-mode ${mode}\n`; } script += buildUserTemplateCommand("$NOCTALIA_WP_PATH", mode); return script + "\n"; } // ================================================================================ // USER TEMPLATES, advanced usage // ================================================================================ function buildUserTemplateCommand(input, mode) { if (!Settings.data.templates.enableUserTheming) return ""; const userConfigPath = getUserConfigPath(); let script = "\n# Execute user config if it exists\n"; script += `if [ -f '${userConfigPath}' ]; then\n`; // If input is a shell variable (starts with $), use double quotes to allow expansion // Otherwise, use single quotes for safety with file paths const inputQuoted = input.startsWith("$") ? `"${input}"` : `'${input.replace(/'/g, "'\\''")}'`; const schemeType = getSchemeType(); // Don't pass --mode so user templates get both dark and light colors // Pass --default-mode so "default" in templates resolves to the current theme mode script += ` python3 "${templateProcessorScript}" ${inputQuoted} --scheme-type ${schemeType} --config '${userConfigPath}' --default-mode ${mode}\n`; script += "fi"; return script; } function buildUserTemplateCommandForPredefined(schemeData, mode, wallpaperPath) { if (!Settings.data.templates.enableUserTheming) return ""; const userConfigPath = getUserConfigPath(); // Reuse the scheme JSON already written by processPredefinedScheme() const schemeJsonPathEsc = schemeJsonPath.replace(/'/g, "'\\''"); const wpArg = wallpaperPath ? `'${wallpaperPath.replace(/'/g, "'\\''")}'` : ""; let script = "\n# Execute user templates with predefined scheme colors\n"; script += `if [ -f '${userConfigPath}' ]; then\n`; // Use --scheme flag with the already-written scheme JSON // Don't pass --mode so user templates get both dark and light colors // Pass --default-mode so "default" in templates resolves to the current theme mode // Pass wallpaper as positional arg so image_path is available in templates script += ` python3 "${templateProcessorScript}" ${wpArg} --scheme '${schemeJsonPathEsc}' --config '${userConfigPath}' --default-mode ${mode}\n`; script += "fi"; return script; } function getUserConfigPath() { return (Settings.configDir + "user-templates.toml").replace(/'/g, "'\\''"); } // ================================================================================ // DEBOUNCE TIMER // ================================================================================ function executePendingRequest() { Logger.d("TemplateProcessor", `executePendingRequest: hasWallpaper=${!!pendingWallpaperRequest}, hasPredefined=${!!pendingPredefinedRequest}`); if (pendingWallpaperRequest) { const req = pendingWallpaperRequest; pendingWallpaperRequest = null; executeWallpaperColors(req.wallpaperPath, req.mode); } else if (pendingPredefinedRequest) { const req = pendingPredefinedRequest; pendingPredefinedRequest = null; executePredefinedScheme(req.schemeData, req.mode, req.wallpaperPath); } else { Logger.d("TemplateProcessor", "executePendingRequest: no pending request"); } } Timer { id: debounceTimer interval: 150 repeat: false onTriggered: { Logger.d("TemplateProcessor", `debounceTimer fired: processRunning=${generateProcess.running}`); // Kill any running process before starting new one if (generateProcess.running) { Logger.d("TemplateProcessor", "debounceTimer: stopping running process"); generateProcess.running = false; // executePendingRequest will be called from onExited } else { executePendingRequest(); } } } // ================================================================================ // PROCESSES // ================================================================================ Process { id: generateProcess workingDirectory: Quickshell.shellDir running: false onExited: function (exitCode, exitStatus) { // Execute any pending request (handles both kill case and debounce timer interval case) if (pendingWallpaperRequest || pendingPredefinedRequest) { Logger.d("TemplateProcessor", "generateProcess onExited: has pending request, executing"); executePendingRequest(); } else if (exitCode === 0) { // No pending request and successful completion - emit signal root.colorsGenerated(); } } stderr: StdioCollector { onStreamFinished: { const text = this.text.trim(); if (text && text.includes("Template error:")) { const errorLines = text.split("\n").filter(l => l.includes("Template error:")); const errors = errorLines.slice(0, 3).join("\n") + (errorLines.length > 3 ? `\n... (+${errorLines.length - 3} more)` : ""); Logger.w("TemplateProcessor", errors); ToastService.showWarning(I18n.tr("toast.theming-processor-failed.title"), errors); } } } } }