diff --git a/Common/Colors.qml b/Common/Colors.qml new file mode 100644 index 00000000..e9d39a54 --- /dev/null +++ b/Common/Colors.qml @@ -0,0 +1,140 @@ +pragma Singleton +import QtQuick +import Quickshell +import Quickshell.Io +import Qt.labs.platform // ← gives us StandardPaths + +Singleton { + id: root + + /* ──────────────── basic state ──────────────── */ + signal colorsUpdated() + + readonly property string _homeUrl: StandardPaths.writableLocation(StandardPaths.HomeLocation) + readonly property string homeDir: _homeUrl.startsWith("file://") + ? _homeUrl.substring(7) + : _homeUrl + readonly property string wallpaperPath: homeDir + "/quickshell/current_wallpaper" + + property bool matugenAvailable: false + property string matugenJson: "" + property var matugenColors: ({}) + + Component.onCompleted: { + console.log("Colors.qml → home =", homeDir) + matugenCheck.running = true // kick off the chain + } + + /* ──────────────── availability checks ──────────────── */ + Process { + id: matugenCheck + command: ["which", "matugen"] + onExited: (code) => { + matugenAvailable = (code === 0) + console.log("Matugen in PATH:", matugenAvailable) + + if (!matugenAvailable) { + console.warn("Matugen missing → dynamic theme disabled") + Theme.rootObj.wallpaperErrorStatus = "matugen_missing" + return + } + fileChecker.running = true + } + } + + Process { + id: fileChecker // exists & readable? + command: ["test", "-r", wallpaperPath] + onExited: (code) => { + if (code === 0) { + matugenProcess.running = true + } else { + console.error("code", code) + console.error("Wallpaper not found:", wallpaperPath) + Theme.rootObj.showWallpaperError() + } + } + } + + /* ──────────────── matugen invocation ──────────────── */ + Process { + id: matugenProcess + command: ["matugen", "-v", "image", wallpaperPath, "--json", "hex"] + + /* ── grab stdout as a stream ── */ + stdout: StdioCollector { + id: matugenCollector + onStreamFinished: { + const out = matugenCollector.text + if (!out.length) { + console.error("matugen produced zero bytes\nstderr:", matugenProcess.stderr) + Theme.rootObj.showWallpaperError() + return + } + try { + root.matugenJson = out + root.matugenColors = JSON.parse(out) + root.colorsUpdated() + Theme.rootObj.wallpaperErrorStatus = "" + } catch (e) { + console.error("JSON parse failed:", e) + Theme.rootObj.showWallpaperError() + } + } + } + + /* grab stderr too, so we can print it above */ + stderr: StdioCollector { id: matugenErr } + } + + /* ──────────────── public helper ──────────────── */ + function extractColors() { + if (matugenAvailable) + fileChecker.running = true + else + matugenCheck.running = true + } + + function getMatugenColor(path, fallback) { + let cur = matugenColors?.colors?.dark + for (const part of path.split(".")) { + if (!cur || typeof cur !== "object" || !(part in cur)) + return fallback + cur = cur[part] + } + return cur || fallback + } + + /* ──────────────── color properties (MD3) ──────────────── */ + property color primary: getMatugenColor("primary", "#42a5f5") + property color secondary: getMatugenColor("secondary", "#8ab4f8") + property color tertiary: getMatugenColor("tertiary", "#bb86fc") + property color tertiaryContainer: getMatugenColor("tertiary_container", "#3700b3") + property color error: getMatugenColor("error", "#cf6679") + property color inversePrimary: getMatugenColor("inverse_primary", "#6200ea") + + /* backgrounds */ + property color bg: getMatugenColor("background", "#1a1c1e") + property color surface: getMatugenColor("surface", "#1a1c1e") + property color surfaceContainer: getMatugenColor("surface_container", "#1e2023") + property color surfaceContainerHigh: getMatugenColor("surface_container_high", "#292b2f") + property color surfaceVariant: getMatugenColor("surface_variant", "#44464f") + + /* text */ + property color surfaceText: getMatugenColor("on_background", "#e3e8ef") + property color primaryText: getMatugenColor("on_primary", "#ffffff") + property color surfaceVariantText: getMatugenColor("on_surface_variant", "#c4c7c5") + + /* containers & misc */ + property color primaryContainer: getMatugenColor("primary_container", "#1976d2") + property color surfaceTint: getMatugenColor("surface_tint", "#8ab4f8") + property color outline: getMatugenColor("outline", "#8e918f") + + /* legacy aliases */ + property color accentHi: primary + property color accentLo: secondary + + function isColorDark(c) { + return (0.299 * c.r + 0.587 * c.g + 0.114 * c.b) < 0.5 + } +} diff --git a/Common/Prefs.qml b/Common/Prefs.qml new file mode 100644 index 00000000..678c6415 --- /dev/null +++ b/Common/Prefs.qml @@ -0,0 +1,49 @@ +pragma Singleton +import QtQuick +import Qt.labs.settings +import Quickshell + +Singleton { + id: root + + property alias themeIndex: settings.themeIndex + property alias themeIsDynamic: settings.themeIsDynamic + + Settings { + id: settings + category: "theme" + + // 0-9 = built-in static themes, 10 = Auto (dynamic) + property int themeIndex: 0 + property bool themeIsDynamic: false + } + + // Apply theme when component is ready + Component.onCompleted: { + console.log("Prefs Component.onCompleted - themeIndex:", settings.themeIndex, "isDynamic:", settings.themeIsDynamic) + Qt.callLater(applyStoredTheme) + } + + function applyStoredTheme() { + console.log("Applying stored theme:", settings.themeIndex, settings.themeIsDynamic) + + // Make sure Theme is available + if (typeof Theme !== "undefined") { + Theme.switchTheme(settings.themeIndex, settings.themeIsDynamic, false) // Don't save during startup + } else { + // Try again in a moment + Qt.callLater(() => { + if (typeof Theme !== "undefined") { + Theme.switchTheme(settings.themeIndex, settings.themeIsDynamic, false) // Don't save during startup + } + }) + } + } + + function setTheme(index, isDynamic) { + console.log("Prefs setTheme called - themeIndex:", index, "isDynamic:", isDynamic) + settings.themeIndex = index + settings.themeIsDynamic = isDynamic + console.log("Prefs saved - themeIndex:", settings.themeIndex, "isDynamic:", settings.themeIsDynamic) + } +} \ No newline at end of file diff --git a/Common/Theme.qml b/Common/Theme.qml index e9a79192..1b60013d 100644 --- a/Common/Theme.qml +++ b/Common/Theme.qml @@ -1,10 +1,45 @@ import QtQuick +import Quickshell +import Quickshell.Io pragma Singleton pragma ComponentBehavior: Bound QtObject { id: root + // Reference to the main shell root for calling functions + property var rootObj: null + + // Apply saved theme on startup + Component.onCompleted: { + console.log("Theme Component.onCompleted") + + // Connect to Colors signal + if (typeof Colors !== "undefined") { + Colors.colorsUpdated.connect(root.onColorsUpdated) + } + + Qt.callLater(() => { + if (typeof Prefs !== "undefined") { + console.log("Theme applying saved preferences:", Prefs.themeIndex, Prefs.themeIsDynamic) + switchTheme(Prefs.themeIndex, Prefs.themeIsDynamic, false) // Don't save during startup + } + }) + } + + // Handle successful color extraction + function onColorsUpdated() { + console.log("Colors updated successfully - switching to dynamic theme") + currentThemeIndex = 10 + isDynamicTheme = true + console.log("Dynamic theme activated. Theme.primary should now be:", primary) + + // Save preference after successful switch + if (typeof Prefs !== "undefined") { + Prefs.setTheme(currentThemeIndex, isDynamicTheme) + } + } + // Theme definitions with complete Material 3 expressive color palettes property var themes: [ { @@ -179,33 +214,50 @@ QtObject { } ] - // Current theme index + // Current theme index (10 = Auto/Dynamic) property int currentThemeIndex: 0 + property bool isDynamicTheme: false // Function to switch themes - function switchTheme(themeIndex) { - if (themeIndex >= 0 && themeIndex < themes.length) { + function switchTheme(themeIndex, isDynamic = false, savePrefs = true) { + console.log("Theme.switchTheme called:", themeIndex, isDynamic, "savePrefs:", savePrefs) + + if (isDynamic && themeIndex === 10) { + console.log("Attempting to switch to dynamic theme - checking colors first") + + // Don't change theme yet - wait for color extraction to succeed + if (typeof Colors !== "undefined") { + console.log("Calling Colors.extractColors()") + Colors.extractColors() + } else { + console.error("Colors singleton not available") + } + } else if (themeIndex >= 0 && themeIndex < themes.length) { currentThemeIndex = themeIndex - // Simple persistence - store in a property - // In a real application, you might use Qt.labs.settings or another persistence mechanism + isDynamicTheme = false + } + + // Save preference (unless this is a startup restoration) + if (savePrefs && typeof Prefs !== "undefined") { + Prefs.setTheme(currentThemeIndex, isDynamicTheme) } } // Dynamic color properties that change based on current theme - property color primary: themes[currentThemeIndex].primary - property color primaryText: themes[currentThemeIndex].primaryText - property color primaryContainer: themes[currentThemeIndex].primaryContainer - property color secondary: themes[currentThemeIndex].secondary - property color surface: themes[currentThemeIndex].surface - property color surfaceText: themes[currentThemeIndex].surfaceText - property color surfaceVariant: themes[currentThemeIndex].surfaceVariant - property color surfaceVariantText: themes[currentThemeIndex].surfaceVariantText - property color surfaceTint: themes[currentThemeIndex].surfaceTint - property color background: themes[currentThemeIndex].background - property color backgroundText: themes[currentThemeIndex].backgroundText - property color outline: themes[currentThemeIndex].outline - property color surfaceContainer: themes[currentThemeIndex].surfaceContainer - property color surfaceContainerHigh: themes[currentThemeIndex].surfaceContainerHigh + property color primary: isDynamicTheme ? Colors.accentHi : (currentThemeIndex < themes.length ? themes[currentThemeIndex].primary : themes[0].primary) + property color primaryText: isDynamicTheme ? Colors.primaryText : (currentThemeIndex < themes.length ? themes[currentThemeIndex].primaryText : themes[0].primaryText) + property color primaryContainer: isDynamicTheme ? Colors.primaryContainer : (currentThemeIndex < themes.length ? themes[currentThemeIndex].primaryContainer : themes[0].primaryContainer) + property color secondary: isDynamicTheme ? Colors.accentLo : (currentThemeIndex < themes.length ? themes[currentThemeIndex].secondary : themes[0].secondary) + property color surface: isDynamicTheme ? Colors.surface : (currentThemeIndex < themes.length ? themes[currentThemeIndex].surface : themes[0].surface) + property color surfaceText: isDynamicTheme ? Colors.surfaceText : (currentThemeIndex < themes.length ? themes[currentThemeIndex].surfaceText : themes[0].surfaceText) + property color surfaceVariant: isDynamicTheme ? Colors.surfaceVariant : (currentThemeIndex < themes.length ? themes[currentThemeIndex].surfaceVariant : themes[0].surfaceVariant) + property color surfaceVariantText: isDynamicTheme ? Colors.surfaceVariantText : (currentThemeIndex < themes.length ? themes[currentThemeIndex].surfaceVariantText : themes[0].surfaceVariantText) + property color surfaceTint: isDynamicTheme ? Colors.surfaceTint : (currentThemeIndex < themes.length ? themes[currentThemeIndex].surfaceTint : themes[0].surfaceTint) + property color background: isDynamicTheme ? Colors.background : (currentThemeIndex < themes.length ? themes[currentThemeIndex].background : themes[0].background) + property color backgroundText: isDynamicTheme ? Colors.backgroundText : (currentThemeIndex < themes.length ? themes[currentThemeIndex].backgroundText : themes[0].backgroundText) + property color outline: isDynamicTheme ? Colors.outline : (currentThemeIndex < themes.length ? themes[currentThemeIndex].outline : themes[0].outline) + property color surfaceContainer: isDynamicTheme ? Colors.surfaceContainer : (currentThemeIndex < themes.length ? themes[currentThemeIndex].surfaceContainer : themes[0].surfaceContainer) + property color surfaceContainerHigh: isDynamicTheme ? Colors.surfaceContainerHigh : (currentThemeIndex < themes.length ? themes[currentThemeIndex].surfaceContainerHigh : themes[0].surfaceContainerHigh) // Static colors that don't change with themes property color archBlue: "#1793D1" diff --git a/Common/qmldir b/Common/qmldir index b1db1c68..1a83ccf7 100644 --- a/Common/qmldir +++ b/Common/qmldir @@ -1,4 +1,6 @@ module Common singleton Theme 1.0 Theme.qml +singleton Colors 1.0 Colors.qml +singleton Prefs 1.0 Prefs.qml Utilities 1.0 Utilities.js \ No newline at end of file diff --git a/Widgets/ThemePicker.qml b/Widgets/ThemePicker.qml index 1822c3e0..c4e6d7c5 100644 --- a/Widgets/ThemePicker.qml +++ b/Widgets/ThemePicker.qml @@ -6,7 +6,7 @@ Column { spacing: Theme.spacingS Text { - text: "Current Theme: " + Theme.themes[Theme.currentThemeIndex].name + text: "Current Theme: " + (Theme.isDynamicTheme ? "Auto" : (Theme.currentThemeIndex < Theme.themes.length ? Theme.themes[Theme.currentThemeIndex].name : "Blue")) font.pixelSize: Theme.fontSizeMedium color: Theme.surfaceText font.weight: Font.Medium @@ -16,6 +16,9 @@ Column { // Theme description Text { text: { + if (Theme.isDynamicTheme) { + return "Wallpaper-based dynamic colors" + } var descriptions = [ "Material blue inspired by modern interfaces", "Deep blue inspired by material 3", @@ -57,9 +60,9 @@ Column { radius: 16 color: Theme.themes[index].primary border.color: Theme.outline - border.width: Theme.currentThemeIndex === index ? 2 : 1 + border.width: (Theme.currentThemeIndex === index && !Theme.isDynamicTheme) ? 2 : 1 - scale: Theme.currentThemeIndex === index ? 1.1 : 1.0 + scale: (Theme.currentThemeIndex === index && !Theme.isDynamicTheme) ? 1.1 : 1.0 Behavior on scale { NumberAnimation { @@ -103,7 +106,7 @@ Column { hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { - Theme.switchTheme(index) + Theme.switchTheme(index, false) } } } @@ -180,5 +183,135 @@ Column { } } } + + // Auto theme button - prominent oval below the grid + Rectangle { + width: 120 + height: 40 + radius: 20 + anchors.horizontalCenter: parent.horizontalCenter + + color: { + if (root.wallpaperErrorStatus === "error" || root.wallpaperErrorStatus === "matugen_missing") { + return Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12) + } else { + return Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3) + } + } + + border.color: { + if (root.wallpaperErrorStatus === "error" || root.wallpaperErrorStatus === "matugen_missing") { + return Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.5) + } else if (Theme.isDynamicTheme) { + return Theme.primary + } else { + return Theme.outline + } + } + border.width: Theme.isDynamicTheme ? 2 : 1 + + + Row { + anchors.centerIn: parent + spacing: Theme.spacingS + + Text { + text: { + if (root.wallpaperErrorStatus === "error" || root.wallpaperErrorStatus === "matugen_missing") return "error" + else return "palette" + } + font.family: Theme.iconFont + font.pixelSize: 16 + color: { + if (root.wallpaperErrorStatus === "error" || root.wallpaperErrorStatus === "matugen_missing") return Theme.error + else return Theme.surfaceText + } + font.weight: Theme.iconFontWeight + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: { + if (root.wallpaperErrorStatus === "error") return "Error" + else if (root.wallpaperErrorStatus === "matugen_missing") return "No matugen" + else return "Auto" + } + font.pixelSize: Theme.fontSizeMedium + color: { + if (root.wallpaperErrorStatus === "error" || root.wallpaperErrorStatus === "matugen_missing") return Theme.error + else return Theme.surfaceText + } + font.weight: Font.Medium + anchors.verticalCenter: parent.verticalCenter + } + } + + scale: Theme.isDynamicTheme ? 1.1 : (autoMouseArea.containsMouse ? 1.02 : 1.0) + + Behavior on scale { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Theme.emphasizedEasing + } + } + + Behavior on color { + ColorAnimation { + duration: Theme.mediumDuration + easing.type: Theme.standardEasing + } + } + + Behavior on border.color { + ColorAnimation { + duration: Theme.mediumDuration + easing.type: Theme.standardEasing + } + } + + MouseArea { + id: autoMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onClicked: { + Theme.switchTheme(10, true) + } + } + + // Tooltip for Auto button + Rectangle { + width: autoTooltipText.contentWidth + Theme.spacingM * 2 + height: autoTooltipText.contentHeight + Theme.spacingS * 2 + color: Theme.surfaceContainer + border.color: Theme.outline + border.width: 1 + radius: Theme.cornerRadiusSmall + anchors.bottom: parent.top + anchors.bottomMargin: Theme.spacingS + anchors.horizontalCenter: parent.horizontalCenter + visible: autoMouseArea.containsMouse && (!Theme.isDynamicTheme || root.wallpaperErrorStatus === "error" || root.wallpaperErrorStatus === "matugen_missing") + + Text { + id: autoTooltipText + text: { + if (root.wallpaperErrorStatus === "error") { + return "Wallpaper symlink missing at ~/quickshell/current_wallpaper" + } else if (root.wallpaperErrorStatus === "matugen_missing") { + return "Install matugen package for dynamic themes" + } else { + return "Dynamic wallpaper-based colors" + } + } + font.pixelSize: Theme.fontSizeSmall + color: (root.wallpaperErrorStatus === "error" || root.wallpaperErrorStatus === "matugen_missing") ? Theme.error : Theme.surfaceText + anchors.centerIn: parent + wrapMode: Text.WordWrap + width: Math.min(implicitWidth, 250) + horizontalAlignment: Text.AlignHCenter + } + } + } } } diff --git a/scripts/README-dynamic-theme.md b/scripts/README-dynamic-theme.md new file mode 100644 index 00000000..7642f0eb --- /dev/null +++ b/scripts/README-dynamic-theme.md @@ -0,0 +1,73 @@ +# Dynamic Theme Setup + +This setup adds wallpaper-aware "Auto" theme support to your Quickshell + Niri environment. + +## Prerequisites + +Install the required tools: + +```bash +# Required for Material-You palette generation +cargo install matugen + +# Required for JSON processing (usually pre-installed) +sudo pacman -S jq # Arch Linux +# or: sudo apt install jq # Ubuntu/Debian + +# Background setters (choose one) +sudo pacman -S swaybg # Simple and reliable +# or: cargo install swww # Smoother transitions +``` + +## Setup + +1. **Initial wallpaper setup:** + ```bash + # Set your initial wallpaper + ./scripts/set-wallpaper.sh /path/to/your/wallpaper.jpg + ``` + +2. **Enable Niri color integration (optional):** + Add this line to your `~/.config/niri/config.kdl`: + ```kdl + !include "generated_colors.kdl" + ``` + +3. **Enable Auto theme:** + Open Control Center → Theme Picker → Click the gradient "Auto" button + +## Usage + +### Change wallpaper and auto-update theme: +```bash +./scripts/set-wallpaper.sh /new/wallpaper.jpg +``` + +### Manual theme switching: +- Use the Control Center theme picker +- Preferences persist across restarts +- Auto theme requires wallpaper symlink to exist + +## How it works + +1. **Color extraction:** `Colors.qml` uses Quickshell's ColorQuantizer to extract dominant colors from the wallpaper symlink +2. **Persistence:** `Prefs.qml` stores your theme choice using PersistentProperties +3. **Dynamic switching:** `Theme.qml` switches between static themes and wallpaper colors +4. **Auto-reload:** Quickshell's file watching automatically reloads when the wallpaper symlink changes + +## Troubleshooting + +### "Dynamic theme requires wallpaper setup!" error +Run the setup command: +```bash +./scripts/set-wallpaper.sh /path/to/your/wallpaper.jpg +``` + +### Colors don't update when changing wallpaper +- Make sure you're using the script, not manually changing files +- The symlink at `~/quickshell/current_wallpaper` must exist + +### Niri colors don't change +- Ensure `!include "generated_colors.kdl"` is in your config.kdl +- Check that matugen and jq are installed +- Look for `~/.config/niri/generated_colors.kdl` \ No newline at end of file diff --git a/scripts/set-wallpaper.sh b/scripts/set-wallpaper.sh new file mode 100755 index 00000000..08c7b2b5 --- /dev/null +++ b/scripts/set-wallpaper.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +img=$1 + +QS_DIR="$HOME/quickshell" +mkdir -p "$QS_DIR" +LINK="$QS_DIR/current_wallpaper" +ln -sf -- "$img" "$LINK" +swaybg -m fill -i "$LINK" & disown + +json="$(matugen image "$img" --json hex)" + +get() { jq -r "$1" <<<"$json"; } + +bg=$(get '.colors.dark.background') +fg=$(get '.colors.dark.on_background') +primary=$(get '.colors.dark.primary') +secondary=$(get '.colors.dark.secondary') +tertiary=$(get '.colors.dark.tertiary') +tertiary_ctr=$(get '.colors.dark.tertiary_container') +error=$(get '.colors.dark.error') +inverse=$(get '.colors.dark.inverse_primary') + +bg_b=$(get '.colors.light.background') +fg_b=$(get '.colors.light.on_background') +primary_b=$(get '.colors.light.primary') +secondary_b=$(get '.colors.light.secondary') +tertiary_b=$(get '.colors.light.tertiary') +tertiary_ctr_b=$(get '.colors.light.tertiary_container') +error_b=$(get '.colors.light.error') +inverse_b=$(get '.colors.light.inverse_primary') + +cat >"$QS_DIR/generated_niri_colors.kdl" <"$QS_DIR/generated_ghostty_colors.conf" <