1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2025-12-05 21:15:38 -05:00

Persist theme preference, dynamic auto theme with matugen

This commit is contained in:
bbedward
2025-07-11 11:41:41 -04:00
parent 6e64dfe499
commit 0b4464fe2c
8 changed files with 576 additions and 23 deletions

140
Common/Colors.qml Normal file
View File

@@ -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
}
}

49
Common/Prefs.qml Normal file
View File

@@ -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)
}
}

View File

@@ -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"

View File

@@ -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

View File

@@ -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
}
}
}
}
}

View File

@@ -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`

78
scripts/set-wallpaper.sh Executable file
View File

@@ -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" <<EOF
// AUTO-GENERATED on $(date)
layout {
    border {
        active-color   "$primary"
        inactive-color "$secondary"
    }
    focus-ring {
        active-color   "$inverse"
    }
    background-color "$bg"
}
EOF
echo "→ Niri colours:   $QS_DIR/generated_niri_colors.kdl"
cat >"$QS_DIR/generated_ghostty_colors.conf" <<EOF
# AUTO-GENERATED on $(date)
background = $bg
foreground = $fg
cursor-color = $inverse
selection-background = $secondary
selection-foreground = #ffffff
palette = 0=$bg
palette = 1=$error
palette = 2=$tertiary
palette = 3=$secondary
palette = 4=$primary
palette = 5=$tertiary_ctr
palette = 6=$inverse
palette = 7=$fg
palette = 8=$bg_b
palette = 9=$error_b
palette = 10=$tertiary_b
palette = 11=$secondary_b
palette = 12=$primary_b
palette = 13=$tertiary_ctr_b
palette = 14=$inverse_b
palette = 15=$fg_b
EOF
echo "→ Ghostty theme:  $QS_DIR/generated_ghostty_colors.conf"
echo "   (use in ghostty:  theme = $QS_DIR/generated_ghostty_colors.conf )"
niri msg action do-screen-transition --delay-ms 100

View File

@@ -18,6 +18,11 @@ import "Common/Utilities.js" as Utils
ShellRoot {
id: root
Component.onCompleted: {
// Make root accessible to Theme singleton for error handling
Theme.rootObj = root
}
property bool calendarVisible: false
property bool showTrayMenu: false
property real trayMenuX: 0
@@ -73,6 +78,9 @@ ShellRoot {
property string wifiConnectionStatus: ""
property bool wifiAutoRefreshEnabled: false
// Wallpaper error status
property string wallpaperErrorStatus: ""
// Notification action handling - ALWAYS invoke action if exists
function handleNotificationClick(notifObj) {
console.log("Handling notification click for:", notifObj.appName)
@@ -167,6 +175,24 @@ ShellRoot {
}
}
// Wallpaper Error Status Timer
Timer {
id: wallpaperErrorTimer
interval: 5000 // 5 seconds
running: false
repeat: false
onTriggered: {
root.wallpaperErrorStatus = ""
}
}
// Function to show wallpaper error
function showWallpaperError() {
console.log("showWallpaperError called - setting error status")
root.wallpaperErrorStatus = "error"
wallpaperErrorTimer.restart()
}
// Notification Server
NotificationServer {
id: notificationServer