diff --git a/Helpers/ColorsConvert.js b/Helpers/ColorsConvert.js index a29d922e..b4f85143 100644 --- a/Helpers/ColorsConvert.js +++ b/Helpers/ColorsConvert.js @@ -90,3 +90,176 @@ function hslToHex(h, s, l) { .join("") ); } + +function hexToRgb(hex) { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16) + } : { r: 0, g: 0, b: 0 }; +} + +function rgbToHex(r, g, b) { + return "#" + [r, g, b].map(x => { + const hex = Math.round(Math.max(0, Math.min(255, x))).toString(16); + return hex.length === 1 ? "0" + hex : hex; + }).join(""); +} + +function rgbToHsl(r, g, b) { + r /= 255; + g /= 255; + b /= 255; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + let h, s, l = (max + min) / 2; + + if (max === min) { + h = s = 0; + } else { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + + switch (max) { + case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break; + case g: h = ((b - r) / d + 2) / 6; break; + case b: h = ((r - g) / d + 4) / 6; break; + } + } + + return { h: h * 360, s: s * 100, l: l * 100 }; +} + +function hslToRgb(h, s, l) { + h /= 360; + s /= 100; + l /= 100; + + let r, g, b; + + if (s === 0) { + r = g = b = l; + } else { + const hue2rgb = (p, q, t) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1/6) return p + (q - p) * 6 * t; + if (t < 1/2) return q; + if (t < 2/3) return p + (q - p) * (2/3 - t) * 6; + return p; + }; + + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + + r = hue2rgb(p, q, h + 1/3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1/3); + } + + return { r: r * 255, g: g * 255, b: b * 255 }; +} + +// Calculate relative luminance (WCAG standard) +function getLuminance(hex) { + const rgb = hexToRgb(hex); + const [r, g, b] = [rgb.r, rgb.g, rgb.b].map(val => { + val /= 255; + return val <= 0.03928 ? val / 12.92 : Math.pow((val + 0.055) / 1.055, 2.4); + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +// Calculate contrast ratio between two colors +function getContrastRatio(hex1, hex2) { + const lum1 = getLuminance(hex1); + const lum2 = getLuminance(hex2); + const brightest = Math.max(lum1, lum2); + const darkest = Math.min(lum1, lum2); + return (brightest + 0.05) / (darkest + 0.05); +} + +// Check if a color is considered "light" +function isLightColor(hex) { + return getLuminance(hex) > 0.5; +} + +// Adjust color lightness +function adjustLightness(hex, amount) { + const rgb = hexToRgb(hex); + const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); + hsl.l = Math.max(0, Math.min(100, hsl.l + amount)); + const newRgb = hslToRgb(hsl.h, hsl.s, hsl.l); + return rgbToHex(newRgb.r, newRgb.g, newRgb.b); +} + +// Adjust color saturation +function adjustSaturation(hex, amount) { + const rgb = hexToRgb(hex); + const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); + hsl.s = Math.max(0, Math.min(100, hsl.s + amount)); + const newRgb = hslToRgb(hsl.h, hsl.s, hsl.l); + return rgbToHex(newRgb.r, newRgb.g, newRgb.b); +} + +// Generate "on" color with proper contrast (for text/icons) +function generateOnColor(baseColor, isDarkMode) { + const isBaseLight = isLightColor(baseColor); + + // If base is light, we need dark text; if base is dark, we need light text + if (isBaseLight) { + // Try darker variants + let testColor = "#000000"; + if (getContrastRatio(baseColor, testColor) >= 4.5) { + return testColor; + } + // Fallback to dark gray + return "#1c1b1f"; + } else { + // Try lighter variants + let testColor = "#ffffff"; + if (getContrastRatio(baseColor, testColor) >= 4.5) { + return testColor; + } + // Fallback to light gray + return "#e6e1e5"; + } +} + +// Generate container color (lighter in light mode, darker in dark mode) +function generateContainerColor(baseColor, isDarkMode) { + const rgb = hexToRgb(baseColor); + const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); + + if (isDarkMode) { + // In dark mode, containers are darker and more saturated + hsl.l = Math.max(10, Math.min(30, hsl.l - 20)); + hsl.s = Math.min(100, hsl.s + 10); + } else { + // In light mode, containers are lighter and less saturated + hsl.l = Math.min(90, Math.max(75, hsl.l + 30)); + hsl.s = Math.max(0, hsl.s - 10); + } + + const newRgb = hslToRgb(hsl.h, hsl.s, hsl.l); + return rgbToHex(newRgb.r, newRgb.g, newRgb.b); +} + +// Generate surface variant colors +function generateSurfaceVariant(backgroundColor, step, isDarkMode) { + const rgb = hexToRgb(backgroundColor); + const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); + + if (isDarkMode) { + // In dark mode, variants get progressively lighter + hsl.l = Math.min(100, hsl.l + (step * 3)); + } else { + // In light mode, variants get progressively darker + hsl.l = Math.max(0, hsl.l - (step * 2)); + } + + const newRgb = hslToRgb(hsl.h, hsl.s, hsl.l); + return rgbToHex(newRgb.r, newRgb.g, newRgb.b); +} diff --git a/Services/MatugenService.qml b/Services/MatugenService.qml index 76b097f5..48f77f8f 100644 --- a/Services/MatugenService.qml +++ b/Services/MatugenService.qml @@ -12,47 +12,6 @@ Singleton { readonly property string colorsApplyScript: Quickshell.shellDir + '/Bin/colors-apply.sh' readonly property string dynamicConfigPath: Settings.cacheDir + "matugen.dynamic.toml" - - readonly property var templateConfigs: ({ - "gtk": { - "input": "gtk.css", - "outputs": [{ - "path": "~/.config/gtk-3.0/gtk.css" - }, { - "path": "~/.config/gtk-4.0/gtk.css" - }], - "postProcess": mode => `gsettings set org.gnome.desktop.interface color-scheme prefer-${mode}\n` - }, - "qt": { - "input": "qtct.conf", - "outputs": [{ - "path": "~/.config/qt5ct/colors/noctalia.conf" - }, { - "path": "~/.config/qt6ct/colors/noctalia.conf" - }] - }, - "fuzzel": { - "input": "fuzzel.conf", - "outputs": [{ - "path": "~/.config/fuzzel/themes/noctalia" - }], - "postProcess": () => `${colorsApplyScript} fuzzel\n` - }, - "pywalfox": { - "input": "pywalfox.json", - "outputs": [{ - "path": "~/.cache/wal/colors.json" - }], - "postProcess": () => `${colorsApplyScript} pywalfox\n` - }, - "vesktop": { - "input": "vesktop.css", - "outputs": [{ - "path": "~/.config/vesktop/themes/noctalia.theme.css" - }] - } - }) - readonly property var terminalPaths: ({ "foot": "~/.config/foot/themes/noctalia", "ghostty": "~/.config/ghostty/themes/noctalia", @@ -64,13 +23,50 @@ Singleton { "Noctalia (legacy)": "Noctalia-legacy", "Tokyo Night": "Tokyo-Night" }) + readonly property var predefinedTemplateConfigs: ({ + "gtk": { + "input": "gtk.css", + "outputs": [{ + "path": "~/.config/gtk-3.0/gtk.css" + }, { + "path": "~/.config/gtk-4.0/gtk.css" + }], + "postProcess": mode => `gsettings set org.gnome.desktop.interface color-scheme prefer-${mode}\n` + }, + "qt": { + "input": "qtct.conf", + "outputs": [{ + "path": "~/.config/qt5ct/colors/noctalia.conf" + }, { + "path": "~/.config/qt6ct/colors/noctalia.conf" + }] + }, + "fuzzel": { + "input": "fuzzel.conf", + "outputs": [{ + "path": "~/.config/fuzzel/themes/noctalia" + }], + "postProcess": () => `${colorsApplyScript} fuzzel\n` + }, + "pywalfox": { + "input": "pywalfox.json", + "outputs": [{ + "path": "~/.cache/wal/colors.json" + }], + "postProcess": () => `${colorsApplyScript} pywalfox\n` + }, + "vesktop": { + "input": "vesktop.css", + "outputs": [{ + "path": "~/.config/vesktop/themes/noctalia.theme.css" + }] + } + }) - // ===== Lifecycle ===== function init() { Logger.log("Matugen", "Service started") } - // ===== External Connections ===== Connections { target: WallpaperService function onWallpaperChanged(screenName, path) { @@ -90,7 +86,9 @@ Singleton { } } - // ===== Wallpaper Generation ===== + // -------------------------------------------------------------------------------- + // Wallpaper Colors Generation + // -------------------------------------------------------------------------------- function generateFromWallpaper() { Logger.log("Matugen", "Generating from wallpaper on screen:", Screen.name) @@ -122,79 +120,126 @@ Singleton { return script + "\n" } - // ===== Predefined Scheme Generation ===== + // -------------------------------------------------------------------------------- + // Predefined Scheme Generation + // For predefined color schemes, we bypass matugen's generation which do not gives good results. + // Instead, we use 'sed' to apply a custom palette to the existing matugen templates. + // -------------------------------------------------------------------------------- function generateFromPredefinedScheme(schemeData) { Logger.log("Matugen", "Generating templates from predefined color scheme") handleTerminalThemes() - const mode = Settings.data.colorSchemes.darkMode ? "dark" : "light" - const colors = schemeData[mode] || schemeData.dark || schemeData.light - const matugenColors = buildMatugenColorObject(colors) + const isDarkMode = Settings.data.colorSchemes.darkMode + const colors = schemeData[isDarkMode ? "dark" : "light"] + const matugenColors = generatePalette(colors.mPrimary, colors.mSecondary, colors.mTertiary, colors.mSurface, isDarkMode) + + const mode = isDarkMode ? "dark" : "light" const script = processAllTemplates(matugenColors, mode) generateProcess.command = ["bash", "-lc", script] generateProcess.running = true } - function buildMatugenColorObject(colors) { - // Helper with fallback support - const c = (color, fallback) => ({ - "default": { - "hex": colors[color] || colors[fallback] || "#000000" - } - }) + function generatePalette(primaryColor, secondaryColor, tertiaryColor, backgroundColor, isDarkMode) { + const c = hex => ({ + "default": { + "hex": hex + } + }) + + // Generate container colors + const primaryContainer = ColorsConvert.generateContainerColor(primaryColor, isDarkMode) + const secondaryContainer = ColorsConvert.generateContainerColor(secondaryColor, isDarkMode) + const tertiaryContainer = ColorsConvert.generateContainerColor(tertiaryColor, isDarkMode) + + // Generate "on" colors (for text/icons) + const onPrimary = ColorsConvert.generateOnColor(primaryColor, isDarkMode) + const onSecondary = ColorsConvert.generateOnColor(secondaryColor, isDarkMode) + const onTertiary = ColorsConvert.generateOnColor(tertiaryColor, isDarkMode) + const onBackground = ColorsConvert.generateOnColor(backgroundColor, isDarkMode) + + const onPrimaryContainer = ColorsConvert.generateOnColor(primaryContainer, isDarkMode) + const onSecondaryContainer = ColorsConvert.generateOnColor(secondaryContainer, isDarkMode) + const onTertiaryContainer = ColorsConvert.generateOnColor(tertiaryContainer, isDarkMode) + + // Generate error colors (standard red-based) + const errorColor = isDarkMode ? "#f2b8b5" : "#ba1a1a" + const errorContainer = isDarkMode ? "#8c1d18" : "#ffdad6" + const onError = ColorsConvert.generateOnColor(errorColor, isDarkMode) + const onErrorContainer = ColorsConvert.generateOnColor(errorContainer, isDarkMode) + + // Surface is same as background in Material Design 3 + const surface = backgroundColor + const onSurface = onBackground + + // Generate surface variant (slightly different tone) + const surfaceVariant = ColorsConvert.adjustLightness(backgroundColor, isDarkMode ? 5 : -3) + const onSurfaceVariant = ColorsConvert.generateOnColor(surfaceVariant, isDarkMode) + + // Generate surface containers (progressive elevation) + const surfaceContainerLowest = ColorsConvert.generateSurfaceVariant(backgroundColor, 0, isDarkMode) + const surfaceContainerLow = ColorsConvert.generateSurfaceVariant(backgroundColor, 1, isDarkMode) + const surfaceContainer = ColorsConvert.generateSurfaceVariant(backgroundColor, 2, isDarkMode) + const surfaceContainerHigh = ColorsConvert.generateSurfaceVariant(backgroundColor, 3, isDarkMode) + const surfaceContainerHighest = ColorsConvert.generateSurfaceVariant(backgroundColor, 4, isDarkMode) + + // Generate outline colors (for borders/dividers) + const outline = isDarkMode ? "#938f99" : "#79747e" + const outlineVariant = ColorsConvert.adjustLightness(outline, isDarkMode ? -10 : 10) + + // Shadow is always very dark + const shadow = "#000000" return { - "primary": c("mPrimary"), - "on_primary": c("mOnPrimary"), - "primary_container": c("mPrimaryContainer", "mPrimary"), - "on_primary_container": c("mOnPrimaryContainer", "mOnPrimary"), - "secondary": c("mSecondary"), - "on_secondary": c("mOnSecondary"), - "secondary_container": c("mSecondaryContainer", "mSecondary"), - "on_secondary_container": c("mOnSecondaryContainer", "mOnSecondary"), - "tertiary": c("mTertiary"), - "on_tertiary": c("mOnTertiary"), - "tertiary_container": c("mTertiaryContainer", "mTertiary"), - "on_tertiary_container": c("mOnTertiaryContainer", "mOnTertiary"), - "error": c("mError"), - "on_error": c("mOnError"), - "error_container": c("mErrorContainer", "mError"), - "on_error_container": c("mOnErrorContainer", "mOnError"), - "background": c("mBackground", "mSurface"), - "on_background": c("mOnBackground", "mOnSurface"), - "surface": c("mSurface"), - "on_surface": c("mOnSurface"), - "surface_variant": c("mSurfaceVariant", "mSurface"), - "on_surface_variant": c("mOnSurfaceVariant", "mOnSurface"), - "surface_container_lowest": c("mSurfaceContainerLowest", "mSurface"), - "surface_container_low": c("mSurfaceContainerLow", "mSurface"), - "surface_container": c("mSurfaceContainer", "mSurfaceVariant"), - "surface_container_high": c("mSurfaceContainerHigh", "mSurfaceVariant"), - "surface_container_highest": c("mSurfaceContainerHighest", "mOutline"), - "outline": c("mOutline"), - "outline_variant": c("mOutlineVariant", "mOutline"), - "shadow": c("mShadow") + "primary": c(primaryColor), + "on_primary": c(onPrimary), + "primary_container": c(primaryContainer), + "on_primary_container": c(onPrimaryContainer), + "secondary": c(secondaryColor), + "on_secondary": c(onSecondary), + "secondary_container": c(secondaryContainer), + "on_secondary_container": c(onSecondaryContainer), + "tertiary": c(tertiaryColor), + "on_tertiary": c(onTertiary), + "tertiary_container": c(tertiaryContainer), + "on_tertiary_container": c(onTertiaryContainer), + "error": c(errorColor), + "on_error": c(onError), + "error_container": c(errorContainer), + "on_error_container": c(onErrorContainer), + "background": c(backgroundColor), + "on_background": c(onBackground), + "surface": c(surface), + "on_surface": c(onSurface), + "surface_variant": c(surfaceVariant), + "on_surface_variant": c(onSurfaceVariant), + "surface_container_lowest": c(surfaceContainerLowest), + "surface_container_low": c(surfaceContainerLow), + "surface_container": c(surfaceContainer), + "surface_container_high": c(surfaceContainerHigh), + "surface_container_highest": c(surfaceContainerHighest), + "outline": c(outline), + "outline_variant": c(outlineVariant), + "shadow": c(shadow) } } - function processAllTemplates(colors, mode) { let script = "" const homeDir = Quickshell.env("HOME") - Object.keys(templateConfigs).forEach(appName => { - if (Settings.data.templates[appName]) { - script += processTemplate(appName, colors, mode, homeDir) - } - }) + Object.keys(predefinedTemplateConfigs).forEach(appName => { + if (Settings.data.templates[appName]) { + script += processTemplate(appName, colors, mode, homeDir) + } + }) return script } function processTemplate(appName, colors, mode, homeDir) { - const config = templateConfigs[appName] + const config = predefinedTemplateConfigs[appName] const templatePath = `${Quickshell.shellDir}/Assets/MatugenTemplates/${config.input}` let script = "" @@ -224,7 +269,9 @@ Singleton { return script } - // ===== Terminal Themes ===== + // -------------------------------------------------------------------------------- + // Terminal Themes + // -------------------------------------------------------------------------------- function handleTerminalThemes() { const commands = [] @@ -256,7 +303,9 @@ Singleton { return `${Quickshell.shellDir}/Assets/ColorScheme/${colorScheme}/terminal/${terminal}/${colorScheme}-${mode}${extension}` } - // ===== User Templates ===== + // -------------------------------------------------------------------------------- + // User Templates + // -------------------------------------------------------------------------------- function buildUserTemplateCommand(input, mode) { if (!Settings.data.templates.enableUserTemplates) { return "" @@ -275,25 +324,9 @@ Singleton { return (Quickshell.env("HOME") + "/.config/matugen/config.toml").replace(/'/g, "'\\''") } - // ===== Utilities ===== - function selectVibrantColor(schemeData, mode) { - const colors = [schemeData[mode]["mPrimary"], schemeData[mode]["mSecondary"], schemeData[mode]["mTertiary"]] - - let bestScore = 0 - let bestIndex = 0 - - colors.forEach((color, i) => { - const hsl = ColorsConvert.hexToHSL(color) - if (hsl.s > bestScore) { - bestScore = hsl.s - bestIndex = i - } - }) - - return colors[bestIndex] - } - - // ===== Processes ===== + // -------------------------------------------------------------------------------- + // Processes + // -------------------------------------------------------------------------------- Process { id: generateProcess workingDirectory: Quickshell.shellDir