Fix dynamic theming: enable filesystem writes and add theme switching

- Add LD_LIBRARY_PATH and ILLOGICAL_IMPULSE_VIRTUAL_ENV to quickshell service
- Set ProtectSystem=false to allow color generation scripts to write files
- Fix MaterialThemeLoader to properly detect file changes with onFileChanged
- Add switchwall-wrapper.sh to source environment variables dynamically
- Fix light/dark mode buttons to use Process with current wallpaper
- Add --choose flag to switchwall.sh for wallpaper selection dialog
- Add IPC commands 'dark' and 'light' for console theme switching
- Update keybinds: Ctrl+Super+T (choose), Ctrl+Super+Shift+T (random)
- Fix terminal color application in applycolor.sh
This commit is contained in:
Celes Renata
2025-11-29 18:57:23 -08:00
parent 3655e7aaee
commit d192bee3d9
9 changed files with 333 additions and 70 deletions
+23 -23
View File
@@ -1,3 +1,5 @@
import qs.modules.common
import qs
import QtQuick
import Quickshell
import Quickshell.Hyprland
@@ -15,34 +17,19 @@ Singleton {
property bool osdVolumeOpen: false
property bool oskOpen: false
property bool overviewOpen: false
property bool sessionOpen: false
property bool workspaceShowNumbers: false
property bool superReleaseMightTrigger: true
property bool screenLocked: false
property bool screenLockContainsCharacters: false
property bool sessionOpen: false
property bool superDown: false
property bool superReleaseMightTrigger: true
property bool workspaceShowNumbers: false
property real screenZoom: 1
onScreenZoomChanged: {
Quickshell.execDetached(["hyprctl", "keyword", "cursor:zoom_factor", root.screenZoom.toString()]);
}
Behavior on screenZoom {
NumberAnimation { duration: 200; easing.type: Easing.OutCubic }
// animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
// When user is not reluctant while pressing super, they probably don't need to see workspace numbers
onSuperReleaseMightTriggerChanged: {
workspaceShowNumbersTimer.stop()
}
Timer {
id: workspaceShowNumbersTimer
interval: 500 // Config.options.bar.workspaces.showNumberDelay
// interval: 0
repeat: false
onTriggered: {
workspaceShowNumbers = true
}
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
GlobalShortcut {
@@ -50,11 +37,10 @@ Singleton {
description: "Hold to show workspace numbers, release to show icons"
onPressed: {
workspaceShowNumbersTimer.start()
root.superDown = true
}
onReleased: {
workspaceShowNumbersTimer.stop()
workspaceShowNumbers = false
root.superDown = false
}
}
@@ -69,4 +55,18 @@ Singleton {
screenZoom = Math.max(screenZoom - 0.4, 1)
}
}
IpcHandler {
target: "theme"
function dark() {
const wallpaper = Config.options.background.wallpaperPath || `${Quickshell.env("HOME")}/Pictures/Wallpapers/konachan_random_image.png`
Quickshell.execDetached(["bash", `${Quickshell.env("HOME")}/.config/quickshell/scripts/colors/switchwall-wrapper.sh`, wallpaper, "--mode", "dark"])
}
function light() {
const wallpaper = Config.options.background.wallpaperPath || `${Quickshell.env("HOME")}/Pictures/Wallpapers/konachan_random_image.png`
Quickshell.execDetached(["bash", `${Quickshell.env("HOME")}/.config/quickshell/scripts/colors/switchwall-wrapper.sh`, wallpaper, "--mode", "light"])
}
}
}
@@ -5,6 +5,7 @@ import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Hyprland
import Quickshell.Io
import Quickshell.Services.Pipewire
import Quickshell.Services.UPower
@@ -14,6 +15,20 @@ Item {
implicitWidth: rowLayout.implicitWidth + rowLayout.spacing * 2
implicitHeight: rowLayout.implicitHeight
Process {
id: themeSwitchProcess
running: false
stdout: SplitParser {
onRead: data => console.log("switchwall:", data)
}
stderr: SplitParser {
onRead: data => console.log("switchwall err:", data)
}
onExited: (code, status) => {
console.log("switchwall exited:", code)
}
}
RowLayout {
id: rowLayout
@@ -25,7 +40,7 @@ Item {
visible: Config.options.bar.utilButtons.showScreenSnip
sourceComponent: CircleUtilButton {
Layout.alignment: Qt.AlignVCenter
onClicked: Quickshell.execDetached(["qs", "-p", Quickshell.shellPath("screenshot.qml")])
onClicked: Quickshell.execDetached(["quickshell", "-p", Quickshell.shellPath("screenshot.qml")])
MaterialSymbol {
horizontalAlignment: Qt.AlignHCenter
fill: 1
@@ -90,11 +105,11 @@ Item {
sourceComponent: CircleUtilButton {
Layout.alignment: Qt.AlignVCenter
onClicked: event => {
if (Appearance.m3colors.darkmode) {
Hyprland.dispatch(`exec ${Directories.wallpaperSwitchScriptPath} --mode light --noswitch`);
} else {
Hyprland.dispatch(`exec ${Directories.wallpaperSwitchScriptPath} --mode dark --noswitch`);
}
const mode = Appearance.m3colors.darkmode ? "light" : "dark"
const wallpaper = Config.options.background.wallpaperPath || `${Quickshell.env("HOME")}/Pictures/Wallpapers/konachan_random_image.png`
themeSwitchProcess.command = ["bash", `${Directories.scriptPath}/colors/switchwall-wrapper.sh`, wallpaper, "--mode", mode]
themeSwitchProcess.running = false
themeSwitchProcess.running = true
}
MaterialSymbol {
horizontalAlignment: Qt.AlignHCenter
@@ -129,9 +144,9 @@ Item {
horizontalAlignment: Qt.AlignHCenter
fill: 0
text: switch(PowerProfiles.profile) {
case PowerProfile.PowerSaver: return "battery_saver"
case PowerProfile.Balanced: return "dynamic_form"
case PowerProfile.Performance: return "speed"
case PowerProfile.PowerSaver: return "energy_savings_leaf"
case PowerProfile.Balanced: return "settings_slow_motion"
case PowerProfile.Performance: return "local_fire_department"
}
iconSize: Appearance.font.pixelSize.large
color: Appearance.colors.colOnLayer2
@@ -17,7 +17,7 @@ GroupButton {
colBackground: Appearance.colors.colLayer2
toggled: Appearance.m3colors.darkmode === dark
onClicked: {
Quickshell.execDetached(["bash", "-c", `${Directories.wallpaperSwitchScriptPath} --mode ${dark ? "dark" : "light"} --noswitch`])
Quickshell.execDetached(["bash", `${Directories.scriptPath}/colors/switchwall-wrapper.sh`, "--mode", dark ? "dark" : "light", "--noswitch"])
}
contentItem: Item {
anchors.centerIn: parent
@@ -2,6 +2,7 @@
import argparse
import math
import json
import os
from PIL import Image
from materialyoucolor.quantize import QuantizeCelebi
from materialyoucolor.score.score import Score
@@ -86,6 +87,11 @@ if args.path is not None:
elif args.color is not None:
argb = hex_to_argb(args.color)
hct = Hct.from_int(argb)
elif args.cache is not None and os.path.exists(args.cache):
with open(args.cache, 'r') as file:
cached_color = file.read().strip()
argb = hex_to_argb(cached_color)
hct = Hct.from_int(argb)
if args.scheme == 'scheme-fruit-salad':
from materialyoucolor.scheme.scheme_fruit_salad import SchemeFruitSalad as Scheme
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Wrapper to set up environment for switchwall.sh
# Source environment config
[ -f "$HOME/.config/quickshell/env.sh" ] && source "$HOME/.config/quickshell/env.sh"
export ILLOGICAL_IMPULSE_VIRTUAL_ENV="${ILLOGICAL_IMPULSE_VIRTUAL_ENV:-$HOME/.local/state/quickshell/.venv}"
echo "[wrapper] Called with args: $@" >> /tmp/switchwall-wrapper.log
echo "[wrapper] LD_LIBRARY_PATH: $LD_LIBRARY_PATH" >> /tmp/switchwall-wrapper.log
# Run switchwall.sh with all arguments
exec "$(dirname "$0")/switchwall.sh" "$@"
@@ -1,5 +1,16 @@
#!/usr/bin/env bash
# Log execution
LOG="/tmp/switchwall.log"
echo "[$(date)] switchwall.sh started" >> "$LOG"
echo "ILLOGICAL_IMPULSE_VIRTUAL_ENV=$ILLOGICAL_IMPULSE_VIRTUAL_ENV" >> "$LOG"
# Ensure LD_LIBRARY_PATH is set for Python venv
if [ -z "$LD_LIBRARY_PATH" ]; then
export LD_LIBRARY_PATH="/run/current-system/sw/lib"
echo "Set LD_LIBRARY_PATH=$LD_LIBRARY_PATH" >> "$LOG"
fi
QUICKSHELL_CONFIG_NAME="ii"
XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"
XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}"
@@ -84,9 +95,10 @@ check_and_prompt_upscale() {
img_height=$(identify -format "%h" "$img" 2>/dev/null)
fi
if [[ "$img_width" -lt "$min_width_desired" || "$img_height" -lt "$min_height_desired" ]]; then
action=$(notify-send "Upscale?" \
action=$(timeout 5 notify-send "Upscale?" \
"Image resolution (${img_width}x${img_height}) is lower than screen resolution (${min_width_desired}x${min_height_desired})" \
-A "open_upscayl=Open Upscayl"\
-t 5000 \
-a "Wallpaper switcher")
if [[ "$action" == "open_upscayl" ]]; then
if command -v upscayl &>/dev/null; then
@@ -187,7 +199,10 @@ switch() {
exit 0
fi
check_and_prompt_upscale "$imgpath" &
# Only check upscale if not using --noswitch
if [[ -z "$noswitch_flag" ]]; then
check_and_prompt_upscale "$imgpath" &
fi
kill_existing_mpvpaper
if is_video "$imgpath"; then
@@ -281,11 +296,34 @@ switch() {
fi
matugen "${matugen_args[@]}"
source "$(eval echo $ILLOGICAL_IMPULSE_VIRTUAL_ENV)/bin/activate"
python3 "$SCRIPT_DIR/generate_colors_material.py" "${generate_colors_material_args[@]}" \
> "$STATE_DIR"/user/generated/material_colors.scss
"$SCRIPT_DIR"/applycolor.sh
deactivate
echo "[$(date)] Running python script" >> "$LOG"
"$(eval echo $ILLOGICAL_IMPULSE_VIRTUAL_ENV)/bin/python3" "$SCRIPT_DIR/generate_colors_material.py" "${generate_colors_material_args[@]}" \
> "$STATE_DIR"/user/generated/material_colors.scss 2>> "$LOG"
echo "[$(date)] Python done, scss size: $(wc -l < "$STATE_DIR"/user/generated/material_colors.scss)" >> "$LOG"
# Only convert to JSON if SCSS was generated successfully
if [ -s "$STATE_DIR"/user/generated/material_colors.scss ]; then
# Convert SCSS to JSON for quickshell MaterialThemeLoader
echo "[$(date)] Converting SCSS to JSON" >> "$LOG"
awk -F': ' '/^\$/ {gsub(/\$|;/, "", $0); print "\"" $1 "\": \"" $2 "\","}' \
"$STATE_DIR"/user/generated/material_colors.scss | \
sed '$ s/,$//' | \
(echo "{"; cat; echo "}") > "$STATE_DIR"/user/generated/colors.json.tmp
mv "$STATE_DIR"/user/generated/colors.json.tmp "$STATE_DIR"/user/generated/colors.json
sync "$STATE_DIR"/user/generated/colors.json
echo "[$(date)] JSON created, size: $(wc -l < "$STATE_DIR"/user/generated/colors.json)" >> "$LOG"
else
echo "[$(date)] SCSS generation failed, skipping JSON creation" >> "$LOG"
fi
"$XDG_CONFIG_HOME/quickshell/scripts/colors/applycolor.sh"
# Wait for all file operations to complete
wait
sleep 1
# Trigger quickshell to reload theme via IPC (doesn't restart the process)
quickshell ipc -c ii call materialTheme reload 2>/dev/null || true
# Pass screen width, height, and wallpaper path to post_process
max_width_desired="$(hyprctl monitors -j | jq '([.[].width] | min)' | xargs)"
@@ -300,6 +338,7 @@ main() {
color_flag=""
color=""
noswitch_flag=""
choose_flag=""
get_type_from_config() {
jq -r '.appearance.palette.type' "$SHELL_CONFIG_FILE" 2>/dev/null || echo "auto"
@@ -339,6 +378,10 @@ main() {
imgpath=$(jq -r '.background.wallpaperPath' "$SHELL_CONFIG_FILE" 2>/dev/null || echo "")
shift
;;
--choose)
choose_flag="1"
shift
;;
*)
if [[ -z "$imgpath" ]]; then
imgpath="$1"
@@ -369,8 +412,17 @@ main() {
# Only prompt for wallpaper if not using --color and not using --noswitch and no imgpath set
if [[ -z "$imgpath" && -z "$color_flag" && -z "$noswitch_flag" ]]; then
cd "$(xdg-user-dir PICTURES)/Wallpapers/showcase" 2>/dev/null || cd "$(xdg-user-dir PICTURES)/Wallpapers" 2>/dev/null || cd "$(xdg-user-dir PICTURES)" || return 1
imgpath="$(kdialog --getopenfilename . --title 'Choose wallpaper')"
# Try to pick a random wallpaper from Wallpapers directory
WALLPAPER_DIR="$(xdg-user-dir PICTURES)/Wallpapers"
if [[ -d "$WALLPAPER_DIR" ]] && [[ -z "$choose_flag" ]]; then
imgpath=$(find "$WALLPAPER_DIR" -type f \( -name "*.jpg" -o -name "*.png" \) 2>/dev/null | shuf -n 1)
fi
# If --choose flag is set or still no wallpaper, prompt with kdialog
if [[ -n "$choose_flag" ]] || [[ -z "$imgpath" ]]; then
cd "$(xdg-user-dir PICTURES)/Wallpapers/showcase" 2>/dev/null || cd "$(xdg-user-dir PICTURES)/Wallpapers" 2>/dev/null || cd "$(xdg-user-dir PICTURES)" || return 1
imgpath="$(kdialog --getopenfilename . --title 'Choose wallpaper')"
fi
fi
# If type_flag is 'auto', detect scheme type from image (after imgpath is set)
@@ -2,6 +2,7 @@ pragma Singleton
pragma ComponentBehavior: Bound
import qs.modules.common
import qs.modules.common.functions
import QtQuick
import Quickshell
import Quickshell.Io
@@ -14,31 +15,37 @@ Singleton {
id: root
property string filePath: Directories.generatedMaterialThemePath
function reapplyTheme() {
themeFileView.reload()
}
Component.onCompleted: delayedFileRead.restart()
function applyColors(fileContent) {
const json = JSON.parse(fileContent)
for (const key in json) {
if (json.hasOwnProperty(key)) {
// Convert snake_case to CamelCase
const camelCaseKey = key.replace(/_([a-z])/g, (g) => g[1].toUpperCase())
const m3Key = `m3${camelCaseKey}`
Appearance.m3colors[m3Key] = json[key]
}
}
Appearance.m3colors.darkmode = (Appearance.m3colors.m3background.hslLightness < 0.5)
function reapplyTheme() {
delayedFileRead.restart()
}
Timer {
id: delayedFileRead
interval: Config.options?.hacks?.arbitraryRaceConditionDelay ?? 100
interval: Config.options?.hacks?.arbitraryRaceConditionDelay ?? 300
repeat: false
running: false
onTriggered: {
root.applyColors(themeFileView.text())
console.log("MaterialThemeLoader: Timer triggered")
const fileContent = themeFileView.text()
console.log("MaterialThemeLoader: Read", fileContent.length, "bytes")
const json = JSON.parse(fileContent)
let colorCount = 0
for (const key in json) {
if (json.hasOwnProperty(key)) {
if (key === 'darkmode' || key === 'transparent' || key.includes('paletteKeyColor') || key.startsWith('term')) {
continue
}
const camelCaseKey = key.replace(/_([a-z])/g, (g) => g[1].toUpperCase())
const m3Key = `m3${camelCaseKey}`
Appearance.m3colors[m3Key] = json[key]
colorCount++
}
}
console.log("MaterialThemeLoader: Applied", colorCount, "colors")
console.log("MaterialThemeLoader: m3primary =", Appearance.m3colors.m3primary)
Appearance.m3colors.darkmode = (Appearance.m3colors.m3background.hslLightness < 0.5)
}
}
@@ -48,11 +55,8 @@ Singleton {
watchChanges: true
onFileChanged: {
this.reload()
delayedFileRead.start()
}
onLoadedChanged: {
const fileContent = themeFileView.text()
root.applyColors(fileContent)
delayedFileRead.restart()
}
onLoadedChanged: if (loaded) delayedFileRead.restart()
}
}