+
+
+
+
+
+
+
+
+
+
] [/path/to/Assets/Translations]
+# Or set the secret in environment and pass the path as argument
+#
+# Options:
+# --overwrite Overwrite existing translations
+# --lang Upload only a single language (e.g., --lang fr)
+
+set -e
+
+# Parse arguments
+OVERWRITE=false
+TRANSLATIONS_DIR="Assets/Translations"
+SINGLE_LANG=""
+
+while [[ $# -gt 0 ]]; do
+ case $1 in
+ --overwrite)
+ OVERWRITE=true
+ shift
+ ;;
+ --lang)
+ SINGLE_LANG="$2"
+ shift 2
+ ;;
+ *)
+ TRANSLATIONS_DIR="$1"
+ shift
+ ;;
+ esac
+done
+
+# Configuration
+API_URL="${TRANSLATION_API_URL:-https://i18n.noctalia.dev}"
+PROJECT_SLUG="${TRANSLATION_PROJECT:-noctalia-shell}"
+
+# Check for secret
+if [ -z "$NOCTALIA_SHELL_TRANSLATION_PUSH_SECRET" ]; then
+ echo "Error: NOCTALIA_SHELL_TRANSLATION_PUSH_SECRET environment variable is required"
+ exit 1
+fi
+
+# Check if directory exists
+if [ ! -d "$TRANSLATIONS_DIR" ]; then
+ echo "Error: Directory not found: $TRANSLATIONS_DIR"
+ exit 1
+fi
+
+# Check for jq
+if ! command -v jq &> /dev/null; then
+ echo "Error: jq is required but not installed"
+ echo "Install with: apt install jq"
+ exit 1
+fi
+
+echo "Pushing translations from: $TRANSLATIONS_DIR"
+echo "Target: $API_URL/api/projects/$PROJECT_SLUG/push"
+
+# Build combined JSON object
+COMBINED_JSON="{}"
+
+if [ -n "$SINGLE_LANG" ]; then
+ # Single language mode
+ file="$TRANSLATIONS_DIR/$SINGLE_LANG.json"
+ if [ ! -f "$file" ]; then
+ echo "Error: Language file not found: $file"
+ exit 1
+ fi
+ echo " Loading: $SINGLE_LANG ($SINGLE_LANG.json)"
+ COMBINED_JSON=$(echo "$COMBINED_JSON" | jq --arg locale "$SINGLE_LANG" --slurpfile content "$file" '. + {($locale): $content[0]}')
+else
+ # All languages mode
+ for file in "$TRANSLATIONS_DIR"/*.json; do
+ if [ -f "$file" ]; then
+ # Extract locale from filename (e.g., en.json -> en)
+ filename=$(basename "$file")
+ locale="${filename%.json}"
+
+ echo " Loading: $locale ($filename)"
+
+ # Add this locale's translations to the combined object
+ COMBINED_JSON=$(echo "$COMBINED_JSON" | jq --arg locale "$locale" --slurpfile content "$file" '. + {($locale): $content[0]}')
+ fi
+ done
+fi
+
+# Count locales
+LOCALE_COUNT=$(echo "$COMBINED_JSON" | jq 'keys | length')
+echo "Found $LOCALE_COUNT locale(s)"
+
+if [ "$LOCALE_COUNT" -eq 0 ]; then
+ echo "Error: No JSON files found in $TRANSLATIONS_DIR"
+ exit 1
+fi
+
+# Check if English exists (only required when pushing all languages)
+if [ -z "$SINGLE_LANG" ] && ! echo "$COMBINED_JSON" | jq -e '.en' > /dev/null 2>&1; then
+ echo "Error: English (en.json) is required"
+ exit 1
+fi
+
+# Confirmation
+echo ""
+if [ -n "$SINGLE_LANG" ]; then
+ read -p "Push '$SINGLE_LANG' to $API_URL? [y/N] " -n 1 -r
+else
+ read -p "Push $LOCALE_COUNT locale(s) to $API_URL? [y/N] " -n 1 -r
+fi
+echo ""
+if [[ ! $REPLY =~ ^[Yy]$ ]]; then
+ echo "Aborted."
+ exit 0
+fi
+
+# Build URL with optional overwrite parameter
+PUSH_URL="$API_URL/api/projects/$PROJECT_SLUG/push"
+if [ "$OVERWRITE" = true ]; then
+ PUSH_URL="$PUSH_URL?overwrite=true"
+ echo "Overwrite mode enabled"
+fi
+
+# Push to API
+echo "Pushing to API..."
+RESPONSE=$(echo "$COMBINED_JSON" | curl -s -w "\n%{http_code}" -X POST \
+ "$PUSH_URL" \
+ -H "Authorization: Bearer $NOCTALIA_SHELL_TRANSLATION_PUSH_SECRET" \
+ -H "Content-Type: application/json" \
+ -d @-)
+
+# Extract HTTP status code (last line) and body (everything else)
+HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
+BODY=$(echo "$RESPONSE" | sed '$d')
+
+if [ "$HTTP_CODE" -eq 200 ]; then
+ echo "Success!"
+ echo "$BODY" | jq .
+else
+ echo "Error: HTTP $HTTP_CODE"
+ echo "$BODY" | jq . 2>/dev/null || echo "$BODY"
+ exit 1
+fi
diff --git a/arch/.config/quickshell/noctalia-shell/Scripts/dev/notifications-test-replace.sh b/arch/.config/quickshell/noctalia-shell/Scripts/dev/notifications-test-replace.sh
new file mode 100755
index 0000000..50fcf1a
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Scripts/dev/notifications-test-replace.sh
@@ -0,0 +1,23 @@
+#!/usr/bin/env bash
+# Test script for notification replacement functionality
+
+echo "Testing notification replacement..."
+echo ""
+
+# Send initial notification and capture the ID
+echo "Step 1: Sending initial notification 'asdf'..."
+NOTIF_ID=$(notify-send -p "asdf")
+echo "Notification ID: $NOTIF_ID"
+echo ""
+
+# Wait a moment for the notification to appear
+sleep 1
+
+# Replace the notification
+echo "Step 2: Replacing notification $NOTIF_ID with 'test'..."
+notify-send -r "$NOTIF_ID" -p "test"
+echo ""
+
+echo "The notification should now show 'test' instead of 'asdf'."
+echo "If it still shows 'asdf', the replacement is not working."
+
diff --git a/arch/.config/quickshell/noctalia-shell/Scripts/dev/notifications-test.sh b/arch/.config/quickshell/noctalia-shell/Scripts/dev/notifications-test.sh
new file mode 100755
index 0000000..db985c8
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Scripts/dev/notifications-test.sh
@@ -0,0 +1,52 @@
+#!/usr/bin/env -S bash
+
+echo "Sending test notifications..."
+
+# Send a bunch of notifications with numbers
+for i in {1..4}; do
+ notify-send "Notification $i" "This is test notification number $i with a very long text that will probably break the layout or maybe not? Who knows? Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
+ sleep 1
+done
+
+echo "All notifications sent!"
+
+# Additional tests for icon/image handling
+if command -v notify-send >/dev/null 2>&1; then
+ echo "Sending icon/image tests..."
+
+ # 1) Themed icon name
+ notify-send -i dialog-information "Icon name test" "Should resolve from theme (dialog-information)"
+
+ sleep 1
+
+ # 2) Absolute path if a sample image exists
+ SAMPLE_IMG="/usr/share/pixmaps/steam.png"
+ if [ -f "$SAMPLE_IMG" ]; then
+ notify-send -i "$SAMPLE_IMG" "Absolute path test" "Should show the provided image path"
+ fi
+
+ sleep 1
+
+ # 3) file:// URL form
+ if [ -f "$SAMPLE_IMG" ]; then
+ notify-send -i "file://$SAMPLE_IMG" "file:// URL test" "Should display after stripping scheme"
+ fi
+
+ sleep 1
+
+ echo "Icon/image tests sent!"
+fi
+
+# A test notification with actions
+gdbus call --session \
+ --dest org.freedesktop.Notifications \
+ --object-path /org/freedesktop/Notifications \
+ --method org.freedesktop.Notifications.Notify \
+ "my-app" \
+ 0 \
+ "dialog-question" \
+ "Confirmation Required" \
+ "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Do you want to proceed with the action? " \
+ "['default', 'OK', 'cancel', 'Cancel', 'maybe', 'Maybe', 'undecided', 'Undecided']" \
+ "{}" \
+ 5000
diff --git a/arch/.config/quickshell/noctalia-shell/Scripts/dev/qmlfmt.sh b/arch/.config/quickshell/noctalia-shell/Scripts/dev/qmlfmt.sh
new file mode 100755
index 0000000..049070c
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Scripts/dev/qmlfmt.sh
@@ -0,0 +1,51 @@
+#!/usr/bin/env -S bash
+set -euo pipefail
+
+# QML Formatter Script
+
+# Suppress Qt debug logging from qmlformat
+export QT_LOGGING_RULES="qt.qmldom.*=false"
+
+# Find qmlformat binary
+QMLFORMAT=""
+for path in "/usr/lib64/qt6/bin/qmlformat" "/usr/lib/qt6/bin/qmlformat"; do
+ if [ -x "$path" ]; then
+ QMLFORMAT="$path"
+ break
+ fi
+done
+
+# Fallback to PATH
+if [ -z "$QMLFORMAT" ] && command -v qmlformat &>/dev/null; then
+ QMLFORMAT="qmlformat"
+fi
+
+if [ -z "$QMLFORMAT" ]; then
+ echo "No 'qmlformat' found in standard locations or PATH." >&2
+ echo "To proceed, install it via 'qt6-tools', 'qt6-declarative-tools' or 'qt6-qtdeclarative-devel'" >&2
+ exit 1
+fi
+
+# Detect qmlformat version for flag compatibility
+EXTRA_FLAGS=""
+if version=$("$QMLFORMAT" --version 2>&1) && [[ "$version" =~ ([0-9]+\.[0-9]+) ]]; then
+ if [[ "$(printf '%s\n6.10\n' "${BASH_REMATCH[1]}" | sort -V | head -1)" == "6.10" ]]; then
+ EXTRA_FLAGS="-S --semicolon-rule always"
+ fi
+fi
+
+format_file() {
+ ${QMLFORMAT} -w 2 -W 360 ${EXTRA_FLAGS} -i "$1" || { echo "Failed: $1" >&2; return 1; }
+}
+
+export -f format_file
+export QMLFORMAT EXTRA_FLAGS
+
+# Find all .qml files
+mapfile -t all_files < <(find "${1:-.}" -name "*.qml" -type f)
+[ ${#all_files[@]} -eq 0 ] && { echo "No QML files found"; exit 0; }
+
+echo "Formatting ${#all_files[@]} files..."
+printf '%s\0' "${all_files[@]}" | \
+ xargs -0 -P "${QMLFMT_JOBS:-$(nproc)}" -I {} bash -c 'format_file "$@"' _ {} \
+ && echo "Done" || { echo "Errors occurred" >&2; exit 1; }
diff --git a/arch/.config/quickshell/noctalia-shell/Scripts/dev/shaders-compile.sh b/arch/.config/quickshell/noctalia-shell/Scripts/dev/shaders-compile.sh
new file mode 100755
index 0000000..415c60d
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Scripts/dev/shaders-compile.sh
@@ -0,0 +1,92 @@
+#!/bin/bash
+
+# Find qsb binary in common locations.
+QSB_PATHS=(
+ "/usr/lib/qt6/bin/qsb"
+ "/usr/lib64/qt6/bin/qsb"
+)
+
+QSB_BIN=""
+for path in "${QSB_PATHS[@]}"; do
+ if [ -x "$path" ]; then
+ QSB_BIN="$path"
+ break
+ fi
+done
+
+if [ -z "$QSB_BIN" ]; then
+ echo "Error: qsb binary not found in any of: ${QSB_PATHS[*]}"
+ exit 1
+fi
+
+# Directory containing the source shaders.
+SOURCE_DIR="Shaders/frag/"
+
+# Directory where the compiled shaders will be saved.
+DEST_DIR="Shaders/qsb/"
+
+# Check if the source directory exists.
+if [ ! -d "$SOURCE_DIR" ]; then
+ echo "Source directory $SOURCE_DIR not found!"
+ exit 1
+fi
+
+# Create the destination directory if it doesn't exist.
+mkdir -p "$DEST_DIR"
+
+# Array to hold the list of full paths to the shaders.
+SHADERS_TO_COMPILE=()
+
+# Specific files mode.
+if [ "$#" -gt 0 ]; then
+
+ # Loop through all command-line arguments ($@ holds all arguments).
+ for SINGLE_FILE in "$@"; do
+
+ # Construct the full path to the source file.
+ FULL_PATH="$SOURCE_DIR$SINGLE_FILE"
+
+ # Check if the specified file exists in the SOURCE_DIR.
+ if [ ! -f "$FULL_PATH" ]; then
+ echo "Error: Specified file '$SINGLE_FILE' not found in $SOURCE_DIR! Skipping."
+ continue
+ fi
+
+ # Add the valid file to the compilation list.
+ SHADERS_TO_COMPILE+=("$FULL_PATH")
+ done
+
+ # Check if any valid files were found to compile.
+ if [ ${#SHADERS_TO_COMPILE[@]} -eq 0 ]; then
+ echo "No valid shaders found to compile."
+ exit 1
+ fi
+
+# Whole directory mode (no argument provided).
+else
+ # Use find to generate the list of files and assign it to the array.
+ while IFS= read -r shader_path; do
+ if [ -n "$shader_path" ]; then
+ SHADERS_TO_COMPILE+=("$shader_path")
+ fi
+ done < <(find "$SOURCE_DIR" -maxdepth 1 -name "*.frag")
+
+fi
+
+# Loop through the list of shaders to compile.
+for shader in "${SHADERS_TO_COMPILE[@]}"; do
+
+ # Get the base name of the file (e.g., wp_fade).
+ shader_name=$(basename "$shader" .frag)
+
+ # Construct the output path for the compiled shader.
+ output_path="$DEST_DIR$shader_name.frag.qsb"
+
+ # Construct and run the qsb command.
+ "$QSB_BIN" --qt6 -o "$output_path" "$shader"
+
+ # Print a message to confirm compilation.
+ echo "Compiled $(basename "$shader") to $output_path"
+done
+
+echo "Shader compilation complete."
diff --git a/arch/.config/quickshell/noctalia-shell/Scripts/dev/template-processor-analysis.py b/arch/.config/quickshell/noctalia-shell/Scripts/dev/template-processor-analysis.py
new file mode 100755
index 0000000..72d0588
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Scripts/dev/template-processor-analysis.py
@@ -0,0 +1,331 @@
+#!/usr/bin/env python3
+"""
+Analyze Noctalia's template-processor color extraction.
+
+Usage:
+ ./template-processor-analysis.py
+ ./template-processor-analysis.py ~/Pictures/Wallpapers/example.png
+
+Shows extracted colors for all scheme types and compares M3 schemes with matugen.
+
+Scheme types:
+- M3 schemes (tonal-spot, fruit-salad, rainbow, content): Compared with matugen
+- vibrant: Prioritizes the most saturated colors regardless of area
+- faithful: Prioritizes dominant colors by area coverage
+- dysfunctional: Like faithful but picks the 2nd most dominant color family
+- muted: Preserves hue but caps saturation low (for monochrome wallpapers)
+"""
+
+import argparse
+import json
+import subprocess
+import sys
+from pathlib import Path
+
+# Add the theming lib to path
+SCRIPT_DIR = Path(__file__).parent.resolve()
+THEMING_DIR = SCRIPT_DIR.parent / "python" / "src" / "theming"
+sys.path.insert(0, str(THEMING_DIR))
+
+from lib.color import Color
+from lib.hct import Hct
+
+
+def hue_diff(h1: float, h2: float) -> float:
+ """Calculate circular hue difference."""
+ diff = abs(h1 - h2)
+ return min(diff, 360.0 - diff)
+
+
+def hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
+ """Convert hex to RGB tuple."""
+ h = hex_color.lstrip('#')
+ return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
+
+
+def rgb_distance(hex1: str, hex2: str) -> float:
+ """Calculate Euclidean RGB distance (0-441 range)."""
+ r1, g1, b1 = hex_to_rgb(hex1)
+ r2, g2, b2 = hex_to_rgb(hex2)
+ return ((r1-r2)**2 + (g1-g2)**2 + (b1-b2)**2) ** 0.5
+
+
+def get_hct(hex_color: str) -> Hct:
+ """Convert hex color to HCT."""
+ return Color.from_hex(hex_color).to_hct()
+
+
+def hue_to_name(hue: float) -> str:
+ """Convert hue to color name."""
+ if hue < 30 or hue >= 330:
+ return "RED"
+ elif hue < 60:
+ return "ORANGE"
+ elif hue < 90:
+ return "YELLOW"
+ elif hue < 150:
+ return "GREEN"
+ elif hue < 210:
+ return "CYAN"
+ elif hue < 270:
+ return "BLUE"
+ elif hue < 330:
+ return "PURPLE"
+ return "RED"
+
+
+def run_our_processor(image_path: Path, scheme: str) -> dict | None:
+ """Run our template-processor and return colors."""
+ cmd = [
+ sys.executable,
+ str(THEMING_DIR / "template-processor.py"),
+ str(image_path),
+ "--scheme-type", scheme,
+ "--dark"
+ ]
+ try:
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
+ data = json.loads(result.stdout)
+ return data.get("dark", {})
+ except (subprocess.CalledProcessError, json.JSONDecodeError) as e:
+ print(f"Error running our processor: {e}", file=sys.stderr)
+ return None
+
+
+def run_matugen(image_path: Path, scheme: str) -> dict | None:
+ """Run matugen and return colors."""
+ matugen_scheme = f"scheme-{scheme}"
+ cmd = [
+ "matugen", "image", str(image_path),
+ "--json", "hex",
+ "--dry-run",
+ "-t", matugen_scheme
+ ]
+ try:
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
+ data = json.loads(result.stdout)
+ colors = data.get("colors", {})
+ # Extract dark mode values
+ return {k: v.get("dark", v) for k, v in colors.items() if isinstance(v, dict)}
+ except subprocess.CalledProcessError as e:
+ print(f"Error running matugen: {e}", file=sys.stderr)
+ return None
+ except json.JSONDecodeError as e:
+ print(f"Error parsing matugen output: {e}", file=sys.stderr)
+ return None
+
+
+def analyze_vibrant_faithful_muted(image_path: Path) -> None:
+ """Analyze vibrant, faithful, dysfunctional, and muted mode outputs."""
+ print("\n" + "=" * 78)
+ print("VIBRANT vs FAITHFUL vs DYSFUNCTIONAL vs MUTED COMPARISON")
+ print("=" * 78)
+ print()
+ print("Vibrant: Prioritizes the most saturated colors regardless of area")
+ print("Faithful: Prioritizes dominant colors by area coverage")
+ print("Dysfunctional: Like faithful but picks 2nd most dominant color family")
+ print("Muted: Preserves hue but caps saturation low (monochrome wallpapers)")
+ print()
+ print("-" * 78)
+ print(f"{'Mode':<14} {'Color':<12} {'Hex':<10} {'Hue':>8} {'Chroma':>8} {'Name':<10}")
+ print("-" * 78)
+
+ for scheme in ["vibrant", "faithful", "dysfunctional", "muted"]:
+ colors = run_our_processor(image_path, scheme)
+ if not colors:
+ print(f"{scheme}: Failed to get colors")
+ continue
+
+ for key in ["primary", "secondary", "tertiary"]:
+ hex_color = colors.get(key, "")
+ if not hex_color:
+ continue
+
+ try:
+ hct = get_hct(hex_color)
+ name = hue_to_name(hct.hue)
+ print(f"{scheme:<14} {key:<12} {hex_color:<10} {hct.hue:>7.1f}ยฐ {hct.chroma:>7.1f} {name:<10}")
+ except Exception as e:
+ print(f"{scheme:<14} {key:<12} Error: {e}")
+
+ print("-" * 78)
+
+ # Summary comparison
+ vibrant = run_our_processor(image_path, "vibrant")
+ faithful = run_our_processor(image_path, "faithful")
+ dysfunctional = run_our_processor(image_path, "dysfunctional")
+ muted = run_our_processor(image_path, "muted")
+
+ if vibrant and faithful and dysfunctional and muted:
+ print()
+ print("Summary:")
+ v_hct = get_hct(vibrant.get("primary", "#000000"))
+ f_hct = get_hct(faithful.get("primary", "#000000"))
+ d_hct = get_hct(dysfunctional.get("primary", "#000000"))
+ m_hct = get_hct(muted.get("primary", "#000000"))
+
+ v_name = hue_to_name(v_hct.hue)
+ f_name = hue_to_name(f_hct.hue)
+ d_name = hue_to_name(d_hct.hue)
+ m_name = hue_to_name(m_hct.hue)
+
+ vf_diff = hue_diff(v_hct.hue, f_hct.hue)
+ fd_diff = hue_diff(f_hct.hue, d_hct.hue)
+
+ print(f" Vibrant primary: {vibrant.get('primary')} ({v_name}, hue {v_hct.hue:.0f}ยฐ, chroma {v_hct.chroma:.1f})")
+ print(f" Faithful primary: {faithful.get('primary')} ({f_name}, hue {f_hct.hue:.0f}ยฐ, chroma {f_hct.chroma:.1f})")
+ print(f" Dysfunctional primary:{dysfunctional.get('primary')} ({d_name}, hue {d_hct.hue:.0f}ยฐ, chroma {d_hct.chroma:.1f})")
+ print(f" Muted primary: {muted.get('primary')} ({m_name}, hue {m_hct.hue:.0f}ยฐ, chroma {m_hct.chroma:.1f})")
+ print(f" V-F hue diff: {vf_diff:.1f}ยฐ")
+ print(f" F-D hue diff: {fd_diff:.1f}ยฐ")
+
+ if vf_diff > 60:
+ print(f" โ Vibrant/Faithful picked DIFFERENT color families ({v_name} vs {f_name})")
+ else:
+ print(f" โ Vibrant/Faithful picked SIMILAR colors")
+
+ if fd_diff > 30:
+ print(f" โ Faithful/Dysfunctional picked DIFFERENT color families ({f_name} vs {d_name})")
+ else:
+ print(f" โ Faithful/Dysfunctional picked SIMILAR colors (may only have 1 dominant family)")
+
+ # Note the muted chroma reduction
+ if m_hct.chroma < 20:
+ print(f" โ Muted successfully reduced chroma to {m_hct.chroma:.1f}")
+ else:
+ print(f" โ Muted chroma still moderately high ({m_hct.chroma:.1f})")
+
+
+def compare_m3_schemes(image_path: Path, has_matugen: bool) -> None:
+ """Compare all M3 schemes between our processor and matugen."""
+ schemes = ["tonal-spot", "fruit-salad", "rainbow", "content", "monochrome"]
+ color_keys = ["primary", "secondary", "tertiary", "surface", "on_surface"]
+
+ print("\n" + "=" * 78)
+ print("M3 SCHEMES" + (" (compared with matugen)" if has_matugen else ""))
+ print("=" * 78)
+
+ if has_matugen:
+ # Header for comparison mode
+ print(f"{'Scheme':<12} {'Color':<14} {'Ours':<10} {'Matugen':<10} {'Diff':>10} {'Match':<10}")
+ print("-" * 78)
+
+ for scheme in schemes:
+ ours = run_our_processor(image_path, scheme)
+ matugen = run_matugen(image_path, scheme)
+
+ if not ours or not matugen:
+ print(f"{scheme}: Failed to get colors")
+ continue
+
+ for key in color_keys:
+ our_hex = ours.get(key, "")
+ mat_hex = matugen.get(key, "")
+
+ if not our_hex or not mat_hex:
+ continue
+
+ try:
+ our_hct = get_hct(our_hex)
+ mat_hct = get_hct(mat_hex)
+ avg_chroma = (our_hct.chroma + mat_hct.chroma) / 2
+
+ # For low-chroma colors, use RGB distance instead of hue
+ if avg_chroma < 15:
+ rgb_dist = rgb_distance(our_hex, mat_hex)
+ if rgb_dist < 10:
+ match = "excellent"
+ elif rgb_dist < 25:
+ match = "good"
+ elif rgb_dist < 50:
+ match = "fair"
+ else:
+ match = "poor"
+ diff_str = f"{rgb_dist:>5.1f} rgb"
+ else:
+ diff = hue_diff(our_hct.hue, mat_hct.hue)
+ if diff < 5:
+ match = "excellent"
+ elif diff < 15:
+ match = "good"
+ elif diff < 30:
+ match = "fair"
+ else:
+ match = "poor"
+ diff_str = f"{diff:>5.1f} hue"
+
+ print(f"{scheme:<12} {key:<14} {our_hex:<10} {mat_hex:<10} {diff_str:>10} {match:<10}")
+ except Exception as e:
+ print(f"{scheme:<12} {key:<14} Error: {e}")
+
+ print("-" * 78)
+ else:
+ # Header for standalone mode
+ print(f"{'Scheme':<12} {'Color':<14} {'Hex':<10} {'Hue':>8} {'Chroma':>8} {'Name':<10}")
+ print("-" * 78)
+
+ for scheme in schemes:
+ ours = run_our_processor(image_path, scheme)
+
+ if not ours:
+ print(f"{scheme}: Failed to get colors")
+ continue
+
+ for key in ["primary", "secondary", "tertiary"]:
+ our_hex = ours.get(key, "")
+ if not our_hex:
+ continue
+
+ try:
+ hct = get_hct(our_hex)
+ name = hue_to_name(hct.hue)
+ print(f"{scheme:<12} {key:<14} {our_hex:<10} {hct.hue:>7.1f}ยฐ {hct.chroma:>7.1f} {name:<10}")
+ except Exception as e:
+ print(f"{scheme:<12} {key:<14} Error: {e}")
+
+ print("-" * 78)
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(
+ description="Analyze Noctalia template-processor color extraction"
+ )
+ parser.add_argument(
+ "wallpaper",
+ type=Path,
+ help="Path to wallpaper image"
+ )
+ parser.add_argument(
+ "--no-matugen",
+ action="store_true",
+ help="Skip matugen comparison"
+ )
+
+ args = parser.parse_args()
+
+ if not args.wallpaper.exists():
+ print(f"Error: File not found: {args.wallpaper}", file=sys.stderr)
+ return 1
+
+ print(f"\nAnalyzing: {args.wallpaper.name}")
+
+ # Check if matugen is available
+ has_matugen = False
+ if not args.no_matugen:
+ try:
+ subprocess.run(["matugen", "--version"], capture_output=True, check=True)
+ has_matugen = True
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ print("Note: matugen not found, skipping M3 comparison")
+
+ # Always show vibrant vs faithful vs muted first (most useful)
+ analyze_vibrant_faithful_muted(args.wallpaper)
+
+ # Then show M3 schemes
+ compare_m3_schemes(args.wallpaper, has_matugen)
+
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/arch/.config/quickshell/noctalia-shell/Scripts/python/src/calendar/calendar-events.py b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/calendar/calendar-events.py
new file mode 100755
index 0000000..e080eb8
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/calendar/calendar-events.py
@@ -0,0 +1,242 @@
+#!/usr/bin/env python3
+import gi
+gi.require_version('EDataServer', '1.2')
+gi.require_version('ECal', '2.0')
+gi.require_version('ICalGLib', "3.0")
+
+import json, sys
+from datetime import datetime, timedelta, timezone
+from zoneinfo import ZoneInfo
+from gi.repository import ECal, EDataServer, ICalGLib
+
+start_time = int(sys.argv[1])
+end_time = int(sys.argv[2])
+
+print(f"Starting with time range: {start_time} to {end_time}", file=sys.stderr)
+
+all_events = []
+
+def safe_get_time(ical_time):
+ if not ical_time:
+ return None, False
+ try:
+ year, month, day = ical_time.get_year(), ical_time.get_month(), ical_time.get_day()
+ is_all_day = hasattr(ical_time, "is_date") and ical_time.is_date()
+ if is_all_day:
+ # All-day events (birthdays, holidays) should not need
+ # to be timezone converted
+ return int(datetime(year, month, day).timestamp()), True
+
+ hour, minute, second = ical_time.get_hour(), ical_time.get_minute(), ical_time.get_second()
+
+ # Determine timezone for proper conversion
+ tz_obj = ical_time.get_timezone() if hasattr(ical_time, 'get_timezone') else None
+ tzid = tz_obj.get_tzid() if tz_obj else None
+ tz = None
+ if ical_time.is_utc() if hasattr(ical_time, 'is_utc') else False:
+ tz = timezone.utc # Explicit UTC time
+ elif tzid:
+ # Evolution uses non-standard format: /freeassociation.sourceforge.net/America/Los_Angeles
+ # Strip prefix to get IANA name: America/Los_Angeles
+ iana = tzid.replace('/freeassociation.sourceforge.net/', '') if tzid.startswith('/') else tzid
+ try: tz = ZoneInfo(iana)
+ except: pass
+
+ # Create timezone-aware datetime
+ dt = datetime(year, month, day, hour, minute, second, tzinfo=tz)
+ return int(dt.timestamp()), False
+ except:
+ return None, False
+
+def add_event(summary, calendar_name, start_ts, end_ts, location="", description="", all_day=False, calendar_uid="", uid=""):
+ all_events.append({
+ 'calendar': calendar_name,
+ 'summary': summary,
+ 'start': start_ts,
+ 'end': end_ts,
+ 'location': location,
+ 'description': description,
+ 'calendar_uid': calendar_uid,
+ 'uid': uid
+ })
+
+registry = EDataServer.SourceRegistry.new_sync(None)
+sources = registry.list_sources(EDataServer.SOURCE_EXTENSION_CALENDAR)
+
+for source in sources:
+ if not source.get_enabled():
+ continue
+
+ calendar_name = source.get_display_name()
+ print(f"\nProcessing calendar: {calendar_name}", file=sys.stderr)
+
+ try:
+ client = ECal.Client.connect_sync(source, ECal.ClientSourceType.EVENTS, 5, None)
+
+ start_dt = datetime.fromtimestamp(start_time)
+ end_dt = datetime.fromtimestamp(end_time)
+ start_str = start_dt.strftime("%Y%m%dT%H%M%S")
+ end_str = end_dt.strftime("%Y%m%dT%H%M%S")
+
+ query = f'(occur-in-time-range? (make-time "{start_str}") (make-time "{end_str}"))'
+ success, raw_events = client.get_object_list_sync(query, None)
+
+ if not success or not raw_events:
+ continue
+
+ for raw_obj in raw_events:
+ obj = raw_obj[1] if isinstance(raw_obj, tuple) else raw_obj
+ comp = None
+
+ if isinstance(obj, ICalGLib.Component):
+ comp = obj
+ elif isinstance(obj, ECal.Component):
+ try:
+ ical_str = obj.to_string()
+ temp_comp = ICalGLib.Component.new_from_string(ical_str)
+ if temp_comp.getName() == "VEVENT":
+ comp = temp_comp
+ except Exception:
+ comp = None
+
+ if not comp:
+ summary = getattr(obj, "get_summary", lambda: "(No title)")()
+ dtstart = getattr(obj, "get_dtstart", lambda: None)()
+ dtend = getattr(obj, "get_dtend", lambda: None)()
+ location = getattr(obj, "get_location", lambda: "")() or ""
+ description = getattr(obj, "get_description", lambda: "")() or ""
+ start_ts, all_day = safe_get_time(dtstart)
+ end_ts, _ = safe_get_time(dtend)
+ if start_ts:
+ if end_ts is None:
+ end_ts = start_ts + 3600
+ event_uid = getattr(obj, "get_uid", lambda: "")() or ""
+ add_event(summary, calendar_name, start_ts, end_ts, location, description,
+ calendar_uid=source.get_uid(), uid=event_uid)
+ continue
+
+ summary = getattr(comp, "get_summary", lambda: "(No title)")()
+ location = getattr(comp, "get_location", lambda: "")() or ""
+ description = getattr(comp, "get_description", lambda: "")() or ""
+ dtstart = getattr(comp, "get_dtstart", lambda: None)()
+ dtend = getattr(comp, "get_dtend", lambda: None)()
+ start_ts, all_day = safe_get_time(dtstart)
+ end_ts, _ = safe_get_time(dtend)
+ if end_ts is None and start_ts is not None:
+ end_ts = start_ts + 3600
+
+ rrule_getter = getattr(comp, "get_first_property", None)
+ if rrule_getter:
+ rrule_prop = comp.get_first_property(73) # ICAL_RRULE_PROPERTY
+ if rrule_prop:
+ rrule_value = rrule_prop.get_value() # ICalGLib.Value
+
+ try:
+ recurrence = rrule_value.get_recur() # -> ICalGLib.Recurrence
+
+ except AttributeError:
+ rrule_str = str(rrule_value)
+ recurrence = ICalGLib.Recurrence.new_from_string(rrule_str)
+
+ if recurrence:
+ freq = recurrence.get_freq()
+
+ rdates = getattr(comp, "get_rdate_list", lambda: [])()
+ exdates = getattr(comp, "get_exdate_list", lambda: [])()
+
+ # --- normal event ---
+ if not rrule_prop and not rdates:
+ add_event(summary, calendar_name, start_ts, end_ts, location, description,
+ calendar_uid=source.get_uid(), uid=comp.get_uid() or "")
+ continue
+
+ # --- recurrent events ---
+ if freq:
+ summary = comp.get_summary() or "(No title)"
+ dtstart = comp.get_dtstart()
+ dtend = comp.get_dtend()
+ start_ts, all_day = safe_get_time(dtstart)
+ end_ts, _ = safe_get_time(dtend)
+ if end_ts is None and start_ts is not None:
+ end_ts = start_ts + 3600 # 1h default
+
+ interval = recurrence.get_interval() or 1
+ count = recurrence.get_count()
+ until_dt = recurrence.get_until()
+ until_ts, _ = safe_get_time(until_dt) if until_dt else (None, False)
+ if until_ts is None:
+ until_ts = end_time
+
+ occurrences = []
+ current_ts = start_ts
+ added = 0
+
+ match freq:
+ case 0: #SECONDLY
+ delta = timedelta(seconds=interval)
+ while (current_ts <= until_ts) and (not count or added < count):
+ occurrences.append((current_ts, current_ts + (end_ts - start_ts)))
+ current_ts += int(delta.total_seconds())
+ added += 1
+
+ case 1: #MINUTELY
+ delta = timedelta(minutes=interval)
+ while (current_ts <= until_ts) and (not count or added < count):
+ occurrences.append((current_ts, current_ts + (end_ts - start_ts)))
+ current_ts += int(delta.total_seconds())
+ added += 1
+
+ case 2: #HOURLY
+ delta = timedelta(hours=interval)
+ while (current_ts <= until_ts) and (not count or added < count):
+ occurrences.append((current_ts, current_ts + (end_ts - start_ts)))
+ current_ts += int(delta.total_seconds())
+ added += 1
+
+ case 3: # DAILY
+ delta = timedelta(days=interval)
+ while (current_ts <= until_ts) and (not count or added < count):
+ occurrences.append((current_ts, current_ts + (end_ts - start_ts)))
+ current_ts += int(delta.total_seconds())
+ added += 1
+
+ case 4: # WEEKLY
+ delta = timedelta(weeks=interval)
+ while (current_ts <= until_ts) and (not count or added < count):
+ occurrences.append((current_ts, current_ts + (end_ts - start_ts)))
+ current_ts += int(delta.total_seconds())
+ added += 1
+
+ case 5: # MONTHLY
+ from dateutil.relativedelta import relativedelta
+ dt = datetime.fromtimestamp(current_ts)
+ while (current_ts <= until_ts) and (not count or added < count):
+ occurrences.append((current_ts, current_ts + (end_ts - start_ts)))
+ dt += relativedelta(months=interval)
+ current_ts = int(dt.timestamp())
+ added += 1
+
+ case 6: # YEARLY
+ from dateutil.relativedelta import relativedelta
+ dt = datetime.fromtimestamp(current_ts)
+ while (current_ts <= until_ts) and (not count or added < count):
+ occurrences.append((current_ts, current_ts + (end_ts - start_ts)))
+ dt += relativedelta(years=interval)
+ current_ts = int(dt.timestamp())
+ added += 1
+
+ case _: # NONE
+ occurrences.append((start_ts, end_ts))
+
+ # --- add occurences to all_events ---
+ for occ_start, occ_end in occurrences:
+ add_event(summary, calendar_name, occ_start, occ_end, location, description,
+ calendar_uid=source.get_uid(), uid=comp.get_uid() or "")
+
+
+ except Exception as e:
+ print(f" Error for {calendar_name}: {e}", file=sys.stderr)
+
+all_events.sort(key=lambda x: x['start'])
+print(json.dumps(all_events, indent=4))
+
diff --git a/arch/.config/quickshell/noctalia-shell/Scripts/python/src/calendar/check-calendar.py b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/calendar/check-calendar.py
new file mode 100755
index 0000000..463d2e4
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/calendar/check-calendar.py
@@ -0,0 +1,11 @@
+#!/usr/bin/env python3
+import gi
+
+gi.require_version('EDataServer', '1.2')
+gi.require_version('ECal', '2.0')
+
+try:
+ from gi.repository import ECal, EDataServer
+ print("available")
+except ImportError as e:
+ print(f"unavailable: {e}")
diff --git a/arch/.config/quickshell/noctalia-shell/Scripts/python/src/calendar/khal-events.py b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/calendar/khal-events.py
new file mode 100755
index 0000000..6b54542
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/calendar/khal-events.py
@@ -0,0 +1,71 @@
+#!/usr/bin/env python3
+import json
+import os
+import re
+import subprocess
+import sys
+from datetime import datetime
+from pathlib import Path
+
+def get_khal_date_format():
+ """Read the khal config and extract the longdatetimeformat."""
+ xdg_config = os.environ.get('XDG_CONFIG_HOME', os.path.expanduser('~/.config'))
+ config_path = Path(xdg_config) / 'khal' / 'config'
+
+ if not config_path.exists():
+ return '%c'
+
+ with open(config_path, 'r') as f:
+ for line in f:
+ if m := re.match(r'^longdatetimeformat\s?=\s?(.+?)\s*$', line):
+ date_format = m.group(1).strip()
+ return date_format
+
+ return '%c'
+
+
+def to_khal(date_str, khal_format):
+ dt = datetime.strptime(date_str, "%Y-%m-%d")
+ return dt.strftime(khal_format)
+
+def from_khal(date_str, khal_format):
+ if not date_str:
+ return ''
+ dt = datetime.strptime(date_str, khal_format)
+ return dt.isoformat()
+
+def convert_event(event, khal_format):
+ event['start-long-full'] = from_khal(event.get('start-long-full', ''), khal_format)
+ event['end-long-full'] = from_khal(event.get('end-long-full', ''), khal_format)
+ return event
+
+def main():
+ start_date = sys.argv[1]
+ duration = sys.argv[2]
+
+ khal_format = get_khal_date_format()
+ khal_start = to_khal(start_date, khal_format)
+
+ cmd = [
+ 'khal', 'list',
+ '--json', 'uid',
+ '--json', 'title',
+ '--json', 'start-long-full',
+ '--json', 'end-long-full',
+ '--json', 'calendar',
+ '--json', 'description',
+ '--json', 'location',
+ '--json', 'repeat-pattern',
+ khal_start,
+ duration
+ ]
+
+ result = subprocess.run(cmd, capture_output=True, text=True)
+ output = result.stdout.strip()
+
+ for line in output.split('\n'):
+ day_events = json.loads(line)
+ print(json.dumps([convert_event(e, khal_format) for e in day_events]))
+
+if __name__ == '__main__':
+ main()
diff --git a/arch/.config/quickshell/noctalia-shell/Scripts/python/src/calendar/list-calendars.py b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/calendar/list-calendars.py
new file mode 100755
index 0000000..12c67bd
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/calendar/list-calendars.py
@@ -0,0 +1,21 @@
+#!/usr/bin/env python3
+import gi
+
+gi.require_version('EDataServer', '1.2')
+import json
+
+from gi.repository import EDataServer
+
+registry = EDataServer.SourceRegistry.new_sync(None)
+sources = registry.list_sources(EDataServer.SOURCE_EXTENSION_CALENDAR)
+
+calendars = []
+for source in sources:
+ if source.get_enabled():
+ calendars.append({
+ 'uid': source.get_uid(),
+ 'name': source.get_display_name(),
+ 'enabled': True
+ })
+
+print(json.dumps(calendars))
diff --git a/arch/.config/quickshell/noctalia-shell/Scripts/python/src/network/bluetooth-pair.py b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/network/bluetooth-pair.py
new file mode 100644
index 0000000..6d8eddc
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/network/bluetooth-pair.py
@@ -0,0 +1,191 @@
+#!/usr/bin/env python3
+import errno
+import os
+import pty
+import select
+import subprocess
+import sys
+import time
+# flake8: noqa: E501 # Line too long
+version = "0.0.2-1"
+
+
+def log(msg) -> None:
+ sys.stdout.write(f"[pair] {msg}\n")
+ sys.stdout.flush() # Flush to ensure the message is passed
+
+
+def pair_fast():
+ if len(sys.argv) < 5:
+ log("Usage: bluetooth-pair.py ")
+ sys.exit(2)
+
+ addr = sys.argv[1]
+ # We won't use pair_wait_seconds in the same way, but we'll respect the timeout logic.
+ pair_wait_seconds = float(sys.argv[2])
+ if pair_wait_seconds < 30:
+ log(f"Warning: pairWaitSeconds ({pair_wait_seconds}s) is too short. Enforcing 45s minimum.")
+ pair_wait_seconds = 45.0
+
+ attempts = int(sys.argv[3])
+ interval_sec = float(sys.argv[4])
+
+ if not addr or len(addr) < 17:
+ # Basic MAC address length check
+ log(f"Invalid Bluetooth address: '{addr}'")
+ sys.exit(2)
+
+ # m/s PTY for interactive control
+ mfd, sfd = pty.openpty()
+
+ # Start bluetoothctl
+ subprocess.Popen(['bluetoothctl'], stdin=sfd, stdout=sfd, stderr=sfd, close_fds=True, text=True)
+
+ os.close(sfd)
+
+ def send_command(cmd):
+ log(f"Sending cmd: {cmd}")
+ os.write(mfd, (cmd + "\n").encode('utf-8'))
+
+ def read_output(timeout=1.0):
+ # Reads available output from mfd
+ output = b""
+ end_time = time.time() + timeout
+ while time.time() < end_time:
+ r, _, _ = select.select([mfd], [], [], 0.1)
+ if mfd in r:
+ try:
+ data = os.read(mfd, 1024)
+ if not data:
+ break
+ output += data
+ except OSError as e:
+ if e.errno == errno.EIO:
+ break
+ raise
+ else:
+ pass
+ return output.decode('utf-8', errors='replace')
+
+ log("Initializing bluetoothctl...")
+ time.sleep(1) # Wait for startup
+ # initial_out = read_output(timeout=1)
+ # print(initial_out) # Debug
+
+ send_command("agent on")
+ send_command("default-agent")
+ # send_command("power on") # If we are pairing bluetooth is already powered on
+ time.sleep(0.5)
+
+ # Pair directly since the device is already discovered in the UI/Panel (Removed previous scan/wait part)
+ log(f"Attempting to pair with {addr}...")
+ send_command(f"pair {addr}")
+
+ # Loop to watch for confirmation or success
+ start_time = time.time()
+ paired = False
+
+ log("Waiting for pairing sequence start...")
+ while time.time() - start_time < pair_wait_seconds:
+ out = read_output(timeout=0.5)
+ if out:
+ print(out, end='')
+ # Device not found yet
+ device_not_discovered: list[str] = [f"Device {addr} not available"]
+ if any(e in out for e in device_not_discovered):
+ log(f"Device {addr} is discovered yet...")
+ pair_wait_seconds += 30 # Add additional time for device discovery
+
+ # Confirm Passkey
+ # Numberic Comparison (NC) 1 of 4 - Tested pairing with my iPhone.
+ expected_confirmation: list[str] = ["Confirm passkey", "yes/no", "Request confirmation"]
+ if any(e in out for e in expected_confirmation):
+ log("Detected passkey prompt. Sending 'yes'.")
+ send_command("yes")
+
+ # Authorization Request
+ expected_auth: list[str] = ["Authorize service", "Request authorization"]
+ if any(e in out for e in expected_auth):
+ log("Detected authorization request. Sending 'yes'.")
+ send_command("yes")
+
+ # Interactive PIN/Passkey Entry (Device displays code, User must enter on PC)
+ expected_pin: list[str] = ["Enter passkey", "Enter PIN code", "Passkey: "]
+ if any(e in out for e in expected_pin):
+ log("Device requested PIN/Passkey. Waiting for user input...")
+ log("PIN_REQUIRED") # Signal to service, to prompt user.
+
+ try:
+ # Read PIN from stdin (blocking)
+ user_pin = sys.stdin.readline().strip()
+ if user_pin:
+ log(f"Received PIN: {user_pin}, relaying to bluetoothctl...")
+ send_command(user_pin)
+ except Exception as e:
+ log(f"Error reading stdin: {e}")
+ break
+
+ # Just Works (JW) is implicit (no prompt)
+ expected_success: list[str] = ["Pairing successful", "Paired: yes", "Bonded: yes"]
+ if any(e in out for e in expected_success):
+ paired = True
+ log("Pairing successful detected in stream.")
+ break
+
+ if "Failed to pair" in out:
+ log("Pairing failed explicitly.")
+ break
+
+ expected_already_paired: list[str] = ["Already joined", "Already exists"]
+ if any(e in out for e in expected_already_paired):
+ paired = True
+ log("Device already paired.")
+ break
+
+ # Double check pairing status via info command if not sure
+ if not paired:
+ send_command(f"info {addr}")
+ time.sleep(1)
+ out = read_output(timeout=1)
+ if "Paired: yes" in out:
+ paired = True
+
+ if paired:
+ log("Device is paired. Trusting...")
+ send_command(f"trust {addr}")
+ time.sleep(1)
+
+ log("Connecting...")
+ connected = False
+ for i in range(attempts):
+ send_command(f"connect {addr}")
+ # Wait a bit for connection
+ time.sleep(interval_sec)
+
+ # Check status
+ send_command(f"info {addr}")
+ time.sleep(1)
+ out = read_output(timeout=1)
+ if "Connected: yes" in out:
+ log("Connected successfully, we are done here.")
+ connected = True
+ break
+ else:
+ log(f"Connection attempt {i + 1}/{attempts} failed. Retrying...")
+
+ if connected:
+ send_command("quit")
+ sys.exit(0)
+ else:
+ log("Failed to connect after all attempts.")
+ send_command("quit")
+ sys.exit(1)
+
+ else:
+ log("Failed to pair within timeout.")
+ send_command("quit")
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ pair_fast()
diff --git a/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/gtk-refresh.py b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/gtk-refresh.py
new file mode 100644
index 0000000..be21bbc
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/gtk-refresh.py
@@ -0,0 +1,188 @@
+#!/usr/bin/env python3
+
+import asyncio
+import os
+import sys
+import shutil
+from pathlib import Path
+
+
+async def run_command(*args):
+ process = await asyncio.create_subprocess_exec(
+ *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
+ )
+ stdout, stderr = await process.communicate()
+ if process.returncode != 0:
+ print(f"Error running {' '.join(args)}: {stderr.decode().strip()}", file=sys.stderr)
+ return stdout.decode().strip()
+
+
+def theme_exists(theme_name: str) -> bool:
+ """Check if a GTK theme exists in common locations."""
+ search_paths = [
+ Path.home() / ".themes",
+ Path.home() / ".local/share/themes",
+ Path("/usr/share/themes"),
+ Path("/usr/local/share/themes"),
+ ]
+
+ # Add paths from XDG_DATA_DIRS
+ xdg_data_dirs = os.environ.get("XDG_DATA_DIRS", "")
+ if xdg_data_dirs:
+ for path in xdg_data_dirs.split(":"):
+ if path:
+ search_paths.append(Path(path) / "themes")
+
+ for base_path in search_paths:
+ if (base_path / theme_name).is_dir():
+ return True
+
+ return False
+
+
+GTK_IMPORT = '@import url("noctalia.css");'
+
+
+def ensure_gtk_css_import(gtk_css: Path, colors_file: Path, label: str) -> bool:
+ """
+ Append the noctalia.css import to gtk.css if not already present.
+ If gtk.css doesn't exist, create it with the import.
+ Does not overwrite user modifications (similar to niri template).
+ """
+ if not colors_file.exists():
+ print(f"Error: {label} noctalia.css not found at {colors_file}", file=sys.stderr)
+ return False
+
+ if gtk_css.exists() or gtk_css.is_symlink():
+ content = gtk_css.read_text()
+ # Already has the import (flexible: allow optional whitespace / different quoting)
+ if "noctalia.css" in content and "@import" in content:
+ return True
+ # Need to modify โ handle symlinks carefully
+ target = gtk_css
+ if gtk_css.is_symlink():
+ resolved = gtk_css.resolve()
+ if os.access(resolved, os.W_OK):
+ # Writable symlink (e.g. dotfiles): edit the target directly
+ target = resolved
+ else:
+ # Read-only symlink (e.g. NixOS): convert to local file
+ gtk_css.unlink()
+ gtk_css.write_text(resolved.read_text())
+ # Append import to the end
+ new_content = content.rstrip()
+ if new_content and not new_content.endswith("\n"):
+ new_content += "\n"
+ new_content += "\n" + GTK_IMPORT + "\n"
+ target.write_text(new_content)
+ print(f"Appended {label} noctalia.css import to gtk.css")
+ else:
+ gtk_css.write_text(GTK_IMPORT + "\n")
+ print(f"Created {label} gtk.css with noctalia.css import")
+ return True
+
+
+async def apply_gtk3_colors(config_dir: Path):
+ gtk3_dir = config_dir / "gtk-3.0"
+ colors_file = gtk3_dir / "noctalia.css"
+ gtk_css = gtk3_dir / "gtk.css"
+ return ensure_gtk_css_import(gtk_css, colors_file, "GTK3")
+
+
+async def apply_gtk4_colors(config_dir: Path):
+ gtk4_dir = config_dir / "gtk-4.0"
+ colors_file = gtk4_dir / "noctalia.css"
+ gtk_css = gtk4_dir / "gtk.css"
+ return ensure_gtk_css_import(gtk_css, colors_file, "GTK4")
+
+
+async def sync_system_appearance(mode: str, *, update_gtk_theme: bool = True) -> None:
+ """
+ Push light/dark to org.gnome.desktop.interface (gsettings or dconf fallback).
+ Used by the GTK template post-hook and ColorSchemeService when "Sync system theme"
+ is on (both set color-scheme and gtk-theme when themes exist). --appearance-only
+ skips CSS and only updates color-scheme for narrow tooling use.
+ """
+ has_gsettings = shutil.which("gsettings")
+ has_dconf = shutil.which("dconf")
+
+ if not has_gsettings and not has_dconf:
+ print("No gsettings or dconf found, skip system appearance sync")
+ return
+
+ target_theme = "adw-gtk3" if mode == "light" else "adw-gtk3-dark"
+ theme_available = update_gtk_theme and theme_exists(target_theme)
+ if update_gtk_theme and not theme_available:
+ print(f"Theme '{target_theme}' not found, skipping GTK theme set")
+
+ if has_gsettings:
+ schemas = await run_command("gsettings", "list-schemas")
+ if schemas and "org.gnome.desktop.interface" in schemas:
+ await run_command("gsettings", "set", "org.gnome.desktop.interface", "color-scheme", f"prefer-{mode}")
+ if theme_available:
+ await run_command("gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", f"{target_theme}")
+ return
+
+ if has_dconf:
+ await run_command("dconf", "write", "/org/gnome/desktop/interface/color-scheme", f"'prefer-{mode}'")
+ if theme_available:
+ await run_command("dconf", "write", "/org/gnome/desktop/interface/gtk-theme", f"'{target_theme}'")
+
+
+async def get_config_dir() -> Path:
+ # Returns the XDG config home (e.g. ~/.config)
+ # GTK config lives at ~/.config/gtk-3.0/ and ~/.config/gtk-4.0/.
+
+ # 1. XDG standard
+ if value := os.environ.get("XDG_CONFIG_HOME"):
+ return Path(value).expanduser()
+
+ # 2. fallback
+ return Path.home() / ".config"
+
+
+def parse_args():
+ argv = sys.argv[1:]
+ appearance_only = False
+ if argv and argv[0] == "--appearance-only":
+ appearance_only = True
+ argv = argv[1:]
+ if len(argv) != 1 or argv[0] not in ("dark", "light"):
+ print(
+ "Usage: gtk-refresh.py [--appearance-only] (dark|light)",
+ file=sys.stderr,
+ )
+ sys.exit(1)
+ return appearance_only, argv[0]
+
+
+async def main():
+ appearance_only, mode = parse_args()
+
+ if appearance_only:
+ await sync_system_appearance(mode, update_gtk_theme=False)
+ return
+
+ config_dir = await get_config_dir()
+
+ if not config_dir.is_dir():
+ print(f"Error: Config directory not found: {config_dir}", file=sys.stderr)
+ sys.exit(1)
+
+ (config_dir / "gtk-3.0").mkdir(parents=True, exist_ok=True)
+ (config_dir / "gtk-4.0").mkdir(parents=True, exist_ok=True)
+
+ results = await asyncio.gather(apply_gtk3_colors(config_dir), apply_gtk4_colors(config_dir))
+
+ if all(results):
+ await sync_system_appearance(mode, update_gtk_theme=True)
+ print("GTK colors applied successfully")
+ else:
+ # Still push light/dark preference so portal/GTK apps follow the shell even when
+ # gtk.css / noctalia.css setup failed.
+ await sync_system_appearance(mode, update_gtk_theme=False)
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/lib/__init__.py b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/lib/__init__.py
new file mode 100644
index 0000000..644a55a
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/lib/__init__.py
@@ -0,0 +1,58 @@
+"""
+Theming library - Color extraction and theme generation.
+
+This package provides:
+- HCT color space implementation (CAM16, Hct, TonalPalette)
+- Material Design 3 scheme generation
+- Color utilities (RGB, HSL conversions)
+- Image reading and palette extraction
+- Template rendering (Matugen compatible)
+"""
+
+from .color import Color, rgb_to_hsl, hsl_to_rgb, adjust_surface
+from .hct import Hct, Cam16, TonalPalette, TemperatureCache, fix_if_disliked
+from .material import MaterialScheme, SchemeContent, harmonize_color
+from .contrast import ensure_contrast, contrast_ratio, is_dark
+from .image import read_image, ImageReadError
+from .palette import extract_palette
+from .quantizer import extract_source_color, source_color_to_rgb
+from .theme import generate_theme
+from .renderer import TemplateRenderer
+from .scheme import expand_predefined_scheme, inject_terminal_colors
+
+__all__ = [
+ # Color
+ "Color",
+ "rgb_to_hsl",
+ "hsl_to_rgb",
+ "adjust_surface",
+ # HCT
+ "Hct",
+ "Cam16",
+ "TonalPalette",
+ "TemperatureCache",
+ "fix_if_disliked",
+ # Material
+ "MaterialScheme",
+ "SchemeContent",
+ "harmonize_color",
+ # Contrast
+ "ensure_contrast",
+ "contrast_ratio",
+ "is_dark",
+ # Image
+ "read_image",
+ "ImageReadError",
+ # Palette
+ "extract_palette",
+ # Quantizer (Wu + Score algorithm matching matugen)
+ "extract_source_color",
+ "source_color_to_rgb",
+ # Theme
+ "generate_theme",
+ # Renderer
+ "TemplateRenderer",
+ # Scheme
+ "expand_predefined_scheme",
+ "inject_terminal_colors",
+]
diff --git a/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/lib/color.py b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/lib/color.py
new file mode 100644
index 0000000..5881cad
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/lib/color.py
@@ -0,0 +1,353 @@
+"""
+Color representation and conversion utilities.
+
+This module provides the Color class and functions for converting between
+RGB, HSL, and Lab color spaces.
+"""
+
+import math
+from dataclasses import dataclass
+from typing import TYPE_CHECKING
+
+# Type aliases
+RGB = tuple[int, int, int]
+HSL = tuple[float, float, float]
+LAB = tuple[float, float, float]
+
+if TYPE_CHECKING:
+ from .hct import Hct
+
+
+@dataclass
+class Color:
+ """Represents a color with RGB values (0-255)."""
+ r: int
+ g: int
+ b: int
+
+ @classmethod
+ def from_rgb(cls, rgb: RGB) -> 'Color':
+ return cls(rgb[0], rgb[1], rgb[2])
+
+ @classmethod
+ def from_hex(cls, hex_str: str) -> 'Color':
+ """Parse hex color string (#RRGGBB or RRGGBB)."""
+ hex_str = hex_str.lstrip('#')
+ return cls(
+ int(hex_str[0:2], 16),
+ int(hex_str[2:4], 16),
+ int(hex_str[4:6], 16)
+ )
+
+ def to_rgb(self) -> RGB:
+ return (self.r, self.g, self.b)
+
+ def to_hex(self) -> str:
+ """Convert to hex string (#RRGGBB)."""
+ return f"#{self.r:02x}{self.g:02x}{self.b:02x}"
+
+ def to_hsl(self) -> HSL:
+ """Convert RGB to HSL."""
+ return rgb_to_hsl(self.r, self.g, self.b)
+
+ def to_hct(self) -> 'Hct':
+ """Convert to HCT color space."""
+ from .hct import Hct
+ return Hct.from_rgb(self.r, self.g, self.b)
+
+ @classmethod
+ def from_hsl(cls, h: float, s: float, l: float) -> 'Color':
+ """Create Color from HSL values."""
+ r, g, b = hsl_to_rgb(h, s, l)
+ return cls(r, g, b)
+
+ @classmethod
+ def from_hct(cls, hct: 'Hct') -> 'Color':
+ """Create Color from HCT."""
+ r, g, b = hct.to_rgb()
+ return cls(r, g, b)
+
+
+def rgb_to_hsl(r: int, g: int, b: int) -> HSL:
+ """
+ Convert RGB (0-255) to HSL (0-360, 0-1, 0-1).
+
+ Args:
+ r: Red component (0-255)
+ g: Green component (0-255)
+ b: Blue component (0-255)
+
+ Returns:
+ Tuple of (hue, saturation, lightness)
+ """
+ r_norm = r / 255.0
+ g_norm = g / 255.0
+ b_norm = b / 255.0
+
+ max_c = max(r_norm, g_norm, b_norm)
+ min_c = min(r_norm, g_norm, b_norm)
+ delta = max_c - min_c
+
+ # Lightness
+ l = (max_c + min_c) / 2.0
+
+ if delta == 0:
+ h = 0.0
+ s = 0.0
+ else:
+ # Saturation
+ s = delta / (1 - abs(2 * l - 1)) if l != 0 and l != 1 else 0
+
+ # Hue
+ if max_c == r_norm:
+ h = 60.0 * (((g_norm - b_norm) / delta) % 6)
+ elif max_c == g_norm:
+ h = 60.0 * (((b_norm - r_norm) / delta) + 2)
+ else:
+ h = 60.0 * (((r_norm - g_norm) / delta) + 4)
+
+ return (h, s, l)
+
+
+def hsl_to_rgb(h: float, s: float, l: float) -> RGB:
+ """
+ Convert HSL (0-360, 0-1, 0-1) to RGB (0-255).
+
+ Args:
+ h: Hue (0-360)
+ s: Saturation (0-1)
+ l: Lightness (0-1)
+
+ Returns:
+ Tuple of (r, g, b)
+ """
+ if s == 0:
+ # Achromatic (gray)
+ v = int(round(l * 255))
+ return (v, v, v)
+
+ def hue_to_rgb(p: float, q: float, t: float) -> float:
+ 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
+
+ q = l * (1 + s) if l < 0.5 else l + s - l * s
+ p = 2 * l - q
+ h_norm = h / 360.0
+
+ r = hue_to_rgb(p, q, h_norm + 1/3)
+ g = hue_to_rgb(p, q, h_norm)
+ b = hue_to_rgb(p, q, h_norm - 1/3)
+
+ return (
+ int(round(r * 255)),
+ int(round(g * 255)),
+ int(round(b * 255))
+ )
+
+
+def adjust_lightness(color: Color, target_l: float) -> Color:
+ """Adjust a color's lightness to a target value (0-1)."""
+ h, s, _ = color.to_hsl()
+ return Color.from_hsl(h, s, target_l)
+
+
+def shift_hue(color: Color, degrees: float) -> Color:
+ """Shift a color's hue by specified degrees."""
+ h, s, l = color.to_hsl()
+ new_h = (h + degrees) % 360
+ return Color.from_hsl(new_h, s, l)
+
+
+def hue_distance(h1: float, h2: float) -> float:
+ """Calculate minimum angular distance between two hues (0-180)."""
+ diff = abs(h1 - h2)
+ return min(diff, 360 - diff)
+
+
+def adjust_surface(color: Color, s_max: float, l_target: float) -> Color:
+ """Derive a surface color from a base color with saturation limit and target lightness."""
+ h, s, _ = color.to_hsl()
+ return Color.from_hsl(h, min(s, s_max), l_target)
+
+
+def saturate(color: Color, amount: float) -> Color:
+ """Adjust saturation by amount (-1 to 1)."""
+ h, s, l = color.to_hsl()
+ new_s = max(0.0, min(1.0, s + amount))
+ return Color.from_hsl(h, new_s, l)
+
+
+# =============================================================================
+# Lab Color Space (CIE L*a*b*)
+# =============================================================================
+
+# D65 white point
+_WHITE_X = 95.047
+_WHITE_Y = 100.0
+_WHITE_Z = 108.883
+
+
+def _linearize(channel: int) -> float:
+ """Convert sRGB channel (0-255) to linear RGB (0-1)."""
+ normalized = channel / 255.0
+ if normalized <= 0.04045:
+ return normalized / 12.92
+ return math.pow((normalized + 0.055) / 1.055, 2.4)
+
+
+def _delinearize(linear: float) -> int:
+ """Convert linear RGB (0-1) to sRGB channel (0-255)."""
+ if linear <= 0.0031308:
+ normalized = linear * 12.92
+ else:
+ normalized = 1.055 * math.pow(linear, 1.0 / 2.4) - 0.055
+ return max(0, min(255, round(normalized * 255)))
+
+
+def _lab_f(t: float) -> float:
+ """Lab forward transform function."""
+ if t > 0.008856:
+ return math.pow(t, 1.0 / 3.0)
+ return (903.3 * t + 16.0) / 116.0
+
+
+def _lab_f_inv(t: float) -> float:
+ """Lab inverse transform function."""
+ if t > 0.206893:
+ return t * t * t
+ return (116.0 * t - 16.0) / 903.3
+
+
+def rgb_to_lab(r: int, g: int, b: int) -> LAB:
+ """
+ Convert sRGB (0-255) to CIE L*a*b*.
+
+ Returns:
+ Tuple of (L*, a*, b*) where L* is 0-100
+ """
+ # sRGB to linear RGB
+ linear_r = _linearize(r)
+ linear_g = _linearize(g)
+ linear_b = _linearize(b)
+
+ # Linear RGB to XYZ (D65)
+ x = 0.4124564 * linear_r + 0.3575761 * linear_g + 0.1804375 * linear_b
+ y = 0.2126729 * linear_r + 0.7151522 * linear_g + 0.0721750 * linear_b
+ z = 0.0193339 * linear_r + 0.1191920 * linear_g + 0.9503041 * linear_b
+
+ # Scale to 0-100 range
+ x *= 100.0
+ y *= 100.0
+ z *= 100.0
+
+ # XYZ to Lab
+ fx = _lab_f(x / _WHITE_X)
+ fy = _lab_f(y / _WHITE_Y)
+ fz = _lab_f(z / _WHITE_Z)
+
+ L = 116.0 * fy - 16.0
+ a = 500.0 * (fx - fy)
+ b = 200.0 * (fy - fz)
+
+ return (L, a, b)
+
+
+def lab_to_rgb(L: float, a: float, b: float) -> RGB:
+ """
+ Convert CIE L*a*b* to sRGB (0-255).
+
+ Args:
+ L: Lightness (0-100)
+ a: Green-red component
+ b: Blue-yellow component
+
+ Returns:
+ Tuple of (r, g, b)
+ """
+ # Lab to XYZ
+ fy = (L + 16.0) / 116.0
+ fx = a / 500.0 + fy
+ fz = fy - b / 200.0
+
+ x = _WHITE_X * _lab_f_inv(fx)
+ y = _WHITE_Y * _lab_f_inv(fy)
+ z = _WHITE_Z * _lab_f_inv(fz)
+
+ # Scale back to 0-1 range
+ x /= 100.0
+ y /= 100.0
+ z /= 100.0
+
+ # XYZ to linear RGB
+ linear_r = 3.2404542 * x - 1.5371385 * y - 0.4985314 * z
+ linear_g = -0.9692660 * x + 1.8760108 * y + 0.0415560 * z
+ linear_b = 0.0556434 * x - 0.2040259 * y + 1.0572252 * z
+
+ # Clamp and delinearize
+ return (
+ _delinearize(max(0.0, min(1.0, linear_r))),
+ _delinearize(max(0.0, min(1.0, linear_g))),
+ _delinearize(max(0.0, min(1.0, linear_b)))
+ )
+
+
+def lab_distance(lab1: LAB, lab2: LAB) -> float:
+ """
+ Calculate Euclidean distance between two Lab colors.
+
+ This is a simple perceptual distance metric.
+ """
+ dL = lab1[0] - lab2[0]
+ da = lab1[1] - lab2[1]
+ db = lab1[2] - lab2[2]
+ return math.sqrt(dL * dL + da * da + db * db)
+
+
+def find_closest_color(
+ compare_to: str,
+ colors: list[dict[str, str]]
+) -> str:
+ """
+ Find the closest named color from a list (matugen-compatible).
+
+ Uses Lab color space Euclidean distance for perceptual color matching.
+
+ Args:
+ compare_to: Hex color to compare (e.g., "#ff5500")
+ colors: List of {"name": "...", "color": "#..."} dicts
+
+ Returns:
+ Name of the closest color, or empty string if no colors provided
+ """
+ if not colors:
+ return ""
+
+ # Parse target color
+ target = Color.from_hex(compare_to)
+ target_lab = rgb_to_lab(target.r, target.g, target.b)
+
+ closest_name = ""
+ closest_dist = float('inf')
+
+ for entry in colors:
+ try:
+ entry_color = Color.from_hex(entry["color"])
+ entry_lab = rgb_to_lab(entry_color.r, entry_color.g, entry_color.b)
+ dist = lab_distance(target_lab, entry_lab)
+ if dist < closest_dist:
+ closest_dist = dist
+ closest_name = entry["name"]
+ except (KeyError, ValueError):
+ # Skip invalid entries
+ continue
+
+ return closest_name
diff --git a/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/lib/contrast.py b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/lib/contrast.py
new file mode 100644
index 0000000..2f67157
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/lib/contrast.py
@@ -0,0 +1,123 @@
+"""
+Contrast calculation utilities (WCAG luminance and contrast).
+
+This module provides functions for calculating relative luminance,
+contrast ratios, and ensuring accessible color combinations.
+"""
+
+from .color import Color
+
+
+def relative_luminance(r: int, g: int, b: int) -> float:
+ """
+ Calculate relative luminance per WCAG 2.1.
+
+ The formula converts sRGB to linear RGB, then applies the luminance formula:
+ L = 0.2126 * R + 0.7152 * G + 0.0722 * B
+
+ Args:
+ r, g, b: RGB components (0-255)
+
+ Returns:
+ Relative luminance (0-1)
+ """
+ def linearize(c: int) -> float:
+ c_norm = c / 255.0
+ if c_norm <= 0.03928:
+ return c_norm / 12.92
+ return ((c_norm + 0.055) / 1.055) ** 2.4
+
+ r_lin = linearize(r)
+ g_lin = linearize(g)
+ b_lin = linearize(b)
+
+ return 0.2126 * r_lin + 0.7152 * g_lin + 0.0722 * b_lin
+
+
+def contrast_ratio(color1: Color, color2: Color) -> float:
+ """
+ Calculate WCAG contrast ratio between two colors.
+
+ Returns a value between 1:1 (identical) and 21:1 (black/white).
+ """
+ l1 = relative_luminance(color1.r, color1.g, color1.b)
+ l2 = relative_luminance(color2.r, color2.g, color2.b)
+
+ lighter = max(l1, l2)
+ darker = min(l1, l2)
+
+ return (lighter + 0.05) / (darker + 0.05)
+
+
+def is_dark(color: Color) -> bool:
+ """Determine if a color is perceptually dark."""
+ return relative_luminance(color.r, color.g, color.b) < 0.179
+
+
+def ensure_contrast(
+ foreground: Color,
+ background: Color,
+ min_ratio: float = 4.5,
+ prefer_light: bool | None = None
+) -> Color:
+ """
+ Adjust foreground color to meet minimum contrast ratio against background.
+
+ Args:
+ foreground: The color to adjust
+ background: The background color (not modified)
+ min_ratio: Minimum contrast ratio (default 4.5 for WCAG AA)
+ prefer_light: If True, prefer lightening; if False, prefer darkening;
+ if None, auto-detect based on background
+
+ Returns:
+ Adjusted foreground color meeting contrast requirements
+ """
+ current_ratio = contrast_ratio(foreground, background)
+ if current_ratio >= min_ratio:
+ return foreground
+
+ h, s, l = foreground.to_hsl()
+ bg_dark = is_dark(background)
+
+ # Determine direction to adjust
+ if prefer_light is None:
+ prefer_light = bg_dark
+
+ # Binary search for the right lightness
+ if prefer_light:
+ low, high = l, 1.0
+ else:
+ low, high = 0.0, l
+
+ best_color = foreground
+ for _ in range(20): # Max iterations
+ mid = (low + high) / 2
+ test_color = Color.from_hsl(h, s, mid)
+ ratio = contrast_ratio(test_color, background)
+
+ if ratio >= min_ratio:
+ best_color = test_color
+ if prefer_light:
+ high = mid
+ else:
+ low = mid
+ else:
+ if prefer_light:
+ low = mid
+ else:
+ high = mid
+
+ return best_color
+
+
+def get_contrasting_color(background: Color, min_ratio: float = 4.5) -> Color:
+ """Get a contrasting foreground color (black or white variant)."""
+ if is_dark(background):
+ # Light foreground for dark background
+ fg = Color(243, 237, 247) # Off-white
+ else:
+ # Dark foreground for light background
+ fg = Color(14, 14, 67) # Dark blue-black
+
+ return ensure_contrast(fg, background, min_ratio)
diff --git a/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/lib/hct.py b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/lib/hct.py
new file mode 100644
index 0000000..2500e36
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/lib/hct.py
@@ -0,0 +1,1071 @@
+"""
+HCT (Hue, Chroma, Tone) Color Space Implementation.
+
+Based on Material Color Utilities (Google).
+HCT combines CAM16 hue and chroma with CIELAB lightness (L*) for
+Material Design 3's perceptual color space.
+"""
+
+from __future__ import annotations
+import math
+
+# =============================================================================
+# Type Definitions
+# =============================================================================
+
+RGB = tuple[int, int, int]
+
+# =============================================================================
+# CAM16 / HCT Color Space Implementation
+# =============================================================================
+
+# sRGB to XYZ matrix (D65 illuminant)
+SRGB_TO_XYZ = [
+ [0.41233895, 0.35762064, 0.18051042],
+ [0.2126, 0.7152, 0.0722],
+ [0.01932141, 0.11916382, 0.95034478],
+]
+
+# XYZ to sRGB matrix
+XYZ_TO_SRGB = [
+ [3.2413774792388685, -1.5376652402851851, -0.49885366846268053],
+ [-0.9691452513005321, 1.8758853451067872, 0.04156585616912061],
+ [0.05562093689691305, -0.20395524564742123, 1.0571799111220335],
+]
+
+
+class ViewingConditions:
+ """CAM16 viewing conditions for sRGB display."""
+ # White point (D65)
+ WHITE_POINT_D65 = [95.047, 100.0, 108.883]
+
+ # Precomputed values for standard conditions
+ n = 0.18418651851244416
+ aw = 29.980997194447333
+ nbb = 1.0169191804458755
+ ncb = 1.0169191804458755
+ c = 0.69
+ nc = 1.0
+ fl = 0.3884814537800353
+ fl_root = 0.7894826179304937
+ z = 1.909169568483652
+
+ # RGB to CAM16 adaptation matrix
+ RGB_D = [1.0211931250282205, 0.9862992588498498, 0.9338046048498166]
+
+
+def _linearize(channel: int) -> float:
+ """Convert sRGB channel (0-255) to linear RGB (0-1)."""
+ normalized = channel / 255.0
+ if normalized <= 0.040449936:
+ return normalized / 12.92
+ return math.pow((normalized + 0.055) / 1.055, 2.4)
+
+
+def _delinearize(linear: float) -> int:
+ """Convert linear RGB (0-1) to sRGB channel (0-255)."""
+ if linear <= 0.0031308:
+ normalized = linear * 12.92
+ else:
+ normalized = 1.055 * math.pow(linear, 1.0 / 2.4) - 0.055
+ return max(0, min(255, round(normalized * 255)))
+
+
+def _matrix_multiply(matrix: list[list[float]], vector: list[float]) -> list[float]:
+ """Multiply 3x3 matrix by 3-element vector."""
+ return [
+ matrix[0][0] * vector[0] + matrix[0][1] * vector[1] + matrix[0][2] * vector[2],
+ matrix[1][0] * vector[0] + matrix[1][1] * vector[1] + matrix[1][2] * vector[2],
+ matrix[2][0] * vector[0] + matrix[2][1] * vector[1] + matrix[2][2] * vector[2],
+ ]
+
+
+def _signum(x: float) -> float:
+ """Return sign of x: -1, 0, or 1."""
+ if x < 0:
+ return -1.0
+ elif x > 0:
+ return 1.0
+ return 0.0
+
+
+def _lerp(a: float, b: float, t: float) -> float:
+ """Linear interpolation between a and b."""
+ return a + (b - a) * t
+
+
+def _sanitize_degrees(degrees: float) -> float:
+ """Ensure degrees is in [0, 360) range."""
+ degrees = degrees % 360.0
+ if degrees < 0:
+ degrees += 360.0
+ return degrees
+
+
+# =============================================================================
+# HCT Solver - Ported from Material Color Utilities
+# =============================================================================
+
+# Matrices for chromatic adaptation
+_SCALED_DISCOUNT_FROM_LINRGB = [
+ [0.001200833568784504, 0.002389694492170889, 0.0002795742885861124],
+ [0.0005891086651375999, 0.0029785502573438758, 0.0003270666104008398],
+ [0.00010146692491640572, 0.0005364214359186694, 0.0032979401770712076],
+]
+
+_LINRGB_FROM_SCALED_DISCOUNT = [
+ [1373.2198709594231, -1100.4251190754821, -7.278681089101213],
+ [-271.815969077903, 559.6580465940733, -32.46047482791194],
+ [1.9622899599665666, -57.173814538844006, 308.7233197812385],
+]
+
+_Y_FROM_LINRGB = [0.2126, 0.7152, 0.0722]
+
+# Critical planes for bisection (precomputed delinearized values 0-254)
+_CRITICAL_PLANES = [
+ 0.015176349177441876, 0.045529047532325624, 0.07588174588720938,
+ 0.10623444424209313, 0.13658714259697685, 0.16693984095186062,
+ 0.19729253930674434, 0.2276452376616281, 0.2579979360165119,
+ 0.28835063437139563, 0.3188300904430532, 0.350925934958123,
+ 0.3848314933096426, 0.42057480301049466, 0.458183274052838,
+ 0.4976837250274023, 0.5391024159806381, 0.5824650784040898,
+ 0.6277969426914107, 0.6751227633498623, 0.7244668422128921,
+ 0.775853049866786, 0.829304845476233, 0.8848452951698498,
+ 0.942497089126609, 1.0022825574869039, 1.0642236851973577,
+ 1.1283421258858297, 1.1946592148522128, 1.2631959812511864,
+ 1.3339731595349034, 1.407011200216447, 1.4823302800086415,
+ 1.5599503113873272, 1.6398909516233677, 1.7221716113234105,
+ 1.8068114625156377, 1.8938294463134073, 1.9832442801866852,
+ 2.075074464868551, 2.1693382909216234, 2.2660538449872063,
+ 2.36523901573795, 2.4669114995532007, 2.5710888059345764,
+ 2.6777882626779785, 2.7870270208169257, 2.898822059350997,
+ 3.0131901897720907, 3.1301480604002863, 3.2497121605402226,
+ 3.3718988244681087, 3.4967242352587946, 3.624204428461639,
+ 3.754355295633311, 3.887192587735158, 4.022731918402185,
+ 4.160988767090289, 4.301978482107941, 4.445716283538092,
+ 4.592217266055746, 4.741496401646282, 4.893568542229298,
+ 5.048448422192488, 5.20615066083972, 5.3666897647573375,
+ 5.5300801301023865, 5.696336044816294, 5.865471690767354,
+ 6.037501145825082, 6.212438385869475, 6.390297286737924,
+ 6.571091626112461, 6.7548350853498045, 6.941541251256611,
+ 7.131223617812143, 7.323895587840543, 7.5195704746346665,
+ 7.7182615035334345, 7.919981813454504, 8.124744458384042,
+ 8.332562408825165, 8.543448553206703, 8.757415699253682,
+ 8.974476575321063, 9.194643831691977, 9.417930041841839,
+ 9.644347703669503, 9.873909240696694, 10.106627003236781,
+ 10.342513269534024, 10.58158024687427, 10.8238400726681,
+ 11.069304815507364, 11.317986476196008, 11.569896988756009,
+ 11.825048221409341, 12.083451977536606, 12.345119996613247,
+ 12.610063955123938, 12.878295467455942, 13.149826086772048,
+ 13.42466730586372, 13.702830557985108, 13.984327217668513,
+ 14.269168601521828, 14.55736596900856, 14.848930523210871,
+ 15.143873411576273, 15.44220572664832, 15.743938506781891,
+ 16.04908273684337, 16.35764934889634, 16.66964922287304,
+ 16.985093187232053, 17.30399201960269, 17.62635644741625,
+ 17.95219714852476, 18.281524751807332, 18.614349837764564,
+ 18.95068293910138, 19.290534541298456, 19.633915083172692,
+ 19.98083495742689, 20.331304511189067, 20.685334046541502,
+ 21.042933821039977, 21.404114048223256, 21.76888489811322,
+ 22.137256497705877, 22.50923893145328, 22.884842241736916,
+ 23.264076429332462, 23.6469514538663, 24.033477234264016,
+ 24.42366364919083, 24.817520537484558, 25.21505769858089,
+ 25.61628489293138, 26.021211842414342, 26.429848230738664,
+ 26.842203703840827, 27.258287870275353, 27.678110301598522,
+ 28.10168053274597, 28.529008062403893, 28.96010235337422,
+ 29.39497283293396, 29.83362889318845, 30.276079891419332,
+ 30.722335150426627, 31.172403958865512, 31.62629557157785,
+ 32.08401920991837, 32.54558406207592, 33.010999283389665,
+ 33.4802739966603, 33.953417292456834, 34.430438229418264,
+ 34.911345834551085, 35.39614910352207, 35.88485700094671,
+ 36.37747846067349, 36.87402238606382, 37.37449765026789,
+ 37.87891309649659, 38.38727753828926, 38.89959975977785,
+ 39.41588851594697, 39.93615253289054, 40.460400508064545,
+ 40.98864111053629, 41.520882981230194, 42.05713473317016,
+ 42.597404951718396, 43.141702194811224, 43.6900349931913,
+ 44.24241185063697, 44.798841244188324, 45.35933162437017,
+ 45.92389141541209, 46.49252901546552, 47.065252796817916,
+ 47.64207110610409, 48.22299226451468, 48.808024568002054,
+ 49.3971762874833, 49.9904556690408, 50.587870934119984,
+ 51.189430279724725, 51.79514187861014, 52.40501387947288,
+ 53.0190544071392, 53.637271562750364, 54.259673423945976,
+ 54.88626804504493, 55.517063457223934, 56.15206766869424,
+ 56.79128866487574, 57.43473440856916, 58.08241284012621,
+ 58.734331877617365, 59.39049941699807, 60.05092333227251,
+ 60.715611475655585, 61.38457167773311, 62.057811747619894,
+ 62.7353394731159, 63.417162620860914, 64.10328893648692,
+ 64.79372614476921, 65.48848194977529, 66.18756403501224,
+ 66.89098006357258, 67.59873767827808, 68.31084450182222,
+ 69.02730813691093, 69.74813616640164, 70.47333615344107,
+ 71.20291564160104, 71.93688215501312, 72.67524319850172,
+ 73.41800625771542, 74.16517879925733, 74.9167682708136,
+ 75.67278210128072, 76.43322770089146, 77.1981124613393,
+ 77.96744375590167, 78.74122893956174, 79.51947534912904,
+ 80.30219030335869, 81.08938110306934, 81.88105503125999,
+ 82.67721935322541, 83.4778813166706, 84.28304815182372,
+ 85.09272707154808, 85.90692527145302, 86.72564993000343,
+ 87.54890820862819, 88.3767072518277, 89.2090541872801,
+ 90.04595612594655, 90.88742016217518, 91.73345337380438,
+ 92.58406282226491, 93.43925555268066, 94.29903859396902,
+ 95.16341895893969, 96.03240364439274, 96.9059996312159,
+ 97.78421388448044, 98.6670533535366, 99.55452497210776,
+]
+
+
+class HctSolver:
+ """
+ Solves HCT to RGB conversion with proper gamut mapping.
+
+ Ported from Material Color Utilities (Rust/TypeScript).
+ When the requested chroma is out of gamut, this solver finds
+ the maximum achievable chroma while preserving the exact hue.
+ """
+
+ @staticmethod
+ def _sanitize_radians(angle: float) -> float:
+ """Ensure angle is in [0, 2ฯ) range."""
+ return (angle + math.pi * 8) % (math.pi * 2)
+
+ @staticmethod
+ def _true_delinearized(rgb_component: float) -> float:
+ """Delinearize RGB component (0-100) to (0-255)."""
+ normalized = rgb_component / 100.0
+ if normalized <= 0.0031308:
+ delinearized = normalized * 12.92
+ else:
+ delinearized = 1.055 * (normalized ** (1.0 / 2.4)) - 0.055
+ return delinearized * 255.0
+
+ @staticmethod
+ def _chromatic_adaptation(component: float) -> float:
+ """Apply chromatic adaptation."""
+ af = abs(component) ** 0.42
+ return _signum(component) * 400.0 * af / (af + 27.13)
+
+ @staticmethod
+ def _hue_of(linrgb: list[float]) -> float:
+ """Calculate hue of linear RGB color in radians."""
+ scaled_discount = _matrix_multiply(_SCALED_DISCOUNT_FROM_LINRGB, linrgb)
+
+ r_a = HctSolver._chromatic_adaptation(scaled_discount[0])
+ g_a = HctSolver._chromatic_adaptation(scaled_discount[1])
+ b_a = HctSolver._chromatic_adaptation(scaled_discount[2])
+
+ # redness-greenness
+ a = (11.0 * r_a - 12.0 * g_a + b_a) / 11.0
+ # yellowness-blueness
+ b = (r_a + g_a - 2.0 * b_a) / 9.0
+
+ return math.atan2(b, a)
+
+ @staticmethod
+ def _are_in_cyclic_order(a: float, b: float, c: float) -> bool:
+ """Check if a, b, c are in cyclic order."""
+ delta_ab = HctSolver._sanitize_radians(b - a)
+ delta_ac = HctSolver._sanitize_radians(c - a)
+ return delta_ab < delta_ac
+
+ @staticmethod
+ def _intercept(source: float, mid: float, target: float) -> float:
+ """Solve lerp equation: find t such that lerp(source, target, t) = mid."""
+ return (mid - source) / (target - source)
+
+ @staticmethod
+ def _lerp_point(source: list[float], t: float, target: list[float]) -> list[float]:
+ """Linear interpolation between two 3D points."""
+ return [
+ source[0] + (target[0] - source[0]) * t,
+ source[1] + (target[1] - source[1]) * t,
+ source[2] + (target[2] - source[2]) * t,
+ ]
+
+ @staticmethod
+ def _set_coordinate(source: list[float], coordinate: float,
+ target: list[float], axis: int) -> list[float]:
+ """Find point on segment where axis equals coordinate."""
+ t = HctSolver._intercept(source[axis], coordinate, target[axis])
+ return HctSolver._lerp_point(source, t, target)
+
+ @staticmethod
+ def _is_bounded(x: float) -> bool:
+ """Check if x is in [0, 100]."""
+ return 0.0 <= x <= 100.0
+
+ @staticmethod
+ def _nth_vertex(y: float, n: int) -> list[float]:
+ """
+ Get nth vertex of RGB cube intersection with Y plane.
+
+ Returns [-1, -1, -1] if vertex is outside cube.
+ """
+ k_r, k_g, k_b = _Y_FROM_LINRGB
+
+ coord_a = 0.0 if n % 4 <= 1 else 100.0
+ coord_b = 0.0 if n % 2 == 0 else 100.0
+
+ if n < 4:
+ g = coord_a
+ b = coord_b
+ r = (y - k_g * g - k_b * b) / k_r
+ if HctSolver._is_bounded(r):
+ return [r, g, b]
+ return [-1.0, -1.0, -1.0]
+ elif n < 8:
+ b = coord_a
+ r = coord_b
+ g = (y - k_r * r - k_b * b) / k_g
+ if HctSolver._is_bounded(g):
+ return [r, g, b]
+ return [-1.0, -1.0, -1.0]
+ else:
+ r = coord_a
+ g = coord_b
+ b = (y - k_r * r - k_g * g) / k_b
+ if HctSolver._is_bounded(b):
+ return [r, g, b]
+ return [-1.0, -1.0, -1.0]
+
+ @staticmethod
+ def _bisect_to_segment(y: float, target_hue: float) -> list[list[float]]:
+ """Find segment on RGB cube containing target hue."""
+ left = [-1.0, -1.0, -1.0]
+ right = [-1.0, -1.0, -1.0]
+ left_hue = 0.0
+ right_hue = 0.0
+ initialized = False
+ uncut = True
+
+ for n in range(12):
+ mid = HctSolver._nth_vertex(y, n)
+
+ if mid[0] < 0:
+ continue
+
+ mid_hue = HctSolver._hue_of(mid)
+
+ if not initialized:
+ left = mid
+ right = mid
+ left_hue = mid_hue
+ right_hue = mid_hue
+ initialized = True
+ continue
+
+ if uncut or HctSolver._are_in_cyclic_order(left_hue, mid_hue, right_hue):
+ uncut = False
+
+ if HctSolver._are_in_cyclic_order(left_hue, target_hue, mid_hue):
+ right = mid
+ right_hue = mid_hue
+ else:
+ left = mid
+ left_hue = mid_hue
+
+ return [left, right]
+
+ @staticmethod
+ def _mid_point(a: list[float], b: list[float]) -> list[float]:
+ """Calculate midpoint of two 3D points."""
+ return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2]
+
+ @staticmethod
+ def _critical_plane_below(x: float) -> int:
+ """Get critical plane index below x."""
+ return int(math.floor(x - 0.5))
+
+ @staticmethod
+ def _critical_plane_above(x: float) -> int:
+ """Get critical plane index above x."""
+ return int(math.ceil(x - 0.5))
+
+ @staticmethod
+ def _bisect_to_limit(y: float, target_hue: float) -> list[float]:
+ """
+ Find color on RGB cube boundary with exact target hue.
+
+ This is the key function for hue-preserving gamut mapping.
+ """
+ segment = HctSolver._bisect_to_segment(y, target_hue)
+ left = segment[0]
+ left_hue = HctSolver._hue_of(left)
+ right = segment[1]
+
+ for axis in range(3):
+ if abs(left[axis] - right[axis]) > 1e-10:
+ if left[axis] < right[axis]:
+ l_plane = HctSolver._critical_plane_below(
+ HctSolver._true_delinearized(left[axis]))
+ r_plane = HctSolver._critical_plane_above(
+ HctSolver._true_delinearized(right[axis]))
+ else:
+ l_plane = HctSolver._critical_plane_above(
+ HctSolver._true_delinearized(left[axis]))
+ r_plane = HctSolver._critical_plane_below(
+ HctSolver._true_delinearized(right[axis]))
+
+ for _ in range(8):
+ if abs(r_plane - l_plane) <= 1:
+ break
+
+ m_plane = int((l_plane + r_plane) / 2)
+ # Clamp to valid index range
+ m_plane = max(0, min(len(_CRITICAL_PLANES) - 1, m_plane))
+ mid_plane_coordinate = _CRITICAL_PLANES[m_plane]
+ mid = HctSolver._set_coordinate(left, mid_plane_coordinate, right, axis)
+ mid_hue = HctSolver._hue_of(mid)
+
+ if HctSolver._are_in_cyclic_order(left_hue, target_hue, mid_hue):
+ right = mid
+ r_plane = m_plane
+ else:
+ left = mid
+ left_hue = mid_hue
+ l_plane = m_plane
+
+ return HctSolver._mid_point(left, right)
+
+ @staticmethod
+ def _inverse_chromatic_adaptation(adapted: float) -> float:
+ """Inverse of chromatic adaptation."""
+ adapted_abs = abs(adapted)
+ base = max(0.0, 27.13 * adapted_abs / (400.0 - adapted_abs))
+ return _signum(adapted) * (base ** (1.0 / 0.42))
+
+ @staticmethod
+ def _find_result_by_j(hue_radians: float, chroma: float, y: float) -> tuple[int, int, int] | None:
+ """
+ Try to find exact color with given hue, chroma, and Y.
+
+ Returns None if out of gamut.
+ """
+ j = math.sqrt(y) * 11.0
+
+ t_inner_coeff = 1.0 / ((1.64 - (0.29 ** ViewingConditions.n)) ** 0.73)
+ e_hue = 0.25 * (math.cos(hue_radians + 2.0) + 3.8)
+ p1 = e_hue * (50000.0 / 13.0) * ViewingConditions.nc * ViewingConditions.ncb
+ h_sin = math.sin(hue_radians)
+ h_cos = math.cos(hue_radians)
+
+ for iteration in range(5):
+ j_normalized = j / 100.0
+ if chroma == 0 or j == 0:
+ alpha = 0.0
+ else:
+ alpha = chroma / math.sqrt(j_normalized)
+
+ t = (alpha * t_inner_coeff) ** (1.0 / 0.9)
+ ac = ViewingConditions.aw * (j_normalized ** (1.0 / ViewingConditions.c / ViewingConditions.z))
+ p2 = ac / ViewingConditions.nbb
+ gamma = 23.0 * (p2 + 0.305) * t / (23.0 * p1 + 11.0 * t * h_cos + 108.0 * t * h_sin)
+ a = gamma * h_cos
+ b = gamma * h_sin
+
+ r_a = (460.0 * p2 + 451.0 * a + 288.0 * b) / 1403.0
+ g_a = (460.0 * p2 - 891.0 * a - 261.0 * b) / 1403.0
+ b_a = (460.0 * p2 - 220.0 * a - 6300.0 * b) / 1403.0
+
+ r_cscaled = HctSolver._inverse_chromatic_adaptation(r_a)
+ g_cscaled = HctSolver._inverse_chromatic_adaptation(g_a)
+ b_cscaled = HctSolver._inverse_chromatic_adaptation(b_a)
+
+ linrgb = _matrix_multiply(_LINRGB_FROM_SCALED_DISCOUNT,
+ [r_cscaled, g_cscaled, b_cscaled])
+
+ # Check if in gamut
+ if linrgb[0] < 0 or linrgb[1] < 0 or linrgb[2] < 0:
+ return None
+
+ k_r, k_g, k_b = _Y_FROM_LINRGB
+ fnj = k_r * linrgb[0] + k_g * linrgb[1] + k_b * linrgb[2]
+
+ if fnj <= 0:
+ return None
+
+ if iteration == 4 or abs(fnj - y) < 0.002:
+ if linrgb[0] > 100.01 or linrgb[1] > 100.01 or linrgb[2] > 100.01:
+ return None
+
+ # Convert linear RGB to sRGB
+ return (
+ _delinearize(linrgb[0] / 100.0),
+ _delinearize(linrgb[1] / 100.0),
+ _delinearize(linrgb[2] / 100.0),
+ )
+
+ # Newton iteration
+ j = j - (fnj - y) * j / (2.0 * fnj)
+
+ return None
+
+ @staticmethod
+ def solve_to_rgb(hue_degrees: float, chroma: float, tone: float) -> tuple[int, int, int]:
+ """
+ Solve HCT to RGB with proper gamut mapping.
+
+ If the exact color is out of gamut, finds the maximum achievable
+ chroma while preserving the exact hue.
+ """
+ if chroma < 0.0001 or tone < 0.0001 or tone > 99.9999:
+ # Achromatic - just convert tone to gray
+ y = lstar_to_y(tone)
+ gray = _delinearize(y / 100.0)
+ return (gray, gray, gray)
+
+ hue_degrees = _sanitize_degrees(hue_degrees)
+ hue_radians = math.radians(hue_degrees)
+ # Y is in 0-100 range (same scale as internal linear RGB in the solver)
+ y = lstar_to_y(tone)
+
+ # Try to find exact solution
+ exact = HctSolver._find_result_by_j(hue_radians, chroma, y)
+ if exact is not None:
+ return exact
+
+ # Fall back to bisection - find max chroma that preserves hue
+ linrgb = HctSolver._bisect_to_limit(y, hue_radians)
+
+ return (
+ _delinearize(linrgb[0] / 100.0),
+ _delinearize(linrgb[1] / 100.0),
+ _delinearize(linrgb[2] / 100.0),
+ )
+
+
+def rgb_to_xyz(r: int, g: int, b: int) -> tuple[float, float, float]:
+ """Convert sRGB to CIE XYZ."""
+ linear_r = _linearize(r)
+ linear_g = _linearize(g)
+ linear_b = _linearize(b)
+ xyz = _matrix_multiply(SRGB_TO_XYZ, [linear_r, linear_g, linear_b])
+ return (xyz[0] * 100, xyz[1] * 100, xyz[2] * 100)
+
+
+def xyz_to_rgb(x: float, y: float, z: float) -> tuple[int, int, int]:
+ """Convert CIE XYZ to sRGB."""
+ linear = _matrix_multiply(XYZ_TO_SRGB, [x / 100, y / 100, z / 100])
+ return (_delinearize(linear[0]), _delinearize(linear[1]), _delinearize(linear[2]))
+
+
+def y_to_lstar(y: float) -> float:
+ """Convert XYZ Y component to L* (CIELAB lightness / HCT Tone)."""
+ if y <= 0:
+ return 0.0
+ y_normalized = y / 100.0
+ if y_normalized <= 0.008856:
+ return 903.2962962962963 * y_normalized
+ return 116.0 * math.pow(y_normalized, 1.0 / 3.0) - 16.0
+
+
+def lstar_to_y(lstar: float) -> float:
+ """Convert L* (Tone) to XYZ Y component."""
+ if lstar <= 0:
+ return 0.0
+ if lstar > 100:
+ lstar = 100.0
+ if lstar <= 8.0:
+ return lstar / 903.2962962962963 * 100.0
+ fy = (lstar + 16.0) / 116.0
+ return fy * fy * fy * 100.0
+
+
+def argb_to_int(r: int, g: int, b: int) -> int:
+ """Convert RGB to ARGB integer (alpha = 255)."""
+ return (255 << 24) | (r << 16) | (g << 8) | b
+
+
+def int_to_rgb(argb: int) -> tuple[int, int, int]:
+ """Convert ARGB integer to RGB tuple."""
+ return ((argb >> 16) & 0xFF, (argb >> 8) & 0xFF, argb & 0xFF)
+
+
+class Cam16:
+ """CAM16 color appearance model representation."""
+
+ def __init__(self, hue: float, chroma: float, j: float, q: float,
+ m: float, s: float, jstar: float, astar: float, bstar: float):
+ self.hue = hue
+ self.chroma = chroma
+ self.j = j # Lightness
+ self.q = q # Brightness
+ self.m = m # Colorfulness
+ self.s = s # Saturation
+ self.jstar = jstar # CAM16-UCS J*
+ self.astar = astar # CAM16-UCS a*
+ self.bstar = bstar # CAM16-UCS b*
+
+ @classmethod
+ def from_rgb(cls, r: int, g: int, b: int) -> 'Cam16':
+ """Create CAM16 from sRGB values."""
+ x, y, z = rgb_to_xyz(r, g, b)
+
+ r_c = 0.401288 * x + 0.650173 * y - 0.051461 * z
+ g_c = -0.250268 * x + 1.204414 * y + 0.045854 * z
+ b_c = -0.002079 * x + 0.048952 * y + 0.953127 * z
+
+ r_d = ViewingConditions.RGB_D[0] * r_c
+ g_d = ViewingConditions.RGB_D[1] * g_c
+ b_d = ViewingConditions.RGB_D[2] * b_c
+
+ r_af = math.pow(ViewingConditions.fl * abs(r_d) / 100.0, 0.42)
+ g_af = math.pow(ViewingConditions.fl * abs(g_d) / 100.0, 0.42)
+ b_af = math.pow(ViewingConditions.fl * abs(b_d) / 100.0, 0.42)
+
+ r_a = _signum(r_d) * 400.0 * r_af / (r_af + 27.13)
+ g_a = _signum(g_d) * 400.0 * g_af / (g_af + 27.13)
+ b_a = _signum(b_d) * 400.0 * b_af / (b_af + 27.13)
+
+ a = (11.0 * r_a + -12.0 * g_a + b_a) / 11.0
+ b = (r_a + g_a - 2.0 * b_a) / 9.0
+
+ hue_radians = math.atan2(b, a)
+ hue = math.degrees(hue_radians)
+ if hue < 0:
+ hue += 360.0
+
+ u = (20.0 * r_a + 20.0 * g_a + 21.0 * b_a) / 20.0
+ p2 = (40.0 * r_a + 20.0 * g_a + b_a) / 20.0
+ ac = p2 * ViewingConditions.nbb
+
+ j = 100.0 * math.pow(ac / ViewingConditions.aw, ViewingConditions.c * ViewingConditions.z)
+ q = (4.0 / ViewingConditions.c) * math.sqrt(j / 100.0) * (ViewingConditions.aw + 4.0) * ViewingConditions.fl_root
+
+ hue_prime = hue + 360.0 if hue < 20.14 else hue
+ e_hue = 0.25 * (math.cos(math.radians(hue_prime) + 2.0) + 3.8)
+
+ t = 50000.0 / 13.0 * ViewingConditions.nc * ViewingConditions.ncb * e_hue * math.sqrt(a * a + b * b) / (u + 0.305)
+ alpha = math.pow(t, 0.9) * math.pow(1.64 - math.pow(0.29, ViewingConditions.n), 0.73)
+ chroma = alpha * math.sqrt(j / 100.0)
+
+ m = chroma * ViewingConditions.fl_root
+ s = 50.0 * math.sqrt((ViewingConditions.c * alpha) / (ViewingConditions.aw + 4.0))
+
+ jstar = (1.0 + 100.0 * 0.007) * j / (1.0 + 0.007 * j)
+ mstar = 1.0 / 0.0228 * math.log(1.0 + 0.0228 * m) if m > 0 else 0
+ astar = mstar * math.cos(hue_radians)
+ bstar = mstar * math.sin(hue_radians)
+
+ return cls(hue, chroma, j, q, m, s, jstar, astar, bstar)
+
+ @classmethod
+ def from_jch(cls, j: float, chroma: float, hue: float) -> 'Cam16':
+ """Create CAM16 from J (lightness), chroma, and hue."""
+ q = (4.0 / ViewingConditions.c) * math.sqrt(j / 100.0) * (ViewingConditions.aw + 4.0) * ViewingConditions.fl_root
+ m = chroma * ViewingConditions.fl_root
+ alpha = chroma / math.sqrt(j / 100.0) if j > 0 else 0
+ s = 50.0 * math.sqrt((ViewingConditions.c * alpha) / (ViewingConditions.aw + 4.0))
+
+ hue_radians = math.radians(hue)
+ jstar = (1.0 + 100.0 * 0.007) * j / (1.0 + 0.007 * j)
+ mstar = 1.0 / 0.0228 * math.log(1.0 + 0.0228 * m) if m > 0 else 0
+ astar = mstar * math.cos(hue_radians)
+ bstar = mstar * math.sin(hue_radians)
+
+ return cls(hue, chroma, j, q, m, s, jstar, astar, bstar)
+
+ def to_rgb(self) -> tuple[int, int, int]:
+ """Convert CAM16 back to sRGB."""
+ if self.chroma == 0 or self.j == 0:
+ y = lstar_to_y(self.j)
+ return xyz_to_rgb(y, y, y)
+
+ hue_radians = math.radians(self.hue)
+
+ alpha = self.chroma / math.sqrt(self.j / 100.0) if self.j > 0 else 0
+ t = math.pow(alpha / math.pow(1.64 - math.pow(0.29, ViewingConditions.n), 0.73), 1.0 / 0.9)
+
+ hue_prime = self.hue + 360.0 if self.hue < 20.14 else self.hue
+ e_hue = 0.25 * (math.cos(math.radians(hue_prime) + 2.0) + 3.8)
+
+ ac = ViewingConditions.aw * math.pow(self.j / 100.0, 1.0 / (ViewingConditions.c * ViewingConditions.z))
+ p1 = 50000.0 / 13.0 * ViewingConditions.nc * ViewingConditions.ncb * e_hue
+ p2 = ac / ViewingConditions.nbb
+
+ gamma = 23.0 * (p2 + 0.305) * t / (23.0 * p1 + 11.0 * t * math.cos(hue_radians) + 108.0 * t * math.sin(hue_radians))
+
+ a = gamma * math.cos(hue_radians)
+ b = gamma * math.sin(hue_radians)
+
+ r_a = (460.0 * p2 + 451.0 * a + 288.0 * b) / 1403.0
+ g_a = (460.0 * p2 - 891.0 * a - 261.0 * b) / 1403.0
+ b_a = (460.0 * p2 - 220.0 * a - 6300.0 * b) / 1403.0
+
+ def reverse_adapt(adapted: float) -> float:
+ abs_adapted = abs(adapted)
+ base = max(0, 27.13 * abs_adapted / (400.0 - abs_adapted))
+ return _signum(adapted) * 100.0 / ViewingConditions.fl * math.pow(base, 1.0 / 0.42)
+
+ r_c = reverse_adapt(r_a) / ViewingConditions.RGB_D[0]
+ g_c = reverse_adapt(g_a) / ViewingConditions.RGB_D[1]
+ b_c = reverse_adapt(b_a) / ViewingConditions.RGB_D[2]
+
+ x = 1.8620678 * r_c - 1.0112547 * g_c + 0.1491867 * b_c
+ y = 0.3875265 * r_c + 0.6214474 * g_c - 0.0089739 * b_c
+ z = -0.0158415 * r_c - 0.0344156 * g_c + 1.0502571 * b_c
+
+ return xyz_to_rgb(x, y, z)
+
+
+class Hct:
+ """
+ HCT (Hue, Chroma, Tone) color representation.
+
+ Material Design 3's perceptual color space combining:
+ - Hue: CAM16 hue (0-360)
+ - Chroma: CAM16 chroma (colorfulness, typically 0-120+)
+ - Tone: CIELAB L* lightness (0-100)
+ """
+
+ def __init__(self, hue: float, chroma: float, tone: float):
+ self._hue = hue % 360.0
+ self._chroma = max(0.0, chroma)
+ self._tone = max(0.0, min(100.0, tone))
+ self._argb: int | None = None
+
+ @property
+ def hue(self) -> float:
+ return self._hue
+
+ @property
+ def chroma(self) -> float:
+ return self._chroma
+
+ @property
+ def tone(self) -> float:
+ return self._tone
+
+ @classmethod
+ def from_rgb(cls, r: int, g: int, b: int) -> 'Hct':
+ """Create HCT from sRGB values."""
+ cam = Cam16.from_rgb(r, g, b)
+ _, y, _ = rgb_to_xyz(r, g, b)
+ tone = y_to_lstar(y)
+ return cls(cam.hue, cam.chroma, tone)
+
+ @classmethod
+ def from_argb(cls, argb: int) -> 'Hct':
+ """Create HCT from ARGB integer."""
+ r, g, b = int_to_rgb(argb)
+ return cls.from_rgb(r, g, b)
+
+ def to_rgb(self) -> tuple[int, int, int]:
+ """Convert HCT to sRGB, solving for the color."""
+ return self._solve_to_rgb(self._hue, self._chroma, self._tone)
+
+ def to_argb(self) -> int:
+ """Convert HCT to ARGB integer."""
+ if self._argb is None:
+ r, g, b = self.to_rgb()
+ self._argb = argb_to_int(r, g, b)
+ return self._argb
+
+ def to_hex(self) -> str:
+ """Convert HCT to hex string."""
+ r, g, b = self.to_rgb()
+ return f"#{r:02x}{g:02x}{b:02x}"
+
+ @staticmethod
+ def _solve_to_rgb(hue: float, chroma: float, tone: float) -> tuple[int, int, int]:
+ """
+ Solve for RGB given HCT values using the Material HctSolver.
+
+ This uses proper gamut mapping that preserves hue exactly.
+ When the requested chroma is out of gamut, it finds the maximum
+ achievable chroma while maintaining the exact target hue.
+ """
+ return HctSolver.solve_to_rgb(hue, chroma, tone)
+
+ def set_hue(self, hue: float) -> 'Hct':
+ """Return new HCT with different hue."""
+ return Hct(hue, self._chroma, self._tone)
+
+ def set_chroma(self, chroma: float) -> 'Hct':
+ """Return new HCT with different chroma."""
+ return Hct(self._hue, chroma, self._tone)
+
+ def set_tone(self, tone: float) -> 'Hct':
+ """Return new HCT with different tone."""
+ return Hct(self._hue, self._chroma, tone)
+
+
+class TemperatureCache:
+ """
+ Color temperature analysis for finding harmonious colors.
+
+ Based on Material Color Utilities - calculates relative warmth of colors
+ and finds analogous colors based on temperature similarity.
+ """
+
+ def __init__(self, input_hct: Hct):
+ self.input = input_hct
+ self._hcts_by_temp: list[Hct] | None = None
+ self._hcts_by_hue: list[Hct] | None = None
+ self._temps_by_hct: dict[tuple[float, float, float], float] | None = None
+ self._input_relative_temp: float | None = None
+ self._complement: Hct | None = None
+
+ @staticmethod
+ def raw_temperature(hct: Hct) -> float:
+ """
+ Calculate raw temperature of a color using Ou-Woodcock-Wright algorithm.
+
+ Based on material-colors Rust implementation.
+ Uses LAB a* and b* to determine warm-cool factor.
+ Values below 0 are cool, above 0 are warm.
+ """
+ # Convert HCT to RGB then to LAB
+ rgb = hct.to_rgb()
+ x, y, z = rgb_to_xyz(rgb[0], rgb[1], rgb[2])
+
+ # XYZ to LAB
+ def f(t: float) -> float:
+ delta = 6.0 / 29.0
+ if t > delta ** 3:
+ return t ** (1.0 / 3.0)
+ return t / (3 * delta * delta) + 4.0 / 29.0
+
+ xn, yn, zn = 95.047, 100.0, 108.883 # D65 reference
+ lab_a = 500.0 * (f(x / xn) - f(y / yn))
+ lab_b = 200.0 * (f(y / yn) - f(z / zn))
+
+ # Calculate LAB hue and chroma
+ lab_hue = math.degrees(math.atan2(lab_b, lab_a))
+ if lab_hue < 0:
+ lab_hue += 360.0
+ lab_chroma = math.hypot(lab_a, lab_b)
+
+ # Ou-Woodcock-Wright formula for temperature
+ # temp = -0.5 + 0.02 * chroma^1.07 * cos(toRadians(hue - 50))
+ hue_rad = math.radians((lab_hue - 50.0) % 360.0)
+ return -0.5 + 0.02 * (lab_chroma ** 1.07) * math.cos(hue_rad)
+
+ def _get_hcts_by_hue(self) -> list[Hct]:
+ """Generate HCT colors at regular hue intervals."""
+ if self._hcts_by_hue is not None:
+ return self._hcts_by_hue
+
+ hcts = []
+ for hue in range(360):
+ color_at_hue = Hct(float(hue), self.input.chroma, self.input.tone)
+ hcts.append(color_at_hue)
+
+ self._hcts_by_hue = hcts
+ return hcts
+
+ def _get_temps_by_hct(self) -> dict[tuple[float, float, float], float]:
+ """Cache temperatures for all hue variants."""
+ if self._temps_by_hct is not None:
+ return self._temps_by_hct
+
+ hcts = self._get_hcts_by_hue()
+ temps = {}
+ for hct in hcts:
+ key = (hct.hue, hct.chroma, hct.tone)
+ temps[key] = self.raw_temperature(hct)
+
+ self._temps_by_hct = temps
+ return temps
+
+ def _get_hcts_by_temp(self) -> list[Hct]:
+ """Get HCT colors sorted by temperature."""
+ if self._hcts_by_temp is not None:
+ return self._hcts_by_temp
+
+ hcts = list(self._get_hcts_by_hue())
+ temps = self._get_temps_by_hct()
+ hcts.sort(key=lambda h: temps[(h.hue, h.chroma, h.tone)])
+
+ self._hcts_by_temp = hcts
+ return hcts
+
+ def _relative_temperature(self, hct: Hct) -> float:
+ """
+ Calculate relative temperature (0-1) based on position in temperature-sorted list.
+ """
+ temps = self._get_temps_by_hct()
+ hcts_by_temp = self._get_hcts_by_temp()
+
+ key = (hct.hue, hct.chroma, hct.tone)
+ if key in temps:
+ raw = temps[key]
+ else:
+ raw = self.raw_temperature(hct)
+
+ # Find position in sorted list
+ coldest = self.raw_temperature(hcts_by_temp[0])
+ warmest = self.raw_temperature(hcts_by_temp[-1])
+
+ if warmest == coldest:
+ return 0.5
+
+ return (raw - coldest) / (warmest - coldest)
+
+ def _input_relative_temperature_value(self) -> float:
+ """Get relative temperature of the input color."""
+ if self._input_relative_temp is None:
+ self._input_relative_temp = self._relative_temperature(self.input)
+ return self._input_relative_temp
+
+ def complement(self) -> Hct:
+ """
+ Find the complement: color with opposite temperature.
+ """
+ if self._complement is not None:
+ return self._complement
+
+ input_temp = self._input_relative_temperature_value()
+ hcts_by_temp = self._get_hcts_by_temp()
+ temps = self._get_temps_by_hct()
+
+ # Target is opposite temperature
+ target_temp = 1.0 - input_temp
+
+ # Find closest match
+ best_hct = hcts_by_temp[0]
+ best_diff = float('inf')
+
+ for hct in hcts_by_temp:
+ key = (hct.hue, hct.chroma, hct.tone)
+ raw = temps.get(key, self.raw_temperature(hct))
+ rel = self._relative_temperature(hct)
+ diff = abs(rel - target_temp)
+ if diff < best_diff:
+ best_diff = diff
+ best_hct = hct
+
+ self._complement = best_hct
+ return best_hct
+
+ def analogous(self, count: int | None = None, divisions: int | None = None) -> list[Hct]:
+ """
+ Find analogous colors based on temperature.
+
+ Uses material-colors algorithm:
+ 1. Build a list of all `divisions` colors at equal temperature steps
+ 2. Pick `count` colors from this list, centered around the input
+
+ Args:
+ count: Number of colors to return (default 5)
+ divisions: How many divisions of the temperature range (default 12)
+
+ Returns:
+ List of HCT colors including the input, spread by temperature.
+ """
+ if count is None:
+ count = 5
+ if divisions is None:
+ divisions = 12
+
+ hcts_by_hue = self._get_hcts_by_hue()
+ start_hue = round(self.input.hue) % 360
+ start_hct = hcts_by_hue[start_hue]
+
+ # Calculate total absolute temperature delta around the color wheel
+ last_temp = self._relative_temperature(start_hct)
+ absolute_total_temp_delta = 0.0
+
+ for i in range(360):
+ hue = (start_hue + i) % 360
+ hct = hcts_by_hue[hue]
+ temp = self._relative_temperature(hct)
+ temp_delta = abs(temp - last_temp)
+ last_temp = temp
+ absolute_total_temp_delta += temp_delta
+
+ # Build list of all colors at equal temperature steps
+ temp_step = absolute_total_temp_delta / divisions
+ all_colors: list[Hct] = [start_hct]
+ total_temp_delta = 0.0
+ last_temp = self._relative_temperature(start_hct)
+ hue_addend = 1
+
+ while len(all_colors) < divisions and hue_addend <= 360:
+ hue = (start_hue + hue_addend) % 360
+ hct = hcts_by_hue[hue]
+ temp = self._relative_temperature(hct)
+ temp_delta = abs(temp - last_temp)
+ total_temp_delta += temp_delta
+
+ desired_total = len(all_colors) * temp_step
+
+ # Add this hue until its temperature is insufficient
+ while total_temp_delta >= desired_total and len(all_colors) < divisions:
+ all_colors.append(hct)
+ desired_total = (len(all_colors) + 1) * temp_step
+
+ last_temp = temp
+ hue_addend += 1
+
+ # Fill remaining slots if needed
+ while len(all_colors) < divisions:
+ all_colors.append(all_colors[-1] if all_colors else start_hct)
+
+ # Build final answer list centered around input
+ answers: list[Hct] = [self.input]
+
+ # Counter-clockwise (negative indices)
+ increase_hue_count = int((count - 1) // 2)
+ for i in range(1, increase_hue_count + 1):
+ index = (-i) % len(all_colors)
+ answers.insert(0, all_colors[index])
+
+ # Clockwise (positive indices)
+ decrease_hue_count = count - increase_hue_count - 1
+ for i in range(1, decrease_hue_count + 1):
+ index = i % len(all_colors)
+ answers.append(all_colors[index])
+
+ return answers
+
+
+def fix_if_disliked(hct: Hct) -> Hct:
+ """
+ Fix colors in the "disliked" hue range (yellow-green).
+
+ These colors often look muddy or unpleasant. If detected,
+ shift the hue slightly to improve appearance.
+ """
+ # Disliked range: roughly 80-110 degrees (yellow-green)
+ if hct.hue >= 80.0 and hct.hue <= 110.0 and hct.chroma > 16.0:
+ # Shift towards warmer yellow or cooler green
+ new_hue = 75.0 if hct.hue < 95.0 else 115.0
+ return Hct(new_hue, hct.chroma, hct.tone)
+ return hct
+
+
+class TonalPalette:
+ """
+ A palette of tones for a single hue and chroma.
+
+ Material Design 3 uses specific tone values for different UI elements.
+ """
+
+ def __init__(self, hue: float, chroma: float):
+ self.hue = hue
+ self.chroma = chroma
+ self._cache: dict[int, int] = {}
+
+ @classmethod
+ def from_hct(cls, hct: Hct) -> 'TonalPalette':
+ """Create TonalPalette from HCT color."""
+ return cls(hct.hue, hct.chroma)
+
+ @classmethod
+ def from_rgb(cls, r: int, g: int, b: int) -> 'TonalPalette':
+ """Create TonalPalette from RGB color."""
+ hct = Hct.from_rgb(r, g, b)
+ return cls(hct.hue, hct.chroma)
+
+ def tone(self, t: int) -> int:
+ """Get ARGB color at the specified tone (0-100)."""
+ if t not in self._cache:
+ hct = Hct(self.hue, self.chroma, float(t))
+ self._cache[t] = hct.to_argb()
+ return self._cache[t]
+
+ def get_rgb(self, t: int) -> tuple[int, int, int]:
+ """Get RGB color at the specified tone."""
+ return int_to_rgb(self.tone(t))
+
+ def get_hex(self, t: int) -> str:
+ """Get hex color at the specified tone."""
+ r, g, b = self.get_rgb(t)
+ return f"#{r:02x}{g:02x}{b:02x}"
diff --git a/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/lib/image.py b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/lib/image.py
new file mode 100644
index 0000000..7e0c31a
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/lib/image.py
@@ -0,0 +1,366 @@
+"""
+Image reading utilities for PNG and JPEG files.
+
+This module provides functions for extracting RGB pixels from image files
+without external dependencies (except ImageMagick for fallback).
+"""
+
+import struct
+import zlib
+from pathlib import Path
+
+# Type alias
+RGB = tuple[int, int, int]
+
+
+class ImageReadError(Exception):
+ """Raised when image cannot be read or parsed."""
+ pass
+
+
+def read_png(path: Path) -> list[RGB]:
+ """
+ Parse a PNG file and extract RGB pixels.
+
+ Supports 8-bit RGB and RGBA color types (most common for wallpapers).
+ Uses zlib for IDAT decompression and handles PNG filters.
+ """
+ with open(path, 'rb') as f:
+ data = f.read()
+
+ # Verify PNG signature
+ if data[:8] != b'\x89PNG\r\n\x1a\n':
+ raise ImageReadError("Invalid PNG signature")
+
+ pos = 8
+ width = 0
+ height = 0
+ bit_depth = 0
+ color_type = 0
+ idat_chunks: list[bytes] = []
+
+ while pos < len(data):
+ # Read chunk length and type
+ chunk_len = struct.unpack('>I', data[pos:pos+4])[0]
+ chunk_type = data[pos+4:pos+8]
+ chunk_data = data[pos+8:pos+8+chunk_len]
+ pos += 12 + chunk_len # length + type + data + crc
+
+ if chunk_type == b'IHDR':
+ width = struct.unpack('>I', chunk_data[0:4])[0]
+ height = struct.unpack('>I', chunk_data[4:8])[0]
+ bit_depth = chunk_data[8]
+ color_type = chunk_data[9]
+
+ if bit_depth != 8:
+ raise ImageReadError(f"Unsupported bit depth: {bit_depth}")
+ if color_type not in (2, 6): # RGB or RGBA
+ raise ImageReadError(f"Unsupported color type: {color_type}")
+
+ elif chunk_type == b'IDAT':
+ idat_chunks.append(chunk_data)
+
+ elif chunk_type == b'IEND':
+ break
+
+ if not idat_chunks or width == 0:
+ raise ImageReadError("Missing image data")
+
+ # Decompress all IDAT chunks
+ compressed = b''.join(idat_chunks)
+ raw_data = zlib.decompress(compressed)
+
+ # Calculate bytes per pixel and row
+ bpp = 3 if color_type == 2 else 4 # RGB or RGBA
+ stride = width * bpp + 1 # +1 for filter byte
+
+ pixels: list[RGB] = []
+ prev_row: list[int] = [0] * (width * bpp)
+
+ for y in range(height):
+ row_start = y * stride
+ filter_type = raw_data[row_start]
+ row_data = list(raw_data[row_start + 1:row_start + stride])
+
+ # Apply PNG filter reconstruction
+ unfiltered = _png_unfilter(row_data, prev_row, bpp, filter_type)
+ prev_row = unfiltered
+
+ # Extract RGB values (skip alpha if present)
+ for x in range(width):
+ idx = x * bpp
+ r, g, b = unfiltered[idx], unfiltered[idx+1], unfiltered[idx+2]
+ pixels.append((r, g, b))
+
+ return pixels
+
+
+def _png_unfilter(
+ row: list[int],
+ prev_row: list[int],
+ bpp: int,
+ filter_type: int
+) -> list[int]:
+ """Apply PNG filter reconstruction."""
+ result = [0] * len(row)
+
+ for i in range(len(row)):
+ x = row[i]
+ a = result[i - bpp] if i >= bpp else 0
+ b = prev_row[i]
+ c = prev_row[i - bpp] if i >= bpp else 0
+
+ if filter_type == 0: # None
+ result[i] = x
+ elif filter_type == 1: # Sub
+ result[i] = (x + a) & 0xFF
+ elif filter_type == 2: # Up
+ result[i] = (x + b) & 0xFF
+ elif filter_type == 3: # Average
+ result[i] = (x + (a + b) // 2) & 0xFF
+ elif filter_type == 4: # Paeth
+ result[i] = (x + _paeth_predictor(a, b, c)) & 0xFF
+ else:
+ raise ImageReadError(f"Unknown PNG filter type: {filter_type}")
+
+ return result
+
+
+def _paeth_predictor(a: int, b: int, c: int) -> int:
+ """Paeth predictor for PNG filter reconstruction."""
+ p = a + b - c
+ pa = abs(p - a)
+ pb = abs(p - b)
+ pc = abs(p - c)
+
+ if pa <= pb and pa <= pc:
+ return a
+ elif pb <= pc:
+ return b
+ return c
+
+
+def read_jpeg(path: Path) -> list[RGB]:
+ """
+ Parse a JPEG file and extract RGB pixels.
+
+ Supports baseline (SOF0), extended (SOF1), and progressive (SOF2) JPEG.
+ This is a simplified decoder that extracts dimensions then samples colors.
+ """
+ with open(path, 'rb') as f:
+ data = f.read()
+
+ # Verify JPEG signature (SOI marker)
+ if data[:2] != b'\xff\xd8':
+ raise ImageReadError("Invalid JPEG signature")
+
+ pos = 2
+ width = 0
+ height = 0
+
+ # SOF markers that contain image dimensions
+ # SOF0=Baseline, SOF1=Extended, SOF2=Progressive, SOF3=Lossless
+ # SOF5-7=Differential variants, SOF9-11=Arithmetic coding variants
+ sof_markers = {0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7,
+ 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF}
+
+ # Standalone markers (no length field)
+ standalone_markers = {0xD0, 0xD1, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, # RST0-7
+ 0xD8, # SOI
+ 0xD9, # EOI
+ 0x01} # TEM
+
+ while pos < len(data) - 1:
+ # Find next marker
+ if data[pos] != 0xFF:
+ pos += 1
+ continue
+
+ # Skip padding 0xFF bytes
+ while pos < len(data) and data[pos] == 0xFF:
+ pos += 1
+
+ if pos >= len(data):
+ break
+
+ marker = data[pos]
+ pos += 1
+
+ # Check for SOF marker (contains dimensions)
+ if marker in sof_markers:
+ if pos + 7 <= len(data):
+ # Skip segment length (2 bytes), precision (1 byte)
+ height = struct.unpack('>H', data[pos+3:pos+5])[0]
+ width = struct.unpack('>H', data[pos+5:pos+7])[0]
+ break
+
+ # End of image
+ if marker == 0xD9:
+ break
+
+ # Skip segment data for markers with length field
+ if marker not in standalone_markers and marker != 0x00:
+ if pos + 2 <= len(data):
+ seg_len = struct.unpack('>H', data[pos:pos+2])[0]
+ pos += seg_len
+
+ if width == 0 or height == 0:
+ raise ImageReadError("Could not parse JPEG dimensions")
+
+ # Since full JPEG decoding is extremely complex without external libraries,
+ # we fall back to sampling the raw data for color approximation.
+ return _sample_jpeg_colors(data, width, height)
+
+
+def _sample_jpeg_colors(data: bytes, width: int, height: int) -> list[RGB]:
+ """
+ Sample colors from JPEG data without full decoding.
+
+ This is a rough approximation that samples byte triplets from the
+ compressed data. Not accurate, but provides some color information.
+ For accurate results, use ImageMagick via read_image().
+ """
+ # Sample every Nth byte triplet from the image data
+ # Skip headers and look for image data after SOS marker
+ pixels: list[RGB] = []
+ step = max(1, len(data) // (width * height // 16))
+
+ for i in range(0, len(data) - 2, step):
+ r, g, b = data[i], data[i+1], data[i+2]
+ # Filter out obviously non-image data (markers, etc.)
+ if not (r == 0xFF and g in (0xD8, 0xD9, 0xE0, 0xE1)):
+ pixels.append((r, g, b))
+
+ return pixels if pixels else [(128, 128, 128)]
+
+
+def _read_image_imagemagick(path: Path, resize_filter: str = "Triangle") -> list[RGB]:
+ """
+ Read image using ImageMagick's convert command.
+
+ Converts image to PPM format (trivial to parse) and extracts RGB pixels.
+ This method works accurately for any image format ImageMagick supports.
+ """
+ import subprocess
+
+ # Use magick or convert command
+ # -depth 8: 8 bits per channel
+ # -resize: downsample for performance (we don't need full resolution for color extraction)
+ # ppm: output as PPM format (easy to parse)
+
+ # Resize to 112x112 to match matugen's color extraction
+ # Use -filter Triangle (bilinear) for M3 schemes to match matugen's FilterType::Triangle default
+ # Use -filter Box for k-means schemes (sharper, preserves distinct color regions)
+ # Use -depth 8 -colorspace sRGB -strip to reduce variance between HDRI/non-HDRI builds
+ resize_spec = "112x112!"
+
+ try:
+ # Try 'magick' first (ImageMagick 7+), fallback to 'convert' (ImageMagick 6)
+ try:
+ result = subprocess.run(
+ ['magick', str(path), '-filter', resize_filter, '-resize', resize_spec,
+ '-depth', '8', '-colorspace', 'sRGB', '-strip', 'ppm:-'],
+ capture_output=True,
+ check=True
+ )
+ except FileNotFoundError:
+ result = subprocess.run(
+ ['convert', str(path), '-filter', resize_filter, '-resize', resize_spec,
+ '-depth', '8', '-colorspace', 'sRGB', '-strip', 'ppm:-'],
+ capture_output=True,
+ check=True
+ )
+ except subprocess.CalledProcessError as e:
+ raise ImageReadError(f"ImageMagick failed: {e.stderr.decode()}")
+ except FileNotFoundError:
+ raise ImageReadError("ImageMagick not found. Please install imagemagick.")
+
+ ppm_data = result.stdout
+ return _parse_ppm(ppm_data)
+
+
+def _parse_ppm(data: bytes) -> list[RGB]:
+ """
+ Parse PPM (Portable Pixmap) binary format.
+
+ PPM P6 format:
+ P6
+ width height
+ maxval
+
+ """
+ pos = 0
+ tokens: list[str] = []
+
+ # Read header tokens (need 4: P6, width, height, maxval)
+ while len(tokens) < 4 and pos < len(data):
+ # Skip whitespace
+ while pos < len(data) and data[pos:pos+1] in (b' ', b'\t', b'\n', b'\r'):
+ pos += 1
+
+ # Skip comments
+ if pos < len(data) and data[pos:pos+1] == b'#':
+ while pos < len(data) and data[pos:pos+1] != b'\n':
+ pos += 1
+ continue
+
+ # Read token
+ token_start = pos
+ while pos < len(data) and data[pos:pos+1] not in (b' ', b'\t', b'\n', b'\r', b'#'):
+ pos += 1
+
+ if pos > token_start:
+ tokens.append(data[token_start:pos].decode('ascii'))
+
+ if len(tokens) < 4 or tokens[0] != 'P6':
+ raise ImageReadError(f"Invalid PPM format: {tokens}")
+
+ width = int(tokens[1])
+ height = int(tokens[2])
+ maxval = int(tokens[3])
+
+ # Skip exactly one whitespace character after maxval (per PPM spec)
+ if pos < len(data) and data[pos:pos+1] in (b' ', b'\t', b'\n', b'\r'):
+ pos += 1
+
+ pixel_data = data[pos:]
+
+ # Parse RGB triplets
+ pixels: list[RGB] = []
+ scale = 255.0 / maxval if maxval != 255 else 1.0
+
+ for i in range(0, min(len(pixel_data), width * height * 3), 3):
+ if i + 2 < len(pixel_data):
+ r = int(pixel_data[i] * scale)
+ g = int(pixel_data[i + 1] * scale)
+ b = int(pixel_data[i + 2] * scale)
+ pixels.append((r, g, b))
+
+ if not pixels:
+ raise ImageReadError("No pixels extracted from PPM data")
+
+ return pixels
+
+
+def read_image(path: Path, resize_filter: str = "Triangle") -> list[RGB]:
+ """
+ Read an image file and return its pixels as RGB tuples.
+
+ Uses ImageMagick for accurate color extraction from any format.
+ Falls back to native PNG parsing if ImageMagick is unavailable.
+
+ Args:
+ path: Path to the image file.
+ resize_filter: ImageMagick resize filter. "Triangle" for M3 schemes
+ (matches matugen), "Box" for k-means schemes.
+ """
+ suffix = path.suffix.lower()
+
+ # Try ImageMagick first (works for any format)
+ try:
+ return _read_image_imagemagick(path, resize_filter)
+ except ImageReadError:
+ # Fall back to native parsing for PNG
+ if suffix == '.png':
+ return read_png(path)
+ raise
diff --git a/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/lib/material.py b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/lib/material.py
new file mode 100644
index 0000000..9bc318a
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/lib/material.py
@@ -0,0 +1,540 @@
+"""
+Material Design 3 color scheme implementation.
+
+This module provides scheme classes for generating MD3 color schemes
+from a source color using the HCT color space.
+
+Supported schemes (matching Matugen):
+- SchemeTonalSpot: Default Android 12-13 scheme, mid-vibrancy
+- SchemeFruitSalad: Bold/playful with -50ยฐ hue rotation
+- SchemeRainbow: Chromatic accents with grayscale neutrals
+- SchemeContent: Preserves source color's chroma
+"""
+
+from .hct import Hct, TonalPalette, TemperatureCache, fix_if_disliked
+
+
+# =============================================================================
+# Tone Values (shared across all schemes)
+# =============================================================================
+
+# Tone values for Material Design 3 (dark theme)
+DARK_TONES = {
+ 'primary': 80,
+ 'on_primary': 20,
+ 'primary_container': 30,
+ 'on_primary_container': 90,
+ 'secondary': 80,
+ 'on_secondary': 20,
+ 'secondary_container': 30,
+ 'on_secondary_container': 90,
+ 'tertiary': 80,
+ 'on_tertiary': 20,
+ 'tertiary_container': 30,
+ 'on_tertiary_container': 90,
+ 'error': 80,
+ 'on_error': 20,
+ 'error_container': 30,
+ 'on_error_container': 90,
+ 'surface': 6,
+ 'on_surface': 90,
+ 'surface_variant': 30,
+ 'on_surface_variant': 80,
+ 'surface_container_lowest': 4,
+ 'surface_container_low': 10,
+ 'surface_container': 12,
+ 'surface_container_high': 17,
+ 'surface_container_highest': 22,
+ 'outline': 60,
+ 'outline_variant': 30,
+ 'shadow': 0,
+ 'scrim': 0,
+ 'inverse_surface': 90,
+ 'inverse_on_surface': 20,
+ 'inverse_primary': 40,
+}
+
+# Tone values for Material Design 3 (light theme)
+LIGHT_TONES = {
+ 'primary': 40,
+ 'on_primary': 100,
+ 'primary_container': 90,
+ 'on_primary_container': 10,
+ 'secondary': 40,
+ 'on_secondary': 100,
+ 'secondary_container': 90,
+ 'on_secondary_container': 10,
+ 'tertiary': 40,
+ 'on_tertiary': 100,
+ 'tertiary_container': 90,
+ 'on_tertiary_container': 10,
+ 'error': 40,
+ 'on_error': 100,
+ 'error_container': 90,
+ 'on_error_container': 10,
+ 'surface': 98,
+ 'on_surface': 10,
+ 'surface_variant': 90,
+ 'on_surface_variant': 30,
+ 'surface_container_lowest': 100,
+ 'surface_container_low': 96,
+ 'surface_container': 94,
+ 'surface_container_high': 92,
+ 'surface_container_highest': 90,
+ 'outline': 50,
+ 'outline_variant': 80,
+ 'shadow': 0,
+ 'scrim': 0,
+ 'inverse_surface': 20,
+ 'inverse_on_surface': 95,
+ 'inverse_primary': 80,
+}
+
+# Monochrome scheme uses different tone values (from material-colors library)
+# Primary/tertiary get special treatment for higher contrast in grayscale
+MONOCHROME_DARK_TONES = {
+ **DARK_TONES,
+ 'primary': 100, # White (was 80)
+ 'on_primary': 10, # Near-black (was 20)
+ 'primary_container': 85, # Light gray (was 30)
+ 'on_primary_container': 0, # Black (was 90)
+ 'tertiary': 90, # Light gray (was 80)
+ 'on_tertiary': 10, # Near-black (was 20)
+ 'tertiary_container': 60, # Mid gray (was 30)
+ 'on_tertiary_container': 0, # Black (was 90)
+ 'secondary_container': 30, # Same as normal
+}
+
+MONOCHROME_LIGHT_TONES = {
+ **LIGHT_TONES,
+ 'primary': 0, # Black (was 40)
+ 'on_primary': 90, # Light gray (was 100)
+ 'primary_container': 25, # Dark gray (was 90)
+ 'on_primary_container': 100, # White (was 10)
+ 'tertiary': 25, # Dark gray (was 40)
+ 'on_tertiary': 90, # Light gray (was 100)
+ 'tertiary_container': 49, # Mid gray (was 90)
+ 'on_tertiary_container': 100, # White (was 10)
+ 'secondary_container': 90, # Same as normal
+}
+
+
+# =============================================================================
+# Base Scheme Class
+# =============================================================================
+
+class _BaseScheme:
+ """Base class for all Material Design 3 schemes."""
+
+ # Error palette is the same for all schemes
+ error_palette: TonalPalette
+
+ def __init__(self, source_color: Hct):
+ """Initialize with source color. Subclasses must set palettes."""
+ self.source = source_color
+ self.error_palette = TonalPalette(25.0, 84.0) # Material red
+
+ @classmethod
+ def from_rgb(cls, r: int, g: int, b: int) -> '_BaseScheme':
+ """Create scheme from RGB color."""
+ return cls(Hct.from_rgb(r, g, b))
+
+ @classmethod
+ def from_hex(cls, hex_color: str) -> '_BaseScheme':
+ """Create scheme from hex color string."""
+ hex_color = hex_color.lstrip('#')
+ r = int(hex_color[0:2], 16)
+ g = int(hex_color[2:4], 16)
+ b = int(hex_color[4:6], 16)
+ return cls.from_rgb(r, g, b)
+
+ def get_dark_scheme(self) -> dict[str, str]:
+ """Generate dark theme color dictionary."""
+ return self._generate_scheme(is_dark=True)
+
+ def get_light_scheme(self) -> dict[str, str]:
+ """Generate light theme color dictionary."""
+ return self._generate_scheme(is_dark=False)
+
+ def _generate_scheme(self, is_dark: bool) -> dict[str, str]:
+ """Generate scheme with appropriate tone values."""
+ tones = DARK_TONES if is_dark else LIGHT_TONES
+
+ scheme = {
+ # Primary colors
+ 'primary': self.primary_palette.get_hex(tones['primary']),
+ 'on_primary': self.primary_palette.get_hex(tones['on_primary']),
+ 'primary_container': self.primary_palette.get_hex(tones['primary_container']),
+ 'on_primary_container': self.primary_palette.get_hex(tones['on_primary_container']),
+
+ # Surface tint (same as primary, used for M3 elevation tinting)
+ 'surface_tint': self.primary_palette.get_hex(tones['primary']),
+
+ # Secondary colors
+ 'secondary': self.secondary_palette.get_hex(tones['secondary']),
+ 'on_secondary': self.secondary_palette.get_hex(tones['on_secondary']),
+ 'secondary_container': self.secondary_palette.get_hex(tones['secondary_container']),
+ 'on_secondary_container': self.secondary_palette.get_hex(tones['on_secondary_container']),
+
+ # Tertiary colors
+ 'tertiary': self.tertiary_palette.get_hex(tones['tertiary']),
+ 'on_tertiary': self.tertiary_palette.get_hex(tones['on_tertiary']),
+ 'tertiary_container': self.tertiary_palette.get_hex(tones['tertiary_container']),
+ 'on_tertiary_container': self.tertiary_palette.get_hex(tones['on_tertiary_container']),
+
+ # Error colors
+ 'error': self.error_palette.get_hex(tones['error']),
+ 'on_error': self.error_palette.get_hex(tones['on_error']),
+ 'error_container': self.error_palette.get_hex(tones['error_container']),
+ 'on_error_container': self.error_palette.get_hex(tones['on_error_container']),
+
+ # Surface colors
+ 'surface': self.neutral_palette.get_hex(tones['surface']),
+ 'on_surface': self.neutral_palette.get_hex(tones['on_surface']),
+ 'surface_variant': self.neutral_variant_palette.get_hex(tones['surface_variant']),
+ 'on_surface_variant': self.neutral_variant_palette.get_hex(tones['on_surface_variant']),
+
+ # Surface containers
+ 'surface_container_lowest': self.neutral_palette.get_hex(tones['surface_container_lowest']),
+ 'surface_container_low': self.neutral_palette.get_hex(tones['surface_container_low']),
+ 'surface_container': self.neutral_palette.get_hex(tones['surface_container']),
+ 'surface_container_high': self.neutral_palette.get_hex(tones['surface_container_high']),
+ 'surface_container_highest': self.neutral_palette.get_hex(tones['surface_container_highest']),
+
+ # Outline and other
+ 'outline': self.neutral_variant_palette.get_hex(tones['outline']),
+ 'outline_variant': self.neutral_variant_palette.get_hex(tones['outline_variant']),
+ 'shadow': self.neutral_palette.get_hex(tones['shadow']),
+ 'scrim': self.neutral_palette.get_hex(tones['scrim']),
+
+ # Inverse colors
+ 'inverse_surface': self.neutral_palette.get_hex(tones['inverse_surface']),
+ 'inverse_on_surface': self.neutral_palette.get_hex(tones['inverse_on_surface']),
+ 'inverse_primary': self.primary_palette.get_hex(tones['inverse_primary']),
+
+ # Background (alias for surface)
+ 'background': self.neutral_palette.get_hex(tones['surface']),
+ 'on_background': self.neutral_palette.get_hex(tones['on_surface']),
+
+ # Surface dim and bright
+ 'surface_dim': self.neutral_palette.get_hex(87 if not is_dark else 6),
+ 'surface_bright': self.neutral_palette.get_hex(98 if not is_dark else 24),
+
+ # Fixed colors - consistent across light/dark modes (MD3 spec)
+ 'primary_fixed': self.primary_palette.get_hex(90),
+ 'primary_fixed_dim': self.primary_palette.get_hex(80),
+ 'on_primary_fixed': self.primary_palette.get_hex(10),
+ 'on_primary_fixed_variant': self.primary_palette.get_hex(30),
+
+ 'secondary_fixed': self.secondary_palette.get_hex(90),
+ 'secondary_fixed_dim': self.secondary_palette.get_hex(80),
+ 'on_secondary_fixed': self.secondary_palette.get_hex(10),
+ 'on_secondary_fixed_variant': self.secondary_palette.get_hex(30),
+
+ 'tertiary_fixed': self.tertiary_palette.get_hex(90),
+ 'tertiary_fixed_dim': self.tertiary_palette.get_hex(80),
+ 'on_tertiary_fixed': self.tertiary_palette.get_hex(10),
+ 'on_tertiary_fixed_variant': self.tertiary_palette.get_hex(30),
+ }
+
+ return scheme
+
+
+# =============================================================================
+# Scheme Implementations
+# =============================================================================
+
+class SchemeTonalSpot(_BaseScheme):
+ """
+ Tonal Spot scheme - the default Android 12-13 Material You scheme.
+
+ Uses fixed chroma values for consistent, harmonious palettes:
+ - Primary: source hue, chroma 48
+ - Secondary: source hue, chroma 16
+ - Tertiary: hue +60ยฐ, chroma 24
+ - Neutrals: low chroma (tinted with source hue)
+ """
+
+ def __init__(self, source_color: Hct):
+ super().__init__(source_color)
+
+ # Primary: source hue with fixed chroma 48
+ self.primary_palette = TonalPalette(source_color.hue, 48.0)
+
+ # Secondary: source hue with lower chroma 16
+ self.secondary_palette = TonalPalette(source_color.hue, 16.0)
+
+ # Tertiary: 60ยฐ hue rotation with chroma 24
+ tertiary_hue = (source_color.hue + 60.0) % 360.0
+ self.tertiary_palette = TonalPalette(tertiary_hue, 24.0)
+
+ # Neutral: source hue with very low chroma (tinted grays)
+ self.neutral_palette = TonalPalette(source_color.hue, 4.0)
+
+ # Neutral variant: slightly more chroma for contrast
+ self.neutral_variant_palette = TonalPalette(source_color.hue, 8.0)
+
+
+class SchemeFruitSalad(_BaseScheme):
+ """
+ Fruit Salad scheme - bold, playful theme with hue rotation.
+
+ Designed for expressive, colorful themes:
+ - Primary: hue -50ยฐ, chroma 48
+ - Secondary: hue -50ยฐ, chroma 36
+ - Tertiary: source hue (original), chroma 36
+ - Neutrals: tinted (chroma 10-16)
+ """
+
+ def __init__(self, source_color: Hct):
+ super().__init__(source_color)
+
+ # Rotate hue by -50ยฐ for primary and secondary
+ rotated_hue = (source_color.hue - 50.0) % 360.0
+
+ # Primary: rotated hue with chroma 48
+ self.primary_palette = TonalPalette(rotated_hue, 48.0)
+
+ # Secondary: rotated hue with chroma 36
+ self.secondary_palette = TonalPalette(rotated_hue, 36.0)
+
+ # Tertiary: original source hue with chroma 36
+ self.tertiary_palette = TonalPalette(source_color.hue, 36.0)
+
+ # Neutral: source hue with higher chroma (tinted)
+ self.neutral_palette = TonalPalette(source_color.hue, 10.0)
+
+ # Neutral variant: even more tinted
+ self.neutral_variant_palette = TonalPalette(source_color.hue, 16.0)
+
+
+class SchemeRainbow(_BaseScheme):
+ """
+ Rainbow scheme - chromatic accents with grayscale neutrals.
+
+ Same structure as Tonal Spot but with pure grayscale neutrals:
+ - Primary: source hue, chroma 48
+ - Secondary: source hue, chroma 16
+ - Tertiary: hue +60ยฐ, chroma 24
+ - Neutrals: pure grayscale (chroma 0)
+ """
+
+ def __init__(self, source_color: Hct):
+ super().__init__(source_color)
+
+ # Primary: source hue with fixed chroma 48
+ self.primary_palette = TonalPalette(source_color.hue, 48.0)
+
+ # Secondary: source hue with lower chroma 16
+ self.secondary_palette = TonalPalette(source_color.hue, 16.0)
+
+ # Tertiary: 60ยฐ hue rotation with chroma 24
+ tertiary_hue = (source_color.hue + 60.0) % 360.0
+ self.tertiary_palette = TonalPalette(tertiary_hue, 24.0)
+
+ # Neutral: pure grayscale (chroma 0)
+ self.neutral_palette = TonalPalette(0.0, 0.0)
+
+ # Neutral variant: also grayscale
+ self.neutral_variant_palette = TonalPalette(0.0, 0.0)
+
+
+class SchemeContent(_BaseScheme):
+ """
+ Content scheme - preserves source color's chroma.
+
+ This is the Material Design 3 "content" scheme that preserves the source
+ color's characteristics while creating harmonious palettes:
+ - Primary: source hue and chroma (full preservation)
+ - Secondary: same hue, reduced chroma: max(chroma - 32, chroma * 0.5)
+ - Tertiary: analogous color from temperature analysis (warm-cool harmony)
+ - Neutrals: low chroma (chroma / 8, tinted with source hue)
+ """
+
+ def __init__(self, source_color: Hct):
+ super().__init__(source_color)
+
+ # Primary: preserve source color's hue and chroma (full preservation)
+ self.primary_palette = TonalPalette(source_color.hue, source_color.chroma)
+
+ # Secondary: same hue, reduced chroma
+ # Formula from matugen: max(chroma - 32, chroma * 0.5)
+ secondary_chroma = max(source_color.chroma - 32.0, source_color.chroma * 0.5)
+ self.secondary_palette = TonalPalette(source_color.hue, secondary_chroma)
+
+ # Tertiary: use analogous color from temperature analysis
+ # Get 3 analogous colors with 6 divisions, pick the last one (most different)
+ temp_cache = TemperatureCache(source_color)
+ analogous_colors = temp_cache.analogous(3, 6)
+ tertiary_hct = fix_if_disliked(analogous_colors[-1])
+ self.tertiary_palette = TonalPalette.from_hct(tertiary_hct)
+
+ # Neutral: source hue, low chroma (chroma / 8)
+ neutral_chroma = source_color.chroma / 8.0
+ self.neutral_palette = TonalPalette(source_color.hue, neutral_chroma)
+
+ # Neutral variant: slightly more chroma (chroma / 8 + 4)
+ neutral_variant_chroma = (source_color.chroma / 8.0) + 4.0
+ self.neutral_variant_palette = TonalPalette(source_color.hue, neutral_variant_chroma)
+
+
+class SchemeMonochrome(_BaseScheme):
+ """
+ Material Design 3 Monochrome scheme.
+
+ All color palettes use chroma=0.0, producing a pure grayscale theme.
+ Only the error color retains saturation for accessibility.
+
+ Uses special tone mappings (different from other M3 schemes) for higher
+ contrast in grayscale - e.g., primary is tone 100 (white) in dark mode.
+
+ Palette configuration:
+ - Primary: chroma 0.0 (grayscale)
+ - Secondary: chroma 0.0 (grayscale)
+ - Tertiary: chroma 0.0 (grayscale)
+ - Neutral: chroma 0.0 (grayscale)
+ - Neutral variant: chroma 0.0 (grayscale)
+ - Error: hue 25ยฐ, chroma 84 (vibrant red)
+ """
+
+ def __init__(self, source_color: Hct):
+ super().__init__(source_color)
+
+ # All palettes use chroma=0 (grayscale)
+ # Source hue is preserved but irrelevant at chroma 0
+ self.primary_palette = TonalPalette(source_color.hue, 0.0)
+ self.secondary_palette = TonalPalette(source_color.hue, 0.0)
+ self.tertiary_palette = TonalPalette(source_color.hue, 0.0)
+ self.neutral_palette = TonalPalette(source_color.hue, 0.0)
+ self.neutral_variant_palette = TonalPalette(source_color.hue, 0.0)
+
+ # Error palette keeps vibrant red for accessibility
+ self.error_palette = TonalPalette(25.0, 84.0)
+
+ def _generate_scheme(self, is_dark: bool) -> dict[str, str]:
+ """Generate scheme with monochrome-specific tone values."""
+ # Monochrome uses different tones for higher contrast in grayscale
+ tones = MONOCHROME_DARK_TONES if is_dark else MONOCHROME_LIGHT_TONES
+
+ scheme = {
+ # Primary colors
+ 'primary': self.primary_palette.get_hex(tones['primary']),
+ 'on_primary': self.primary_palette.get_hex(tones['on_primary']),
+ 'primary_container': self.primary_palette.get_hex(tones['primary_container']),
+ 'on_primary_container': self.primary_palette.get_hex(tones['on_primary_container']),
+
+ # Surface tint (same as primary, used for M3 elevation tinting)
+ 'surface_tint': self.primary_palette.get_hex(tones['primary']),
+
+ # Secondary colors
+ 'secondary': self.secondary_palette.get_hex(tones['secondary']),
+ 'on_secondary': self.secondary_palette.get_hex(tones['on_secondary']),
+ 'secondary_container': self.secondary_palette.get_hex(tones['secondary_container']),
+ 'on_secondary_container': self.secondary_palette.get_hex(tones['on_secondary_container']),
+
+ # Tertiary colors
+ 'tertiary': self.tertiary_palette.get_hex(tones['tertiary']),
+ 'on_tertiary': self.tertiary_palette.get_hex(tones['on_tertiary']),
+ 'tertiary_container': self.tertiary_palette.get_hex(tones['tertiary_container']),
+ 'on_tertiary_container': self.tertiary_palette.get_hex(tones['on_tertiary_container']),
+
+ # Error colors
+ 'error': self.error_palette.get_hex(tones['error']),
+ 'on_error': self.error_palette.get_hex(tones['on_error']),
+ 'error_container': self.error_palette.get_hex(tones['error_container']),
+ 'on_error_container': self.error_palette.get_hex(tones['on_error_container']),
+
+ # Surface colors
+ 'surface': self.neutral_palette.get_hex(tones['surface']),
+ 'on_surface': self.neutral_palette.get_hex(tones['on_surface']),
+ 'surface_variant': self.neutral_variant_palette.get_hex(tones['surface_variant']),
+ 'on_surface_variant': self.neutral_variant_palette.get_hex(tones['on_surface_variant']),
+
+ # Surface containers
+ 'surface_container_lowest': self.neutral_palette.get_hex(tones['surface_container_lowest']),
+ 'surface_container_low': self.neutral_palette.get_hex(tones['surface_container_low']),
+ 'surface_container': self.neutral_palette.get_hex(tones['surface_container']),
+ 'surface_container_high': self.neutral_palette.get_hex(tones['surface_container_high']),
+ 'surface_container_highest': self.neutral_palette.get_hex(tones['surface_container_highest']),
+
+ # Outline and other
+ 'outline': self.neutral_variant_palette.get_hex(tones['outline']),
+ 'outline_variant': self.neutral_variant_palette.get_hex(tones['outline_variant']),
+ 'shadow': self.neutral_palette.get_hex(tones['shadow']),
+ 'scrim': self.neutral_palette.get_hex(tones['scrim']),
+
+ # Inverse colors
+ 'inverse_surface': self.neutral_palette.get_hex(tones['inverse_surface']),
+ 'inverse_on_surface': self.neutral_palette.get_hex(tones['inverse_on_surface']),
+ 'inverse_primary': self.primary_palette.get_hex(tones['inverse_primary']),
+
+ # Background (same as surface in MD3)
+ 'background': self.neutral_palette.get_hex(tones['surface']),
+ 'on_background': self.neutral_palette.get_hex(tones['on_surface']),
+
+ # Surface dim and bright
+ 'surface_dim': self.neutral_palette.get_hex(tones['surface']),
+ 'surface_bright': self.neutral_palette.get_hex(tones['surface_container_highest'] + 5),
+
+ # Fixed colors
+ 'primary_fixed': self.primary_palette.get_hex(90),
+ 'primary_fixed_dim': self.primary_palette.get_hex(80),
+ 'on_primary_fixed': self.primary_palette.get_hex(10),
+ 'on_primary_fixed_variant': self.primary_palette.get_hex(30),
+ 'secondary_fixed': self.secondary_palette.get_hex(90),
+ 'secondary_fixed_dim': self.secondary_palette.get_hex(80),
+ 'on_secondary_fixed': self.secondary_palette.get_hex(10),
+ 'on_secondary_fixed_variant': self.secondary_palette.get_hex(30),
+ 'tertiary_fixed': self.tertiary_palette.get_hex(90),
+ 'tertiary_fixed_dim': self.tertiary_palette.get_hex(80),
+ 'on_tertiary_fixed': self.tertiary_palette.get_hex(10),
+ 'on_tertiary_fixed_variant': self.tertiary_palette.get_hex(30),
+ }
+
+ return scheme
+
+
+# Backward compatibility alias
+MaterialScheme = SchemeContent
+
+
+# =============================================================================
+# Helper Functions
+# =============================================================================
+
+def harmonize_color(design_color: Hct, source_color: Hct, amount: float = 0.5) -> Hct:
+ """
+ Shift a design color's hue towards a source color's hue.
+
+ Used to make custom colors feel more cohesive with the theme.
+
+ Args:
+ design_color: The color to adjust
+ source_color: The reference color to harmonize towards
+ amount: How much to shift (0-1, default 0.5)
+
+ Returns:
+ Harmonized HCT color
+ """
+ diff = _hue_difference(source_color.hue, design_color.hue)
+ rotation = min(diff * amount, 15.0) # Max 15ยฐ rotation
+ if _shorter_rotation(source_color.hue, design_color.hue) < 0:
+ rotation = -rotation
+ new_hue = (design_color.hue + rotation) % 360.0
+ return Hct(new_hue, design_color.chroma, design_color.tone)
+
+
+def _hue_difference(hue1: float, hue2: float) -> float:
+ """Calculate the absolute difference between two hues."""
+ diff = abs(hue1 - hue2)
+ return min(diff, 360.0 - diff)
+
+
+def _shorter_rotation(from_hue: float, to_hue: float) -> float:
+ """Calculate the shorter rotation direction between hues."""
+ diff = to_hue - from_hue
+ if diff > 180.0:
+ return diff - 360.0
+ elif diff < -180.0:
+ return diff + 360.0
+ return diff
diff --git a/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/lib/palette.py b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/lib/palette.py
new file mode 100644
index 0000000..2d8d507
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/lib/palette.py
@@ -0,0 +1,672 @@
+"""
+Palette extraction using K-means clustering.
+
+This module provides functions for extracting dominant colors from images
+using perceptual color distance calculations and k-means clustering.
+"""
+
+import math
+
+from .color import Color, rgb_to_hsl, hsl_to_rgb, hue_distance, rgb_to_lab, lab_to_rgb, lab_distance
+from .hct import Cam16, Hct
+
+# Type aliases
+RGB = tuple[int, int, int]
+HSL = tuple[float, float, float]
+LAB = tuple[float, float, float]
+
+
+def downsample_pixels(pixels: list[RGB], factor: int = 4) -> list[RGB]:
+ """
+ Downsample pixels for faster processing.
+
+ Takes every Nth pixel to reduce dataset size while maintaining
+ color distribution characteristics.
+ """
+ if factor <= 1:
+ return pixels
+
+ # Calculate step based on factor squared (for 2D image)
+ step = factor * factor
+ return pixels[::step]
+
+
+def kmeans_cluster(
+ colors: list[RGB],
+ k: int = 5,
+ iterations: int = 10
+) -> list[tuple[RGB, RGB, int]]:
+ """
+ Perform K-means clustering on colors in Lab color space.
+
+ Lab space is perceptually uniform, matching matugen's approach.
+ Returns list of (centroid_rgb, representative_rgb, cluster_size) tuples,
+ sorted by cluster size.
+
+ - centroid_rgb: averaged color from the cluster (smoother, blended)
+ - representative_rgb: actual image pixel closest to centroid
+ """
+ if len(colors) < k:
+ # Not enough colors, return what we have (same color for centroid and representative)
+ unique = list(set(colors))
+ return [(c, c, colors.count(c)) for c in unique[:k]]
+
+ # Convert to Lab for perceptual clustering (like matugen's WSMeans)
+ colors_lab = [rgb_to_lab(*c) for c in colors]
+
+ # Deterministic initialization: pick evenly spaced colors from sorted list
+ # Sort by L (lightness) first for better spread
+ sorted_indices = sorted(range(len(colors_lab)), key=lambda i: colors_lab[i][0])
+ step = len(sorted_indices) // k
+ centroids = [colors_lab[sorted_indices[i * step]] for i in range(k)]
+
+ # K-means iterations
+ assignments = [0] * len(colors_lab)
+ for _ in range(iterations):
+ # Assign colors to nearest centroid
+ for idx, color in enumerate(colors_lab):
+ min_dist = float('inf')
+ min_cluster = 0
+ for i, centroid in enumerate(centroids):
+ dist = lab_distance(color, centroid)
+ if dist < min_dist:
+ min_dist = dist
+ min_cluster = i
+ assignments[idx] = min_cluster
+
+ # Update centroids (simple mean in Lab space)
+ new_centroids = []
+ for i in range(k):
+ cluster_colors = [colors_lab[j] for j in range(len(colors_lab)) if assignments[j] == i]
+ if cluster_colors:
+ avg_L = sum(c[0] for c in cluster_colors) / len(cluster_colors)
+ avg_a = sum(c[1] for c in cluster_colors) / len(cluster_colors)
+ avg_b = sum(c[2] for c in cluster_colors) / len(cluster_colors)
+ new_centroids.append((avg_L, avg_a, avg_b))
+ else:
+ new_centroids.append(centroids[i])
+
+ centroids = new_centroids
+
+ # Final assignment and count, also find representative pixel (closest to centroid)
+ cluster_counts = [0] * k
+ cluster_representatives: list[tuple[RGB, float]] = [(colors[0], float('inf'))] * k
+
+ for idx, color_lab in enumerate(colors_lab):
+ cluster_idx = assignments[idx]
+ cluster_counts[cluster_idx] += 1
+
+ # Track the pixel closest to the centroid as the representative
+ dist = lab_distance(color_lab, centroids[cluster_idx])
+ if dist < cluster_representatives[cluster_idx][1]:
+ cluster_representatives[cluster_idx] = (colors[idx], dist)
+
+ # Return both centroid (averaged) and representative (actual pixel) colors
+ results = []
+ for i in range(k):
+ if cluster_counts[i] > 0:
+ # Convert Lab centroid back to RGB
+ centroid_rgb = lab_to_rgb(*centroids[i])
+ representative_rgb = cluster_representatives[i][0]
+ results.append((centroid_rgb, representative_rgb, cluster_counts[i]))
+
+ # Sort by cluster size (most common first)
+ results.sort(key=lambda x: -x[2])
+
+ return results
+
+
+def _score_colors_chroma(
+ colors_with_counts: list[tuple[RGB, int]],
+) -> list[tuple[Color, float]]:
+ """
+ Score colors prioritizing chroma (vibrancy) over area coverage.
+
+ Uses count^0.3 weighting so saturated colors win even with small area.
+ Used for "vibrant" mode to find the most eye-catching colors.
+
+ Args:
+ colors_with_counts: List of (RGB, count) tuples from clustering
+
+ Returns:
+ List of (Color, score) tuples, sorted by score descending
+ """
+ result_colors = []
+ for rgb, count in colors_with_counts:
+ color = Color.from_rgb(rgb)
+ try:
+ hct = color.to_hct()
+
+ # Chroma contribution - prefer colorful colors
+ chroma_score = hct.chroma
+
+ # Tone penalty - prefer mid-tones (40-60 is ideal)
+ if hct.tone < 20:
+ tone_penalty = (20 - hct.tone) * 2
+ elif hct.tone > 80:
+ tone_penalty = (hct.tone - 80) * 1.5
+ elif hct.tone < 40:
+ tone_penalty = (40 - hct.tone) * 0.5
+ elif hct.tone > 60:
+ tone_penalty = (hct.tone - 60) * 0.3
+ else:
+ tone_penalty = 0
+
+ # Hue penalty - slight penalty for yellow-green hues
+ if 80 < hct.hue < 110:
+ hue_penalty = 5
+ else:
+ hue_penalty = 0
+
+ # Combined score: chroma minus penalties, balanced with count
+ # Using count^0.3 so chroma dominates while still considering area
+ score = (chroma_score - tone_penalty - hue_penalty) * (count ** 0.3)
+ result_colors.append((color, score))
+ except (ValueError, ZeroDivisionError):
+ result_colors.append((color, 0.0))
+
+ result_colors.sort(key=lambda x: -x[1])
+ return result_colors
+
+
+def _hue_to_family(hue: float) -> int:
+ """
+ Map hue to perceptual color family.
+
+ Uses non-uniform ranges that match human color perception:
+ - 0: RED (330-30ยฐ, wraps around)
+ - 1: ORANGE (30-60ยฐ)
+ - 2: YELLOW (60-105ยฐ)
+ - 3: GREEN (105-190ยฐ, includes green-leaning teal)
+ - 4: BLUE (190-270ยฐ, includes cyan)
+ - 5: PURPLE (270-330ยฐ)
+ """
+ if hue >= 330 or hue < 30:
+ return 0 # RED
+ elif hue < 60:
+ return 1 # ORANGE
+ elif hue < 105:
+ return 2 # YELLOW
+ elif hue < 190:
+ return 3 # GREEN (includes green-leaning teal)
+ elif hue < 270:
+ return 4 # BLUE (includes cyan)
+ else:
+ return 5 # PURPLE
+
+
+def _score_colors_count(
+ colors_with_counts: list[tuple[RGB, int]],
+) -> list[tuple[Color, float]]:
+ """
+ Score colors prioritizing pixel count (area coverage) by hue family.
+
+ Groups colors into perceptual hue families, sums counts per family,
+ then picks the dominant family. This is more faithful to human perception
+ where we see "green" as a category, not individual shades.
+
+ Args:
+ colors_with_counts: List of (RGB, count) tuples from clustering
+
+ Returns:
+ List of (Color, score) tuples, sorted by family dominance then count
+ """
+ MIN_CHROMA = 10.0 # Filter out near-gray colors
+
+ # First pass: collect colorful colors and group by hue family
+ hue_families: dict[int, list[tuple[Color, float, float, int]]] = {} # family -> [(color, hue, chroma, count), ...]
+
+ for rgb, count in colors_with_counts:
+ color = Color.from_rgb(rgb)
+ try:
+ hct = color.to_hct()
+ if hct.chroma >= MIN_CHROMA:
+ family = _hue_to_family(hct.hue)
+ if family not in hue_families:
+ hue_families[family] = []
+ hue_families[family].append((color, hct.hue, hct.chroma, count))
+ except (ValueError, ZeroDivisionError):
+ pass
+
+ # If no colorful colors found, fall back to all colors
+ if not hue_families:
+ result = []
+ for rgb, count in colors_with_counts:
+ color = Color.from_rgb(rgb)
+ result.append((color, float(count)))
+ result.sort(key=lambda x: -x[1])
+ return result
+
+ # Calculate total count per hue family
+ family_totals: list[tuple[int, int]] = []
+ for family, colors in hue_families.items():
+ total = sum(c[3] for c in colors)
+ family_totals.append((family, total))
+
+ # Sort families by total count (dominant family first)
+ family_totals.sort(key=lambda x: -x[1])
+
+ # Build result: colors from dominant families first, sorted by count within each family
+ result_colors = []
+ for family, _ in family_totals:
+ family_colors = hue_families[family]
+ # Sort by count descending, chroma as tiebreaker
+ family_colors.sort(key=lambda x: (-x[3], -x[2]))
+ for color, hue, chroma, count in family_colors:
+ # Score encodes family rank + count for proper ordering
+ family_rank = next(i for i, (f, _) in enumerate(family_totals) if f == family)
+ score = (len(family_totals) - family_rank) * 1000000 + count * 1000 + chroma
+ result_colors.append((color, score))
+
+ result_colors.sort(key=lambda x: -x[1])
+ return result_colors
+
+
+def _family_center_hue(family: int) -> float:
+ """Get the center hue for a family index."""
+ # Family centers based on _hue_to_family ranges:
+ # 0: RED (330-30ยฐ, wraps) -> center 0ยฐ
+ # 1: ORANGE (30-60ยฐ) -> center 45ยฐ
+ # 2: YELLOW (60-105ยฐ) -> center 82.5ยฐ
+ # 3: GREEN (105-190ยฐ) -> center 147.5ยฐ
+ # 4: BLUE (190-270ยฐ) -> center 230ยฐ
+ # 5: PURPLE (270-330ยฐ) -> center 300ยฐ
+ centers = [0.0, 45.0, 82.5, 147.5, 230.0, 300.0]
+ return centers[family]
+
+
+def _circular_hue_diff(h1: float, h2: float) -> float:
+ """Calculate circular hue difference (0-180)."""
+ diff = abs(h1 - h2)
+ return min(diff, 360.0 - diff)
+
+
+def _score_colors_dysfunctional(
+ colors_with_counts: list[tuple[RGB, int]],
+) -> list[tuple[Color, float]]:
+ """
+ Score colors prioritizing the 2nd most dominant hue family.
+
+ Like count scoring but skips the dominant family (and any families
+ too close to it) to pick a visually distinct secondary color.
+
+ Args:
+ colors_with_counts: List of (RGB, count) tuples from clustering
+
+ Returns:
+ List of (Color, score) tuples, sorted by family dominance then count
+ """
+ MIN_CHROMA = 10.0 # Filter out near-gray colors
+ MIN_HUE_DISTANCE = 45.0 # Minimum hue distance from dominant family
+ MIN_COUNT_RATIO = 0.02 # Distant family must have at least 2% of total colorful pixels
+
+ # First pass: collect colorful colors and group by hue family
+ hue_families: dict[int, list[tuple[Color, float, float, int]]] = {} # family -> [(color, hue, chroma, count), ...]
+
+ for rgb, count in colors_with_counts:
+ color = Color.from_rgb(rgb)
+ try:
+ hct = color.to_hct()
+ if hct.chroma >= MIN_CHROMA:
+ family = _hue_to_family(hct.hue)
+ if family not in hue_families:
+ hue_families[family] = []
+ hue_families[family].append((color, hct.hue, hct.chroma, count))
+ except (ValueError, ZeroDivisionError):
+ pass
+
+ # If no colorful colors found, fall back to all colors
+ if not hue_families:
+ result = []
+ for rgb, count in colors_with_counts:
+ color = Color.from_rgb(rgb)
+ result.append((color, float(count)))
+ result.sort(key=lambda x: -x[1])
+ return result
+
+ # Calculate total count per hue family
+ family_totals: list[tuple[int, int]] = []
+ for family, colors in hue_families.items():
+ total = sum(c[3] for c in colors)
+ family_totals.append((family, total))
+
+ # Sort families by total count (dominant family first)
+ family_totals.sort(key=lambda x: -x[1])
+
+ # Find the dominant family and its center hue
+ dominant_family, dominant_count = family_totals[0]
+ dominant_center = _family_center_hue(dominant_family)
+ total_colorful_pixels = sum(count for _, count in family_totals)
+ min_count = total_colorful_pixels * MIN_COUNT_RATIO
+
+ # Find families that are far enough from the dominant one AND have enough pixels
+ distant_families = []
+ close_families = [dominant_family]
+ for family, count in family_totals[1:]:
+ family_center = _family_center_hue(family)
+ hue_diff = _circular_hue_diff(dominant_center, family_center)
+ if hue_diff >= MIN_HUE_DISTANCE and count >= min_count:
+ # Get max chroma in this family - we want families with vibrant colors
+ max_chroma = max(c[2] for c in hue_families[family])
+ distant_families.append((family, count, hue_diff, max_chroma))
+ else:
+ close_families.append(family)
+
+ # Build result: colors from distant families first
+ result_colors = []
+
+ # Sort distant families by weighted score: hue_distance * max_chroma
+ # This balances visual distinctness (hue distance) with color quality (chroma)
+ # A family that's far away AND has good colors beats one that's close with great colors
+ distant_families.sort(key=lambda x: -(x[2] * x[3]))
+
+ for family, _, _, _ in distant_families:
+ family_colors = hue_families[family]
+ # Sort by chroma descending - we want the most vibrant color from this family
+ # Count is tiebreaker to avoid picking tiny noise clusters
+ family_colors.sort(key=lambda x: (-x[2], -x[3]))
+ for color, hue, chroma, count in family_colors:
+ # Score encodes family rank + chroma for proper ordering
+ # Chroma is primary (we want vibrant), count is tiebreaker
+ family_rank = next(i for i, (f, _, _, _) in enumerate(distant_families) if f == family)
+ score = (len(distant_families) - family_rank) * 1000000 + chroma * 1000 + count
+ result_colors.append((color, score))
+
+ # Add colors from close families (including dominant) at lower priority
+ for family in close_families:
+ family_colors = hue_families[family]
+ family_colors.sort(key=lambda x: (-x[3], -x[2]))
+ for color, hue, chroma, count in family_colors:
+ # Lower score than all distant-family colors
+ score = count * 1000 + chroma
+ result_colors.append((color, score))
+
+ result_colors.sort(key=lambda x: -x[1])
+ return result_colors
+
+
+def _score_colors_muted(
+ colors_with_counts: list[tuple[RGB, int]],
+) -> list[tuple[Color, float]]:
+ """
+ Score colors for muted mode - pure pixel count without chroma filtering.
+
+ Unlike count scoring which filters to chroma >= 10, this accepts all colors
+ including grayscale. Designed for monochrome/monotonal wallpapers where
+ the dominant color may have very low or zero saturation.
+
+ Args:
+ colors_with_counts: List of (RGB, count) tuples from clustering
+
+ Returns:
+ List of (Color, score) tuples, sorted by count descending
+ """
+ result = []
+ for rgb, count in colors_with_counts:
+ color = Color.from_rgb(rgb)
+ result.append((color, float(count)))
+
+ result.sort(key=lambda x: -x[1])
+ return result
+
+
+def _score_colors_population(
+ colors_with_counts: list[tuple[RGB, int]],
+ total_pixels: int
+) -> list[tuple[Color, float]]:
+ """
+ Score colors using Material Design's Score algorithm.
+
+ This matches matugen's scoring approach exactly:
+ - Build per-hue population histogram (360 buckets)
+ - Calculate "excited proportions" (ยฑ15ยฐ hue window sum)
+ - Score: proportion * 100 * 0.7 + (chroma - 48) * weight
+ - Filter by chroma >= 5 and proportion >= 1%
+ - Deduplicate by maximizing hue distance
+
+ Args:
+ colors_with_counts: List of (RGB, count) tuples from clustering
+ total_pixels: Total number of pixels in the sample
+
+ Returns:
+ List of (Color, score) tuples, sorted by score descending
+ """
+ # Constants matching Material Score
+ TARGET_CHROMA = 48.0
+ WEIGHT_PROPORTION = 0.7
+ WEIGHT_CHROMA_ABOVE = 0.3
+ WEIGHT_CHROMA_BELOW = 0.1
+ CUTOFF_CHROMA = 5.0
+ CUTOFF_EXCITED_PROPORTION = 0.01
+
+ # Build per-hue population histogram (360 buckets)
+ hue_population = [0] * 360
+ population_sum = 0
+
+ colors_hct: list[tuple[Color, Hct, int]] = []
+ for rgb, count in colors_with_counts:
+ try:
+ color = Color.from_rgb(rgb)
+ hct = color.to_hct()
+ hue_bucket = int(hct.hue) % 360
+ hue_population[hue_bucket] += count
+ population_sum += count
+ colors_hct.append((color, hct, count))
+ except (ValueError, ZeroDivisionError):
+ continue
+
+ if not colors_hct or population_sum == 0:
+ # Fallback: return colors without scoring
+ result = []
+ for rgb, count in colors_with_counts:
+ color = Color.from_rgb(rgb)
+ result.append((color, float(count)))
+ return sorted(result, key=lambda x: -x[1])
+
+ # Calculate "excited proportions" - sum of proportions in ยฑ15ยฐ hue window
+ hue_excited_proportions = [0.0] * 360
+ for hue in range(360):
+ proportion = hue_population[hue] / population_sum
+ # Spread to neighboring hues (ยฑ15ยฐ, so 30ยฐ total window)
+ for offset in range(-14, 16):
+ neighbor_hue = (hue + offset) % 360
+ hue_excited_proportions[neighbor_hue] += proportion
+
+ # Score each color
+ scored_hcts: list[tuple[Color, Hct, float]] = []
+ for color, hct, count in colors_hct:
+ hue_bucket = int(hct.hue) % 360
+ proportion = hue_excited_proportions[hue_bucket]
+
+ # Filter by chroma and proportion
+ if hct.chroma < CUTOFF_CHROMA:
+ continue
+ if proportion <= CUTOFF_EXCITED_PROPORTION:
+ continue
+
+ # Proportion score (70% weight)
+ proportion_score = proportion * 100.0 * WEIGHT_PROPORTION
+
+ # Chroma score: (chroma - target) * weight
+ # This gives bonus for high chroma, penalty for low chroma
+ if hct.chroma < TARGET_CHROMA:
+ chroma_weight = WEIGHT_CHROMA_BELOW
+ else:
+ chroma_weight = WEIGHT_CHROMA_ABOVE
+ chroma_score = (hct.chroma - TARGET_CHROMA) * chroma_weight
+
+ score = proportion_score + chroma_score
+ scored_hcts.append((color, hct, score))
+
+ if not scored_hcts:
+ # Fallback if filtering removed everything
+ result = []
+ for rgb, count in colors_with_counts:
+ color = Color.from_rgb(rgb)
+ result.append((color, float(count)))
+ return sorted(result, key=lambda x: -x[1])
+
+ # Sort by score descending
+ scored_hcts.sort(key=lambda x: -x[2])
+
+ # Deduplicate by hue distance - pick colors maximizing hue diversity
+ # Start at 90ยฐ minimum distance, decrease to 15ยฐ if needed
+ chosen_colors: list[tuple[Color, float]] = []
+
+ for min_hue_diff in range(90, 14, -1):
+ chosen_colors.clear()
+ for color, hct, score in scored_hcts:
+ # Check if this hue is far enough from all chosen colors
+ is_far_enough = True
+ for chosen_color, _ in chosen_colors:
+ chosen_hct = chosen_color.to_hct()
+ if hue_distance(hct.hue, chosen_hct.hue) < min_hue_diff:
+ is_far_enough = False
+ break
+
+ if is_far_enough:
+ chosen_colors.append((color, score))
+
+ # Stop if we have enough colors (4 is Material default)
+ if len(chosen_colors) >= 4:
+ break
+
+ # If we found enough colors, stop decreasing threshold
+ if len(chosen_colors) >= 4:
+ break
+
+ # If deduplication yielded nothing, fall back to top scored
+ if not chosen_colors:
+ chosen_colors = [(c, s) for c, h, s in scored_hcts[:4]]
+
+ return chosen_colors
+
+
+def extract_palette(
+ pixels: list[RGB],
+ k: int = 5,
+ scoring: str = "population"
+) -> list[Color]:
+ """
+ Extract K dominant colors from pixel data.
+
+ Args:
+ pixels: List of RGB tuples
+ k: Number of colors to extract
+ scoring: Scoring method:
+ - "population": matugen-like, representative colors (M3 schemes)
+ - "chroma": vibrant, chroma-prioritized with centroid averaging
+ - "count": area-dominant, picks by pixel count (faithful mode)
+ - "dysfunctional": picks 2nd most dominant color family
+ - "muted": like count but without chroma filtering (monochrome wallpapers)
+
+ Returns:
+ List of Color objects, sorted by score
+ """
+ # Downsample for performance
+ sampled = downsample_pixels(pixels, factor=4)
+ total_sampled = len(sampled)
+
+ # For population scoring, we need many clusters then score/filter them
+ # For chroma scoring, fewer clusters work fine
+ if scoring == "population":
+ # Use more clusters for Material scoring (like matugen's 128-256)
+ cluster_count = min(128, max(k * 10, len(set(sampled)) // 10))
+ # Don't pre-filter for population scoring - let the Score algorithm filter
+ # This matches matugen which quantizes all pixels, then filters in scoring
+ filtered = sampled
+ elif scoring == "count":
+ # Faithful mode: many clusters to capture color diversity, no pre-filtering
+ # Scoring will filter to colorful colors and pick by count
+ cluster_count = 48
+ filtered = sampled
+ elif scoring == "dysfunctional":
+ # Dysfunctional mode: same as count but picks 2nd dominant family
+ cluster_count = 48
+ filtered = sampled
+ elif scoring == "muted":
+ # Muted mode: similar to count but accepts low-chroma colors
+ # For monochrome/monotonal wallpapers
+ cluster_count = 24
+ filtered = sampled
+ else:
+ # Vibrant mode: more clusters to capture high-chroma colors that might
+ # otherwise get averaged away, with colorfulness pre-filter
+ cluster_count = 20
+ # Filter to colorful pixels for smoother averaged results
+ filtered = []
+ for p in sampled:
+ try:
+ cam = Cam16.from_rgb(p[0], p[1], p[2])
+ if cam.chroma >= 5.0:
+ filtered.append(p)
+ except (ValueError, ZeroDivisionError):
+ continue
+
+ if len(filtered) < cluster_count * 2:
+ filtered = sampled
+
+ # Cluster - returns (centroid_rgb, representative_rgb, count) tuples
+ clusters = kmeans_cluster(filtered, k=cluster_count)
+
+ # Score colors based on method
+ # - chroma: centroid colors (averaged, smoother - vibrant mode)
+ # - count: representative pixels by area dominance (faithful mode)
+ # - muted: like count but accepts low/zero chroma (monochrome wallpapers)
+ # - population: representative colors with Material scoring (M3 schemes)
+ if scoring == "chroma":
+ # Use centroid colors for vibrant mode (smoother, blended)
+ colors_for_scoring = [(c[0], c[2]) for c in clusters]
+ scored = _score_colors_chroma(colors_for_scoring)
+ elif scoring == "count":
+ # Use representative colors with count scoring (faithful mode)
+ colors_for_scoring = [(c[1], c[2]) for c in clusters]
+ scored = _score_colors_count(colors_for_scoring)
+ elif scoring == "dysfunctional":
+ # Use representative colors with dysfunctional scoring (2nd dominant family)
+ colors_for_scoring = [(c[1], c[2]) for c in clusters]
+ scored = _score_colors_dysfunctional(colors_for_scoring)
+ elif scoring == "muted":
+ # Use representative colors with muted scoring (no chroma filter)
+ colors_for_scoring = [(c[1], c[2]) for c in clusters]
+ scored = _score_colors_muted(colors_for_scoring)
+ else:
+ # Use representative colors for M3 schemes
+ colors_for_scoring = [(c[1], c[2]) for c in clusters]
+ scored = _score_colors_population(colors_for_scoring, total_sampled)
+
+ # Extract colors
+ final_colors = [c[0] for c in scored]
+
+ # Ensure we have enough colors by deriving from primary using HCT
+ while len(final_colors) < k:
+ if not final_colors:
+ final_colors.append(Color.from_hex("#6750A4"))
+ continue
+
+ primary = final_colors[0]
+ primary_hct = primary.to_hct()
+ offset = len(final_colors) * 60.0
+ new_hct = Hct((primary_hct.hue + offset) % 360.0, primary_hct.chroma, primary_hct.tone)
+ final_colors.append(Color.from_hct(new_hct))
+
+ return final_colors[:k]
+
+
+def find_error_color(palette: list[Color]) -> Color:
+ """
+ Find or generate an error color (red-biased).
+
+ Looks for existing red in palette, otherwise returns a default.
+ """
+ # Look for a red-ish color in the palette
+ for color in palette:
+ h, s, l = color.to_hsl()
+ # Red hues: 0-30 or 330-360
+ if (h <= 30 or h >= 330) and s > 0.4 and 0.3 < l < 0.7:
+ return color
+
+ # Default error red
+ return Color.from_hex("#FD4663")
+
+
diff --git a/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/lib/quantizer.py b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/lib/quantizer.py
new file mode 100644
index 0000000..2432ff2
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/lib/quantizer.py
@@ -0,0 +1,815 @@
+"""
+Wu and WSMeans quantizer implementations matching material-color-utilities.
+
+Wu implements Xiaolin Wu's color quantization algorithm from Graphics Gems II (1991).
+WSMeans refines Wu's output via weighted k-means in Lab space (QuantizerCelebi pipeline).
+Together they match the QuantizerCelebi pipeline used by matugen/material-color-utilities.
+"""
+
+from typing import Dict, List, Tuple
+
+from .color import rgb_to_lab, lab_to_rgb
+
+# Constants matching material-color-utilities
+INDEX_BITS = 5
+SIDE_LENGTH = 33 # (1 << INDEX_BITS) + 1
+TOTAL_SIZE = 35937 # SIDE_LENGTH^3
+
+# Direction constants
+DIR_RED = 0
+DIR_GREEN = 1
+DIR_BLUE = 2
+
+
+class Box:
+ """Represents a box in RGB color space."""
+ __slots__ = ('r0', 'r1', 'g0', 'g1', 'b0', 'b1', 'vol')
+
+ def __init__(self):
+ self.r0 = 0
+ self.r1 = 0
+ self.g0 = 0
+ self.g1 = 0
+ self.b0 = 0
+ self.b1 = 0
+ self.vol = 0
+
+
+def _get_index(r: int, g: int, b: int) -> int:
+ """Calculate 3D array index from RGB coordinates."""
+ return (r << (INDEX_BITS * 2)) + (r << (INDEX_BITS + 1)) + r + (g << INDEX_BITS) + g + b
+
+
+def _argb_from_rgb(r: int, g: int, b: int) -> int:
+ """Convert RGB to ARGB integer format."""
+ return (255 << 24) | ((r & 0xFF) << 16) | ((g & 0xFF) << 8) | (b & 0xFF)
+
+
+def _rgb_from_argb(argb: int) -> Tuple[int, int, int]:
+ """Extract RGB from ARGB integer."""
+ return ((argb >> 16) & 0xFF, (argb >> 8) & 0xFF, argb & 0xFF)
+
+
+class QuantizerWu:
+ """
+ Wu color quantizer implementation.
+
+ Divides image pixels into clusters by recursively cutting an RGB cube,
+ based on the weight of pixels in each area of the cube.
+ """
+
+ def __init__(self):
+ self.weights: List[int] = []
+ self.moments_r: List[int] = []
+ self.moments_g: List[int] = []
+ self.moments_b: List[int] = []
+ self.moments: List[float] = []
+ self.cubes: List[Box] = []
+
+ def quantize(self, pixels: List[int], max_colors: int) -> List[int]:
+ """
+ Quantize pixels to a reduced color palette.
+
+ Args:
+ pixels: List of colors in ARGB integer format
+ max_colors: Maximum number of colors to return
+
+ Returns:
+ List of colors in ARGB format
+ """
+ self._construct_histogram(pixels)
+ self._compute_moments()
+ result_count = self._create_boxes(max_colors)
+ return self._create_result(result_count)
+
+ def _construct_histogram(self, pixels: List[int]):
+ """Build histogram of pixel colors."""
+ self.weights = [0] * TOTAL_SIZE
+ self.moments_r = [0] * TOTAL_SIZE
+ self.moments_g = [0] * TOTAL_SIZE
+ self.moments_b = [0] * TOTAL_SIZE
+ self.moments = [0.0] * TOTAL_SIZE
+
+ # Count pixels by color
+ count_by_color: Dict[int, int] = {}
+ for pixel in pixels:
+ # Only count fully opaque pixels
+ if (pixel >> 24) & 0xFF == 255:
+ count_by_color[pixel] = count_by_color.get(pixel, 0) + 1
+
+ bits_to_remove = 8 - INDEX_BITS
+ for pixel, count in count_by_color.items():
+ red = (pixel >> 16) & 0xFF
+ green = (pixel >> 8) & 0xFF
+ blue = pixel & 0xFF
+
+ i_r = (red >> bits_to_remove) + 1
+ i_g = (green >> bits_to_remove) + 1
+ i_b = (blue >> bits_to_remove) + 1
+ index = _get_index(i_r, i_g, i_b)
+
+ self.weights[index] += count
+ self.moments_r[index] += count * red
+ self.moments_g[index] += count * green
+ self.moments_b[index] += count * blue
+ self.moments[index] += count * (red * red + green * green + blue * blue)
+
+ def _compute_moments(self):
+ """Compute cumulative moments for efficient volume calculations."""
+ for r in range(1, SIDE_LENGTH):
+ area = [0] * SIDE_LENGTH
+ area_r = [0] * SIDE_LENGTH
+ area_g = [0] * SIDE_LENGTH
+ area_b = [0] * SIDE_LENGTH
+ area2 = [0.0] * SIDE_LENGTH
+
+ for g in range(1, SIDE_LENGTH):
+ line = 0
+ line_r = 0
+ line_g = 0
+ line_b = 0
+ line2 = 0.0
+
+ for b in range(1, SIDE_LENGTH):
+ index = _get_index(r, g, b)
+ line += self.weights[index]
+ line_r += self.moments_r[index]
+ line_g += self.moments_g[index]
+ line_b += self.moments_b[index]
+ line2 += self.moments[index]
+
+ area[b] += line
+ area_r[b] += line_r
+ area_g[b] += line_g
+ area_b[b] += line_b
+ area2[b] += line2
+
+ prev_index = _get_index(r - 1, g, b)
+ self.weights[index] = self.weights[prev_index] + area[b]
+ self.moments_r[index] = self.moments_r[prev_index] + area_r[b]
+ self.moments_g[index] = self.moments_g[prev_index] + area_g[b]
+ self.moments_b[index] = self.moments_b[prev_index] + area_b[b]
+ self.moments[index] = self.moments[prev_index] + area2[b]
+
+ def _create_boxes(self, max_colors: int) -> int:
+ """Create color boxes by recursive cutting."""
+ self.cubes = [Box() for _ in range(max_colors)]
+ volume_variance = [0.0] * max_colors
+
+ # Initialize first box to cover entire color space
+ self.cubes[0].r1 = SIDE_LENGTH - 1
+ self.cubes[0].g1 = SIDE_LENGTH - 1
+ self.cubes[0].b1 = SIDE_LENGTH - 1
+
+ generated_color_count = max_colors
+ next_box = 0
+ i = 1
+
+ while i < max_colors:
+ if self._cut(self.cubes[next_box], self.cubes[i]):
+ volume_variance[next_box] = (
+ self._variance(self.cubes[next_box])
+ if self.cubes[next_box].vol > 1 else 0.0
+ )
+ volume_variance[i] = (
+ self._variance(self.cubes[i])
+ if self.cubes[i].vol > 1 else 0.0
+ )
+ else:
+ volume_variance[next_box] = 0.0
+ i -= 1
+
+ # Find box with maximum variance
+ next_box = 0
+ temp = volume_variance[0]
+ for j in range(1, i + 1):
+ if volume_variance[j] > temp:
+ temp = volume_variance[j]
+ next_box = j
+
+ if temp <= 0.0:
+ generated_color_count = i + 1
+ break
+
+ i += 1
+
+ return generated_color_count
+
+ def _create_result(self, color_count: int) -> List[int]:
+ """Extract final colors from boxes."""
+ colors = []
+ for i in range(color_count):
+ cube = self.cubes[i]
+ weight = self._volume(cube, self.weights)
+ if weight > 0:
+ r = int(self._volume(cube, self.moments_r) / weight)
+ g = int(self._volume(cube, self.moments_g) / weight)
+ b = int(self._volume(cube, self.moments_b) / weight)
+ color = _argb_from_rgb(r, g, b)
+ colors.append(color)
+ return colors
+
+ def _variance(self, cube: Box) -> float:
+ """Calculate variance within a box."""
+ dr = self._volume(cube, self.moments_r)
+ dg = self._volume(cube, self.moments_g)
+ db = self._volume(cube, self.moments_b)
+
+ xx = (
+ self.moments[_get_index(cube.r1, cube.g1, cube.b1)]
+ - self.moments[_get_index(cube.r1, cube.g1, cube.b0)]
+ - self.moments[_get_index(cube.r1, cube.g0, cube.b1)]
+ + self.moments[_get_index(cube.r1, cube.g0, cube.b0)]
+ - self.moments[_get_index(cube.r0, cube.g1, cube.b1)]
+ + self.moments[_get_index(cube.r0, cube.g1, cube.b0)]
+ + self.moments[_get_index(cube.r0, cube.g0, cube.b1)]
+ - self.moments[_get_index(cube.r0, cube.g0, cube.b0)]
+ )
+
+ hypotenuse = dr * dr + dg * dg + db * db
+ volume = self._volume(cube, self.weights)
+ if volume == 0:
+ return 0.0
+ return xx - hypotenuse / volume
+
+ def _cut(self, one: Box, two: Box) -> bool:
+ """Cut a box into two boxes along the optimal axis."""
+ whole_r = self._volume(one, self.moments_r)
+ whole_g = self._volume(one, self.moments_g)
+ whole_b = self._volume(one, self.moments_b)
+ whole_w = self._volume(one, self.weights)
+
+ max_r_cut, max_r = self._maximize(
+ one, DIR_RED, one.r0 + 1, one.r1, whole_r, whole_g, whole_b, whole_w
+ )
+ max_g_cut, max_g = self._maximize(
+ one, DIR_GREEN, one.g0 + 1, one.g1, whole_r, whole_g, whole_b, whole_w
+ )
+ max_b_cut, max_b = self._maximize(
+ one, DIR_BLUE, one.b0 + 1, one.b1, whole_r, whole_g, whole_b, whole_w
+ )
+
+ if max_r >= max_g and max_r >= max_b:
+ if max_r_cut < 0:
+ return False
+ direction = DIR_RED
+ cut_location = max_r_cut
+ elif max_g >= max_r and max_g >= max_b:
+ direction = DIR_GREEN
+ cut_location = max_g_cut
+ else:
+ direction = DIR_BLUE
+ cut_location = max_b_cut
+
+ two.r1 = one.r1
+ two.g1 = one.g1
+ two.b1 = one.b1
+
+ if direction == DIR_RED:
+ one.r1 = cut_location
+ two.r0 = one.r1
+ two.g0 = one.g0
+ two.b0 = one.b0
+ elif direction == DIR_GREEN:
+ one.g1 = cut_location
+ two.r0 = one.r0
+ two.g0 = one.g1
+ two.b0 = one.b0
+ else: # DIR_BLUE
+ one.b1 = cut_location
+ two.r0 = one.r0
+ two.g0 = one.g0
+ two.b0 = one.b1
+
+ one.vol = (one.r1 - one.r0) * (one.g1 - one.g0) * (one.b1 - one.b0)
+ two.vol = (two.r1 - two.r0) * (two.g1 - two.g0) * (two.b1 - two.b0)
+ return True
+
+ def _maximize(
+ self,
+ cube: Box,
+ direction: int,
+ first: int,
+ last: int,
+ whole_r: int,
+ whole_g: int,
+ whole_b: int,
+ whole_w: int,
+ ) -> Tuple[int, float]:
+ """Find the optimal cut position along an axis."""
+ bottom_r = self._bottom(cube, direction, self.moments_r)
+ bottom_g = self._bottom(cube, direction, self.moments_g)
+ bottom_b = self._bottom(cube, direction, self.moments_b)
+ bottom_w = self._bottom(cube, direction, self.weights)
+
+ max_val = 0.0
+ cut = -1
+
+ for i in range(first, last):
+ half_r = bottom_r + self._top(cube, direction, i, self.moments_r)
+ half_g = bottom_g + self._top(cube, direction, i, self.moments_g)
+ half_b = bottom_b + self._top(cube, direction, i, self.moments_b)
+ half_w = bottom_w + self._top(cube, direction, i, self.weights)
+
+ if half_w == 0:
+ continue
+
+ temp = (half_r * half_r + half_g * half_g + half_b * half_b) / half_w
+
+ half_r = whole_r - half_r
+ half_g = whole_g - half_g
+ half_b = whole_b - half_b
+ half_w = whole_w - half_w
+
+ if half_w == 0:
+ continue
+
+ temp += (half_r * half_r + half_g * half_g + half_b * half_b) / half_w
+
+ if temp > max_val:
+ max_val = temp
+ cut = i
+
+ return cut, max_val
+
+ def _volume(self, cube: Box, moment: List) -> int:
+ """Calculate volume sum using inclusion-exclusion."""
+ return (
+ moment[_get_index(cube.r1, cube.g1, cube.b1)]
+ - moment[_get_index(cube.r1, cube.g1, cube.b0)]
+ - moment[_get_index(cube.r1, cube.g0, cube.b1)]
+ + moment[_get_index(cube.r1, cube.g0, cube.b0)]
+ - moment[_get_index(cube.r0, cube.g1, cube.b1)]
+ + moment[_get_index(cube.r0, cube.g1, cube.b0)]
+ + moment[_get_index(cube.r0, cube.g0, cube.b1)]
+ - moment[_get_index(cube.r0, cube.g0, cube.b0)]
+ )
+
+ def _bottom(self, cube: Box, direction: int, moment: List) -> int:
+ """Calculate bottom sum for maximize."""
+ if direction == DIR_RED:
+ return (
+ -moment[_get_index(cube.r0, cube.g1, cube.b1)]
+ + moment[_get_index(cube.r0, cube.g1, cube.b0)]
+ + moment[_get_index(cube.r0, cube.g0, cube.b1)]
+ - moment[_get_index(cube.r0, cube.g0, cube.b0)]
+ )
+ elif direction == DIR_GREEN:
+ return (
+ -moment[_get_index(cube.r1, cube.g0, cube.b1)]
+ + moment[_get_index(cube.r1, cube.g0, cube.b0)]
+ + moment[_get_index(cube.r0, cube.g0, cube.b1)]
+ - moment[_get_index(cube.r0, cube.g0, cube.b0)]
+ )
+ else: # DIR_BLUE
+ return (
+ -moment[_get_index(cube.r1, cube.g1, cube.b0)]
+ + moment[_get_index(cube.r1, cube.g0, cube.b0)]
+ + moment[_get_index(cube.r0, cube.g1, cube.b0)]
+ - moment[_get_index(cube.r0, cube.g0, cube.b0)]
+ )
+
+ def _top(self, cube: Box, direction: int, position: int, moment: List) -> int:
+ """Calculate top sum for maximize."""
+ if direction == DIR_RED:
+ return (
+ moment[_get_index(position, cube.g1, cube.b1)]
+ - moment[_get_index(position, cube.g1, cube.b0)]
+ - moment[_get_index(position, cube.g0, cube.b1)]
+ + moment[_get_index(position, cube.g0, cube.b0)]
+ )
+ elif direction == DIR_GREEN:
+ return (
+ moment[_get_index(cube.r1, position, cube.b1)]
+ - moment[_get_index(cube.r1, position, cube.b0)]
+ - moment[_get_index(cube.r0, position, cube.b1)]
+ + moment[_get_index(cube.r0, position, cube.b0)]
+ )
+ else: # DIR_BLUE
+ return (
+ moment[_get_index(cube.r1, cube.g1, position)]
+ - moment[_get_index(cube.r1, cube.g0, position)]
+ - moment[_get_index(cube.r0, cube.g1, position)]
+ + moment[_get_index(cube.r0, cube.g0, position)]
+ )
+
+
+def quantize_wu(pixels: List[Tuple[int, int, int]], max_colors: int = 128) -> Dict[int, int]:
+ """
+ Quantize RGB pixels using Wu algorithm.
+
+ Args:
+ pixels: List of (R, G, B) tuples
+ max_colors: Maximum colors to extract
+
+ Returns:
+ Dictionary mapping ARGB colors to pixel counts
+ """
+ # Convert RGB tuples to ARGB integers
+ argb_pixels = [_argb_from_rgb(r, g, b) for r, g, b in pixels]
+
+ # Run Wu quantizer
+ quantizer = QuantizerWu()
+ result_colors = quantizer.quantize(argb_pixels, max_colors)
+
+ # Build color to count mapping in box order (matching Rust's IndexMap insertion order)
+ # Wu returns colors with count 0; WSMeans uses only the keys as starting clusters
+ color_to_count: Dict[int, int] = {c: 0 for c in result_colors}
+
+ return color_to_count
+
+
+# =============================================================================
+# WSMeans Quantizer - weighted k-means refinement in Lab space
+# =============================================================================
+
+# Mask for 48-bit LCG state
+_LCG_MASK = (1 << 48) - 1
+
+
+class _Random:
+ """LCG matching Java's java.util.Random / material-color-utilities."""
+
+ def __init__(self, seed: int):
+ self._seed = (seed ^ 0x5DEECE66D) & _LCG_MASK
+
+ def _next(self, bits: int) -> int:
+ self._seed = (self._seed * 0x5DEECE66D + 0xB) & _LCG_MASK
+ # Unsigned right shift: treat as unsigned 48-bit, shift, return as signed 32-bit
+ val = self._seed >> (48 - bits)
+ # Convert to signed 32-bit int to match Java behavior
+ if val >= (1 << 31):
+ val -= (1 << 32)
+ return val
+
+ def next_range(self, range_val: int) -> int:
+ if (range_val & -range_val) == range_val:
+ # Power of 2
+ return (range_val * self._next(31)) >> 31
+ while True:
+ bits = self._next(31)
+ val = bits % range_val
+ if bits - val + (range_val - 1) >= 0:
+ return val
+
+
+def _lab_distance_squared(a: Tuple[float, float, float], b: Tuple[float, float, float]) -> float:
+ """Squared Euclidean distance in Lab space (no sqrt)."""
+ dL = a[0] - b[0]
+ da = a[1] - b[1]
+ db = a[2] - b[2]
+ return dL * dL + da * da + db * db
+
+
+def quantize_wsmeans(
+ pixels: List[Tuple[int, int, int]],
+ max_colors: int,
+ starting_clusters: List[int],
+) -> Dict[int, int]:
+ """
+ Refine quantized colors via weighted k-means in Lab space.
+
+ Port of QuantizerWsmeans from material-colors-0.4.2 Rust crate.
+
+ Args:
+ pixels: List of (R, G, B) tuples (original image pixels)
+ max_colors: Maximum number of colors
+ starting_clusters: List of ARGB colors from Wu quantizer
+
+ Returns:
+ Dictionary mapping ARGB colors to pixel counts
+ """
+ # Deduplicate pixels, build count map and Lab points
+ pixel_to_count: Dict[int, int] = {}
+ unique_pixels: List[int] = [] # ARGB values in insertion order
+ points: List[Tuple[float, float, float]] = [] # Lab coordinates
+
+ for r, g, b in pixels:
+ argb = _argb_from_rgb(r, g, b)
+ if argb in pixel_to_count:
+ pixel_to_count[argb] += 1
+ else:
+ unique_pixels.append(argb)
+ points.append(rgb_to_lab(r, g, b))
+ pixel_to_count[argb] = 1
+
+ cluster_count = min(max_colors, len(points))
+ if cluster_count == 0:
+ return {}
+
+ # Convert starting clusters from ARGB to Lab
+ clusters: List[Tuple[float, float, float]] = []
+ for argb in starting_clusters:
+ cr, cg, cb = _rgb_from_argb(argb)
+ clusters.append(rgb_to_lab(cr, cg, cb))
+
+ # Fill remaining clusters with actual image pixels using seeded LCG
+ additional_needed = cluster_count - len(clusters)
+ if additional_needed > 0:
+ rng = _Random(0x42688)
+ indices: List[int] = []
+ for _ in range(additional_needed):
+ index = rng.next_range(len(points))
+ while index in indices:
+ index = rng.next_range(len(points))
+ indices.append(index)
+ for index in indices:
+ clusters.append(points[index])
+
+ # Initialize assignments
+ cluster_indices = [i % cluster_count for i in range(len(points))]
+
+ # Distance matrix and sorted index matrix
+ distance_to_index_matrix: List[List[List]] = [
+ [[0.0, j] for j in range(cluster_count)]
+ for _ in range(cluster_count)
+ ]
+ pixel_count_sums = [0] * cluster_count
+
+ for iteration in range(10):
+ points_moved = 0
+
+ # Compute inter-cluster distance matrix
+ for i in range(cluster_count):
+ for j in range(i + 1, cluster_count):
+ dist = _lab_distance_squared(clusters[i], clusters[j])
+ distance_to_index_matrix[j][i][0] = dist
+ distance_to_index_matrix[j][i][1] = i
+ distance_to_index_matrix[i][j][0] = dist
+ distance_to_index_matrix[i][j][1] = j
+
+ # Sort row by distance
+ distance_to_index_matrix[i].sort(key=lambda x: x[0])
+
+ # Assignment step: find nearest cluster for each point
+ for i in range(len(points)):
+ point = points[i]
+ prev_idx = cluster_indices[i]
+ prev_dist = _lab_distance_squared(point, clusters[prev_idx])
+
+ min_dist = prev_dist
+ new_idx = -1
+
+ for j in range(cluster_count):
+ # Triangle inequality: skip if inter-cluster dist >= 4 * current dist
+ if distance_to_index_matrix[prev_idx][j][0] >= 4.0 * prev_dist:
+ continue
+
+ dist = _lab_distance_squared(point, clusters[j])
+ if dist < min_dist:
+ min_dist = dist
+ new_idx = j
+
+ if new_idx != -1:
+ points_moved += 1
+ cluster_indices[i] = new_idx
+
+ # Early stop
+ if points_moved == 0 and iteration > 0:
+ break
+
+ # Update step: compute new centroids as weighted mean in Lab space
+ component_l = [0.0] * cluster_count
+ component_a = [0.0] * cluster_count
+ component_b = [0.0] * cluster_count
+ for k in range(cluster_count):
+ pixel_count_sums[k] = 0
+
+ for i in range(len(points)):
+ cidx = cluster_indices[i]
+ pt = points[i]
+ count = pixel_to_count[unique_pixels[i]]
+ pixel_count_sums[cidx] += count
+ component_l[cidx] += pt[0] * count
+ component_a[cidx] += pt[1] * count
+ component_b[cidx] += pt[2] * count
+
+ for i in range(cluster_count):
+ count = pixel_count_sums[i]
+ if count == 0:
+ clusters[i] = (0.0, 0.0, 0.0)
+ else:
+ clusters[i] = (
+ component_l[i] / count,
+ component_a[i] / count,
+ component_b[i] / count,
+ )
+
+ # Build result: convert cluster centroids from Lab to ARGB with populations
+ cluster_argbs: List[int] = []
+ cluster_populations: List[int] = []
+
+ for i in range(cluster_count):
+ count = pixel_count_sums[i]
+ if count == 0:
+ continue
+
+ lab = clusters[i]
+ cr, cg, cb = lab_to_rgb(lab[0], lab[1], lab[2])
+ argb = _argb_from_rgb(cr, cg, cb)
+
+ if argb in cluster_argbs:
+ continue
+
+ cluster_argbs.append(argb)
+ cluster_populations.append(count)
+
+ color_to_count: Dict[int, int] = {}
+ for i in range(len(cluster_argbs)):
+ color_to_count[cluster_argbs[i]] = cluster_populations[i]
+
+ return color_to_count
+
+
+# =============================================================================
+# Score Algorithm - ranks colors for UI theme suitability
+# =============================================================================
+
+# Score constants matching material-color-utilities
+TARGET_CHROMA = 48.0
+WEIGHT_PROPORTION = 0.7
+WEIGHT_CHROMA_ABOVE = 0.3
+WEIGHT_CHROMA_BELOW = 0.1
+CUTOFF_CHROMA = 5.0
+CUTOFF_EXCITED_PROPORTION = 0.01
+FALLBACK_COLOR_ARGB = 0xFF4285F4 # Google Blue
+
+
+def _sanitize_degrees(degrees: float) -> int:
+ """Sanitize degrees to 0-359 range."""
+ return int(degrees) % 360
+
+
+def _difference_degrees(a: float, b: float) -> float:
+ """Calculate the shortest distance between two angles."""
+ diff = abs(a - b)
+ return min(diff, 360.0 - diff)
+
+
+def score_colors(
+ color_to_population: Dict[int, int],
+ desired: int = 4,
+ fallback_color: int = FALLBACK_COLOR_ARGB,
+ filter_colors: bool = True,
+) -> List[int]:
+ """
+ Rank colors based on suitability for UI themes.
+
+ Given a map of colors to population counts, removes unsuitable colors
+ and ranks the rest based on chroma and proportion.
+
+ Args:
+ color_to_population: Dict mapping ARGB colors to pixel counts
+ desired: Maximum number of colors to return
+ fallback_color: Color to return if no suitable colors found
+ filter_colors: Whether to filter out low-chroma/low-proportion colors
+
+ Returns:
+ List of ARGB colors sorted by suitability (best first)
+ """
+ # Import here to avoid circular dependency
+ from .hct import Cam16, Hct
+
+ # Build HCT colors and hue population histogram
+ colors_hct: List[Tuple[int, Hct]] = []
+ hue_population = [0] * 360
+ population_sum = 0
+
+ for argb, population in color_to_population.items():
+ r = (argb >> 16) & 0xFF
+ g = (argb >> 8) & 0xFF
+ b = argb & 0xFF
+
+ try:
+ hct = Hct.from_rgb(r, g, b)
+ colors_hct.append((argb, hct))
+ hue = _sanitize_degrees(hct.hue)
+ hue_population[hue] += population
+ population_sum += population
+ except (ValueError, ZeroDivisionError):
+ continue
+
+ if not colors_hct or population_sum == 0:
+ return [fallback_color]
+
+ # Calculate "excited proportions" - sum of proportions in ยฑ15ยฐ hue window
+ hue_excited_proportions = [0.0] * 360
+ for hue in range(360):
+ proportion = hue_population[hue] / population_sum
+ for offset in range(-14, 16):
+ neighbor_hue = _sanitize_degrees(hue + offset)
+ hue_excited_proportions[neighbor_hue] += proportion
+
+ # Score each color
+ scored_hct: List[Tuple[int, Hct, float]] = []
+ for argb, hct in colors_hct:
+ hue = _sanitize_degrees(round(hct.hue))
+ proportion = hue_excited_proportions[hue]
+
+ # Filter by chroma and proportion
+ if filter_colors:
+ if hct.chroma < CUTOFF_CHROMA:
+ continue
+ if proportion <= CUTOFF_EXCITED_PROPORTION:
+ continue
+
+ # Proportion score (70% weight)
+ proportion_score = proportion * 100.0 * WEIGHT_PROPORTION
+
+ # Chroma score
+ if hct.chroma < TARGET_CHROMA:
+ chroma_weight = WEIGHT_CHROMA_BELOW
+ else:
+ chroma_weight = WEIGHT_CHROMA_ABOVE
+ chroma_score = (hct.chroma - TARGET_CHROMA) * chroma_weight
+
+ score = proportion_score + chroma_score
+ scored_hct.append((argb, hct, score))
+
+ if not scored_hct:
+ return [fallback_color]
+
+ # Sort by score descending
+ scored_hct.sort(key=lambda x: -x[2])
+
+ # Deduplicate by hue distance - maximize hue diversity
+ # Start at 90ยฐ (max for 4 colors), decrease to 15ยฐ minimum
+ chosen_colors: List[Tuple[int, Hct]] = []
+
+ for diff_degrees in range(90, 14, -1):
+ chosen_colors.clear()
+ for argb, hct, score in scored_hct:
+ # Check if this hue is far enough from all chosen colors
+ is_duplicate = False
+ for chosen_argb, chosen_hct in chosen_colors:
+ if _difference_degrees(hct.hue, chosen_hct.hue) < diff_degrees:
+ is_duplicate = True
+ break
+
+ if not is_duplicate:
+ chosen_colors.append((argb, hct))
+
+ if len(chosen_colors) >= desired:
+ break
+
+ if len(chosen_colors) >= desired:
+ break
+
+ if not chosen_colors:
+ return [fallback_color]
+
+ return [argb for argb, hct in chosen_colors]
+
+
+def extract_source_color(
+ pixels: List[Tuple[int, int, int]],
+ fallback_color: int = FALLBACK_COLOR_ARGB,
+) -> int:
+ """
+ Extract the primary source color from image pixels.
+
+ Uses Wu + WSMeans quantizer (QuantizerCelebi) + Score algorithm matching
+ matugen/material-color-utilities.
+
+ Args:
+ pixels: List of (R, G, B) tuples
+ fallback_color: Color to return if extraction fails
+
+ Returns:
+ Source color in ARGB format
+ """
+ from .hct import Cam16
+
+ if not pixels:
+ return fallback_color
+
+ # Quantize using Wu + WSMeans (QuantizerCelebi pipeline like matugen)
+ wu_result = quantize_wu(pixels, max_colors=128)
+ starting_clusters = list(wu_result.keys())
+ color_to_count = quantize_wsmeans(pixels, 128, starting_clusters)
+
+ # Filter out low-chroma colors before scoring (like matugen)
+ filtered = {}
+ for argb, count in color_to_count.items():
+ r = (argb >> 16) & 0xFF
+ g = (argb >> 8) & 0xFF
+ b = argb & 0xFF
+ try:
+ cam = Cam16.from_rgb(r, g, b)
+ if cam.chroma >= 5.0:
+ filtered[argb] = count
+ except (ValueError, ZeroDivisionError):
+ continue
+
+ if not filtered:
+ filtered = color_to_count
+
+ # Score and rank colors
+ ranked = score_colors(filtered, desired=4, fallback_color=fallback_color)
+
+ return ranked[0] if ranked else fallback_color
+
+
+def source_color_to_rgb(argb: int) -> Tuple[int, int, int]:
+ """Convert ARGB integer to RGB tuple."""
+ return _rgb_from_argb(argb)
diff --git a/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/lib/renderer.py b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/lib/renderer.py
new file mode 100644
index 0000000..bb900c3
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/lib/renderer.py
@@ -0,0 +1,1181 @@
+"""
+Template rendering for Matugen compatibility.
+
+This module provides the TemplateRenderer class for processing template files
+using the {{colors.name.mode.format}} syntax compatible with Matugen.
+
+Supports:
+- Pipe filters: {{ colors.primary.dark.hex | set_alpha 0.5 | grayscale }}
+- For loops: <* for name, value in colors *> ... <* endfor *>
+- If/else: <* if {{ expr }} *> ... <* else *> ... <* endif *>
+- Loop variables: loop.index, loop.first, loop.last
+- Color filters: grayscale, invert, set_alpha, set_lightness, set_hue,
+ set_saturation, set_red, set_green, set_blue, lighten, darken,
+ saturate, desaturate, auto_lightness, blend, harmonize, to_color
+- String filters: replace, lower_case, camel_case, pascal_case,
+ snake_case, kebab_case
+- Custom colors: [config.custom_colors] in TOML config generates
+ {name}, on_{name}, {name}_container, on_{name}_container tokens
+"""
+
+import re
+import sys
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, Optional, Union
+
+try:
+ import tomllib
+except ImportError:
+ tomllib = None
+
+from .color import Color, find_closest_color
+from .hct import Hct
+
+
+# --- Node Types for the template AST ---
+
+@dataclass
+class TextNode:
+ text: str
+
+@dataclass
+class ForNode:
+ variables: list[str]
+ iterable: str
+ body: list
+
+@dataclass
+class IfNode:
+ condition_expr: str
+ negated: bool
+ then_body: list
+ else_body: list = field(default_factory=list)
+
+
+# --- Variable Scope Stack ---
+
+class VariableScope:
+ """Stack-based variable scope for loop variables."""
+
+ def __init__(self):
+ self._scopes: list[dict[str, Any]] = []
+
+ def push(self, bindings: Optional[dict[str, Any]] = None):
+ self._scopes.append(bindings or {})
+
+ def pop(self):
+ if self._scopes:
+ self._scopes.pop()
+
+ def set(self, name: str, value: Any):
+ if self._scopes:
+ self._scopes[-1][name] = value
+
+ def get(self, name: str) -> Optional[Any]:
+ """Look up variable, searching from innermost scope outward."""
+ for scope in reversed(self._scopes):
+ if name in scope:
+ return scope[name]
+ return None
+
+ @property
+ def is_active(self) -> bool:
+ return len(self._scopes) > 0
+
+
+# --- Known color format types ---
+
+KNOWN_FORMATS = frozenset({
+ "hex", "hex_stripped", "rgb", "rgba", "hsl", "hsla",
+ "red", "green", "blue", "alpha", "hue", "saturation", "lightness",
+})
+
+
+class TemplateRenderer:
+ """
+ Renders templates using the generated theme colors.
+ Compatible with Matugen-style {{colors.name.mode.format}} tags.
+
+ Supports filters via pipe syntax:
+ {{ colors.primary.dark.hex | grayscale }}
+ {{ colors.primary.dark.rgba | set_alpha 0.5 }}
+
+ Supports block constructs:
+ <* for name, value in colors *> ... <* endfor *>
+ <* if {{ loop.first }} *> ... <* else *> ... <* endif *>
+
+ Theme data uses snake_case keys (e.g., 'primary', 'surface_container').
+ """
+
+ # Aliases for custom/legacy keys
+ COLOR_ALIASES = {
+ "hover": "surface_container_high",
+ "on_hover": "on_surface",
+ }
+
+ # Supported color filters and their argument requirements
+ SUPPORTED_FILTERS = {
+ # No arguments
+ "grayscale": 0,
+ "invert": 0,
+ # One argument (float)
+ "set_alpha": 1,
+ "set_lightness": 1,
+ "set_hue": 1,
+ "set_saturation": 1,
+ "set_red": 1,
+ "set_green": 1,
+ "set_blue": 1,
+ "lighten": 1,
+ "darken": 1,
+ "saturate": 1,
+ "desaturate": 1,
+ "auto_lightness": 1,
+ }
+
+ # Filters that take a hex color argument (+ optional numeric)
+ COLOR_ARG_FILTERS = {"blend", "harmonize"}
+
+ # Regex for block delimiters: <* ... *>
+ _BLOCK_RE = re.compile(r'<\*(.*?)\*>', re.DOTALL)
+
+ # Regex for expression tags: {{ ... }}
+ _EXPR_RE = re.compile(r"\{\{([^}\n]+?)\}\}")
+
+ def __init__(self, theme_data: dict[str, dict[str, str]], verbose: bool = True, default_mode: str = "dark", image_path: Optional[str] = None, scheme_type: str = "content"):
+ self.theme_data = theme_data
+ self.closest_color = ""
+ self.verbose = verbose
+ self.default_mode = default_mode
+ self.image_path = image_path
+ self.scheme_type = scheme_type
+ self._current_file: Optional[str] = None
+ self._error_count = 0
+ self._colors_map: Optional[dict[str, dict[str, str]]] = None
+
+ def _log_error(self, message: str, line_hint: str = ""):
+ """Log an error to stderr."""
+ self._error_count += 1
+ prefix = f"[{self._current_file}] " if self._current_file else ""
+ hint = f" near '{line_hint}'" if line_hint else ""
+ print(f"Template error: {prefix}{message}{hint}", file=sys.stderr)
+
+ def _log_warning(self, message: str):
+ """Log a warning to stderr."""
+ if self.verbose:
+ prefix = f"[{self._current_file}] " if self._current_file else ""
+ print(f"Template warning: {prefix}{message}", file=sys.stderr)
+
+ # --- Colors Map ---
+
+ def _build_colors_map(self) -> dict[str, dict[str, str]]:
+ """Build iterable colors map: {name: {mode: hex_value, ...}}."""
+ if self._colors_map is not None:
+ return self._colors_map
+
+ colors_map: dict[str, dict[str, str]] = {}
+ all_names: set[str] = set()
+ for mode_data in self.theme_data.values():
+ all_names.update(mode_data.keys())
+
+ for name in sorted(all_names):
+ color_modes: dict[str, str] = {}
+ for mode, mode_data in self.theme_data.items():
+ if name in mode_data:
+ color_modes[mode] = mode_data[name]
+ # Add "default" as alias for self.default_mode
+ if self.default_mode in color_modes:
+ color_modes["default"] = color_modes[self.default_mode]
+ colors_map[name] = color_modes
+
+ self._colors_map = colors_map
+ return colors_map
+
+ # --- Palette Support ---
+
+ def _get_palette_entries(self, palette_name: str) -> list[dict[str, str]]:
+ """Get tonal palette entries for iteration."""
+ from .hct import Hct, TonalPalette
+
+ TONES = [0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100]
+
+ palette_color_map = {
+ "primary": "primary",
+ "secondary": "secondary",
+ "tertiary": "tertiary",
+ "error": "error",
+ "neutral": "surface",
+ "neutral_variant": "surface_variant",
+ }
+
+ color_name = palette_color_map.get(palette_name)
+ if not color_name:
+ self._log_warning(f"Unknown palette: {palette_name}")
+ return []
+
+ mode_data = self.theme_data.get(self.default_mode, {})
+ hex_color = mode_data.get(color_name)
+ if not hex_color:
+ return []
+
+ color = Color.from_hex(hex_color)
+ palette = TonalPalette.from_rgb(color.r, color.g, color.b)
+
+ entries = []
+ for tone in TONES:
+ tone_hex = palette.get_hex(tone)
+ # Each entry is a color modes dict (same hex for all modes in palette context)
+ entries.append({"default": tone_hex, "dark": tone_hex, "light": tone_hex})
+
+ return entries
+
+ # --- Block Parser ---
+
+ def _parse_template(self, text: str) -> list:
+ """Parse template text into a tree of nodes."""
+ tokens = self._tokenize(text)
+ nodes, _ = self._parse_nodes(tokens, 0)
+ return nodes
+
+ def _tokenize(self, text: str) -> list[Union[str, tuple[str, str]]]:
+ """Split text into raw text strings and ('block', content) tuples.
+
+ Standalone block tags (only content on their line) consume the entire
+ line including the trailing newline, matching matugen's behavior.
+ """
+ tokens = []
+ last_end = 0
+
+ for match in self._BLOCK_RE.finditer(text):
+ start = match.start()
+ end = match.end()
+
+ # Check if this block tag is standalone on its line:
+ # preceded only by whitespace (after newline or start), followed by optional whitespace + newline
+ line_start = text.rfind('\n', last_end, start)
+ line_start = line_start + 1 if line_start != -1 else (last_end if last_end <= start else 0)
+ before_on_line = text[line_start:start]
+
+ if before_on_line.strip() == '':
+ # Check what follows the block tag
+ after_end = end
+ while after_end < len(text) and text[after_end] in (' ', '\t'):
+ after_end += 1
+ if after_end < len(text) and text[after_end] == '\n':
+ # Standalone: consume leading whitespace and trailing newline
+ start = line_start
+ end = after_end + 1
+ elif after_end == len(text):
+ # At end of file, still standalone
+ start = line_start
+ end = after_end
+
+ # Text before the block
+ if start > last_end:
+ tokens.append(text[last_end:start])
+ # The block command
+ tokens.append(("block", match.group(1).strip()))
+ last_end = end
+
+ # Remaining text after last block
+ if last_end < len(text):
+ tokens.append(text[last_end:])
+
+ return tokens
+
+ def _parse_nodes(self, tokens: list, pos: int, stop_keywords: Optional[set[str]] = None) -> tuple[list, int]:
+ """Parse tokens into nodes, stopping at specified keywords.
+
+ Returns (nodes, position_after_stop_keyword).
+ """
+ nodes = []
+ stop_keywords = stop_keywords or set()
+
+ while pos < len(tokens):
+ token = tokens[pos]
+
+ if isinstance(token, str):
+ # Raw text
+ if token:
+ nodes.append(TextNode(token))
+ pos += 1
+ else:
+ # Block command
+ _, cmd = token
+
+ # Check if this is a stop keyword
+ if any(cmd.startswith(kw) for kw in stop_keywords):
+ return nodes, pos
+
+ if cmd.startswith("for "):
+ node, pos = self._parse_for(tokens, pos)
+ nodes.append(node)
+ elif cmd.startswith("if "):
+ node, pos = self._parse_if(tokens, pos)
+ nodes.append(node)
+ else:
+ # Unknown block command - treat as text
+ self._log_warning(f"Unknown block command: {cmd}")
+ pos += 1
+
+ return nodes, pos
+
+ def _parse_for(self, tokens: list, pos: int) -> tuple[ForNode, int]:
+ """Parse a for loop block starting at pos."""
+ _, cmd = tokens[pos]
+ pos += 1
+
+ # Parse: for var1, var2 in iterable
+ for_match = re.match(r'for\s+(.+?)\s+in\s+(.+)$', cmd)
+ if not for_match:
+ self._log_error(f"Invalid for syntax: {cmd}")
+ return ForNode([], "", []), pos
+
+ vars_str, iterable = for_match.groups()
+ variables = [v.strip() for v in vars_str.split(',')]
+ iterable = iterable.strip()
+
+ # Parse body until endfor
+ body, pos = self._parse_nodes(tokens, pos, stop_keywords={"endfor"})
+
+ # Skip past 'endfor'
+ if pos < len(tokens):
+ pos += 1
+
+ return ForNode(variables, iterable, body), pos
+
+ def _parse_if(self, tokens: list, pos: int) -> tuple[IfNode, int]:
+ """Parse an if/else/endif block starting at pos."""
+ _, cmd = tokens[pos]
+ pos += 1
+
+ # Parse: if [not] {{ expr }}
+ negated = False
+ condition_part = cmd[3:].strip() # Remove 'if '
+
+ if condition_part.startswith("not "):
+ negated = True
+ condition_part = condition_part[4:].strip()
+
+ # Extract expression from {{ ... }} if present
+ expr_match = re.match(r'\{\{(.+?)\}\}', condition_part)
+ if expr_match:
+ condition_expr = expr_match.group(1).strip()
+ else:
+ condition_expr = condition_part
+
+ # Parse then body until else or endif
+ then_body, pos = self._parse_nodes(tokens, pos, stop_keywords={"else", "endif"})
+
+ else_body = []
+ if pos < len(tokens):
+ _, stop_cmd = tokens[pos]
+ if stop_cmd.strip() == "else":
+ pos += 1
+ # Parse else body until endif
+ else_body, pos = self._parse_nodes(tokens, pos, stop_keywords={"endif"})
+
+ # Skip past 'endif'
+ if pos < len(tokens):
+ pos += 1
+
+ return IfNode(condition_expr, negated, then_body, else_body), pos
+
+ # --- Node Evaluation ---
+
+ def _evaluate_nodes(self, nodes: list, scope: VariableScope) -> str:
+ """Evaluate a list of nodes with the given variable scope."""
+ parts = []
+ for node in nodes:
+ if isinstance(node, TextNode):
+ parts.append(self._resolve_text(node.text, scope))
+ elif isinstance(node, ForNode):
+ parts.append(self._evaluate_for(node, scope))
+ elif isinstance(node, IfNode):
+ parts.append(self._evaluate_if(node, scope))
+ return ''.join(parts)
+
+ def _evaluate_for(self, node: ForNode, scope: VariableScope) -> str:
+ """Evaluate a for loop node."""
+ iterable = self._resolve_iterable(node.iterable, scope)
+ if not iterable:
+ return ""
+
+ results = []
+ total = len(iterable)
+
+ for index, item in enumerate(iterable):
+ loop_meta = {
+ "index": index,
+ "first": index == 0,
+ "last": index == total - 1,
+ }
+
+ scope.push({"loop": loop_meta})
+
+ if isinstance(item, tuple) and len(item) == 2:
+ # Map iteration: (key, value)
+ if len(node.variables) >= 2:
+ scope.set(node.variables[0], item[0])
+ scope.set(node.variables[1], item[1])
+ elif len(node.variables) == 1:
+ scope.set(node.variables[0], item[0])
+ else:
+ # Array or range iteration
+ if node.variables:
+ scope.set(node.variables[0], item)
+
+ results.append(self._evaluate_nodes(node.body, scope))
+ scope.pop()
+
+ return ''.join(results)
+
+ def _evaluate_if(self, node: IfNode, scope: VariableScope) -> str:
+ """Evaluate an if/else node."""
+ condition_value = self._resolve_expression(node.condition_expr, scope)
+ is_truthy = self._is_truthy(condition_value)
+
+ if node.negated:
+ is_truthy = not is_truthy
+
+ if is_truthy:
+ return self._evaluate_nodes(node.then_body, scope)
+ else:
+ return self._evaluate_nodes(node.else_body, scope)
+
+ def _is_truthy(self, value: Any) -> bool:
+ """Determine if a value is truthy."""
+ if value is None:
+ return False
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, (int, float)):
+ return value != 0
+ s = str(value).strip().lower()
+ return s not in ("", "false", "0", "none")
+
+ # --- Iterable Resolution ---
+
+ def _resolve_iterable(self, iterable_expr: str, scope: VariableScope) -> list:
+ """Resolve an iterable expression to a list of items."""
+ # Range: "0..10" or "-5..5"
+ range_match = re.match(r'^(-?\d+)\.\.(-?\d+)$', iterable_expr)
+ if range_match:
+ start, end = int(range_match.group(1)), int(range_match.group(2))
+ return list(range(start, end))
+
+ # Colors map: "colors"
+ if iterable_expr == "colors":
+ colors_map = self._build_colors_map()
+ return list(colors_map.items())
+
+ # Palette: "palettes.primary" etc.
+ if iterable_expr.startswith("palettes."):
+ palette_name = iterable_expr.split('.', 1)[1]
+ entries = self._get_palette_entries(palette_name)
+ # Return as list of dicts (each entry is a color modes dict)
+ return entries
+
+ # Check scope for iterable variable
+ val = scope.get(iterable_expr)
+ if val is not None:
+ if isinstance(val, dict):
+ return list(val.items())
+ if isinstance(val, (list, tuple)):
+ return list(val)
+
+ self._log_warning(f"Unknown iterable: {iterable_expr}")
+ return []
+
+ # --- Expression Resolution ---
+
+ def _resolve_text(self, text: str, scope: VariableScope) -> str:
+ """Resolve all {{ expr }} tags in a text segment."""
+ def replace(match):
+ expr = match.group(1).strip()
+ return str(self._resolve_expression(expr, scope))
+
+ return self._EXPR_RE.sub(replace, text)
+
+ def _resolve_expression(self, expr: str, scope: VariableScope) -> Any:
+ """Resolve an expression, checking scope variables first, then colors."""
+ # Split by pipe for filters
+ parts = self._split_pipes(expr)
+
+ if not parts:
+ return ""
+
+ base = parts[0].strip()
+ filters = [p.strip() for p in parts[1:]]
+
+ # Handle {{mode}} tag - resolves to current theme mode
+ if base == "mode":
+ result_str = self.default_mode
+ for filter_str in filters:
+ result_str = self._apply_string_or_color_filter(result_str, filter_str, expr)
+ return result_str
+
+ # Try scope resolution first
+ resolved = self._resolve_from_scope(base, scope)
+ if resolved is not None:
+ result_str = str(resolved)
+ for filter_str in filters:
+ result_str = self._apply_string_or_color_filter(result_str, filter_str, expr)
+ return result_str
+
+ # Handle {{image}} tag - resolves to source image path
+ if base == 'image':
+ result_str = self.image_path or ""
+ for filter_str in filters:
+ result_str = self._apply_string_or_color_filter(result_str, filter_str, expr)
+ return result_str
+
+ # Fall back to colors.name.mode.format parsing
+ if base.startswith('colors.'):
+ return self._process_color_expression(base, filters, expr)
+
+ # Unknown expression - return as-is
+ return f"{{{{{expr}}}}}"
+
+ def _split_pipes(self, expr: str) -> list[str]:
+ """Split expression by pipe, respecting quoted strings."""
+ parts = []
+ current = []
+ in_quotes = False
+ quote_char = None
+
+ for char in expr:
+ if char in ('"', "'") and not in_quotes:
+ in_quotes = True
+ quote_char = char
+ current.append(char)
+ elif char == quote_char and in_quotes:
+ in_quotes = False
+ quote_char = None
+ current.append(char)
+ elif char == '|' and not in_quotes:
+ parts.append(''.join(current))
+ current = []
+ else:
+ current.append(char)
+
+ if current:
+ parts.append(''.join(current))
+
+ return parts
+
+ def _resolve_from_scope(self, base: str, scope: VariableScope) -> Optional[Any]:
+ """Resolve a dotted path from scope variables."""
+ if not scope.is_active:
+ return None
+
+ parts = base.split('.')
+ first = parts[0]
+
+ val = scope.get(first)
+ if val is None:
+ return None
+
+ # Navigate dotted path
+ for i, part in enumerate(parts[1:], 1):
+ if isinstance(val, dict) and part in val:
+ val = val[part]
+ elif isinstance(val, str) and val.startswith('#') and part in KNOWN_FORMATS:
+ # It's a hex color string and remaining part is a format specifier
+ color = Color.from_hex(val)
+ return self._format_color(color, part)
+ else:
+ return None
+
+ # If the final value is a hex color string, return it as-is
+ return val
+
+ def _process_color_expression(self, base: str, filters: list[str], raw_expr: str) -> str:
+ """Process a colors.name.mode.format expression with optional filters."""
+ base_match = re.match(r'^colors\.([a-z_0-9]+)\.([a-z_0-9]+)\.([a-z_0-9]+)$', base)
+
+ if not base_match:
+ self._log_error(f"Invalid syntax '{base}'. Expected: colors...", raw_expr)
+ return f"{{{{{raw_expr}}}}}"
+
+ color_name, mode, format_type = base_match.groups()
+
+ hex_color = self._get_hex_color(color_name, mode)
+ if not hex_color:
+ return f"{{{{UNKNOWN:{color_name}.{mode}}}}}"
+
+ color = Color.from_hex(hex_color)
+
+ # Apply color filters
+ for filter_str in filters:
+ filter_name, arg = self._parse_filter(filter_str)
+ if filter_name:
+ if filter_name == "replace":
+ # Replace works on the formatted string, apply after formatting
+ formatted = self._format_color(color, format_type)
+ return self._apply_replace(formatted, arg, raw_expr)
+ elif filter_name in self.COLOR_ARG_FILTERS:
+ hex_result = self._apply_color_arg_filter(color.to_hex(), filter_name, arg, raw_expr)
+ color = Color.from_hex(hex_result)
+ elif filter_name == "to_color":
+ pass # Already a color, no-op
+ elif filter_name in self.SUPPORTED_FILTERS:
+ color = self._apply_filter(color, filter_name, arg, raw_expr)
+ elif filter_name in ("lower_case", "camel_case", "pascal_case", "snake_case", "kebab_case"):
+ # String case filters apply to formatted output
+ formatted = self._format_color(color, format_type)
+ return self._apply_string_or_color_filter(formatted, filter_str, raw_expr)
+ else:
+ self._log_warning(f"Unknown filter '{filter_name}'")
+
+ return self._format_color(color, format_type)
+
+ # --- Color Access ---
+
+ def _get_hex_color(self, color_name: str, mode: str) -> Optional[str]:
+ """Get raw hex color value for a color name and mode."""
+ key = self.COLOR_ALIASES.get(color_name, color_name)
+
+ if mode == "default":
+ mode_data = self.theme_data.get(self.default_mode) or self.theme_data.get("dark") or self.theme_data.get("light")
+ else:
+ mode_data = self.theme_data.get(mode)
+
+ if not mode_data:
+ self._log_error(f"Unknown mode '{mode}'", f"colors.{color_name}.{mode}")
+ return None
+
+ hex_color = mode_data.get(key)
+ if not hex_color:
+ self._log_error(f"Unknown color '{key}'", f"colors.{color_name}.{mode}")
+ return None
+
+ return hex_color
+
+ def _format_color(self, color: Color, format_type: str) -> str:
+ """Format a Color object to the requested format string."""
+ if format_type == "hex":
+ return color.to_hex()
+ elif format_type == "hex_stripped":
+ return color.to_hex().lstrip('#')
+ elif format_type == "rgb":
+ return f"rgb({color.r}, {color.g}, {color.b})"
+ elif format_type == "rgba":
+ alpha = getattr(color, 'alpha', 1.0)
+ return f"rgba({color.r}, {color.g}, {color.b}, {alpha})"
+ elif format_type == "hsl":
+ h, s, l = color.to_hsl()
+ return f"hsl({int(h)}, {int(s * 100)}%, {int(l * 100)}%)"
+ elif format_type == "hsla":
+ h, s, l = color.to_hsl()
+ alpha = getattr(color, 'alpha', 1.0)
+ return f"hsla({int(h)}, {int(s * 100)}%, {int(l * 100)}%, {alpha})"
+ elif format_type == "hue":
+ h, _, _ = color.to_hsl()
+ return str(int(h))
+ elif format_type == "saturation":
+ _, s, _ = color.to_hsl()
+ return str(int(s * 100))
+ elif format_type == "lightness":
+ _, _, l = color.to_hsl()
+ return str(int(l * 100))
+ elif format_type == "red":
+ return str(color.r)
+ elif format_type == "green":
+ return str(color.g)
+ elif format_type == "blue":
+ return str(color.b)
+ elif format_type == "alpha":
+ return str(getattr(color, 'alpha', 1.0))
+ else:
+ self._log_error(f"Unknown format '{format_type}'")
+ return color.to_hex()
+
+ # --- Filters ---
+
+ def _parse_filter(self, filter_str: str) -> tuple[str, Optional[str]]:
+ """Parse a filter string into (name, argument).
+
+ Supports both syntaxes:
+ | set_alpha 0.5 (space-separated)
+ | set_alpha: 0.5 (colon-separated, matugen-style)
+ | replace: "_", "-" (colon with quoted args)
+ """
+ filter_str = filter_str.strip()
+
+ # Check for colon syntax: name: args
+ colon_match = re.match(r'^([a-z_]+)\s*:\s*(.+)$', filter_str)
+ if colon_match:
+ return colon_match.group(1), colon_match.group(2).strip()
+
+ # Space syntax: name arg
+ if ' ' in filter_str:
+ name, arg = filter_str.split(None, 1)
+ return name.strip(), arg.strip()
+
+ return filter_str, None
+
+ def _apply_string_or_color_filter(self, value: str, filter_str: str, raw_expr: str) -> str:
+ """Apply a filter to a string value (may be a color hex or plain string)."""
+ name, arg = self._parse_filter(filter_str)
+
+ if name == "replace":
+ return self._apply_replace(value, arg, raw_expr)
+
+ # String case transforms (work on any string)
+ if name == "lower_case":
+ return value.lower()
+ if name == "camel_case":
+ return self._to_camel_case(value)
+ if name == "pascal_case":
+ return self._to_pascal_case(value)
+ if name == "snake_case":
+ return self._to_snake_case(value)
+ if name == "kebab_case":
+ return self._to_kebab_case(value)
+
+ # to_color: treat value as color string (pass-through, validates hex)
+ if name == "to_color":
+ if value.startswith('#') and len(value) in (7, 9):
+ return value
+ self._log_error(f"to_color: value '{value}' is not a valid hex color", raw_expr)
+ return value
+
+ # Color-arg filters (blend, harmonize) - need hex color argument
+ if name in self.COLOR_ARG_FILTERS:
+ if not (value.startswith('#') and len(value) == 7):
+ self._log_error(f"Filter '{name}' requires a hex color value", raw_expr)
+ return value
+ return self._apply_color_arg_filter(value, name, arg, raw_expr)
+
+ # Try as color filter if value looks like a hex color
+ if name in self.SUPPORTED_FILTERS and value.startswith('#') and len(value) == 7:
+ color = Color.from_hex(value)
+ color = self._apply_filter(color, name, arg, raw_expr)
+ return color.to_hex()
+
+ self._log_warning(f"Cannot apply filter '{name}' to non-color value")
+ return value
+
+ def _apply_color_arg_filter(self, value: str, name: str, arg: Optional[str], raw_expr: str) -> str:
+ """Apply blend or harmonize filter (takes hex color argument)."""
+ if not arg:
+ self._log_error(f"Filter '{name}' requires a color argument", raw_expr)
+ return value
+
+ # Parse arguments: "#hexcolor", amount (for blend) or just "#hexcolor" (for harmonize)
+ # Support both quoted and unquoted hex
+ hex_match = re.match(r'["\']?(#[0-9a-fA-F]{6})["\']?\s*(?:,\s*(.+))?', arg)
+ if not hex_match:
+ self._log_error(f"Filter '{name}' requires a hex color argument, got '{arg}'", raw_expr)
+ return value
+
+ target_hex = hex_match.group(1)
+ extra_arg = hex_match.group(2)
+
+ src = Color.from_hex(value)
+ target = Color.from_hex(target_hex)
+
+ src_hct = Hct.from_rgb(src.r, src.g, src.b)
+ target_hct = Hct.from_rgb(target.r, target.g, target.b)
+
+ if name == "blend":
+ # Blend hue in HCT space by amount, keep source chroma/tone
+ if not extra_arg:
+ self._log_error("blend filter requires amount argument: blend: \"#hex\", 0.5", raw_expr)
+ return value
+ try:
+ amount = float(extra_arg.strip().strip('"\''))
+ except ValueError:
+ self._log_error(f"blend amount must be numeric, got '{extra_arg}'", raw_expr)
+ return value
+ amount = max(0.0, min(1.0, amount))
+
+ # Shortest arc hue interpolation
+ diff = target_hct.hue - src_hct.hue
+ if diff > 180.0:
+ diff -= 360.0
+ elif diff < -180.0:
+ diff += 360.0
+ new_hue = (src_hct.hue + diff * amount) % 360.0
+ result_hct = Hct(new_hue, src_hct.chroma, src_hct.tone)
+
+ elif name == "harmonize":
+ # M3 harmonize: rotate hue toward target by min(diff * 0.5, 15ยฐ)
+ diff = target_hct.hue - src_hct.hue
+ if diff > 180.0:
+ diff -= 360.0
+ elif diff < -180.0:
+ diff += 360.0
+ max_rotation = 15.0
+ rotation = min(abs(diff) * 0.5, max_rotation)
+ if diff < 0:
+ rotation = -rotation
+ new_hue = (src_hct.hue + rotation) % 360.0
+ result_hct = Hct(new_hue, src_hct.chroma, src_hct.tone)
+ else:
+ return value
+
+ r, g, b = result_hct.to_rgb()
+ return Color(r, g, b).to_hex()
+
+ @staticmethod
+ def _split_words(s: str) -> list[str]:
+ """Split a string into words for case conversion."""
+ # Handle camelCase/PascalCase boundaries
+ s = re.sub(r'([a-z])([A-Z])', r'\1_\2', s)
+ # Split on non-alphanumeric
+ return [w for w in re.split(r'[^a-zA-Z0-9]+', s) if w]
+
+ def _to_camel_case(self, s: str) -> str:
+ words = self._split_words(s)
+ if not words:
+ return s
+ return words[0].lower() + ''.join(w.capitalize() for w in words[1:])
+
+ def _to_pascal_case(self, s: str) -> str:
+ words = self._split_words(s)
+ if not words:
+ return s
+ return ''.join(w.capitalize() for w in words)
+
+ def _to_snake_case(self, s: str) -> str:
+ words = self._split_words(s)
+ return '_'.join(w.lower() for w in words)
+
+ def _to_kebab_case(self, s: str) -> str:
+ words = self._split_words(s)
+ return '-'.join(w.lower() for w in words)
+
+ def _apply_replace(self, value: str, args: Optional[str], raw_expr: str) -> str:
+ """Apply the replace filter: | replace: "search", "replacement" """
+ if not args:
+ self._log_error("replace filter requires arguments", raw_expr)
+ return value
+
+ # Parse quoted arguments: "search", "replacement"
+ match = re.match(r'"([^"]*?)"\s*,\s*"([^"]*?)"', args)
+ if match:
+ find_str, replace_str = match.groups()
+ return value.replace(find_str, replace_str)
+
+ # Try single-quoted: 'search', 'replacement'
+ match = re.match(r"'([^']*?)'\s*,\s*'([^']*?)'", args)
+ if match:
+ find_str, replace_str = match.groups()
+ return value.replace(find_str, replace_str)
+
+ self._log_error(f"replace filter syntax: replace: \"find\", \"replacement\"", raw_expr)
+ return value
+
+ def _apply_filter(self, color: Color, filter_name: str, arg: Optional[str], raw_expr: str) -> Color:
+ """Apply a single color filter."""
+ if filter_name not in self.SUPPORTED_FILTERS:
+ supported = ", ".join(sorted(self.SUPPORTED_FILTERS.keys()))
+ self._log_error(f"Unknown filter '{filter_name}'. Supported: {supported}", raw_expr)
+ return color
+
+ expected_args = self.SUPPORTED_FILTERS[filter_name]
+
+ if expected_args > 0 and arg is None:
+ self._log_error(f"Filter '{filter_name}' requires an argument", raw_expr)
+ return color
+ if expected_args == 0 and arg is not None:
+ self._log_warning(f"Filter '{filter_name}' ignores argument '{arg}'")
+
+ num_arg = None
+ if expected_args > 0:
+ try:
+ num_arg = float(arg)
+ except (ValueError, TypeError):
+ self._log_error(f"Filter '{filter_name}' requires numeric argument, got '{arg}'", raw_expr)
+ return color
+
+ h, s, l = color.to_hsl()
+
+ if filter_name == "grayscale":
+ gray = int(0.299 * color.r + 0.587 * color.g + 0.114 * color.b)
+ result = Color(gray, gray, gray)
+ elif filter_name == "invert":
+ result = Color(255 - color.r, 255 - color.g, 255 - color.b)
+ elif filter_name == "set_alpha":
+ result = Color(color.r, color.g, color.b)
+ result.alpha = max(0.0, min(1.0, num_arg))
+ elif filter_name == "set_lightness":
+ new_l = max(0.0, min(1.0, num_arg / 100.0))
+ result = Color.from_hsl(h, s, new_l)
+ elif filter_name == "set_hue":
+ new_h = num_arg % 360
+ result = Color.from_hsl(new_h, s, l)
+ elif filter_name == "set_saturation":
+ new_s = max(0.0, min(1.0, num_arg / 100.0))
+ result = Color.from_hsl(h, new_s, l)
+ elif filter_name == "lighten":
+ new_l = max(0.0, min(1.0, l + num_arg / 100.0))
+ result = Color.from_hsl(h, s, new_l)
+ elif filter_name == "darken":
+ new_l = max(0.0, min(1.0, l - num_arg / 100.0))
+ result = Color.from_hsl(h, s, new_l)
+ elif filter_name == "saturate":
+ new_s = max(0.0, min(1.0, s + num_arg / 100.0))
+ result = Color.from_hsl(h, new_s, l)
+ elif filter_name == "desaturate":
+ new_s = max(0.0, min(1.0, s - num_arg / 100.0))
+ result = Color.from_hsl(h, new_s, l)
+ elif filter_name == "auto_lightness":
+ # If lightness < 50%, lighten; otherwise darken (push toward mid-lightness)
+ if l < 0.5:
+ new_l = max(0.0, min(1.0, l + num_arg / 100.0))
+ else:
+ new_l = max(0.0, min(1.0, l - num_arg / 100.0))
+ result = Color.from_hsl(h, s, new_l)
+ elif filter_name == "set_red":
+ result = Color(max(0, min(255, int(num_arg))), color.g, color.b)
+ elif filter_name == "set_green":
+ result = Color(color.r, max(0, min(255, int(num_arg))), color.b)
+ elif filter_name == "set_blue":
+ result = Color(color.r, color.g, max(0, min(255, int(num_arg))))
+ else:
+ result = color
+
+ if hasattr(color, 'alpha') and not hasattr(result, 'alpha'):
+ result.alpha = color.alpha
+
+ return result
+
+ # --- Main Render Methods ---
+
+ def render(self, template_text: str) -> str:
+ """Render a template string, processing blocks and expressions."""
+ self._error_count = 0
+
+ # Parse template into node tree
+ nodes = self._parse_template(template_text)
+
+ # Evaluate with empty scope
+ scope = VariableScope()
+ result = self._evaluate_nodes(nodes, scope)
+
+ if self.closest_color:
+ result = self._substitute_closest_color(result)
+
+ # Process escape sequences (matugen-compatible)
+ result = result.replace('\\\\', '\\')
+
+ if self._error_count > 0:
+ print(f"Template rendering completed with {self._error_count} error(s)", file=sys.stderr)
+
+ return result
+
+ def render_file(self, input_path: Path, output_path: Path) -> bool:
+ """Render a template file to an output path.
+
+ Returns True if successful, False if skipped due to errors.
+ """
+ self._current_file = str(input_path)
+ success = False
+ try:
+ template_text = input_path.read_text()
+ rendered_text = self.render(template_text)
+
+ if self._error_count > 0:
+ print(f"Skipping {output_path}: template has {self._error_count} error(s)", file=sys.stderr)
+ else:
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ output_path.write_text(rendered_text)
+ success = True
+ except FileNotFoundError:
+ self._log_error(f"Template file not found: {input_path}")
+ except PermissionError:
+ self._log_error(f"Permission denied: {output_path}")
+ except Exception as e:
+ self._log_error(f"Unexpected error: {e}")
+ finally:
+ self._current_file = None
+ return success
+
+ # --- Custom Colors ---
+
+ # Standard M3 tone mappings for primary color group
+ _CUSTOM_COLOR_TONES = {
+ "dark": {
+ "color": 80,
+ "on_color": 20,
+ "color_container": 30,
+ "on_color_container": 90,
+ },
+ "light": {
+ "color": 40,
+ "on_color": 100,
+ "color_container": 90,
+ "on_color_container": 10,
+ },
+ }
+
+ def _apply_custom_colors(self, custom_colors: dict[str, Any]):
+ """Generate and merge custom color tokens into theme_data.
+
+ Matches matugen's [config.custom_colors] behavior:
+ - Each custom color generates 6 tokens per mode
+ - Optional blend (harmonize toward source color)
+ - Uses the same scheme type as the main theme for palette generation
+ - Tokens: {name}_source, {name}_value, {name}, on_{name},
+ {name}_container, on_{name}_container
+ """
+ from .hct import Hct, TonalPalette
+ from .material import (
+ SchemeTonalSpot, SchemeFruitSalad, SchemeRainbow,
+ SchemeContent, SchemeMonochrome
+ )
+
+ SCHEME_CLASSES = {
+ "tonal-spot": SchemeTonalSpot,
+ "content": SchemeContent,
+ "fruit-salad": SchemeFruitSalad,
+ "rainbow": SchemeRainbow,
+ "monochrome": SchemeMonochrome,
+ }
+ scheme_class = SCHEME_CLASSES.get(self.scheme_type, SchemeContent)
+
+ # Get source color for harmonization (primary from default mode)
+ source_hex = None
+ mode_data = self.theme_data.get(self.default_mode, {})
+ source_hex = mode_data.get("primary") or mode_data.get("source_color")
+
+ for name, config in custom_colors.items():
+ # Parse config: either a string "#hex" or {color: "#hex", blend: bool}
+ if isinstance(config, str):
+ color_hex = config
+ blend = True
+ elif isinstance(config, dict):
+ color_hex = config.get("color", "")
+ blend = config.get("blend", True)
+ else:
+ self._log_warning(f"Invalid custom_color config for '{name}': {config}")
+ continue
+
+ # Validate hex color
+ if not (color_hex.startswith('#') and len(color_hex) == 7):
+ self._log_error(f"Custom color '{name}' has invalid hex: '{color_hex}'")
+ continue
+
+ original_color = Color.from_hex(color_hex)
+
+ # Optionally harmonize toward source color
+ if blend and source_hex:
+ src = Color.from_hex(color_hex)
+ target = Color.from_hex(source_hex)
+ src_hct = Hct.from_rgb(src.r, src.g, src.b)
+ target_hct = Hct.from_rgb(target.r, target.g, target.b)
+
+ # M3 harmonize: rotate hue toward target by min(diff * 0.5, 15ยฐ)
+ diff = target_hct.hue - src_hct.hue
+ if diff > 180.0:
+ diff -= 360.0
+ elif diff < -180.0:
+ diff += 360.0
+ max_rotation = 15.0
+ rotation = min(abs(diff) * 0.5, max_rotation)
+ if diff < 0:
+ rotation = -rotation
+ new_hue = (src_hct.hue + rotation) % 360.0
+ harmonized_hct = Hct(new_hue, src_hct.chroma, src_hct.tone)
+ r, g, b = harmonized_hct.to_rgb()
+ palette_color = Color(r, g, b)
+ else:
+ palette_color = original_color
+
+ # Generate palette using the scheme class (matches matugen behavior)
+ palette_hct = Hct.from_rgb(palette_color.r, palette_color.g, palette_color.b)
+ scheme = scheme_class(palette_hct)
+ palette = scheme.primary_palette
+
+ # Generate tokens for each mode
+ for mode in self.theme_data:
+ tones = self._CUSTOM_COLOR_TONES.get(mode)
+ if not tones:
+ continue
+
+ # Source/value tokens (always the original unharmonized color)
+ self.theme_data[mode][f"{name}_source"] = original_color.to_hex()
+ self.theme_data[mode][f"{name}_value"] = original_color.to_hex()
+
+ # Primary role tokens from the tonal palette
+ self.theme_data[mode][name] = palette.get_hex(tones["color"])
+ self.theme_data[mode][f"on_{name}"] = palette.get_hex(tones["on_color"])
+ self.theme_data[mode][f"{name}_container"] = palette.get_hex(tones["color_container"])
+ self.theme_data[mode][f"on_{name}_container"] = palette.get_hex(tones["on_color_container"])
+
+ # Invalidate colors map cache so new colors appear in iterations
+ self._colors_map = None
+
+ def _substitute_closest_color(self, text: str) -> str:
+ """Substitute {{closest_color}} in text."""
+ return re.sub(r"\{\{\s*closest_color\s*\}\}", self.closest_color, text)
+
+ def process_config_file(self, config_path: Path):
+ """Process Matugen TOML configuration file."""
+ if not tomllib:
+ print("Error: tomllib module not available (requires Python 3.11+)", file=sys.stderr)
+ return
+
+ try:
+ with open(config_path, "rb") as f:
+ data = tomllib.load(f)
+
+ # Apply custom colors before rendering templates
+ config_section = data.get("config", {})
+ custom_colors = config_section.get("custom_colors")
+ if custom_colors:
+ self._apply_custom_colors(custom_colors)
+
+ templates = data.get("templates", {})
+ for name, template in templates.items():
+ input_path = template.get("input_path")
+ output_path = template.get("output_path")
+
+ if not input_path or not output_path:
+ print(f"Warning: Template '{name}' missing input_path or output_path", file=sys.stderr)
+ continue
+
+ # Handle closest_color if configured (matugen-compatible)
+ # Reset for each template to avoid state pollution between templates
+ self.closest_color = ""
+ colors_to_compare = template.get("colors_to_compare")
+ compare_to = template.get("compare_to")
+
+ if colors_to_compare and compare_to:
+ rendered_compare_to = self.render(compare_to)
+ self.closest_color = find_closest_color(rendered_compare_to, colors_to_compare)
+
+ self.render_file(Path(input_path).expanduser(), Path(output_path).expanduser())
+
+ # Execute pre_hook if specified
+ pre_hook = template.get("pre_hook")
+ if pre_hook:
+ import subprocess
+ if self.closest_color:
+ pre_hook = self._substitute_closest_color(pre_hook)
+ pre_hook = self.render(pre_hook)
+ try:
+ subprocess.run(pre_hook, shell=True, check=False)
+ except Exception as e:
+ print(f"Error running pre_hook for {name}: {e}", file=sys.stderr)
+
+ # Execute post_hook if specified
+ post_hook = template.get("post_hook")
+ if post_hook:
+ import subprocess
+ if self.closest_color:
+ post_hook = self._substitute_closest_color(post_hook)
+ post_hook = self.render(post_hook)
+ try:
+ subprocess.run(post_hook, shell=True, check=False)
+ except Exception as e:
+ print(f"Error running post_hook for {name}: {e}", file=sys.stderr)
+
+ except FileNotFoundError:
+ print(f"Error: Config file not found: {config_path}", file=sys.stderr)
+ except Exception as e:
+ print(f"Error processing config file {config_path}: {e}", file=sys.stderr)
diff --git a/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/lib/scheme.py b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/lib/scheme.py
new file mode 100644
index 0000000..6ea0d62
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/lib/scheme.py
@@ -0,0 +1,351 @@
+"""
+Predefined scheme expansion - Convert 14-color schemes to full palette.
+
+This module expands predefined color schemes (like Tokyo-Night) from their
+14 core colors to the full 48-color palette used by templates.
+
+Input format (14 colors):
+ mPrimary, mOnPrimary, mSecondary, mOnSecondary, mTertiary, mOnTertiary,
+ mError, mOnError, mSurface, mOnSurface, mSurfaceVariant, mOnSurfaceVariant,
+ mOutline, mHover
+
+Output: Full 48-color palette matching generate_theme() output.
+"""
+
+from typing import Literal
+
+from .color import Color
+from .contrast import ensure_contrast
+
+ThemeMode = Literal["dark", "light"]
+
+
+def _hex_to_color(hex_str: str) -> Color:
+ """Convert hex string to Color object."""
+ hex_str = hex_str.lstrip("#")
+ r = int(hex_str[0:2], 16)
+ g = int(hex_str[2:4], 16)
+ b = int(hex_str[4:6], 16)
+ return Color(r, g, b)
+
+
+def _make_container_dark(base: Color) -> Color:
+ """Generate container color for dark mode."""
+ h, s, l = base.to_hsl()
+ return Color.from_hsl(h, min(s + 0.15, 1.0), max(l - 0.35, 0.15))
+
+
+def _make_container_light(base: Color) -> Color:
+ """Generate container color for light mode."""
+ h, s, l = base.to_hsl()
+ return Color.from_hsl(h, max(s - 0.20, 0.30), min(l + 0.35, 0.85))
+
+
+def _make_fixed_dark(base: Color) -> tuple[Color, Color]:
+ """Generate fixed and fixed_dim colors for dark mode."""
+ h, s, _ = base.to_hsl()
+ fixed = Color.from_hsl(h, max(s, 0.70), 0.85)
+ fixed_dim = Color.from_hsl(h, max(s, 0.65), 0.75)
+ return fixed, fixed_dim
+
+
+def _make_fixed_light(base: Color) -> tuple[Color, Color]:
+ """Generate fixed and fixed_dim colors for light mode."""
+ h, s, _ = base.to_hsl()
+ fixed = Color.from_hsl(h, max(s, 0.70), 0.40)
+ fixed_dim = Color.from_hsl(h, max(s, 0.65), 0.30)
+ return fixed, fixed_dim
+
+
+def _interpolate_color(c1: Color, c2: Color, t: float) -> Color:
+ """Interpolate between two colors. t=0 returns c1, t=1 returns c2."""
+ r = int(c1.r + (c2.r - c1.r) * t)
+ g = int(c1.g + (c2.g - c1.g) * t)
+ b = int(c1.b + (c2.b - c1.b) * t)
+ return Color(max(0, min(255, r)), max(0, min(255, g)), max(0, min(255, b)))
+
+
+def expand_predefined_scheme(scheme_data: dict[str, str], mode: ThemeMode) -> dict[str, str]:
+ """
+ Expand 14-color predefined scheme to full 48-color palette.
+
+ Args:
+ scheme_data: Dictionary with keys like mPrimary, mSecondary, etc.
+ mode: "dark" or "light"
+
+ Returns:
+ Dictionary with all 48 color names mapped to hex values.
+ """
+ is_dark = mode == "dark"
+
+ # Parse input colors
+ primary = _hex_to_color(scheme_data["mPrimary"])
+ on_primary = _hex_to_color(scheme_data["mOnPrimary"])
+ secondary = _hex_to_color(scheme_data["mSecondary"])
+ on_secondary = _hex_to_color(scheme_data["mOnSecondary"])
+ tertiary = _hex_to_color(scheme_data["mTertiary"])
+ on_tertiary = _hex_to_color(scheme_data["mOnTertiary"])
+ error = _hex_to_color(scheme_data["mError"])
+ on_error = _hex_to_color(scheme_data["mOnError"])
+ surface = _hex_to_color(scheme_data["mSurface"])
+ on_surface = _hex_to_color(scheme_data["mOnSurface"])
+ surface_variant = _hex_to_color(scheme_data["mSurfaceVariant"])
+ on_surface_variant = _hex_to_color(scheme_data["mOnSurfaceVariant"])
+ outline_raw = _hex_to_color(scheme_data["mOutline"])
+ shadow = _hex_to_color(scheme_data.get("mShadow", scheme_data["mSurface"]))
+
+ # Generate container colors
+ if is_dark:
+ primary_container = _make_container_dark(primary)
+ secondary_container = _make_container_dark(secondary)
+ tertiary_container = _make_container_dark(tertiary)
+ error_container = _make_container_dark(error)
+ else:
+ primary_container = _make_container_light(primary)
+ secondary_container = _make_container_light(secondary)
+ tertiary_container = _make_container_light(tertiary)
+ error_container = _make_container_light(error)
+
+ # Generate "on container" colors with proper contrast
+ primary_h, primary_s, _ = primary.to_hsl()
+ secondary_h, secondary_s, _ = secondary.to_hsl()
+ tertiary_h, tertiary_s, _ = tertiary.to_hsl()
+ error_h, error_s, _ = error.to_hsl()
+
+ if is_dark:
+ # Light text on dark containers
+ on_primary_container = ensure_contrast(
+ Color.from_hsl(primary_h, primary_s, 0.90), primary_container, 4.5
+ )
+ on_secondary_container = ensure_contrast(
+ Color.from_hsl(secondary_h, secondary_s, 0.90), secondary_container, 4.5
+ )
+ on_tertiary_container = ensure_contrast(
+ Color.from_hsl(tertiary_h, tertiary_s, 0.90), tertiary_container, 4.5
+ )
+ on_error_container = ensure_contrast(
+ Color.from_hsl(error_h, error_s, 0.90), error_container, 4.5
+ )
+ else:
+ # Dark text on light containers
+ on_primary_container = ensure_contrast(
+ Color.from_hsl(primary_h, primary_s, 0.15), primary_container, 4.5
+ )
+ on_secondary_container = ensure_contrast(
+ Color.from_hsl(secondary_h, secondary_s, 0.15), secondary_container, 4.5
+ )
+ on_tertiary_container = ensure_contrast(
+ Color.from_hsl(tertiary_h, tertiary_s, 0.15), tertiary_container, 4.5
+ )
+ on_error_container = ensure_contrast(
+ Color.from_hsl(error_h, error_s, 0.15), error_container, 4.5
+ )
+
+ # Generate fixed colors
+ if is_dark:
+ primary_fixed, primary_fixed_dim = _make_fixed_dark(primary)
+ secondary_fixed, secondary_fixed_dim = _make_fixed_dark(secondary)
+ tertiary_fixed, tertiary_fixed_dim = _make_fixed_dark(tertiary)
+ else:
+ primary_fixed, primary_fixed_dim = _make_fixed_light(primary)
+ secondary_fixed, secondary_fixed_dim = _make_fixed_light(secondary)
+ tertiary_fixed, tertiary_fixed_dim = _make_fixed_light(tertiary)
+
+ # Generate "on fixed" colors
+ if is_dark:
+ on_primary_fixed = ensure_contrast(
+ Color.from_hsl(primary_h, 0.15, 0.15), primary_fixed, 4.5
+ )
+ on_primary_fixed_variant = ensure_contrast(
+ Color.from_hsl(primary_h, 0.15, 0.20), primary_fixed_dim, 4.5
+ )
+ on_secondary_fixed = ensure_contrast(
+ Color.from_hsl(secondary_h, 0.15, 0.15), secondary_fixed, 4.5
+ )
+ on_secondary_fixed_variant = ensure_contrast(
+ Color.from_hsl(secondary_h, 0.15, 0.20), secondary_fixed_dim, 4.5
+ )
+ on_tertiary_fixed = ensure_contrast(
+ Color.from_hsl(tertiary_h, 0.15, 0.15), tertiary_fixed, 4.5
+ )
+ on_tertiary_fixed_variant = ensure_contrast(
+ Color.from_hsl(tertiary_h, 0.15, 0.20), tertiary_fixed_dim, 4.5
+ )
+ else:
+ on_primary_fixed = ensure_contrast(
+ Color.from_hsl(primary_h, 0.15, 0.90), primary_fixed, 4.5
+ )
+ on_primary_fixed_variant = ensure_contrast(
+ Color.from_hsl(primary_h, 0.15, 0.85), primary_fixed_dim, 4.5
+ )
+ on_secondary_fixed = ensure_contrast(
+ Color.from_hsl(secondary_h, 0.15, 0.90), secondary_fixed, 4.5
+ )
+ on_secondary_fixed_variant = ensure_contrast(
+ Color.from_hsl(secondary_h, 0.15, 0.85), secondary_fixed_dim, 4.5
+ )
+ on_tertiary_fixed = ensure_contrast(
+ Color.from_hsl(tertiary_h, 0.15, 0.90), tertiary_fixed, 4.5
+ )
+ on_tertiary_fixed_variant = ensure_contrast(
+ Color.from_hsl(tertiary_h, 0.15, 0.85), tertiary_fixed_dim, 4.5
+ )
+
+ # Generate surface containers using mSurfaceVariant as the middle container
+ # This respects the scheme author's color choices
+ surface_h, surface_s, surface_l = surface.to_hsl()
+ sv_h, sv_s, sv_l = surface_variant.to_hsl()
+
+ # surface_container = mSurfaceVariant (direct assignment)
+ surface_container = surface_variant
+
+ if is_dark:
+ # Dark mode: surface is darkest, surface_variant is the middle container
+ # Lower containers interpolate between surface and surface_variant
+ surface_container_lowest = _interpolate_color(surface, surface_variant, 0.2)
+ surface_container_low = _interpolate_color(surface, surface_variant, 0.5)
+ # Higher containers go beyond surface_variant (lighter)
+ surface_container_high = Color.from_hsl(sv_h, sv_s, min(sv_l + 0.04, 0.40))
+ surface_container_highest = Color.from_hsl(sv_h, sv_s, min(sv_l + 0.08, 0.45))
+ # Dim is darker than surface, bright is lighter than highest container
+ surface_dim = Color.from_hsl(surface_h, surface_s, max(surface_l - 0.04, 0.02))
+ surface_bright = Color.from_hsl(sv_h, sv_s, min(sv_l + 0.12, 0.50))
+ else:
+ # Light mode: surface is lightest, surface_variant is the middle container
+ # Lower containers interpolate between surface and surface_variant
+ surface_container_lowest = _interpolate_color(surface, surface_variant, 0.2)
+ surface_container_low = _interpolate_color(surface, surface_variant, 0.5)
+ # Higher containers go beyond surface_variant (darker)
+ surface_container_high = Color.from_hsl(sv_h, sv_s, max(sv_l - 0.04, 0.60))
+ surface_container_highest = Color.from_hsl(sv_h, sv_s, max(sv_l - 0.08, 0.55))
+ # Dim is darker than highest, bright is lighter than surface
+ surface_dim = Color.from_hsl(sv_h, sv_s, max(sv_l - 0.12, 0.50))
+ surface_bright = Color.from_hsl(surface_h, surface_s, min(surface_l + 0.03, 0.98))
+
+ # Ensure outline has sufficient contrast against surface (3:1 minimum for UI)
+ outline = ensure_contrast(outline_raw, surface, 3.0)
+
+ # Generate outline variant
+ outline_h, outline_s, outline_l = outline.to_hsl()
+ if is_dark:
+ outline_variant = Color.from_hsl(outline_h, outline_s, max(outline_l - 0.15, 0.1))
+ else:
+ outline_variant = Color.from_hsl(outline_h, outline_s, min(outline_l + 0.15, 0.9))
+
+ # Scrim is always black
+ scrim = Color(0, 0, 0)
+
+ # Inverse colors
+ if is_dark:
+ inverse_surface = Color.from_hsl(surface_h, 0.08, 0.90)
+ inverse_on_surface = Color.from_hsl(surface_h, 0.05, 0.15)
+ inverse_primary = Color.from_hsl(primary_h, max(primary_s * 0.8, 0.5), 0.40)
+ else:
+ inverse_surface = Color.from_hsl(surface_h, 0.08, 0.15)
+ inverse_on_surface = Color.from_hsl(surface_h, 0.05, 0.90)
+ inverse_primary = Color.from_hsl(primary_h, max(primary_s * 0.8, 0.5), 0.70)
+
+ # Background is same as surface in MD3
+ background = surface
+ on_background = on_surface
+
+ return {
+ # Primary
+ "primary": primary.to_hex(),
+ "on_primary": on_primary.to_hex(),
+ "primary_container": primary_container.to_hex(),
+ "on_primary_container": on_primary_container.to_hex(),
+ "primary_fixed": primary_fixed.to_hex(),
+ "primary_fixed_dim": primary_fixed_dim.to_hex(),
+ "on_primary_fixed": on_primary_fixed.to_hex(),
+ "on_primary_fixed_variant": on_primary_fixed_variant.to_hex(),
+ # Secondary
+ "secondary": secondary.to_hex(),
+ "on_secondary": on_secondary.to_hex(),
+ "secondary_container": secondary_container.to_hex(),
+ "on_secondary_container": on_secondary_container.to_hex(),
+ "secondary_fixed": secondary_fixed.to_hex(),
+ "secondary_fixed_dim": secondary_fixed_dim.to_hex(),
+ "on_secondary_fixed": on_secondary_fixed.to_hex(),
+ "on_secondary_fixed_variant": on_secondary_fixed_variant.to_hex(),
+ # Tertiary
+ "tertiary": tertiary.to_hex(),
+ "on_tertiary": on_tertiary.to_hex(),
+ "tertiary_container": tertiary_container.to_hex(),
+ "on_tertiary_container": on_tertiary_container.to_hex(),
+ "tertiary_fixed": tertiary_fixed.to_hex(),
+ "tertiary_fixed_dim": tertiary_fixed_dim.to_hex(),
+ "on_tertiary_fixed": on_tertiary_fixed.to_hex(),
+ "on_tertiary_fixed_variant": on_tertiary_fixed_variant.to_hex(),
+ # Error
+ "error": error.to_hex(),
+ "on_error": on_error.to_hex(),
+ "error_container": error_container.to_hex(),
+ "on_error_container": on_error_container.to_hex(),
+ # Surface
+ "surface": surface.to_hex(),
+ "on_surface": on_surface.to_hex(),
+ "surface_variant": surface_variant.to_hex(),
+ "on_surface_variant": on_surface_variant.to_hex(),
+ "surface_dim": surface_dim.to_hex(),
+ "surface_bright": surface_bright.to_hex(),
+ # Surface containers
+ "surface_container_lowest": surface_container_lowest.to_hex(),
+ "surface_container_low": surface_container_low.to_hex(),
+ "surface_container": surface_container.to_hex(),
+ "surface_container_high": surface_container_high.to_hex(),
+ "surface_container_highest": surface_container_highest.to_hex(),
+ # Outline and other
+ "outline": outline.to_hex(),
+ "outline_variant": outline_variant.to_hex(),
+ "shadow": shadow.to_hex(),
+ "scrim": scrim.to_hex(),
+ # Inverse
+ "inverse_surface": inverse_surface.to_hex(),
+ "inverse_on_surface": inverse_on_surface.to_hex(),
+ "inverse_primary": inverse_primary.to_hex(),
+ # Background
+ "background": background.to_hex(),
+ "on_background": on_background.to_hex(),
+ }
+
+
+def inject_terminal_colors(result: dict[str, str], scheme_mode_data: dict) -> dict[str, str]:
+ """Flatten scheme's terminal section into template-ready color keys.
+
+ Adds keys like terminal_foreground, terminal_normal_black, terminal_bright_red, etc.
+ so predefined terminal templates can reference them as
+ {{colors.terminal_foreground.default.hex_stripped}}.
+
+ Args:
+ result: Expanded color palette dict to augment.
+ scheme_mode_data: Raw scheme JSON mode data (e.g., scheme_data["dark"]).
+
+ Returns:
+ The same result dict with terminal_ keys added.
+ """
+ terminal = scheme_mode_data.get("terminal")
+ if not terminal:
+ return result
+
+ # Map of JSON keys to flattened key names
+ direct_keys = {
+ "foreground": "terminal_foreground",
+ "background": "terminal_background",
+ "cursor": "terminal_cursor",
+ "cursorText": "terminal_cursor_text",
+ "selectionFg": "terminal_selection_fg",
+ "selectionBg": "terminal_selection_bg",
+ }
+
+ for json_key, flat_key in direct_keys.items():
+ if json_key in terminal:
+ result[flat_key] = terminal[json_key]
+
+ # ANSI normal/bright color groups
+ for group in ("normal", "bright"):
+ if group in terminal:
+ for name, hex_val in terminal[group].items():
+ result[f"terminal_{group}_{name}"] = hex_val
+
+ return result
diff --git a/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/lib/theme.py b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/lib/theme.py
new file mode 100644
index 0000000..871dfc8
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/lib/theme.py
@@ -0,0 +1,878 @@
+"""
+Theme generation functions for Material and Normal modes.
+
+This module provides functions for generating complete color themes
+from a color palette, supporting both Material Design 3 and a more
+vibrant "wallust-style" theme.
+
+Supported scheme types:
+- tonal-spot: Default Android 12-13 scheme (recommended)
+- fruit-salad: Bold/playful with hue rotation
+- rainbow: Chromatic accents with grayscale neutrals
+- monochrome: Pure grayscale M3 scheme (chroma = 0)
+- vibrant: Prioritizes the most saturated colors regardless of area
+- faithful: Prioritizes dominant colors by area coverage
+- muted: Preserves hue but caps saturation low (for monochrome wallpapers)
+"""
+
+from typing import Literal
+
+from .color import Color, shift_hue, hue_distance, adjust_surface
+from .contrast import ensure_contrast
+from .material import SchemeTonalSpot, SchemeFruitSalad, SchemeRainbow, SchemeContent, SchemeMonochrome
+from .palette import find_error_color
+
+# Type aliases
+ThemeMode = Literal["dark", "light"]
+SchemeType = Literal["tonal-spot", "fruit-salad", "rainbow", "content", "monochrome", "vibrant", "faithful", "muted"]
+
+# Map scheme type strings to classes
+SCHEME_CLASSES = {
+ "tonal-spot": SchemeTonalSpot,
+ "fruit-salad": SchemeFruitSalad,
+ "rainbow": SchemeRainbow,
+ "content": SchemeContent,
+ "monochrome": SchemeMonochrome,
+ # "vibrant", "faithful", and "muted" use generate_*_* functions, not a scheme class
+}
+
+
+def generate_material_dark(palette: list[Color], scheme_type: str = "tonal-spot") -> dict[str, str]:
+ """
+ Generate Material Design 3 dark theme from palette using HCT color space.
+
+ Args:
+ palette: List of extracted colors (primary color is index 0)
+ scheme_type: One of "tonal-spot", "fruit-salad", "rainbow"
+
+ Returns:
+ Dictionary of color token names to hex values
+ """
+ primary = palette[0] if palette else Color(255, 245, 155)
+
+ # Get the appropriate scheme class
+ scheme_class = SCHEME_CLASSES.get(scheme_type, SchemeTonalSpot)
+ scheme = scheme_class.from_rgb(primary.r, primary.g, primary.b)
+ return scheme.get_dark_scheme()
+
+
+def generate_material_light(palette: list[Color], scheme_type: str = "tonal-spot") -> dict[str, str]:
+ """
+ Generate Material Design 3 light theme from palette using HCT color space.
+
+ Args:
+ palette: List of extracted colors (primary color is index 0)
+ scheme_type: One of "tonal-spot", "fruit-salad", "rainbow"
+
+ Returns:
+ Dictionary of color token names to hex values
+ """
+ primary = palette[0] if palette else Color(93, 101, 245)
+
+ # Get the appropriate scheme class
+ scheme_class = SCHEME_CLASSES.get(scheme_type, SchemeTonalSpot)
+ scheme = scheme_class.from_rgb(primary.r, primary.g, primary.b)
+ return scheme.get_light_scheme()
+
+
+def generate_normal_dark(palette: list[Color]) -> dict[str, str]:
+ """
+ Generate wallust-style dark theme from palette.
+
+ More vibrant than Material - uses palette colors directly and keeps
+ surfaces saturated with the primary hue. Outputs same keys as Material.
+ """
+ # Use extracted colors directly (wallust style)
+ # But check if colors are distinct enough - if not, derive from primary
+ primary = palette[0] if palette else Color(255, 245, 155)
+ primary_h, primary_s, primary_l = primary.to_hsl()
+
+ # Secondary: use palette[1] only if hue is >30ยฐ different, otherwise derive
+ MIN_HUE_DISTANCE = 30
+ if len(palette) > 1:
+ sec_h, _, _ = palette[1].to_hsl()
+ if hue_distance(primary_h, sec_h) > MIN_HUE_DISTANCE:
+ secondary = palette[1]
+ else:
+ # Colors too similar - shift hue by 30ยฐ to stay in same color family
+ secondary = shift_hue(primary, 30)
+ else:
+ secondary = shift_hue(primary, 30)
+
+ # Tertiary: use palette[2] only if hue is >30ยฐ different from both primary and secondary
+ if len(palette) > 2:
+ ter_h, _, _ = palette[2].to_hsl()
+ sec_h, _, _ = secondary.to_hsl()
+ if hue_distance(primary_h, ter_h) > MIN_HUE_DISTANCE and hue_distance(sec_h, ter_h) > MIN_HUE_DISTANCE:
+ tertiary = palette[2]
+ else:
+ # Colors too similar - shift hue by 60ยฐ from primary to stay closer to original
+ tertiary = shift_hue(primary, 60)
+ else:
+ tertiary = shift_hue(primary, 60)
+
+ error = find_error_color(palette)
+
+ # Keep colors vibrant - preserve saturation
+ h, s, l = primary.to_hsl()
+ primary_adjusted = Color.from_hsl(h, max(s, 0.7), max(l, 0.65))
+
+ h, s, l = secondary.to_hsl()
+ secondary_adjusted = Color.from_hsl(h, max(s, 0.6), max(l, 0.60))
+
+ h, s, l = tertiary.to_hsl()
+ tertiary_adjusted = Color.from_hsl(h, max(s, 0.5), max(l, 0.60))
+
+ # Container colors - darker, more saturated versions of accent colors
+ def make_container_dark(base: Color) -> Color:
+ h, s, l = base.to_hsl()
+ return Color.from_hsl(h, min(s + 0.15, 1.0), max(l - 0.35, 0.15))
+
+ primary_container = make_container_dark(primary_adjusted)
+ secondary_container = make_container_dark(secondary_adjusted)
+ tertiary_container = make_container_dark(tertiary_adjusted)
+ error_container = make_container_dark(error)
+
+ # Surface: COLORFUL dark - a deep, saturated version of primary
+ # Heuristic: Shift Cyan (160-200) slightly towards Blue (+10) to avoid "Teal" look
+ surface_hue, s, _ = palette[0].to_hsl()
+ if 160 <= surface_hue <= 200:
+ surface_hue = (surface_hue + 10) % 360
+
+ # Reduce saturation for warm hues (red/orange/yellow) - they feel overwhelming as surfaces
+ # Warm hues: 0-60 and 300-360
+ if surface_hue < 60 or surface_hue > 300:
+ surface_saturation_cap = 0.35 # More desaturated for warm colors
+ elif 60 <= surface_hue < 120:
+ surface_saturation_cap = 0.50 # Moderate for yellow-greens
+ else:
+ surface_saturation_cap = 0.90 # Keep cool colors vibrant
+
+ base_surface = Color.from_hsl(surface_hue, min(s, surface_saturation_cap), 0.5)
+
+ # Preserving saturation (up to the cap) to be true to primary color
+ surface = adjust_surface(base_surface, surface_saturation_cap, 0.12)
+ surface_variant = adjust_surface(base_surface, min(0.80, surface_saturation_cap), 0.16)
+
+ # Surface containers - progressive lightness for visual hierarchy (keep primary hue)
+ surface_container_lowest = adjust_surface(base_surface, 0.85, 0.06)
+ surface_container_low = adjust_surface(base_surface, 0.85, 0.10)
+ surface_container = adjust_surface(base_surface, 0.70, 0.20)
+ surface_container_high = adjust_surface(base_surface, 0.75, 0.18)
+ surface_container_highest = adjust_surface(base_surface, 0.70, 0.22)
+
+ # Text colors - desaturated
+ text_h, _, _ = palette[0].to_hsl()
+ base_on_surface = Color.from_hsl(text_h, 0.05, 0.95)
+ on_surface = ensure_contrast(base_on_surface, surface, 4.5)
+
+ base_on_surface_variant = Color.from_hsl(text_h, 0.05, 0.70)
+ on_surface_variant = ensure_contrast(base_on_surface_variant, surface_variant, 4.5)
+
+ outline = ensure_contrast(adjust_surface(palette[0], 0.10, 0.30), surface, 3.0)
+ outline_variant = ensure_contrast(adjust_surface(palette[0], 0.10, 0.40), surface, 3.0)
+
+ # Contrasting foregrounds - dark text on bright accent colors
+ dark_fg = Color.from_hsl(palette[0].to_hsl()[0], 0.20, 0.12) # Darker for better contrast
+ on_primary = ensure_contrast(dark_fg, primary_adjusted, 7.0) # Higher contrast target
+ on_secondary = ensure_contrast(dark_fg, secondary_adjusted, 7.0)
+ on_tertiary = ensure_contrast(dark_fg, tertiary_adjusted, 7.0)
+ on_error = ensure_contrast(dark_fg, error, 7.0)
+
+ # "On" colors for containers - light text on dark containers, tinted with respective color
+ # Explicitly prefer_light=True since containers in dark mode are dark
+ on_primary_container = ensure_contrast(Color.from_hsl(primary_h, primary_s, 0.90), primary_container, 4.5, prefer_light=True)
+ sec_h, sec_s, _ = secondary.to_hsl()
+ on_secondary_container = ensure_contrast(Color.from_hsl(sec_h, sec_s, 0.90), secondary_container, 4.5, prefer_light=True)
+ ter_h, ter_s, _ = tertiary.to_hsl()
+ on_tertiary_container = ensure_contrast(Color.from_hsl(ter_h, ter_s, 0.90), tertiary_container, 4.5, prefer_light=True)
+ err_h, err_s, _ = error.to_hsl()
+ on_error_container = ensure_contrast(Color.from_hsl(err_h, err_s, 0.90), error_container, 4.5, prefer_light=True)
+
+ # Shadow and scrim
+ shadow = surface
+ scrim = Color(0, 0, 0) # Pure black
+
+ # Inverse colors - for inverted surfaces (light surface on dark theme)
+ inv_h = palette[0].to_hsl()[0]
+ inverse_surface = Color.from_hsl(inv_h, 0.08, 0.90)
+ inverse_on_surface = Color.from_hsl(inv_h, 0.05, 0.15)
+ inverse_primary = Color.from_hsl(primary_h, max(primary_s * 0.8, 0.5), 0.40)
+
+ # Background aliases (same as surface in MD3)
+ background = surface
+ on_background = on_surface
+
+ # Fixed colors - high-chroma accents consistent across light/dark
+ # In dark mode: lighter versions of accent colors
+ def make_fixed_dark(base: Color) -> tuple[Color, Color]:
+ h, s, _ = base.to_hsl()
+ fixed = Color.from_hsl(h, max(s, 0.70), 0.85) # Light, saturated
+ fixed_dim = Color.from_hsl(h, max(s, 0.65), 0.75) # Slightly darker
+ return fixed, fixed_dim
+
+ primary_fixed, primary_fixed_dim = make_fixed_dark(primary_adjusted)
+ secondary_fixed, secondary_fixed_dim = make_fixed_dark(secondary_adjusted)
+ tertiary_fixed, tertiary_fixed_dim = make_fixed_dark(tertiary_adjusted)
+
+ # "On" colors for fixed - dark text on light fixed colors
+ on_primary_fixed = ensure_contrast(Color.from_hsl(primary_h, 0.15, 0.15), primary_fixed, 4.5)
+ on_primary_fixed_variant = ensure_contrast(Color.from_hsl(primary_h, 0.15, 0.20), primary_fixed_dim, 4.5)
+ on_secondary_fixed = ensure_contrast(Color.from_hsl(secondary.to_hsl()[0], 0.15, 0.15), secondary_fixed, 4.5)
+ on_secondary_fixed_variant = ensure_contrast(Color.from_hsl(secondary.to_hsl()[0], 0.15, 0.20), secondary_fixed_dim, 4.5)
+ on_tertiary_fixed = ensure_contrast(Color.from_hsl(tertiary.to_hsl()[0], 0.15, 0.15), tertiary_fixed, 4.5)
+ on_tertiary_fixed_variant = ensure_contrast(Color.from_hsl(tertiary.to_hsl()[0], 0.15, 0.20), tertiary_fixed_dim, 4.5)
+
+ # Surface dim - darker than surface for dimmed areas
+ surface_dim = adjust_surface(base_surface, 0.85, 0.08)
+ # Surface bright - lighter than surface
+ surface_bright = adjust_surface(base_surface, 0.75, 0.24)
+
+ return {
+ # Primary
+ "primary": primary_adjusted.to_hex(),
+ "on_primary": on_primary.to_hex(),
+ "primary_container": primary_container.to_hex(),
+ "on_primary_container": on_primary_container.to_hex(),
+ "primary_fixed": primary_fixed.to_hex(),
+ "primary_fixed_dim": primary_fixed_dim.to_hex(),
+ "on_primary_fixed": on_primary_fixed.to_hex(),
+ "on_primary_fixed_variant": on_primary_fixed_variant.to_hex(),
+ "surface_tint": primary_adjusted.to_hex(),
+ # Secondary
+ "secondary": secondary_adjusted.to_hex(),
+ "on_secondary": on_secondary.to_hex(),
+ "secondary_container": secondary_container.to_hex(),
+ "on_secondary_container": on_secondary_container.to_hex(),
+ "secondary_fixed": secondary_fixed.to_hex(),
+ "secondary_fixed_dim": secondary_fixed_dim.to_hex(),
+ "on_secondary_fixed": on_secondary_fixed.to_hex(),
+ "on_secondary_fixed_variant": on_secondary_fixed_variant.to_hex(),
+ # Tertiary
+ "tertiary": tertiary_adjusted.to_hex(),
+ "on_tertiary": on_tertiary.to_hex(),
+ "tertiary_container": tertiary_container.to_hex(),
+ "on_tertiary_container": on_tertiary_container.to_hex(),
+ "tertiary_fixed": tertiary_fixed.to_hex(),
+ "tertiary_fixed_dim": tertiary_fixed_dim.to_hex(),
+ "on_tertiary_fixed": on_tertiary_fixed.to_hex(),
+ "on_tertiary_fixed_variant": on_tertiary_fixed_variant.to_hex(),
+ # Error
+ "error": error.to_hex(),
+ "on_error": on_error.to_hex(),
+ "error_container": error_container.to_hex(),
+ "on_error_container": on_error_container.to_hex(),
+ # Surface
+ "surface": surface.to_hex(),
+ "on_surface": on_surface.to_hex(),
+ "surface_variant": surface_variant.to_hex(),
+ "on_surface_variant": on_surface_variant.to_hex(),
+ "surface_dim": surface_dim.to_hex(),
+ "surface_bright": surface_bright.to_hex(),
+ # Surface containers
+ "surface_container_lowest": surface_container_lowest.to_hex(),
+ "surface_container_low": surface_container_low.to_hex(),
+ "surface_container": surface_container.to_hex(),
+ "surface_container_high": surface_container_high.to_hex(),
+ "surface_container_highest": surface_container_highest.to_hex(),
+ # Outline and other
+ "outline": outline.to_hex(),
+ "outline_variant": outline_variant.to_hex(),
+ "shadow": shadow.to_hex(),
+ "scrim": scrim.to_hex(),
+ # Inverse
+ "inverse_surface": inverse_surface.to_hex(),
+ "inverse_on_surface": inverse_on_surface.to_hex(),
+ "inverse_primary": inverse_primary.to_hex(),
+ # Background
+ "background": background.to_hex(),
+ "on_background": on_background.to_hex(),
+ }
+
+
+def generate_normal_light(palette: list[Color]) -> dict[str, str]:
+ """
+ Generate wallust-style light theme from palette.
+
+ More vibrant than Material - uses palette colors directly and keeps
+ surfaces saturated with the primary hue. Outputs same keys as Material.
+ """
+ # Use extracted colors directly, but check if distinct enough
+ primary = palette[0] if palette else Color(93, 101, 245)
+ primary_h, _, _ = primary.to_hsl()
+
+ # Secondary: use palette[1] only if hue is >30ยฐ different
+ MIN_HUE_DISTANCE = 30
+ if len(palette) > 1:
+ sec_h, _, _ = palette[1].to_hsl()
+ if hue_distance(primary_h, sec_h) > MIN_HUE_DISTANCE:
+ secondary = palette[1]
+ else:
+ secondary = shift_hue(primary, 30)
+ else:
+ secondary = shift_hue(primary, 30)
+
+ # Tertiary: use palette[2] only if hue is >30ยฐ different from both
+ if len(palette) > 2:
+ ter_h, _, _ = palette[2].to_hsl()
+ sec_h, _, _ = secondary.to_hsl()
+ if hue_distance(primary_h, ter_h) > MIN_HUE_DISTANCE and hue_distance(sec_h, ter_h) > MIN_HUE_DISTANCE:
+ tertiary = palette[2]
+ else:
+ tertiary = shift_hue(primary, 60)
+ else:
+ tertiary = shift_hue(primary, 60)
+
+ error = find_error_color(palette)
+
+ # Keep colors vibrant - darken for visibility on light bg
+ # Clamp lightness to [0.25, 0.45] so colors are never near-black nor washed out
+ h, s, l = primary.to_hsl()
+ primary_adjusted = Color.from_hsl(h, max(s, 0.7), max(min(l, 0.45), 0.25))
+
+ h, s, l = secondary.to_hsl()
+ secondary_adjusted = Color.from_hsl(h, max(s, 0.6), max(min(l, 0.40), 0.22))
+
+ h, s, l = tertiary.to_hsl()
+ tertiary_adjusted = Color.from_hsl(h, max(s, 0.5), max(min(l, 0.35), 0.20))
+
+ # Container colors - lighter, less saturated versions of accent colors for light mode
+ def make_container_light(base: Color) -> Color:
+ h, s, l = base.to_hsl()
+ return Color.from_hsl(h, max(s - 0.20, 0.30), min(l + 0.35, 0.85))
+
+ primary_container = make_container_light(primary_adjusted)
+ secondary_container = make_container_light(secondary_adjusted)
+ tertiary_container = make_container_light(tertiary_adjusted)
+ error_container = make_container_light(error)
+
+ # Surface: COLORFUL light - a pastel, saturated version of primary
+ # Preserving saturation (up to 0.9) to be true to primary color
+ surface = adjust_surface(palette[0], 0.90, 0.90)
+ surface_variant = adjust_surface(palette[0], 0.80, 0.78) # Darker than surface
+
+ # Surface containers - progressive darkening for light mode (keep primary hue)
+ surface_container_lowest = adjust_surface(palette[0], 0.85, 0.96) # Lightest
+ surface_container_low = adjust_surface(palette[0], 0.85, 0.92)
+ surface_container = adjust_surface(palette[0], 0.80, 0.86)
+ surface_container_high = adjust_surface(palette[0], 0.75, 0.84)
+ surface_container_highest = adjust_surface(palette[0], 0.70, 0.80) # Darkest
+
+ # Foreground colors - tinted with primary hue
+ text_h, _, _ = palette[0].to_hsl()
+ base_on_surface = Color.from_hsl(text_h, 0.05, 0.10)
+ on_surface = ensure_contrast(base_on_surface, surface, 4.5)
+
+ base_on_surface_variant = Color.from_hsl(text_h, 0.05, 0.35)
+ on_surface_variant = ensure_contrast(base_on_surface_variant, surface_variant, 4.5)
+
+ # Contrasting foregrounds - light text on dark accent colors
+ light_fg = Color.from_hsl(text_h, 0.1, 0.98) # Brighter for better contrast
+ on_primary = ensure_contrast(light_fg, primary_adjusted, 7.0) # Higher contrast target
+ on_secondary = ensure_contrast(light_fg, secondary_adjusted, 7.0)
+ on_tertiary = ensure_contrast(light_fg, tertiary_adjusted, 7.0)
+ on_error = ensure_contrast(light_fg, error, 7.0)
+
+ # "On" colors for containers - dark text on light containers, tinted with respective color
+ # Explicitly prefer_light=False since containers in light mode are light
+ primary_h, primary_s, _ = primary.to_hsl()
+ on_primary_container = ensure_contrast(Color.from_hsl(primary_h, primary_s, 0.15), primary_container, 4.5, prefer_light=False)
+ sec_h, sec_s, _ = secondary.to_hsl()
+ on_secondary_container = ensure_contrast(Color.from_hsl(sec_h, sec_s, 0.15), secondary_container, 4.5, prefer_light=False)
+ ter_h, ter_s, _ = tertiary.to_hsl()
+ on_tertiary_container = ensure_contrast(Color.from_hsl(ter_h, ter_s, 0.15), tertiary_container, 4.5, prefer_light=False)
+ err_h, err_s, _ = error.to_hsl()
+ on_error_container = ensure_contrast(Color.from_hsl(err_h, err_s, 0.15), error_container, 4.5, prefer_light=False)
+
+ # Fixed colors - high-chroma accents consistent across light/dark
+ # In light mode: darker versions of accent colors
+ def make_fixed_light(base: Color) -> tuple[Color, Color]:
+ h, s, _ = base.to_hsl()
+ fixed = Color.from_hsl(h, max(s, 0.70), 0.40) # Darker, saturated
+ fixed_dim = Color.from_hsl(h, max(s, 0.65), 0.30) # Even darker
+ return fixed, fixed_dim
+
+ primary_fixed, primary_fixed_dim = make_fixed_light(primary_adjusted)
+ secondary_fixed, secondary_fixed_dim = make_fixed_light(secondary_adjusted)
+ tertiary_fixed, tertiary_fixed_dim = make_fixed_light(tertiary_adjusted)
+
+ # "On" colors for fixed - light text on dark fixed colors
+ on_primary_fixed = ensure_contrast(Color.from_hsl(primary_h, 0.15, 0.90), primary_fixed, 4.5)
+ on_primary_fixed_variant = ensure_contrast(Color.from_hsl(primary_h, 0.15, 0.85), primary_fixed_dim, 4.5)
+ on_secondary_fixed = ensure_contrast(Color.from_hsl(secondary.to_hsl()[0], 0.15, 0.90), secondary_fixed, 4.5)
+ on_secondary_fixed_variant = ensure_contrast(Color.from_hsl(secondary.to_hsl()[0], 0.15, 0.85), secondary_fixed_dim, 4.5)
+ on_tertiary_fixed = ensure_contrast(Color.from_hsl(tertiary.to_hsl()[0], 0.15, 0.90), tertiary_fixed, 4.5)
+ on_tertiary_fixed_variant = ensure_contrast(Color.from_hsl(tertiary.to_hsl()[0], 0.15, 0.85), tertiary_fixed_dim, 4.5)
+
+ # Surface dim - slightly darker than surface
+ surface_dim = adjust_surface(palette[0], 0.85, 0.82)
+ # Surface bright - brighter than surface
+ surface_bright = adjust_surface(palette[0], 0.90, 0.95)
+
+ # Outline uses primary hue, more saturated
+ surface_h, surface_s, _ = palette[0].to_hsl()
+ outline = ensure_contrast(Color.from_hsl(surface_h, max(surface_s * 0.4, 0.25), 0.65), surface, 3.0)
+ outline_variant = ensure_contrast(Color.from_hsl(surface_h, max(surface_s * 0.3, 0.20), 0.75), surface, 3.0)
+ shadow = Color.from_hsl(surface_h, max(surface_s * 0.3, 0.15), 0.80)
+ scrim = Color(0, 0, 0) # Pure black
+
+ # Inverse colors - for inverted surfaces (dark surface on light theme)
+ inverse_surface = Color.from_hsl(surface_h, 0.08, 0.15)
+ inverse_on_surface = Color.from_hsl(surface_h, 0.05, 0.90)
+ inverse_primary = Color.from_hsl(primary_h, max(primary_s * 0.8, 0.5), 0.70)
+
+ # Background aliases (same as surface in MD3)
+ background = surface
+ on_background = on_surface
+
+ return {
+ # Primary
+ "primary": primary_adjusted.to_hex(),
+ "on_primary": on_primary.to_hex(),
+ "primary_container": primary_container.to_hex(),
+ "on_primary_container": on_primary_container.to_hex(),
+ "primary_fixed": primary_fixed.to_hex(),
+ "primary_fixed_dim": primary_fixed_dim.to_hex(),
+ "on_primary_fixed": on_primary_fixed.to_hex(),
+ "on_primary_fixed_variant": on_primary_fixed_variant.to_hex(),
+ "surface_tint": primary_adjusted.to_hex(),
+ # Secondary
+ "secondary": secondary_adjusted.to_hex(),
+ "on_secondary": on_secondary.to_hex(),
+ "secondary_container": secondary_container.to_hex(),
+ "on_secondary_container": on_secondary_container.to_hex(),
+ "secondary_fixed": secondary_fixed.to_hex(),
+ "secondary_fixed_dim": secondary_fixed_dim.to_hex(),
+ "on_secondary_fixed": on_secondary_fixed.to_hex(),
+ "on_secondary_fixed_variant": on_secondary_fixed_variant.to_hex(),
+ # Tertiary
+ "tertiary": tertiary_adjusted.to_hex(),
+ "on_tertiary": on_tertiary.to_hex(),
+ "tertiary_container": tertiary_container.to_hex(),
+ "on_tertiary_container": on_tertiary_container.to_hex(),
+ "tertiary_fixed": tertiary_fixed.to_hex(),
+ "tertiary_fixed_dim": tertiary_fixed_dim.to_hex(),
+ "on_tertiary_fixed": on_tertiary_fixed.to_hex(),
+ "on_tertiary_fixed_variant": on_tertiary_fixed_variant.to_hex(),
+ # Error
+ "error": error.to_hex(),
+ "on_error": on_error.to_hex(),
+ "error_container": error_container.to_hex(),
+ "on_error_container": on_error_container.to_hex(),
+ # Surface
+ "surface": surface.to_hex(),
+ "on_surface": on_surface.to_hex(),
+ "surface_variant": surface_variant.to_hex(),
+ "on_surface_variant": on_surface_variant.to_hex(),
+ "surface_dim": surface_dim.to_hex(),
+ "surface_bright": surface_bright.to_hex(),
+ # Surface containers
+ "surface_container_lowest": surface_container_lowest.to_hex(),
+ "surface_container_low": surface_container_low.to_hex(),
+ "surface_container": surface_container.to_hex(),
+ "surface_container_high": surface_container_high.to_hex(),
+ "surface_container_highest": surface_container_highest.to_hex(),
+ # Outline and other
+ "outline": outline.to_hex(),
+ "outline_variant": outline_variant.to_hex(),
+ "shadow": shadow.to_hex(),
+ "scrim": scrim.to_hex(),
+ # Inverse
+ "inverse_surface": inverse_surface.to_hex(),
+ "inverse_on_surface": inverse_on_surface.to_hex(),
+ "inverse_primary": inverse_primary.to_hex(),
+ # Background
+ "background": background.to_hex(),
+ "on_background": on_background.to_hex(),
+ }
+
+
+def generate_muted_dark(palette: list[Color]) -> dict[str, str]:
+ """
+ Generate muted dark theme from palette.
+
+ Designed for monochrome/monotonal wallpapers - preserves the dominant hue
+ but caps saturation to very low values for a subtle, understated look.
+ Outputs same keys as Material for compatibility.
+ """
+ # Use primary color's hue but with very low saturation
+ primary = palette[0] if palette else Color(128, 128, 128)
+ primary_h, primary_s, primary_l = primary.to_hsl()
+
+ # Derive secondary and tertiary with subtle hue shifts (monochromatic feel)
+ # Much smaller shifts than normal mode since we want cohesion
+ secondary = shift_hue(primary, 15)
+ tertiary = shift_hue(primary, 30)
+ error = find_error_color(palette)
+
+ # Cap saturation low - this is the key difference from normal mode
+ MUTED_SAT_PRIMARY = 0.15
+ MUTED_SAT_SECONDARY = 0.12
+ MUTED_SAT_TERTIARY = 0.10
+ MUTED_SAT_SURFACE = 0.08
+
+ h, s, l = primary.to_hsl()
+ primary_adjusted = Color.from_hsl(h, min(s, MUTED_SAT_PRIMARY), max(l, 0.65))
+
+ h, s, l = secondary.to_hsl()
+ secondary_adjusted = Color.from_hsl(h, min(s, MUTED_SAT_SECONDARY), max(l, 0.60))
+
+ h, s, l = tertiary.to_hsl()
+ tertiary_adjusted = Color.from_hsl(h, min(s, MUTED_SAT_TERTIARY), max(l, 0.60))
+
+ # Container colors - darker, slightly saturated versions
+ def make_container_dark(base: Color) -> Color:
+ h, s, l = base.to_hsl()
+ return Color.from_hsl(h, min(s + 0.05, MUTED_SAT_PRIMARY), max(l - 0.35, 0.15))
+
+ primary_container = make_container_dark(primary_adjusted)
+ secondary_container = make_container_dark(secondary_adjusted)
+ tertiary_container = make_container_dark(tertiary_adjusted)
+ error_container = make_container_dark(error)
+
+ # Surface: very low saturation, preserving hue for subtle tint
+ surface_hue = primary_h
+ base_surface = Color.from_hsl(surface_hue, MUTED_SAT_SURFACE, 0.5)
+
+ surface = adjust_surface(base_surface, MUTED_SAT_SURFACE, 0.12)
+ surface_variant = adjust_surface(base_surface, MUTED_SAT_SURFACE, 0.16)
+
+ # Surface containers - progressive lightness with minimal saturation
+ surface_container_lowest = adjust_surface(base_surface, MUTED_SAT_SURFACE, 0.06)
+ surface_container_low = adjust_surface(base_surface, MUTED_SAT_SURFACE, 0.10)
+ surface_container = adjust_surface(base_surface, MUTED_SAT_SURFACE, 0.20)
+ surface_container_high = adjust_surface(base_surface, MUTED_SAT_SURFACE, 0.18)
+ surface_container_highest = adjust_surface(base_surface, MUTED_SAT_SURFACE, 0.22)
+
+ # Text colors - near-neutral with slight hue tint
+ base_on_surface = Color.from_hsl(primary_h, 0.03, 0.95)
+ on_surface = ensure_contrast(base_on_surface, surface, 4.5)
+
+ base_on_surface_variant = Color.from_hsl(primary_h, 0.03, 0.70)
+ on_surface_variant = ensure_contrast(base_on_surface_variant, surface_variant, 4.5)
+
+ outline = ensure_contrast(Color.from_hsl(primary_h, 0.05, 0.30), surface, 3.0)
+ outline_variant = ensure_contrast(Color.from_hsl(primary_h, 0.05, 0.40), surface, 3.0)
+
+ # Contrasting foregrounds
+ dark_fg = Color.from_hsl(primary_h, 0.10, 0.12)
+ on_primary = ensure_contrast(dark_fg, primary_adjusted, 7.0)
+ on_secondary = ensure_contrast(dark_fg, secondary_adjusted, 7.0)
+ on_tertiary = ensure_contrast(dark_fg, tertiary_adjusted, 7.0)
+ on_error = ensure_contrast(dark_fg, error, 7.0)
+
+ # "On" colors for containers
+ on_primary_container = ensure_contrast(Color.from_hsl(primary_h, 0.05, 0.90), primary_container, 4.5, prefer_light=True)
+ sec_h, _, _ = secondary.to_hsl()
+ on_secondary_container = ensure_contrast(Color.from_hsl(sec_h, 0.05, 0.90), secondary_container, 4.5, prefer_light=True)
+ ter_h, _, _ = tertiary.to_hsl()
+ on_tertiary_container = ensure_contrast(Color.from_hsl(ter_h, 0.05, 0.90), tertiary_container, 4.5, prefer_light=True)
+ err_h, _, _ = error.to_hsl()
+ on_error_container = ensure_contrast(Color.from_hsl(err_h, 0.05, 0.90), error_container, 4.5, prefer_light=True)
+
+ # Shadow and scrim
+ shadow = surface
+ scrim = Color(0, 0, 0)
+
+ # Inverse colors
+ inverse_surface = Color.from_hsl(primary_h, 0.05, 0.90)
+ inverse_on_surface = Color.from_hsl(primary_h, 0.03, 0.15)
+ inverse_primary = Color.from_hsl(primary_h, min(primary_s * 0.5, MUTED_SAT_PRIMARY), 0.40)
+
+ # Background aliases
+ background = surface
+ on_background = on_surface
+
+ # Fixed colors - still muted
+ def make_fixed_dark(base: Color) -> tuple[Color, Color]:
+ h, s, _ = base.to_hsl()
+ fixed = Color.from_hsl(h, min(s, MUTED_SAT_PRIMARY), 0.85)
+ fixed_dim = Color.from_hsl(h, min(s, MUTED_SAT_PRIMARY), 0.75)
+ return fixed, fixed_dim
+
+ primary_fixed, primary_fixed_dim = make_fixed_dark(primary_adjusted)
+ secondary_fixed, secondary_fixed_dim = make_fixed_dark(secondary_adjusted)
+ tertiary_fixed, tertiary_fixed_dim = make_fixed_dark(tertiary_adjusted)
+
+ # "On" colors for fixed
+ on_primary_fixed = ensure_contrast(Color.from_hsl(primary_h, 0.05, 0.15), primary_fixed, 4.5)
+ on_primary_fixed_variant = ensure_contrast(Color.from_hsl(primary_h, 0.05, 0.20), primary_fixed_dim, 4.5)
+ on_secondary_fixed = ensure_contrast(Color.from_hsl(sec_h, 0.05, 0.15), secondary_fixed, 4.5)
+ on_secondary_fixed_variant = ensure_contrast(Color.from_hsl(sec_h, 0.05, 0.20), secondary_fixed_dim, 4.5)
+ on_tertiary_fixed = ensure_contrast(Color.from_hsl(ter_h, 0.05, 0.15), tertiary_fixed, 4.5)
+ on_tertiary_fixed_variant = ensure_contrast(Color.from_hsl(ter_h, 0.05, 0.20), tertiary_fixed_dim, 4.5)
+
+ # Surface dim and bright
+ surface_dim = adjust_surface(base_surface, MUTED_SAT_SURFACE, 0.08)
+ surface_bright = adjust_surface(base_surface, MUTED_SAT_SURFACE, 0.24)
+
+ return {
+ # Primary
+ "primary": primary_adjusted.to_hex(),
+ "on_primary": on_primary.to_hex(),
+ "primary_container": primary_container.to_hex(),
+ "on_primary_container": on_primary_container.to_hex(),
+ "primary_fixed": primary_fixed.to_hex(),
+ "primary_fixed_dim": primary_fixed_dim.to_hex(),
+ "on_primary_fixed": on_primary_fixed.to_hex(),
+ "on_primary_fixed_variant": on_primary_fixed_variant.to_hex(),
+ "surface_tint": primary_adjusted.to_hex(),
+ # Secondary
+ "secondary": secondary_adjusted.to_hex(),
+ "on_secondary": on_secondary.to_hex(),
+ "secondary_container": secondary_container.to_hex(),
+ "on_secondary_container": on_secondary_container.to_hex(),
+ "secondary_fixed": secondary_fixed.to_hex(),
+ "secondary_fixed_dim": secondary_fixed_dim.to_hex(),
+ "on_secondary_fixed": on_secondary_fixed.to_hex(),
+ "on_secondary_fixed_variant": on_secondary_fixed_variant.to_hex(),
+ # Tertiary
+ "tertiary": tertiary_adjusted.to_hex(),
+ "on_tertiary": on_tertiary.to_hex(),
+ "tertiary_container": tertiary_container.to_hex(),
+ "on_tertiary_container": on_tertiary_container.to_hex(),
+ "tertiary_fixed": tertiary_fixed.to_hex(),
+ "tertiary_fixed_dim": tertiary_fixed_dim.to_hex(),
+ "on_tertiary_fixed": on_tertiary_fixed.to_hex(),
+ "on_tertiary_fixed_variant": on_tertiary_fixed_variant.to_hex(),
+ # Error
+ "error": error.to_hex(),
+ "on_error": on_error.to_hex(),
+ "error_container": error_container.to_hex(),
+ "on_error_container": on_error_container.to_hex(),
+ # Surface
+ "surface": surface.to_hex(),
+ "on_surface": on_surface.to_hex(),
+ "surface_variant": surface_variant.to_hex(),
+ "on_surface_variant": on_surface_variant.to_hex(),
+ "surface_dim": surface_dim.to_hex(),
+ "surface_bright": surface_bright.to_hex(),
+ # Surface containers
+ "surface_container_lowest": surface_container_lowest.to_hex(),
+ "surface_container_low": surface_container_low.to_hex(),
+ "surface_container": surface_container.to_hex(),
+ "surface_container_high": surface_container_high.to_hex(),
+ "surface_container_highest": surface_container_highest.to_hex(),
+ # Outline and other
+ "outline": outline.to_hex(),
+ "outline_variant": outline_variant.to_hex(),
+ "shadow": shadow.to_hex(),
+ "scrim": scrim.to_hex(),
+ # Inverse
+ "inverse_surface": inverse_surface.to_hex(),
+ "inverse_on_surface": inverse_on_surface.to_hex(),
+ "inverse_primary": inverse_primary.to_hex(),
+ # Background
+ "background": background.to_hex(),
+ "on_background": on_background.to_hex(),
+ }
+
+
+def generate_muted_light(palette: list[Color]) -> dict[str, str]:
+ """
+ Generate muted light theme from palette.
+
+ Designed for monochrome/monotonal wallpapers - preserves the dominant hue
+ but caps saturation to very low values for a subtle, understated look.
+ Outputs same keys as Material for compatibility.
+ """
+ primary = palette[0] if palette else Color(128, 128, 128)
+ primary_h, primary_s, _ = primary.to_hsl()
+
+ # Derive secondary and tertiary with subtle hue shifts
+ secondary = shift_hue(primary, 15)
+ tertiary = shift_hue(primary, 30)
+ error = find_error_color(palette)
+
+ # Cap saturation low
+ MUTED_SAT_PRIMARY = 0.15
+ MUTED_SAT_SECONDARY = 0.12
+ MUTED_SAT_TERTIARY = 0.10
+ MUTED_SAT_SURFACE = 0.08
+
+ h, s, l = primary.to_hsl()
+ primary_adjusted = Color.from_hsl(h, min(s, MUTED_SAT_PRIMARY), min(l, 0.45))
+
+ h, s, l = secondary.to_hsl()
+ secondary_adjusted = Color.from_hsl(h, min(s, MUTED_SAT_SECONDARY), min(l, 0.40))
+
+ h, s, l = tertiary.to_hsl()
+ tertiary_adjusted = Color.from_hsl(h, min(s, MUTED_SAT_TERTIARY), min(l, 0.35))
+
+ # Container colors - lighter, less saturated
+ def make_container_light(base: Color) -> Color:
+ h, s, l = base.to_hsl()
+ return Color.from_hsl(h, max(s - 0.05, 0.05), min(l + 0.35, 0.85))
+
+ primary_container = make_container_light(primary_adjusted)
+ secondary_container = make_container_light(secondary_adjusted)
+ tertiary_container = make_container_light(tertiary_adjusted)
+ error_container = make_container_light(error)
+
+ # Surface: very low saturation, preserving hue for subtle tint
+ surface = adjust_surface(primary, MUTED_SAT_SURFACE, 0.90)
+ surface_variant = adjust_surface(primary, MUTED_SAT_SURFACE, 0.78)
+
+ # Surface containers - progressive darkening with minimal saturation
+ surface_container_lowest = adjust_surface(primary, MUTED_SAT_SURFACE, 0.96)
+ surface_container_low = adjust_surface(primary, MUTED_SAT_SURFACE, 0.92)
+ surface_container = adjust_surface(primary, MUTED_SAT_SURFACE, 0.86)
+ surface_container_high = adjust_surface(primary, MUTED_SAT_SURFACE, 0.84)
+ surface_container_highest = adjust_surface(primary, MUTED_SAT_SURFACE, 0.80)
+
+ # Text colors - near-neutral with slight hue tint
+ base_on_surface = Color.from_hsl(primary_h, 0.03, 0.10)
+ on_surface = ensure_contrast(base_on_surface, surface, 4.5)
+
+ base_on_surface_variant = Color.from_hsl(primary_h, 0.03, 0.35)
+ on_surface_variant = ensure_contrast(base_on_surface_variant, surface_variant, 4.5)
+
+ # Contrasting foregrounds
+ light_fg = Color.from_hsl(primary_h, 0.05, 0.98)
+ on_primary = ensure_contrast(light_fg, primary_adjusted, 7.0)
+ on_secondary = ensure_contrast(light_fg, secondary_adjusted, 7.0)
+ on_tertiary = ensure_contrast(light_fg, tertiary_adjusted, 7.0)
+ on_error = ensure_contrast(light_fg, error, 7.0)
+
+ # "On" colors for containers
+ on_primary_container = ensure_contrast(Color.from_hsl(primary_h, 0.05, 0.15), primary_container, 4.5, prefer_light=False)
+ sec_h, _, _ = secondary.to_hsl()
+ on_secondary_container = ensure_contrast(Color.from_hsl(sec_h, 0.05, 0.15), secondary_container, 4.5, prefer_light=False)
+ ter_h, _, _ = tertiary.to_hsl()
+ on_tertiary_container = ensure_contrast(Color.from_hsl(ter_h, 0.05, 0.15), tertiary_container, 4.5, prefer_light=False)
+ err_h, _, _ = error.to_hsl()
+ on_error_container = ensure_contrast(Color.from_hsl(err_h, 0.05, 0.15), error_container, 4.5, prefer_light=False)
+
+ # Fixed colors - still muted
+ def make_fixed_light(base: Color) -> tuple[Color, Color]:
+ h, s, _ = base.to_hsl()
+ fixed = Color.from_hsl(h, min(s, MUTED_SAT_PRIMARY), 0.40)
+ fixed_dim = Color.from_hsl(h, min(s, MUTED_SAT_PRIMARY), 0.30)
+ return fixed, fixed_dim
+
+ primary_fixed, primary_fixed_dim = make_fixed_light(primary_adjusted)
+ secondary_fixed, secondary_fixed_dim = make_fixed_light(secondary_adjusted)
+ tertiary_fixed, tertiary_fixed_dim = make_fixed_light(tertiary_adjusted)
+
+ # "On" colors for fixed
+ on_primary_fixed = ensure_contrast(Color.from_hsl(primary_h, 0.05, 0.90), primary_fixed, 4.5)
+ on_primary_fixed_variant = ensure_contrast(Color.from_hsl(primary_h, 0.05, 0.85), primary_fixed_dim, 4.5)
+ on_secondary_fixed = ensure_contrast(Color.from_hsl(sec_h, 0.05, 0.90), secondary_fixed, 4.5)
+ on_secondary_fixed_variant = ensure_contrast(Color.from_hsl(sec_h, 0.05, 0.85), secondary_fixed_dim, 4.5)
+ on_tertiary_fixed = ensure_contrast(Color.from_hsl(ter_h, 0.05, 0.90), tertiary_fixed, 4.5)
+ on_tertiary_fixed_variant = ensure_contrast(Color.from_hsl(ter_h, 0.05, 0.85), tertiary_fixed_dim, 4.5)
+
+ # Surface dim and bright
+ surface_dim = adjust_surface(primary, MUTED_SAT_SURFACE, 0.82)
+ surface_bright = adjust_surface(primary, MUTED_SAT_SURFACE, 0.95)
+
+ # Outline
+ outline = ensure_contrast(Color.from_hsl(primary_h, 0.05, 0.65), surface, 3.0)
+ outline_variant = ensure_contrast(Color.from_hsl(primary_h, 0.05, 0.75), surface, 3.0)
+ shadow = Color.from_hsl(primary_h, 0.05, 0.80)
+ scrim = Color(0, 0, 0)
+
+ # Inverse colors
+ inverse_surface = Color.from_hsl(primary_h, 0.05, 0.15)
+ inverse_on_surface = Color.from_hsl(primary_h, 0.03, 0.90)
+ inverse_primary = Color.from_hsl(primary_h, min(primary_s * 0.5, MUTED_SAT_PRIMARY), 0.70)
+
+ # Background aliases
+ background = surface
+ on_background = on_surface
+
+ return {
+ # Primary
+ "primary": primary_adjusted.to_hex(),
+ "on_primary": on_primary.to_hex(),
+ "primary_container": primary_container.to_hex(),
+ "on_primary_container": on_primary_container.to_hex(),
+ "primary_fixed": primary_fixed.to_hex(),
+ "primary_fixed_dim": primary_fixed_dim.to_hex(),
+ "on_primary_fixed": on_primary_fixed.to_hex(),
+ "on_primary_fixed_variant": on_primary_fixed_variant.to_hex(),
+ "surface_tint": primary_adjusted.to_hex(),
+ # Secondary
+ "secondary": secondary_adjusted.to_hex(),
+ "on_secondary": on_secondary.to_hex(),
+ "secondary_container": secondary_container.to_hex(),
+ "on_secondary_container": on_secondary_container.to_hex(),
+ "secondary_fixed": secondary_fixed.to_hex(),
+ "secondary_fixed_dim": secondary_fixed_dim.to_hex(),
+ "on_secondary_fixed": on_secondary_fixed.to_hex(),
+ "on_secondary_fixed_variant": on_secondary_fixed_variant.to_hex(),
+ # Tertiary
+ "tertiary": tertiary_adjusted.to_hex(),
+ "on_tertiary": on_tertiary.to_hex(),
+ "tertiary_container": tertiary_container.to_hex(),
+ "on_tertiary_container": on_tertiary_container.to_hex(),
+ "tertiary_fixed": tertiary_fixed.to_hex(),
+ "tertiary_fixed_dim": tertiary_fixed_dim.to_hex(),
+ "on_tertiary_fixed": on_tertiary_fixed.to_hex(),
+ "on_tertiary_fixed_variant": on_tertiary_fixed_variant.to_hex(),
+ # Error
+ "error": error.to_hex(),
+ "on_error": on_error.to_hex(),
+ "error_container": error_container.to_hex(),
+ "on_error_container": on_error_container.to_hex(),
+ # Surface
+ "surface": surface.to_hex(),
+ "on_surface": on_surface.to_hex(),
+ "surface_variant": surface_variant.to_hex(),
+ "on_surface_variant": on_surface_variant.to_hex(),
+ "surface_dim": surface_dim.to_hex(),
+ "surface_bright": surface_bright.to_hex(),
+ # Surface containers
+ "surface_container_lowest": surface_container_lowest.to_hex(),
+ "surface_container_low": surface_container_low.to_hex(),
+ "surface_container": surface_container.to_hex(),
+ "surface_container_high": surface_container_high.to_hex(),
+ "surface_container_highest": surface_container_highest.to_hex(),
+ # Outline and other
+ "outline": outline.to_hex(),
+ "outline_variant": outline_variant.to_hex(),
+ "shadow": shadow.to_hex(),
+ "scrim": scrim.to_hex(),
+ # Inverse
+ "inverse_surface": inverse_surface.to_hex(),
+ "inverse_on_surface": inverse_on_surface.to_hex(),
+ "inverse_primary": inverse_primary.to_hex(),
+ # Background
+ "background": background.to_hex(),
+ "on_background": on_background.to_hex(),
+ }
+
+
+def generate_theme(
+ palette: list[Color],
+ mode: ThemeMode,
+ scheme_type: str = "tonal-spot"
+) -> dict[str, str]:
+ """
+ Generate theme for specified mode and scheme type.
+
+ Args:
+ palette: List of extracted colors
+ mode: "dark" or "light"
+ scheme_type: One of "tonal-spot", "fruit-salad", "rainbow", "vibrant", "faithful", "dysfunctional", "muted"
+
+ Returns:
+ Dictionary of color token names to hex values
+ """
+ # Handle vibrant/faithful/dysfunctional modes (use generate_normal_* functions)
+ # All three use same theme generation, but different color extraction (handled in palette.py)
+ if scheme_type in ("vibrant", "faithful", "dysfunctional"):
+ if mode == "dark":
+ return generate_normal_dark(palette)
+ return generate_normal_light(palette)
+
+ # Handle muted mode (low saturation, monochrome wallpapers)
+ if scheme_type == "muted":
+ if mode == "dark":
+ return generate_muted_dark(palette)
+ return generate_muted_light(palette)
+
+ # All other schemes use Material Design 3 generation
+ if mode == "dark":
+ return generate_material_dark(palette, scheme_type)
+ return generate_material_light(palette, scheme_type)
diff --git a/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/migrate-colorschemes.py b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/migrate-colorschemes.py
new file mode 100644
index 0000000..0f87043
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/migrate-colorschemes.py
@@ -0,0 +1,88 @@
+#!/usr/bin/env python3
+import os
+import json
+import sys
+import urllib.request
+import urllib.parse
+from pathlib import Path
+
+# Registry URL for color schemes
+REGISTRY_URL = "https://raw.githubusercontent.com/noctalia-dev/noctalia-colorschemes/main/registry.json"
+RAW_BASE_URL = "https://raw.githubusercontent.com/noctalia-dev/noctalia-colorschemes/main/"
+
+def is_valid_format(data):
+ """Check if the scheme data has the new terminal format."""
+ for variant in ['dark', 'light']:
+ if variant in data:
+ v_data = data[variant]
+ if isinstance(v_data, dict) and 'terminal' in v_data:
+ term = v_data['terminal']
+ if isinstance(term, dict) and 'normal' in term:
+ if isinstance(term['normal'], dict) and 'black' in term['normal']:
+ return True
+ return False
+
+def get_registry():
+ """Fetch the remote registry to get correct paths for schemes."""
+ try:
+ with urllib.request.urlopen(REGISTRY_URL) as response:
+ return json.loads(response.read().decode())
+ except Exception as e:
+ print(f"Error fetching registry: {e}")
+ return None
+
+def migrate(config_dir):
+ colorschemes_dir = Path(config_dir) / "colorschemes"
+ if not colorschemes_dir.exists():
+ return
+
+ registry = get_registry()
+ if not registry:
+ return
+
+ # Map name to path from registry
+ theme_map = {t['name']: t['path'] for t in registry.get('themes', [])}
+
+ for scheme_dir in colorschemes_dir.iterdir():
+ if not scheme_dir.is_dir():
+ continue
+
+ scheme_name = scheme_dir.name
+ json_file = scheme_dir / f"{scheme_name}.json"
+
+ if not json_file.exists():
+ continue
+
+ try:
+ with open(json_file, 'r') as f:
+ data = json.load(f)
+ except Exception:
+ continue
+
+ if not is_valid_format(data):
+ print(f"Scheme '{scheme_name}' has old format. Attempting to redownload...")
+
+ # Use registry path if available, otherwise fallback to name
+ remote_path = theme_map.get(scheme_name, scheme_name)
+
+ # Encode URL parts to handle spaces and special characters
+ encoded_path = urllib.parse.quote(remote_path)
+ encoded_name = urllib.parse.quote(scheme_name)
+ remote_url = f"{RAW_BASE_URL}{encoded_path}/{encoded_name}.json"
+
+ try:
+ with urllib.request.urlopen(remote_url) as response:
+ new_data = json.loads(response.read().decode())
+ with open(json_file, 'w') as f:
+ json.dump(new_data, f, indent=2)
+
+ print(f"Successfully migrated '{scheme_name}'")
+ except Exception as e:
+ print(f"Failed to migrate '{scheme_name}': {e}")
+
+if __name__ == "__main__":
+ if len(sys.argv) < 2:
+ print("Usage: migrate-colorschemes.py ")
+ sys.exit(1)
+
+ migrate(sys.argv[1])
diff --git a/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/template-processor.py b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/template-processor.py
new file mode 100644
index 0000000..f7bad69
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/template-processor.py
@@ -0,0 +1,347 @@
+#!/usr/bin/env python3
+"""
+Noctalia's Template processor - Wallpaper-based color extraction and theme generation.
+
+A CLI tool that extracts dominant colors from wallpaper images and generates palettes with optional templating.
+
+Supported scheme types:
+- tonal-spot: Default Android 12-13 Material You scheme (recommended)
+- content: Preserves source color's chroma with temperature-based tertiary (matugen default)
+- fruit-salad: Bold/playful with -50ยฐ hue rotation
+- rainbow: Chromatic accents with grayscale neutrals
+- monochrome: Pure grayscale M3 scheme (chroma = 0, only error has color)
+- vibrant: Prioritizes the most saturated colors regardless of area coverage
+- faithful: Prioritizes dominant colors by area, what you see is what you get
+- dysfunctional: Like faithful but picks the 2nd most dominant color family
+- muted: Preserves hue but caps saturation low (for monochrome/monotonal wallpapers)
+
+Usage:
+ python3 template-processor.py IMAGE_OR_JSON [OPTIONS]
+
+Options:
+ --scheme-type Scheme type: tonal-spot (default), content, fruit-salad, rainbow, monochrome, vibrant, faithful, dysfunctional, muted
+ --dark Generate dark theme only
+ --light Generate light theme only
+ --both Generate both themes (default)
+ -o, --output Write JSON output to file (stdout if omitted)
+ -r, --render Render a template (input_path:output_path)
+ -c, --config Path to TOML configuration file with template definitions
+ --mode Theme mode: dark or light
+
+Input:
+ Can be an image file (PNG/JPG) or a JSON color palette file.
+
+Example:
+ python3 template-processor.py ~/wallpaper.png --scheme-type tonal-spot
+ python3 template-processor.py ~/wallpaper.png --scheme-type fruit-salad --dark
+ python3 template-processor.py ~/wallpaper.jpg --dark -o theme.json
+ python3 template-processor.py ~/wallpaper.png -r template.txt:output.txt
+ python3 template-processor.py ~/wallpaper.png -c config.toml --mode dark
+
+Author: Noctalia Team
+License: MIT
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from pathlib import Path
+
+# Import from lib package
+from lib import (
+ read_image, ImageReadError, extract_palette, generate_theme,
+ TemplateRenderer, expand_predefined_scheme,
+ extract_source_color, source_color_to_rgb, Color,
+)
+from lib.scheme import inject_terminal_colors
+
+
+def parse_args() -> argparse.Namespace:
+ """Parse command-line arguments."""
+ parser = argparse.ArgumentParser(
+ prog='template-processor',
+ description='Extract color palettes from wallpapers and generate themes',
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog="""
+Examples:
+ python3 template-processor.py wallpaper.png # tonal-spot (default), both themes
+ python3 template-processor.py wallpaper.png --scheme-type content --dark # content scheme, dark only
+ python3 template-processor.py wallpaper.jpg --dark -o theme.json # output to file
+ python3 template-processor.py wallpaper.png -r template.txt:output.txt # render template
+ python3 template-processor.py wallpaper.png -c config.toml --mode dark # render config, dark only
+ """
+ )
+
+ parser.add_argument(
+ 'image',
+ type=Path,
+ nargs='?',
+ help='Path to wallpaper image (PNG/JPG) or JSON color palette (not required if --scheme is used)'
+ )
+
+ # Scheme type selection
+ parser.add_argument(
+ '--scheme-type',
+ choices=['tonal-spot', 'content', 'fruit-salad', 'rainbow', 'monochrome', 'vibrant', 'faithful', 'dysfunctional', 'muted'],
+ default='tonal-spot',
+ help='Color scheme type (default: tonal-spot)'
+ )
+
+ # Theme mode (mutually exclusive)
+ mode_group = parser.add_mutually_exclusive_group()
+ mode_group.add_argument(
+ '--dark',
+ action='store_true',
+ help='Generate dark theme only'
+ )
+ mode_group.add_argument(
+ '--light',
+ action='store_true',
+ help='Generate light theme only'
+ )
+ mode_group.add_argument(
+ '--both',
+ action='store_true',
+ default=True,
+ help='Generate both dark and light themes (default)'
+ )
+
+ parser.add_argument(
+ '--output', '-o',
+ type=Path,
+ help='Write JSON output to file (stdout if omitted)'
+ )
+
+ parser.add_argument(
+ '--render', '-r',
+ action='append',
+ help='Render a template (input_path:output_path)'
+ )
+
+ parser.add_argument(
+ '--config', '-c',
+ type=Path,
+ help='Path to TOML configuration file with template definitions'
+ )
+ parser.add_argument(
+ '--mode',
+ choices=['dark', 'light'],
+ help='Theme mode: dark or light'
+ )
+
+ parser.add_argument(
+ '--scheme',
+ type=Path,
+ help='Path to predefined scheme JSON file (bypasses image extraction)'
+ )
+
+ parser.add_argument(
+ '--default-mode',
+ choices=['dark', 'light'],
+ default='dark',
+ help='Theme mode to use for "default" in templates (default: dark)'
+ )
+
+ return parser.parse_args()
+
+
+def main() -> int:
+ """Main entry point."""
+ args = parse_args()
+
+ # Initialize result dictionary
+ result: dict[str, dict[str, str]] = {}
+
+ # Determine mode from arguments
+ if args.mode == 'dark':
+ modes = ["dark"]
+ elif args.mode == 'light':
+ modes = ["light"]
+ elif args.dark:
+ modes = ["dark"]
+ elif args.light:
+ modes = ["light"]
+ else:
+ modes = ["dark", "light"]
+
+ # Path 1: Predefined scheme (--scheme flag)
+ if args.scheme:
+ if not args.scheme.exists():
+ print(f"Error: Scheme file not found: {args.scheme}", file=sys.stderr)
+ return 1
+
+ try:
+ with open(args.scheme, 'r') as f:
+ scheme_data = json.load(f)
+
+ # Scheme format: {"dark": {"mPrimary": "#...", ...}, "light": {...}}
+ # or single mode: {"mPrimary": "#...", ...}
+ for mode in modes:
+ if mode in scheme_data:
+ # Multi-mode format
+ result[mode] = expand_predefined_scheme(scheme_data[mode], mode)
+ inject_terminal_colors(result[mode], scheme_data[mode])
+ elif "mPrimary" in scheme_data:
+ # Single-mode format - use same colors for requested mode
+ result[mode] = expand_predefined_scheme(scheme_data, mode)
+ inject_terminal_colors(result[mode], scheme_data)
+ else:
+ print(f"Error: Invalid scheme format - missing '{mode}' or 'mPrimary'", file=sys.stderr)
+ return 1
+
+ except json.JSONDecodeError as e:
+ print(f"Error parsing scheme JSON: {e}", file=sys.stderr)
+ return 1
+ except KeyError as e:
+ print(f"Error: Missing required color in scheme: {e}", file=sys.stderr)
+ return 1
+ except Exception as e:
+ print(f"Error processing scheme: {e}", file=sys.stderr)
+ return 1
+
+ # Path 2: Image-based extraction (default)
+ else:
+ # Validate image argument is provided
+ if args.image is None:
+ print("Error: Image path is required (unless --scheme is used)", file=sys.stderr)
+ return 1
+
+ # Validate image path
+ if not args.image.exists():
+ print(f"Error: Image not found: {args.image}", file=sys.stderr)
+ return 1
+
+ # Check if input is a JSON palette (Predefined Color Scheme)
+ if args.image.suffix.lower() == '.json':
+ try:
+ with open(args.image, 'r') as f:
+ input_data = json.load(f)
+
+ # Expect {"colors": ...} or direct dict
+ colors_data = input_data.get("colors", input_data)
+
+ # Flatten QML-style object structure if needed
+ # structure: key -> { default: { hex: "#..." } } or key -> "#..."
+ flat_colors = {}
+ for k, v in colors_data.items():
+ if isinstance(v, dict) and 'default' in v and 'hex' in v['default']:
+ flat_colors[k] = v['default']['hex']
+ elif isinstance(v, str):
+ flat_colors[k] = v
+ else:
+ # Best effort fallback
+ flat_colors[k] = str(v)
+
+ # Assign to requested modes
+ for mode in modes:
+ result[mode] = flat_colors
+
+ except Exception as e:
+ print(f"Error reading JSON palette: {e}", file=sys.stderr)
+ return 1
+ else:
+ # Standard Image Extraction
+ if not args.image.is_file():
+ print(f"Error: Not a file: {args.image}", file=sys.stderr)
+ return 1
+
+ # Determine scheme type
+ scheme_type = args.scheme_type
+
+ # M3 schemes use Triangle filter (matches matugen), others use Box
+ # (sharper downscale preserves distinct color regions for k-means)
+ m3_schemes = {"tonal-spot", "content", "fruit-salad", "rainbow", "monochrome"}
+ resize_filter = "Triangle" if scheme_type in m3_schemes else "Box"
+
+ try:
+ pixels = read_image(args.image, resize_filter)
+ except ImageReadError as e:
+ print(f"Error reading image: {e}", file=sys.stderr)
+ return 1
+ except Exception as e:
+ print(f"Unexpected error reading image: {e}", file=sys.stderr)
+ return 1
+
+ # Extract palette based on scheme type:
+ # - M3 schemes (tonal-spot, fruit-salad, rainbow, content): Use Wu quantizer + Score
+ # This matches matugen's color extraction exactly
+ # - vibrant: Use k-means clustering for colorful/blended colors
+ # - faithful: Use Wu quantizer for primary (dominant by area), k-means for accents
+ # - dysfunctional: Like faithful but picks 2nd most dominant color family
+ # - muted: Like count but without chroma filtering (for monochrome wallpapers)
+ if scheme_type == "vibrant":
+ # K-means with chroma scoring for vibrant, blended colors
+ palette = extract_palette(pixels, k=5, scoring="chroma")
+ elif scheme_type == "faithful":
+ # K-means with count scoring - picks dominant color by area coverage
+ # This ensures primary reflects what you actually see in the image
+ palette = extract_palette(pixels, k=5, scoring="count")
+ elif scheme_type == "dysfunctional":
+ # K-means with dysfunctional scoring - picks 2nd most dominant color family
+ # For when the dominant color is not what you want as primary
+ palette = extract_palette(pixels, k=5, scoring="dysfunctional")
+ elif scheme_type == "muted":
+ # K-means with muted scoring - accepts low/zero chroma colors
+ # For monochrome/monotonal wallpapers where dominant color has low saturation
+ palette = extract_palette(pixels, k=5, scoring="muted")
+ else:
+ # Wu quantizer + Score algorithm (matches matugen)
+ source_argb = extract_source_color(pixels)
+ r, g, b = source_color_to_rgb(source_argb)
+ palette = [Color(r, g, b)]
+
+ if not palette:
+ print("Error: Could not extract colors from image", file=sys.stderr)
+ return 1
+
+ # Generate theme for each mode
+ for mode in modes:
+ result[mode] = generate_theme(palette, mode, scheme_type)
+
+ # Output JSON
+ json_output = json.dumps(result, indent=2)
+
+ if args.output:
+ try:
+ args.output.write_text(json_output)
+ print(f"Theme written to: {args.output}", file=sys.stderr)
+ except IOError as e:
+ print(f"Error writing output: {e}", file=sys.stderr)
+ return 1
+ elif not args.render and not args.config:
+ print(json_output)
+
+ # Process templates
+ if args.render or args.config:
+ image_path = str(args.image) if args.image else None
+ renderer = TemplateRenderer(result, default_mode=args.default_mode, image_path=image_path, scheme_type=args.scheme_type)
+
+ if args.render:
+ for render_spec in args.render:
+ if ':' not in render_spec:
+ print(f"Error: Invalid render spec (must be input:output): {render_spec}", file=sys.stderr)
+ continue
+
+ input_str, output_str = render_spec.split(':', 1)
+ input_path = Path(input_str).expanduser()
+ output_path = Path(output_str).expanduser()
+
+ if not input_path.exists():
+ print(f"Error: Template not found: {input_path}", file=sys.stderr)
+ continue
+
+ renderer.render_file(input_path, output_path)
+
+ if args.config:
+ if not args.config.exists():
+ print(f"Error: Config file not found: {args.config}", file=sys.stderr)
+ else:
+ renderer.process_config_file(args.config)
+
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/vscode-helper.py b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/vscode-helper.py
new file mode 100644
index 0000000..722092a
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Scripts/python/src/theming/vscode-helper.py
@@ -0,0 +1,30 @@
+#!/usr/bin/env python3
+# Finds all installed Noctalia theme extensions for VSCode/VSCodium.
+
+import sys
+from pathlib import Path
+
+
+def find_all_noctalia_themes(extensions_dir: Path, prefix: str) -> list[str]:
+ # Bail early if the extensions directory doesn't exist
+ if not extensions_dir.is_dir():
+ return []
+ # Collect all directories matching the extension prefix
+ candidates = [d for d in extensions_dir.iterdir() if d.is_dir() and d.name.startswith(prefix)]
+ # Return theme file paths for all matching extensions
+ return [str(d / "themes" / "NoctaliaTheme-color-theme.json") for d in candidates]
+
+
+if __name__ == "__main__":
+ # Resolve ~ in the provided extensions directory path
+ extensions_dir = Path(sys.argv[1]).expanduser()
+ prefix = sys.argv[2] if len(sys.argv) > 2 else "noctalia.noctaliatheme-"
+
+ # Print the resolved paths to stdout for the QML Process to capture
+ results = find_all_noctalia_themes(extensions_dir, prefix)
+ if results:
+ for path in results:
+ print(path)
+ else:
+ print(f"No matching extension found in {extensions_dir}", file=sys.stderr)
+ sys.exit(1)
\ No newline at end of file
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Compositor/CompositorService.qml b/arch/.config/quickshell/noctalia-shell/Services/Compositor/CompositorService.qml
new file mode 100644
index 0000000..62b6232
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Compositor/CompositorService.qml
@@ -0,0 +1,665 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+import qs.Services.Control
+import qs.Services.UI
+
+Singleton {
+ id: root
+
+ // Compositor detection
+ property bool isHyprland: false
+ property bool isNiri: false
+ property bool isSway: false
+ property bool isMango: false
+ property bool isLabwc: false
+ property bool isExtWorkspace: false
+ property bool isScroll: false
+
+ // Generic workspace and window data
+ property ListModel workspaces: ListModel {}
+ property ListModel windows: ListModel {}
+ property int focusedWindowIndex: -1
+
+ // Display scale data
+ property var displayScales: ({})
+ property bool displayScalesLoaded: false
+
+ // Overview state (Niri-specific, defaults to false for other compositors)
+ property bool overviewActive: false
+
+ // Global workspaces flag (workspaces shared across all outputs)
+ // True for LabWC (stacking compositor), false for tiling WMs with per-output workspaces
+ property bool globalWorkspaces: false
+
+ // Generic events
+ signal workspaceChanged
+ signal activeWindowChanged
+ signal windowListChanged
+
+ // Backend service loader
+ property var backend: null
+
+ Component.onCompleted: {
+ // Load display scales from ShellState
+ Qt.callLater(() => {
+ if (typeof ShellState !== 'undefined' && ShellState.isLoaded) {
+ loadDisplayScalesFromState();
+ }
+ });
+
+ detectCompositor();
+ }
+
+ Connections {
+ target: typeof ShellState !== 'undefined' ? ShellState : null
+ function onIsLoadedChanged() {
+ if (ShellState.isLoaded) {
+ loadDisplayScalesFromState();
+ }
+ }
+ }
+
+ function detectCompositor() {
+ const hyprlandSignature = Quickshell.env("HYPRLAND_INSTANCE_SIGNATURE");
+ const niriSocket = Quickshell.env("NIRI_SOCKET");
+ const swaySock = Quickshell.env("SWAYSOCK");
+ const currentDesktop = Quickshell.env("XDG_CURRENT_DESKTOP");
+ const labwcPid = Quickshell.env("LABWC_PID");
+
+ // Check for MangoWC using XDG_CURRENT_DESKTOP environment variable
+ // MangoWC sets XDG_CURRENT_DESKTOP=mango
+ if (currentDesktop && currentDesktop.toLowerCase().includes("mango")) {
+ isHyprland = false;
+ isNiri = false;
+ isSway = false;
+ isMango = true;
+ isLabwc = false;
+ isExtWorkspace = false;
+ backendLoader.sourceComponent = mangoComponent;
+ } else if (labwcPid && labwcPid.length > 0) {
+ isHyprland = false;
+ isNiri = false;
+ isSway = false;
+ isMango = false;
+ isLabwc = true;
+ isExtWorkspace = false;
+ backendLoader.sourceComponent = labwcComponent;
+ Logger.i("CompositorService", "Detected LabWC with PID: " + labwcPid);
+ } else if (niriSocket && niriSocket.length > 0) {
+ isHyprland = false;
+ isNiri = true;
+ isSway = false;
+ isMango = false;
+ isLabwc = false;
+ isExtWorkspace = false;
+ backendLoader.sourceComponent = niriComponent;
+ } else if (hyprlandSignature && hyprlandSignature.length > 0) {
+ isHyprland = true;
+ isNiri = false;
+ isSway = false;
+ isMango = false;
+ isLabwc = false;
+ isExtWorkspace = false;
+ backendLoader.sourceComponent = hyprlandComponent;
+ } else if (swaySock && swaySock.length > 0) {
+ isHyprland = false;
+ isNiri = false;
+ isSway = true;
+ isMango = false;
+ isLabwc = false;
+ isExtWorkspace = false;
+ isScroll = currentDesktop && currentDesktop.toLowerCase().includes("scroll");
+ backendLoader.sourceComponent = swayComponent;
+ } else {
+ // Always fallback to ext-workspace-v1
+ isHyprland = false;
+ isNiri = false;
+ isSway = false;
+ isMango = false;
+ isLabwc = false;
+ isExtWorkspace = true;
+ backendLoader.sourceComponent = extWorkspaceComponent;
+ Logger.i("CompositorService", "Using generic ext-workspace backend (no recognized compositor env)");
+ }
+ }
+
+ Loader {
+ id: backendLoader
+ onLoaded: {
+ if (item) {
+ if (isScroll) {
+ item.msgCommand = "scrollmsg";
+ }
+ root.backend = item;
+ setupBackendConnections();
+ backend.initialize();
+ }
+ }
+ }
+
+ // Load display scales from ShellState
+ function loadDisplayScalesFromState() {
+ try {
+ const cached = ShellState.getDisplay();
+ if (cached && Object.keys(cached).length > 0) {
+ displayScales = cached;
+ displayScalesLoaded = true;
+ Logger.d("CompositorService", "Loaded display scales from ShellState");
+ } else {
+ // Migration is now handled in Settings.qml
+ displayScalesLoaded = true;
+ }
+ } catch (error) {
+ Logger.e("CompositorService", "Failed to load display scales:", error);
+ displayScalesLoaded = true;
+ }
+ }
+
+ // Hyprland backend component
+ Component {
+ id: hyprlandComponent
+ HyprlandService {
+ id: hyprlandBackend
+ }
+ }
+
+ // Niri backend component
+ Component {
+ id: niriComponent
+ NiriService {
+ id: niriBackend
+ }
+ }
+
+ // Sway backend component
+ Component {
+ id: swayComponent
+ SwayService {
+ id: swayBackend
+ }
+ }
+
+ // Mango backend component
+ Component {
+ id: mangoComponent
+ MangoService {
+ id: mangoBackend
+ }
+ }
+
+ // Labwc backend component
+ Component {
+ id: labwcComponent
+ LabwcService {
+ id: labwcBackend
+ }
+ }
+
+ // Generic ext-workspace (WindowManager) when compositor env is unknown
+ Component {
+ id: extWorkspaceComponent
+ ExtWorkspaceService {
+ id: extWorkspaceBackend
+ }
+ }
+
+ function setupBackendConnections() {
+ if (!backend)
+ return;
+
+ // Connect backend signals to facade signals
+ backend.workspaceChanged.connect(() => {
+ // Sync workspaces when they change
+ syncWorkspaces();
+ // Forward the signal
+ workspaceChanged();
+ });
+
+ backend.activeWindowChanged.connect(() => {
+ // Only sync focus state, not entire window list
+ syncFocusedWindow();
+ // Forward the signal
+ activeWindowChanged();
+ });
+
+ backend.windowListChanged.connect(() => {
+ syncWindows();
+ });
+
+ // Property bindings - use automatic property change signal
+ backend.focusedWindowIndexChanged.connect(() => {
+ focusedWindowIndex = backend.focusedWindowIndex;
+ });
+
+ // Overview state (Niri-specific)
+ if (backend.overviewActiveChanged) {
+ backend.overviewActiveChanged.connect(() => {
+ overviewActive = backend.overviewActive;
+ });
+ }
+
+ // Initial sync
+ syncWorkspaces();
+ syncWindows();
+ focusedWindowIndex = backend.focusedWindowIndex;
+ if (backend.overviewActive !== undefined) {
+ overviewActive = backend.overviewActive;
+ }
+ if (backend.globalWorkspaces !== undefined) {
+ globalWorkspaces = backend.globalWorkspaces;
+ }
+ }
+
+ function syncWorkspaces() {
+ workspaces.clear();
+ const ws = backend.workspaces;
+ for (var i = 0; i < ws.count; i++) {
+ workspaces.append(ws.get(i));
+ }
+ // Emit signal to notify listeners that workspace list has been updated
+ workspacesChanged();
+ }
+
+ function syncWindows() {
+ windows.clear();
+ const ws = backend.windows;
+ for (var i = 0; i < ws.length; i++) {
+ windows.append(ws[i]);
+ }
+ // Emit signal to notify listeners that window list has been updated
+ windowListChanged();
+ }
+
+ // Sync only the focused window state, not the entire window list
+ function syncFocusedWindow() {
+ const newIndex = backend.focusedWindowIndex;
+
+ // Update isFocused flags by syncing from backend
+ for (var i = 0; i < windows.count && i < backend.windows.length; i++) {
+ const backendFocused = backend.windows[i].isFocused;
+ if (windows.get(i).isFocused !== backendFocused) {
+ windows.setProperty(i, "isFocused", backendFocused);
+ }
+ }
+
+ focusedWindowIndex = newIndex;
+ }
+
+ // Update display scales from backend
+ function updateDisplayScales() {
+ if (!backend || !backend.queryDisplayScales) {
+ Logger.w("CompositorService", "Backend does not support display scale queries");
+ return;
+ }
+
+ backend.queryDisplayScales();
+ }
+
+ // Called by backend when display scales are ready
+ function onDisplayScalesUpdated(scales) {
+ displayScales = scales;
+ saveDisplayScalesToCache();
+ Logger.d("CompositorService", "Display scales updated");
+ }
+
+ // Save display scales to cache
+ function saveDisplayScalesToCache() {
+ try {
+ ShellState.setDisplay(displayScales);
+ Logger.d("CompositorService", "Saved display scales to ShellState");
+ } catch (error) {
+ Logger.e("CompositorService", "Failed to save display scales:", error);
+ }
+ }
+
+ // Public function to get scale for a specific display
+ function getDisplayScale(displayName) {
+ if (!displayName || !displayScales[displayName]) {
+ return 1.0;
+ }
+ return displayScales[displayName].scale || 1.0;
+ }
+
+ // Public function to get all display info for a specific display
+ function getDisplayInfo(displayName) {
+ if (!displayName || !displayScales[displayName]) {
+ return null;
+ }
+ return displayScales[displayName];
+ }
+
+ // Get focused window
+ function getFocusedWindow() {
+ if (focusedWindowIndex >= 0 && focusedWindowIndex < windows.count) {
+ return windows.get(focusedWindowIndex);
+ }
+ return null;
+ }
+
+ // Get focused screen from compositor
+ function getFocusedScreen() {
+ if (backend && backend.getFocusedScreen) {
+ return backend.getFocusedScreen();
+ }
+ return null;
+ }
+
+ // Get focused window title
+ function getFocusedWindowTitle() {
+ if (focusedWindowIndex >= 0 && focusedWindowIndex < windows.count) {
+ var title = windows.get(focusedWindowIndex).title;
+ if (title !== undefined) {
+ title = title.replace(/(\r\n|\n|\r)/g, "");
+ }
+ return title || "";
+ }
+ return "";
+ }
+
+ // Get clean app name from appId
+ // Extracts the last segment from reverse domain notation (e.g., "org.kde.dolphin" -> "Dolphin")
+ // Falls back to title if appId is empty
+ function getCleanAppName(appId, fallbackTitle) {
+ var name = (appId || "").split(".").pop() || fallbackTitle || "Unknown";
+ return name.charAt(0).toUpperCase() + name.slice(1);
+ }
+
+ function getWindowsForWorkspace(workspaceId) {
+ var windowsInWs = [];
+ for (var i = 0; i < windows.count; i++) {
+ var window = windows.get(i);
+ if (window.workspaceId === workspaceId) {
+ // Snapshot to plain JS object so callers never hold live ListModel
+ // proxies that become invalid when syncWindows() clears the model.
+ windowsInWs.push({
+ id: window.id,
+ title: window.title,
+ appId: window.appId,
+ isFocused: window.isFocused,
+ workspaceId: window.workspaceId,
+ handle: window.handle
+ });
+ }
+ }
+ return windowsInWs;
+ }
+
+ // Generic workspace switching
+ function switchToWorkspace(workspace) {
+ if (backend && backend.switchToWorkspace) {
+ backend.switchToWorkspace(workspace);
+ } else {
+ Logger.w("Compositor", "No backend available for workspace switching");
+ }
+ }
+
+ // Scrollable workspace content (Niri)
+ function scrollWorkspaceContent(direction) {
+ if (backend && backend.scrollWorkspaceContent) {
+ backend.scrollWorkspaceContent(direction);
+ }
+ }
+
+ // Get current workspace
+ function getCurrentWorkspace() {
+ for (var i = 0; i < workspaces.count; i++) {
+ const ws = workspaces.get(i);
+ if (ws.isFocused) {
+ return ws;
+ }
+ }
+ return null;
+ }
+
+ // Get active workspaces
+ function getActiveWorkspaces() {
+ const activeWorkspaces = [];
+ for (var i = 0; i < workspaces.count; i++) {
+ const ws = workspaces.get(i);
+ if (ws.isActive) {
+ activeWorkspaces.push(ws);
+ }
+ }
+ return activeWorkspaces;
+ }
+
+ // Set focused window
+ function focusWindow(window) {
+ if (backend && backend.focusWindow) {
+ backend.focusWindow(window);
+ } else {
+ Logger.w("Compositor", "No backend available for window focus");
+ }
+ }
+
+ // Close window
+ function closeWindow(window) {
+ if (backend && backend.closeWindow) {
+ backend.closeWindow(window);
+ } else {
+ Logger.w("Compositor", "No backend available for window closing");
+ }
+ }
+
+ // Spawn command
+ function spawn(command) {
+ // Ensure command is a proper JS array (QML lists can behave unexpectedly in some contexts)
+ const cmdArray = Array.isArray(command) ? command : (command && typeof command === "object" && command.length !== undefined) ? Array.from(command) : [command];
+
+ Logger.d("CompositorService", `Spawning: ${cmdArray.join(" ")}`);
+ if (backend && backend.spawn) {
+ backend.spawn(cmdArray);
+ } else {
+ try {
+ Quickshell.execDetached(cmdArray);
+ } catch (e) {
+ Logger.e("CompositorService", "Failed to execute detached:", e);
+ }
+ }
+ }
+
+ // Session management helper for custom commands
+ function getCustomCommand(action) {
+ const powerOptions = Settings.data.sessionMenu.powerOptions || [];
+ for (let i = 0; i < powerOptions.length; i++) {
+ const option = powerOptions[i];
+ if (option.action === action && option.enabled && option.command && option.command.trim() !== "") {
+ return option.command.trim();
+ }
+ }
+ return "";
+ }
+
+ function executeSessionAction(action, defaultCommand) {
+ const customCommand = getCustomCommand(action);
+ if (customCommand) {
+ Logger.i("Compositor", `Executing custom command for action: ${action} Command: ${customCommand}`);
+ Quickshell.execDetached(["sh", "-c", customCommand]);
+ return true;
+ }
+ return false;
+ }
+
+ // Session management
+ function logout() {
+ Logger.i("Compositor", "Logout requested");
+ if (executeSessionAction("logout"))
+ return;
+
+ if (backend && backend.logout) {
+ backend.logout();
+ } else {
+ Logger.w("Compositor", "No backend available for logout");
+ }
+ }
+
+ function shutdown() {
+ Logger.i("Compositor", "Shutdown requested");
+ if (executeSessionAction("shutdown"))
+ return;
+
+ HooksService.executeSessionHook("shutdown", () => {
+ Quickshell.execDetached(["sh", "-c", "systemctl poweroff || loginctl poweroff"]);
+ });
+ }
+
+ function reboot() {
+ Logger.i("Compositor", "Reboot requested");
+ if (executeSessionAction("reboot"))
+ return;
+
+ HooksService.executeSessionHook("reboot", () => {
+ Quickshell.execDetached(["sh", "-c", "systemctl reboot || loginctl reboot"]);
+ });
+ }
+
+ function userspaceReboot() {
+ Logger.i("Compositor", "Userspace reboot requested");
+ if (executeSessionAction("userspaceReboot"))
+ return;
+
+ HooksService.executeSessionHook("userspaceReboot", () => {
+ Quickshell.execDetached(["sh", "-c", "systemctl soft-reboot"]);
+ });
+ }
+
+ function rebootToUefi() {
+ Logger.i("Compositor", "Reboot to UEFI firmware requested requested");
+ if (executeSessionAction("rebootToUefi"))
+ return;
+
+ HooksService.executeSessionHook("rebootToUefi", () => {
+ Quickshell.execDetached(["sh", "-c", "systemctl reboot --firmware-setup || loginctl reboot --firmware-setup"]);
+ });
+ }
+
+ function turnOffMonitors() {
+ Logger.i("Compositor", "Turn off monitors requested");
+ if (backend && backend.turnOffMonitors) {
+ backend.turnOffMonitors();
+ } else {
+ Logger.w("Compositor", "No backend available for turnOffMonitors");
+ }
+ }
+
+ function turnOnMonitors() {
+ Logger.i("Compositor", "Turn on monitors requested");
+ if (backend && backend.turnOnMonitors) {
+ backend.turnOnMonitors();
+ } else {
+ Logger.w("Compositor", "No backend available for turnOnMonitors");
+ }
+ }
+
+ function suspend() {
+ Logger.i("Compositor", "Suspend requested");
+ if (executeSessionAction("suspend"))
+ return;
+
+ Quickshell.execDetached(["sh", "-c", "systemctl suspend || loginctl suspend"]);
+ }
+
+ function lock() {
+ Logger.i("Compositor", "LockScreen requested");
+ if (executeSessionAction("lock"))
+ return;
+
+ if (PanelService && PanelService.lockScreen) {
+ PanelService.lockScreen.active = true;
+ }
+ }
+
+ function hibernate() {
+ Logger.i("Compositor", "Hibernate requested");
+ if (executeSessionAction("hibernate"))
+ return;
+
+ Quickshell.execDetached(["sh", "-c", "systemctl hibernate || loginctl hibernate"]);
+ }
+
+ function cycleKeyboardLayout() {
+ if (backend && backend.cycleKeyboardLayout) {
+ backend.cycleKeyboardLayout();
+ }
+ }
+
+ property int lockAndSuspendCheckCount: 0
+
+ function lockAndSuspend() {
+ Logger.i("Compositor", "Lock and suspend requested");
+
+ // if a custom lock command exists, execute it and suspend without wait
+ if (executeSessionAction("lock")) {
+ suspend();
+ return;
+ }
+
+ // If already locked, suspend immediately
+ if (PanelService && PanelService.lockScreen && PanelService.lockScreen.active) {
+ Logger.i("Compositor", "Screen already locked, suspending");
+ suspend();
+ return;
+ }
+
+ // Lock the screen first
+ try {
+ if (PanelService && PanelService.lockScreen) {
+ PanelService.lockScreen.active = true;
+ lockAndSuspendCheckCount = 0;
+
+ // Wait for lock screen to be confirmed active before suspending
+ lockAndSuspendTimer.start();
+ } else {
+ Logger.w("Compositor", "Lock screen not available, suspending without lock");
+ suspend();
+ }
+ } catch (e) {
+ Logger.w("Compositor", "Failed to activate lock screen before suspend: " + e);
+ suspend();
+ }
+ }
+
+ Timer {
+ id: lockAndSuspendTimer
+ interval: 100
+ repeat: true
+ running: false
+
+ onTriggered: {
+ lockAndSuspendCheckCount++;
+
+ // Check if lock screen is now active
+ if (PanelService && PanelService.lockScreen && PanelService.lockScreen.active) {
+ // Verify the lock screen component is loaded
+ if (PanelService.lockScreen.item) {
+ Logger.i("Compositor", "Lock screen confirmed active, suspending");
+ stop();
+ lockAndSuspendCheckCount = 0;
+ suspend();
+ } else {
+ // Lock screen is active but component not loaded yet, wait a bit more
+ if (lockAndSuspendCheckCount > 20) {
+ // Max 2 seconds wait
+ Logger.w("Compositor", "Lock screen active but component not loaded, suspending anyway");
+ stop();
+ lockAndSuspendCheckCount = 0;
+ suspend();
+ }
+ }
+ } else {
+ // Lock screen not active yet, keep checking
+ if (lockAndSuspendCheckCount > 30) {
+ // Max 3 seconds wait
+ Logger.w("Compositor", "Lock screen failed to activate, suspending anyway");
+ stop();
+ lockAndSuspendCheckCount = 0;
+ suspend();
+ }
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Compositor/ExtWorkspaceService.qml b/arch/.config/quickshell/noctalia-shell/Services/Compositor/ExtWorkspaceService.qml
new file mode 100644
index 0000000..1ed4551
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Compositor/ExtWorkspaceService.qml
@@ -0,0 +1,300 @@
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import Quickshell.Wayland
+import Quickshell.WindowManager
+import qs.Commons
+
+// Generic ext-workspace-v1 + toplevel handling
+Item {
+ id: root
+
+ property ListModel workspaces: ListModel {}
+ property var windows: []
+ property int focusedWindowIndex: -1
+ property var trackedToplevels: new Set()
+
+ property bool globalWorkspaces: false
+
+ property var nativeWorkspaceMap: ({})
+ property var connectedWorkspaces: ({})
+
+ signal workspaceChanged
+ signal activeWindowChanged
+ signal windowListChanged
+ signal displayScalesChanged
+
+ function initialize() {
+ updateWindows();
+ connectWorkspaceSignals();
+ syncWorkspaces();
+ Logger.i("ExtWorkspaceService", "Service started (generic ext-workspace-v1)");
+ }
+
+ Connections {
+ target: WindowManager
+
+ function onWindowsetsChanged() {
+ root.connectWorkspaceSignals();
+ Qt.callLater(root.syncWorkspaces);
+ }
+
+ function onWindowsetProjectionsChanged() {
+ Qt.callLater(root.syncWorkspaces);
+ }
+ }
+
+ Timer {
+ interval: 500
+ running: true
+ repeat: false
+ onTriggered: {
+ if (WindowManager.windowsets.length > 0) {
+ root.connectWorkspaceSignals();
+ root.syncWorkspaces();
+ }
+ }
+ }
+
+ function connectWorkspaceSignals() {
+ const nativeWs = WindowManager.windowsets;
+ const newConnected = {};
+
+ for (const ws of nativeWs) {
+ const key = ws.id || ws.toString();
+ newConnected[key] = true;
+
+ if (connectedWorkspaces[key])
+ continue;
+
+ ws.activeChanged.connect(() => {
+ Qt.callLater(root.syncWorkspaces);
+ });
+
+ ws.urgentChanged.connect(() => {
+ Qt.callLater(root.syncWorkspaces);
+ });
+
+ ws.shouldDisplayChanged.connect(() => {
+ Qt.callLater(root.syncWorkspaces);
+ });
+
+ ws.nameChanged.connect(() => {
+ Qt.callLater(root.syncWorkspaces);
+ });
+ }
+
+ connectedWorkspaces = newConnected;
+ }
+
+ function syncWorkspaces() {
+ const nativeWs = WindowManager.windowsets;
+
+ workspaces.clear();
+ nativeWorkspaceMap = {};
+
+ /* Per-output (projection) index: compositors expose one workspace group per
+ * monitor with names "1"โฆ"9". A single global idx produced 10โ18 on the
+ * second head because all windowsets were merged into one list. */
+ const perOutputNextIdx = {};
+
+ for (const ws of nativeWs) {
+ if (!ws.shouldDisplay) {
+ continue;
+ }
+
+ let outputName = "";
+ if (ws.projection) {
+ const projScreens = ws.projection.screens;
+ if (projScreens && projScreens.length > 0) {
+ outputName = projScreens[0].name || "";
+ }
+ }
+
+ const groupKey = outputName || "_";
+ let idx;
+ const numericName = ws.name && /^\d+$/.test(String(ws.name)) ? parseInt(ws.name, 10) : NaN;
+ if (!isNaN(numericName) && numericName >= 1) {
+ idx = numericName;
+ } else {
+ if (perOutputNextIdx[groupKey] === undefined) {
+ perOutputNextIdx[groupKey] = 1;
+ }
+ idx = perOutputNextIdx[groupKey]++;
+ }
+
+ const wsEntry = {
+ "id": ws.id || idx.toString(),
+ "idx": idx,
+ "name": ws.name || ("Workspace " + idx),
+ "output": outputName,
+ "isFocused": ws.active,
+ "isActive": true,
+ "isUrgent": ws.urgent,
+ "isOccupied": false,
+ "oid": ws.id || idx.toString()
+ };
+
+ workspaces.append(wsEntry);
+ nativeWorkspaceMap[wsEntry.id] = ws;
+ }
+
+ updateWindowWorkspaces();
+ workspaceChanged();
+ }
+
+ function updateWindowWorkspaces() {
+ let activeId = "";
+ for (let i = 0; i < workspaces.count; i++) {
+ const ws = workspaces.get(i);
+ if (ws.isFocused) {
+ activeId = ws.id;
+ break;
+ }
+ }
+
+ for (let i = 0; i < windows.length; i++) {
+ if (activeId) {
+ windows[i].workspaceId = activeId;
+ }
+ }
+ windowListChanged();
+ }
+
+ Connections {
+ target: ToplevelManager.toplevels
+ function onValuesChanged() {
+ updateWindows();
+ }
+ }
+
+ function connectToToplevel(toplevel) {
+ if (!toplevel)
+ return;
+
+ toplevel.activatedChanged.connect(() => {
+ Qt.callLater(onToplevelActivationChanged);
+ });
+
+ toplevel.titleChanged.connect(() => {
+ Qt.callLater(updateWindows);
+ });
+ }
+
+ function onToplevelActivationChanged() {
+ updateWindows();
+ activeWindowChanged();
+ }
+
+ function updateWindows() {
+ const newWindows = [];
+ const toplevels = ToplevelManager.toplevels?.values || [];
+
+ let focusedIdx = -1;
+ let idx = 0;
+
+ let activeId = "";
+ for (let i = 0; i < workspaces.count; i++) {
+ const ws = workspaces.get(i);
+ if (ws.isFocused) {
+ activeId = ws.id;
+ break;
+ }
+ }
+
+ for (const toplevel of toplevels) {
+ if (!toplevel)
+ continue;
+
+ if (!trackedToplevels.has(toplevel)) {
+ connectToToplevel(toplevel);
+ trackedToplevels.add(toplevel);
+ }
+
+ const output = (toplevel.screens && toplevel.screens.length > 0) ? (toplevel.screens[0].name || "") : "";
+
+ const windowId = (toplevel.appId || "") + ":" + idx;
+
+ newWindows.push({
+ "id": windowId,
+ "appId": toplevel.appId || "",
+ "title": toplevel.title || "",
+ "output": output,
+ "workspaceId": activeId || "1",
+ "isFocused": toplevel.activated || false,
+ "toplevel": toplevel
+ });
+
+ if (toplevel.activated) {
+ focusedIdx = idx;
+ }
+ idx++;
+ }
+ windows = newWindows;
+ focusedWindowIndex = focusedIdx;
+
+ windowListChanged();
+ }
+
+ function focusWindow(window) {
+ if (window.toplevel && typeof window.toplevel.activate === "function") {
+ window.toplevel.activate();
+ }
+ }
+
+ function closeWindow(window) {
+ if (window.toplevel && typeof window.toplevel.close === "function") {
+ window.toplevel.close();
+ }
+ }
+
+ function switchToWorkspace(workspace) {
+ const nativeWs = nativeWorkspaceMap[workspace.id] || nativeWorkspaceMap[workspace.oid];
+ if (nativeWs && nativeWs.canActivate) {
+ nativeWs.activate();
+ } else {
+ Logger.w("ExtWorkspaceService", "Cannot activate workspace: " + (workspace.name || workspace.id));
+ }
+ }
+
+ function turnOffMonitors() {
+ try {
+ Quickshell.execDetached(["wlr-randr", "--off"]);
+ } catch (e) {
+ Logger.e("ExtWorkspaceService", "Failed to turn off monitors:", e);
+ }
+ }
+
+ function turnOnMonitors() {
+ try {
+ Quickshell.execDetached(["wlr-randr", "--on"]);
+ } catch (e) {
+ Logger.e("ExtWorkspaceService", "Failed to turn on monitors:", e);
+ }
+ }
+
+ function logout() {
+ const sid = Quickshell.env("XDG_SESSION_ID");
+ try {
+ if (sid && sid.length > 0) {
+ Quickshell.execDetached(["loginctl", "terminate-session", sid]);
+ } else {
+ Logger.w("ExtWorkspaceService", "logout: XDG_SESSION_ID unset; use session menu custom command or compositor-specific backend");
+ }
+ } catch (e) {
+ Logger.e("ExtWorkspaceService", "Failed to logout:", e);
+ }
+ }
+
+ function cycleKeyboardLayout() {
+ Logger.w("ExtWorkspaceService", "Keyboard layout cycling not supported");
+ }
+
+ function queryDisplayScales() {
+ Logger.w("ExtWorkspaceService", "Display scale queries not supported via ToplevelManager");
+ }
+
+ function getFocusedScreen() {
+ return null;
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Compositor/HyprlandService.qml b/arch/.config/quickshell/noctalia-shell/Services/Compositor/HyprlandService.qml
new file mode 100644
index 0000000..19cd804
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Compositor/HyprlandService.qml
@@ -0,0 +1,615 @@
+import QtQuick
+import Quickshell
+import Quickshell.Hyprland
+import Quickshell.Io
+import qs.Commons
+import qs.Services.Keyboard
+
+Item {
+ id: root
+
+ // Properties that match the facade interface
+ property ListModel workspaces: ListModel {}
+ property var windows: []
+ property int focusedWindowIndex: -1
+
+ // Signals that match the facade interface
+ signal workspaceChanged
+ signal activeWindowChanged
+ signal windowListChanged
+ signal displayScalesChanged
+
+ // Hyprland-specific properties
+ property bool initialized: false
+ property var workspaceCache: ({})
+ property var windowCache: ({})
+
+ // Debounce timer for window updates
+ Timer {
+ id: updateTimer
+ interval: 50
+ repeat: false
+ onTriggered: safeUpdate()
+ }
+
+ // Deferred via Qt.callLater to coalesce workspace updates: onRawEvent calls
+ // refreshWorkspaces() which triggers onValuesChanged synchronously in the
+ // same call stack โ without deferral the ListModel gets cleared+repopulated
+ // twice per event. Qt.callLater deduplicates by function identity.
+ function _deferredWorkspaceUpdate() {
+ safeUpdateWorkspaces();
+ workspaceChanged();
+ }
+
+ // Initialization
+ function initialize() {
+ if (initialized)
+ return;
+ try {
+ Hyprland.refreshWorkspaces();
+ Hyprland.refreshToplevels();
+ Qt.callLater(() => {
+ safeUpdateWorkspaces();
+ safeUpdateWindows();
+ queryDisplayScales();
+ queryKeyboardLayout();
+ });
+ initialized = true;
+ Logger.i("HyprlandService", "Service started");
+ } catch (e) {
+ Logger.e("HyprlandService", "Failed to initialize:", e);
+ }
+ }
+
+ // Query display scales
+ function queryDisplayScales() {
+ hyprlandMonitorsProcess.running = true;
+ }
+
+ // Hyprland monitors process for display scale detection
+ Process {
+ id: hyprlandMonitorsProcess
+ running: false
+ command: ["hyprctl", "monitors", "-j"]
+
+ property string accumulatedOutput: ""
+
+ stdout: SplitParser {
+ onRead: function (line) {
+ // Accumulate lines instead of parsing each one
+ hyprlandMonitorsProcess.accumulatedOutput += line;
+ }
+ }
+
+ onExited: function (exitCode) {
+ if (exitCode !== 0 || !accumulatedOutput) {
+ Logger.e("HyprlandService", "Failed to query monitors, exit code:", exitCode);
+ accumulatedOutput = "";
+ return;
+ }
+
+ try {
+ const monitorsData = JSON.parse(accumulatedOutput);
+ const scales = {};
+
+ for (const monitor of monitorsData) {
+ if (monitor.name) {
+ scales[monitor.name] = {
+ "name": monitor.name,
+ "scale": monitor.scale || 1.0,
+ "width": monitor.width || 0,
+ "height": monitor.height || 0,
+ "refresh_rate": monitor.refreshRate || 0,
+ "x": monitor.x || 0,
+ "y": monitor.y || 0,
+ "active_workspace": monitor.activeWorkspace ? monitor.activeWorkspace.id : -1,
+ "vrr": monitor.vrr || false,
+ "focused": monitor.focused || false
+ };
+ }
+ }
+
+ // Notify CompositorService (it will emit displayScalesChanged)
+ if (CompositorService && CompositorService.onDisplayScalesUpdated) {
+ CompositorService.onDisplayScalesUpdated(scales);
+ }
+ } catch (e) {
+ Logger.e("HyprlandService", "Failed to parse monitors:", e);
+ } finally {
+ // Clear accumulated output for next query
+ accumulatedOutput = "";
+ }
+ }
+ }
+
+ function queryKeyboardLayout() {
+ hyprlandDevicesProcess.running = true;
+ }
+ // Hyprland devices process for keyboard layout detection
+ Process {
+ id: hyprlandDevicesProcess
+ running: false
+ command: ["hyprctl", "devices", "-j"]
+
+ property string accumulatedOutput: ""
+
+ stdout: SplitParser {
+ onRead: function (line) {
+ // Accumulate lines instead of parsing each one
+ hyprlandDevicesProcess.accumulatedOutput += line;
+ }
+ }
+
+ onExited: function (exitCode) {
+ if (exitCode !== 0 || !accumulatedOutput) {
+ Logger.e("HyprlandService", "Failed to query devices, exit code:", exitCode);
+ accumulatedOutput = "";
+ return;
+ }
+
+ try {
+ const devicesData = JSON.parse(accumulatedOutput);
+ for (const keyboard of devicesData.keyboards) {
+ if (keyboard.main) {
+ const layoutName = keyboard.active_keymap;
+ KeyboardLayoutService.setCurrentLayout(layoutName);
+ Logger.d("HyprlandService", "Keyboard layout switched:", layoutName);
+ }
+ }
+ } catch (e) {
+ Logger.e("HyprlandService", "Failed to parse devices:", e);
+ } finally {
+ // Clear accumulated output for next query
+ accumulatedOutput = "";
+ }
+ }
+ }
+
+ // Safe update wrapper
+ function safeUpdate() {
+ safeUpdateWindows();
+ safeUpdateWorkspaces();
+ workspaceChanged();
+ windowListChanged();
+ }
+
+ // Safe workspace update
+ function safeUpdateWorkspaces() {
+ try {
+ workspaces.clear();
+ workspaceCache = {};
+
+ if (!Hyprland.workspaces || !Hyprland.workspaces.values) {
+ return;
+ }
+
+ const hlWorkspaces = Hyprland.workspaces.values;
+ const occupiedIds = getOccupiedWorkspaceIds();
+
+ for (var i = 0; i < hlWorkspaces.length; i++) {
+ const ws = hlWorkspaces[i];
+ if (ws.name && ws.name.startsWith("special:"))
+ continue;
+
+ const wsData = {
+ "id": ws.id,
+ "idx": ws.id,
+ "name": ws.name || "",
+ "output": (ws.monitor && ws.monitor.name) ? ws.monitor.name : "",
+ "isActive": ws.active === true,
+ "isFocused": ws.focused === true,
+ "isUrgent": ws.urgent === true,
+ "isOccupied": occupiedIds[ws.id] === true
+ };
+
+ workspaceCache[ws.id] = wsData;
+ workspaces.append(wsData);
+ }
+ } catch (e) {
+ Logger.e("HyprlandService", "Error updating workspaces:", e);
+ }
+ }
+
+ // Get occupied workspace IDs safely
+ function getOccupiedWorkspaceIds() {
+ const occupiedIds = {};
+
+ try {
+ if (!Hyprland.toplevels || !Hyprland.toplevels.values) {
+ return occupiedIds;
+ }
+
+ const hlToplevels = Hyprland.toplevels.values;
+ for (var i = 0; i < hlToplevels.length; i++) {
+ const toplevel = hlToplevels[i];
+ if (!toplevel)
+ continue;
+ try {
+ const wsId = toplevel.workspace ? toplevel.workspace.id : null;
+ if (wsId !== null && wsId !== undefined) {
+ occupiedIds[wsId] = true;
+ }
+ } catch (e)
+
+ // Ignore individual toplevel errors
+ {}
+ }
+ } catch (e)
+
+ // Return empty if we can't determine occupancy
+ {}
+
+ return occupiedIds;
+ }
+
+ // Safe window update
+ function safeUpdateWindows() {
+ try {
+ const windowsList = [];
+ windowCache = {};
+
+ if (!Hyprland.toplevels || !Hyprland.toplevels.values) {
+ windows = [];
+ focusedWindowIndex = -1;
+ return;
+ }
+
+ const hlToplevels = Hyprland.toplevels.values;
+ let focusedWindowId = null;
+
+ // Get active workspaces to filter focus
+ const activeWorkspaceIds = {};
+ if (Hyprland.workspaces && Hyprland.workspaces.values) {
+ const hlWorkspaces = Hyprland.workspaces.values;
+ for (var j = 0; j < hlWorkspaces.length; j++) {
+ if (hlWorkspaces[j].active) {
+ activeWorkspaceIds[hlWorkspaces[j].id] = true;
+ }
+ }
+ }
+
+ for (var i = 0; i < hlToplevels.length; i++) {
+ const toplevel = hlToplevels[i];
+ if (!toplevel)
+ continue;
+ const windowData = extractWindowData(toplevel);
+ if (windowData) {
+ // If the window claims to be focused, verify it's on an active workspace
+ if (windowData.isFocused) {
+ if (!activeWorkspaceIds[windowData.workspaceId]) {
+ windowData.isFocused = false;
+ }
+ }
+
+ // Normalize to a plain, backend-independent window object
+ const normalized = {
+ "id": windowData.id ? String(windowData.id) : "",
+ "title": windowData.title ? String(windowData.title) : "",
+ "appId": windowData.appId ? String(windowData.appId) : "",
+ "workspaceId": (typeof windowData.workspaceId === "number" && !isNaN(windowData.workspaceId)) ? windowData.workspaceId : -1,
+ "isFocused": windowData.isFocused === true,
+ "output": windowData.output ? String(windowData.output) : "",
+ "x": (typeof windowData.x === "number" && !isNaN(windowData.x)) ? windowData.x : 0,
+ "y": (typeof windowData.y === "number" && !isNaN(windowData.y)) ? windowData.y : 0
+ };
+
+ windowsList.push(normalized);
+ windowCache[normalized.id] = normalized;
+
+ if (normalized.isFocused) {
+ focusedWindowId = normalized.id;
+ }
+ }
+ }
+
+ windows = toSortedWindowList(windowsList);
+
+ // Resolve focused index from sorted list (order changes after sort)
+ let newFocusedIndex = -1;
+ if (focusedWindowId) {
+ for (let k = 0; k < windows.length; k++) {
+ if (windows[k].id === focusedWindowId) {
+ newFocusedIndex = k;
+ break;
+ }
+ }
+ }
+
+ if (newFocusedIndex !== focusedWindowIndex) {
+ focusedWindowIndex = newFocusedIndex;
+ activeWindowChanged();
+ }
+ } catch (e) {
+ Logger.e("HyprlandService", "Error updating windows:", e);
+ }
+ }
+
+ // Extract window data safely from a toplevel
+ function extractWindowData(toplevel) {
+ if (!toplevel)
+ return null;
+
+ try {
+ // Safely extract properties
+ const windowId = safeGetProperty(toplevel, "address", "");
+ if (!windowId)
+ return null;
+
+ const appId = getAppId(toplevel);
+ const title = getAppTitle(toplevel);
+ const wsId = toplevel.workspace ? toplevel.workspace.id : null;
+ const focused = toplevel.activated === true;
+ const output = toplevel.monitor?.name || "";
+
+ // Extract position
+ let x = 0;
+ let y = 0;
+ try {
+ const ipcData = toplevel.lastIpcObject;
+ if (ipcData && ipcData.at) {
+ x = ipcData.at[0];
+ y = ipcData.at[1];
+ } else if (typeof toplevel.x !== 'undefined') {
+ x = toplevel.x;
+ y = toplevel.y;
+ }
+ } catch (e) {}
+
+ // Normalize coordinates to safe numeric values
+ const safeX = (typeof x === "number" && !isNaN(x)) ? x : 0;
+ const safeY = (typeof y === "number" && !isNaN(y)) ? y : 0;
+
+ return {
+ "id": windowId,
+ "title": title,
+ "appId": appId,
+ "workspaceId": wsId || -1,
+ "isFocused": focused,
+ "output": output,
+ "x": safeX,
+ "y": safeY
+ };
+ } catch (e) {
+ return null;
+ }
+ }
+
+ function toSortedWindowList(windowList) {
+ return windowList.sort((a, b) => {
+ // Sort by workspace first (just in case they are mixed)
+ if (a.workspaceId !== b.workspaceId) {
+ return a.workspaceId - b.workspaceId;
+ }
+ // Then sort by X position (left to right)
+ if (a.x !== b.x) {
+ return a.x - b.x;
+ }
+ // Then sort by Y position (top to bottom)
+ if (a.y !== b.y) {
+ return a.y - b.y;
+ }
+ // Fallback to Window ID mapping
+ return a.id.localeCompare(b.id);
+ });
+ }
+
+ function getAppTitle(toplevel) {
+ try {
+ var title = toplevel.wayland.title;
+ if (title)
+ return title;
+ } catch (e) {}
+
+ return safeGetProperty(toplevel, "title", "");
+ }
+
+ function getAppId(toplevel) {
+ if (!toplevel)
+ return "";
+
+ var appId = "";
+
+ // Try the wayland object first!
+ // From my (Lemmy) testing it works fine so we could probably get rid of all the other attempts below.
+ // Leaving them in for now, just in case...
+ try {
+ appId = toplevel.wayland.appId;
+ if (appId)
+ return appId;
+ } catch (e) {}
+
+ // Try direct properties
+ appId = safeGetProperty(toplevel, "class", "");
+ if (appId)
+ return appId;
+
+ appId = safeGetProperty(toplevel, "initialClass", "");
+ if (appId)
+ return appId;
+
+ appId = safeGetProperty(toplevel, "appId", "");
+ if (appId)
+ return appId;
+
+ // Try lastIpcObject
+ try {
+ const ipcData = toplevel.lastIpcObject;
+ if (ipcData) {
+ return String(ipcData.class || ipcData.initialClass || ipcData.appId || ipcData.wm_class || "");
+ }
+ } catch (e) {}
+
+ return "";
+ }
+
+ // Safe property getter
+ function safeGetProperty(obj, prop, defaultValue) {
+ try {
+ const value = obj[prop];
+ if (value !== undefined && value !== null) {
+ return String(value);
+ }
+ } catch (e)
+
+ // Property access failed
+ {}
+ return defaultValue;
+ }
+
+ function handleActiveLayoutEvent(ev) {
+ try {
+ let beforeParenthesis;
+ const parenthesisPos = ev.lastIndexOf('(');
+
+ if (parenthesisPos === -1) {
+ beforeParenthesis = ev;
+ } else {
+ beforeParenthesis = ev.substring(0, parenthesisPos);
+ }
+
+ const layoutNameStart = beforeParenthesis.lastIndexOf(',') + 1;
+ const layoutName = ev.substring(layoutNameStart);
+
+ // Ignore bogus "error" layout reported by virtual keyboards (e.g. wtype)
+ if (layoutName.toLowerCase() === "error") {
+ Logger.d("HyprlandService", "Ignoring bogus 'error' layout from activelayout event");
+ return;
+ }
+
+ KeyboardLayoutService.setCurrentLayout(layoutName);
+ Logger.d("HyprlandService", "Keyboard layout switched:", layoutName);
+ } catch (e) {
+ Logger.e("HyprlandService", "Error handling activelayout:", e);
+ }
+ }
+
+ // Connections to Hyprland
+ Connections {
+ target: Hyprland.workspaces
+ enabled: initialized
+ function onValuesChanged() {
+ Qt.callLater(_deferredWorkspaceUpdate);
+ }
+ }
+
+ Connections {
+ target: Hyprland.toplevels
+ enabled: initialized
+ function onValuesChanged() {
+ updateTimer.restart();
+ }
+ }
+
+ Connections {
+ target: Hyprland
+ enabled: initialized
+ function onRawEvent(event) {
+ Hyprland.refreshWorkspaces();
+ Hyprland.refreshToplevels();
+ // Workspace and window updates are deferred โ refreshWorkspaces()/
+ // refreshToplevels() trigger onValuesChanged which also calls
+ // Qt.callLater, so the deduplication coalesces into one update.
+ Qt.callLater(_deferredWorkspaceUpdate);
+ updateTimer.restart();
+
+ const monitorsEvents = ["configreloaded", "monitoradded", "monitorremoved", "monitoraddedv2", "monitorremovedv2"];
+
+ if (monitorsEvents.includes(event.name)) {
+ Qt.callLater(queryDisplayScales);
+ }
+
+ if (event.name == "activelayout") {
+ handleActiveLayoutEvent(event.data);
+ }
+ }
+ }
+
+ // Public functions
+ function switchToWorkspace(workspace) {
+ try {
+ if (workspace.name) {
+ Hyprland.dispatch(`workspace ${workspace.name}`);
+ return;
+ }
+ Hyprland.dispatch(`workspace ${workspace.idx}`);
+ } catch (e) {
+ Logger.e("HyprlandService", "Failed to switch workspace:", e);
+ }
+ }
+
+ function focusWindow(window) {
+ try {
+ if (!window || !window.id) {
+ Logger.w("HyprlandService", "Invalid window object for focus");
+ return;
+ }
+
+ const windowId = window.id.toString();
+ Hyprland.dispatch(`focuswindow address:0x${windowId}`);
+ Hyprland.dispatch(`alterzorder top,address:0x${windowId}`); // Bring the focused window to the top (essential for Float Mode)
+ } catch (e) {
+ Logger.e("HyprlandService", "Failed to switch window:", e);
+ }
+ }
+
+ function closeWindow(window) {
+ try {
+ Hyprland.dispatch(`killwindow address:0x${window.id}`);
+ } catch (e) {
+ Logger.e("HyprlandService", "Failed to close window:", e);
+ }
+ }
+
+ function turnOffMonitors() {
+ try {
+ Quickshell.execDetached(["hyprctl", "dispatch", "dpms", "off"]);
+ } catch (e) {
+ Logger.e("HyprlandService", "Failed to turn off monitors:", e);
+ }
+ }
+
+ function turnOnMonitors() {
+ try {
+ Quickshell.execDetached(["hyprctl", "dispatch", "dpms", "on"]);
+ } catch (e) {
+ Logger.e("HyprlandService", "Failed to turn on monitors:", e);
+ }
+ }
+
+ function logout() {
+ try {
+ Quickshell.execDetached(["hyprctl", "dispatch", "exit"]);
+ } catch (e) {
+ Logger.e("HyprlandService", "Failed to logout:", e);
+ }
+ }
+
+ function cycleKeyboardLayout() {
+ try {
+ Quickshell.execDetached(["hyprctl", "switchxkblayout", "all", "next"]);
+ } catch (e) {
+ Logger.e("HyprlandService", "Failed to cycle keyboard layout:", e);
+ }
+ }
+
+ function getFocusedScreen() {
+ const hyprMon = Hyprland.focusedMonitor;
+ if (hyprMon) {
+ const monitorName = hyprMon.name;
+ for (let i = 0; i < Quickshell.screens.length; i++) {
+ if (Quickshell.screens[i].name === monitorName) {
+ return Quickshell.screens[i];
+ }
+ }
+ }
+ return null;
+ }
+
+ function spawn(command) {
+ try {
+ Quickshell.execDetached(["hyprctl", "dispatch", "--", "exec"].concat(command));
+ } catch (e) {
+ Logger.e("HyprlandService", "Failed to spawn command:", e);
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Compositor/LabwcService.qml b/arch/.config/quickshell/noctalia-shell/Services/Compositor/LabwcService.qml
new file mode 100644
index 0000000..fe24a8b
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Compositor/LabwcService.qml
@@ -0,0 +1,300 @@
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import Quickshell.Wayland
+import Quickshell.WindowManager
+import qs.Commons
+
+Item {
+ id: root
+
+ property ListModel workspaces: ListModel {}
+ property var windows: []
+ property int focusedWindowIndex: -1
+ property var trackedToplevels: new Set()
+
+ // LabWC typically has global workspaces (shared across all outputs)
+ property bool globalWorkspaces: true
+
+ // Map from native workspace id to the native Workspace object for activation
+ property var nativeWorkspaceMap: ({})
+
+ // Set of workspace objects we've already connected signals to
+ property var connectedWorkspaces: ({})
+
+ signal workspaceChanged
+ signal activeWindowChanged
+ signal windowListChanged
+ signal displayScalesChanged
+
+ function initialize() {
+ updateWindows();
+ connectWorkspaceSignals();
+ syncWorkspaces();
+ Logger.i("LabwcService", "Service started (ext-workspace-v1)");
+ }
+
+ // Watch for windowsets being added/removed
+ Connections {
+ target: WindowManager
+
+ function onWindowsetsChanged() {
+ root.connectWorkspaceSignals();
+ Qt.callLater(root.syncWorkspaces);
+ }
+
+ function onWindowsetProjectionsChanged() {
+ Qt.callLater(root.syncWorkspaces);
+ }
+ }
+
+ // Re-check windowsets after a short delay - the Wayland protocol data
+ // may arrive after init and the changed signal can be missed
+ Timer {
+ interval: 500
+ running: true
+ repeat: false
+ onTriggered: {
+ if (WindowManager.windowsets.length > 0) {
+ root.connectWorkspaceSignals();
+ root.syncWorkspaces();
+ }
+ }
+ }
+
+ // Connect to property change signals on each native windowset object
+ function connectWorkspaceSignals() {
+ const nativeWs = WindowManager.windowsets;
+ const newConnected = {};
+
+ for (const ws of nativeWs) {
+ const key = ws.id || ws.toString();
+ newConnected[key] = true;
+
+ if (connectedWorkspaces[key])
+ continue;
+
+ ws.activeChanged.connect(() => {
+ Qt.callLater(root.syncWorkspaces);
+ });
+
+ ws.urgentChanged.connect(() => {
+ Qt.callLater(root.syncWorkspaces);
+ });
+
+ ws.shouldDisplayChanged.connect(() => {
+ Qt.callLater(root.syncWorkspaces);
+ });
+
+ ws.nameChanged.connect(() => {
+ Qt.callLater(root.syncWorkspaces);
+ });
+ }
+
+ connectedWorkspaces = newConnected;
+ }
+
+ function syncWorkspaces() {
+ const nativeWs = WindowManager.windowsets;
+
+ workspaces.clear();
+ nativeWorkspaceMap = {};
+
+ let idx = 1;
+
+ for (const ws of nativeWs) {
+ // Skip hidden workspaces (shouldDisplay = false means hidden)
+ if (!ws.shouldDisplay) {
+ continue;
+ }
+
+ // Find which outputs this windowset's projection spans
+ let outputName = "";
+ if (ws.projection) {
+ const projScreens = ws.projection.screens;
+ if (projScreens && projScreens.length > 0) {
+ outputName = projScreens[0].name || "";
+ }
+ }
+
+ const wsEntry = {
+ "id": ws.id || idx.toString(),
+ "idx": idx,
+ "name": ws.name || ("Workspace " + idx),
+ "output": outputName,
+ "isFocused": ws.active,
+ "isActive": true,
+ "isUrgent": ws.urgent,
+ "isOccupied": false,
+ "oid": ws.id || idx.toString()
+ };
+
+ workspaces.append(wsEntry);
+ nativeWorkspaceMap[wsEntry.id] = ws;
+
+ idx++;
+ }
+
+ // Update windows with workspace info
+ updateWindowWorkspaces();
+ workspaceChanged();
+ }
+
+ function updateWindowWorkspaces() {
+ // ext-workspace-v1 doesn't provide window-to-workspace mapping
+ // Assign all windows to the active workspace
+ let activeId = "";
+ for (let i = 0; i < workspaces.count; i++) {
+ const ws = workspaces.get(i);
+ if (ws.isFocused) {
+ activeId = ws.id;
+ break;
+ }
+ }
+
+ for (let i = 0; i < windows.length; i++) {
+ if (activeId) {
+ windows[i].workspaceId = activeId;
+ }
+ }
+ windowListChanged();
+ }
+
+ Connections {
+ target: ToplevelManager.toplevels
+ function onValuesChanged() {
+ updateWindows();
+ }
+ }
+
+ function connectToToplevel(toplevel) {
+ if (!toplevel)
+ return;
+
+ toplevel.activatedChanged.connect(() => {
+ Qt.callLater(onToplevelActivationChanged);
+ });
+
+ toplevel.titleChanged.connect(() => {
+ Qt.callLater(updateWindows);
+ });
+ }
+
+ function onToplevelActivationChanged() {
+ updateWindows();
+ activeWindowChanged();
+ }
+
+ function updateWindows() {
+ const newWindows = [];
+ const toplevels = ToplevelManager.toplevels?.values || [];
+
+ let focusedIdx = -1;
+ let idx = 0;
+
+ // Find active workspace id
+ let activeId = "";
+ for (let i = 0; i < workspaces.count; i++) {
+ const ws = workspaces.get(i);
+ if (ws.isFocused) {
+ activeId = ws.id;
+ break;
+ }
+ }
+
+ for (const toplevel of toplevels) {
+ if (!toplevel)
+ continue;
+
+ if (!trackedToplevels.has(toplevel)) {
+ connectToToplevel(toplevel);
+ trackedToplevels.add(toplevel);
+ }
+
+ // Get output name from toplevel's screen list
+ const output = (toplevel.screens && toplevel.screens.length > 0) ? (toplevel.screens[0].name || "") : "";
+
+ // Use appId + title as a stable id since Toplevel has no address property
+ const windowId = (toplevel.appId || "") + ":" + idx;
+
+ newWindows.push({
+ "id": windowId,
+ "appId": toplevel.appId || "",
+ "title": toplevel.title || "",
+ "output": output,
+ "workspaceId": activeId || "1",
+ "isFocused": toplevel.activated || false,
+ "toplevel": toplevel
+ });
+
+ if (toplevel.activated) {
+ focusedIdx = idx;
+ }
+ idx++;
+ }
+ windows = newWindows;
+ focusedWindowIndex = focusedIdx;
+
+ windowListChanged();
+ }
+
+ function focusWindow(window) {
+ if (window.toplevel && typeof window.toplevel.activate === "function") {
+ window.toplevel.activate();
+ }
+ }
+
+ function closeWindow(window) {
+ if (window.toplevel && typeof window.toplevel.close === "function") {
+ window.toplevel.close();
+ }
+ }
+
+ function switchToWorkspace(workspace) {
+ // Find the native Workspace object and activate it directly
+ const nativeWs = nativeWorkspaceMap[workspace.id] || nativeWorkspaceMap[workspace.oid];
+ if (nativeWs && nativeWs.canActivate) {
+ nativeWs.activate();
+ } else {
+ Logger.w("LabwcService", "Cannot activate workspace: " + (workspace.name || workspace.id));
+ }
+ }
+
+ function turnOffMonitors() {
+ try {
+ Quickshell.execDetached(["wlr-randr", "--off"]);
+ } catch (e) {
+ Logger.e("LabwcService", "Failed to turn off monitors:", e);
+ }
+ }
+
+ function turnOnMonitors() {
+ try {
+ Quickshell.execDetached(["wlr-randr", "--on"]);
+ } catch (e) {
+ Logger.e("LabwcService", "Failed to turn on monitors:", e);
+ }
+ }
+
+ function logout() {
+ try {
+ // Exit labwc by sending SIGTERM to $LABWC_PID or using --exit flag
+ Quickshell.execDetached(["sh", "-c", "labwc --exit || kill -s SIGTERM $LABWC_PID"]);
+ } catch (e) {
+ Logger.e("LabwcService", "Failed to logout:", e);
+ }
+ }
+
+ function cycleKeyboardLayout() {
+ Logger.w("LabwcService", "Keyboard layout cycling not supported");
+ }
+
+ function queryDisplayScales() {
+ Logger.w("LabwcService", "Display scale queries not supported via ToplevelManager");
+ }
+
+ function getFocusedScreen() {
+ // de-activated until proper testing
+ return null;
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Compositor/MangoService.qml b/arch/.config/quickshell/noctalia-shell/Services/Compositor/MangoService.qml
new file mode 100644
index 0000000..39c34b1
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Compositor/MangoService.qml
@@ -0,0 +1,491 @@
+import QtQuick
+import Quickshell
+import Quickshell.DWL
+import Quickshell.Io
+import Quickshell.Wayland
+import qs.Commons
+import qs.Services.Keyboard
+
+Item {
+ id: root
+
+ // ===== PUBLIC INTERFACE (CompositorService compatibility) =====
+
+ property ListModel workspaces: ListModel {}
+ property var windows: []
+ property int focusedWindowIndex: -1
+ property bool initialized: false
+
+ signal workspaceChanged
+ signal activeWindowChanged
+ signal windowListChanged
+ signal displayScalesChanged
+
+ // ===== MANGOSERVICE-SPECIFIC PROPERTIES =====
+
+ property string selectedMonitor: ""
+ property string currentLayoutSymbol: ""
+
+ // ===== INTERNAL STATE =====
+
+ QtObject {
+ id: internal
+
+ // Window-to-tag persistence: Map
+ property var windowTagMap: ({})
+
+ // Window-to-output persistence: Map
+ property var windowOutputMap: ({})
+
+ // Toplevel-to-ID mapping: Map
+ property var toplevelIdMap: new Map()
+ property int windowIdCounter: 0
+
+ // Output name to index mapping for unique workspace IDs
+ property var outputIndices: ({})
+ property int outputCounter: 0
+
+ // Monitor scales: Map
+ property var monitorScales: ({})
+
+ // Window signature for change detection
+ property string lastWindowSignature: ""
+
+ // Scale regex
+ readonly property var scalePattern: /^(\S+)\s+scale_factor\s+(\d+(?:\.\d+)?)$/
+
+ // Get all screen names mapped to their DwlIpcOutput
+ function getOutputMap() {
+ const map = {};
+ const screens = Quickshell.screens;
+ for (let i = 0; i < screens.length; i++) {
+ const name = screens[i].name;
+ const dwlOutput = DwlIpc.outputForName(name);
+ if (dwlOutput) {
+ map[name] = dwlOutput;
+ }
+ }
+ return map;
+ }
+
+ // ===== REBUILD WORKSPACES FROM DWL =====
+
+ function rebuildWorkspaces() {
+ if (!DwlIpc.available) {
+ return;
+ }
+
+ const outputMap = getOutputMap();
+ const workspaceList = [];
+
+ for (const outputName in outputMap) {
+ const output = outputMap[outputName];
+
+ // Assign stable index to output
+ if (internal.outputIndices[outputName] === undefined) {
+ internal.outputIndices[outputName] = internal.outputCounter++;
+ }
+ const outputIdx = internal.outputIndices[outputName];
+
+ // Track selected monitor and layout
+ if (output.active) {
+ root.selectedMonitor = outputName;
+ root.currentLayoutSymbol = output.layoutSymbol;
+ }
+
+ const tags = output.tags;
+ for (let ti = 0; ti < tags.length; ti++) {
+ const tag = tags[ti];
+ const tagId = tag.index + 1; // DwlTag.index is zero-based, our IDs are 1-based
+ const uniqueId = outputIdx * 100 + tagId;
+
+ workspaceList.push({
+ id: uniqueId,
+ idx: tagId,
+ name: tagId.toString(),
+ output: outputName,
+ isActive: tag.active,
+ isFocused: tag.active && output.active,
+ isUrgent: tag.urgent,
+ isOccupied: tag.clientCount > 0
+ });
+ }
+ }
+
+ // Sort by unique ID
+ workspaceList.sort((a, b) => a.id - b.id);
+
+ root.workspaces.clear();
+ for (let k = 0; k < workspaceList.length; k++) {
+ root.workspaces.append(workspaceList[k]);
+ }
+
+ root.workspaceChanged();
+ }
+
+ // ===== UPDATE WINDOWS =====
+
+ function updateWindows() {
+ if (!ToplevelManager.toplevels || !DwlIpc.available) {
+ return;
+ }
+
+ const outputMap = getOutputMap();
+ const toplevels = ToplevelManager.toplevels.values;
+ const windowList = [];
+ let newFocusedIdx = -1;
+ const currentWindows = new Set();
+
+ // Build per-output state from DWL
+ // Map
+ // Always populated (activeTagId needed for visible-window inference),
+ // title/appId may be empty if no window is focused.
+ const outputState = {};
+ for (const outputName in outputMap) {
+ const output = outputMap[outputName];
+
+ // Find active tag for this output
+ let activeTagId = 1;
+ const tags = output.tags;
+ for (let ti = 0; ti < tags.length; ti++) {
+ if (tags[ti].active) {
+ activeTagId = tags[ti].index + 1;
+ break;
+ }
+ }
+
+ outputState[outputName] = {
+ title: output.title || "",
+ appId: output.appId || "",
+ activeTagId: activeTagId
+ };
+
+ // Ensure output index exists
+ if (internal.outputIndices[outputName] === undefined) {
+ internal.outputIndices[outputName] = internal.outputCounter++;
+ }
+ }
+
+ for (let i = 0; i < toplevels.length; i++) {
+ const toplevel = toplevels[i];
+ if (!toplevel || toplevel.outliers) {
+ continue;
+ }
+
+ const appId = toplevel.appId || toplevel.wayland?.appId || "";
+ const title = toplevel.title || toplevel.wayland?.title || "";
+ const isFocused = toplevel.activated;
+
+ // Get or assign a stable ID
+ let windowId;
+ if (internal.toplevelIdMap.has(toplevel)) {
+ windowId = internal.toplevelIdMap.get(toplevel);
+ } else {
+ windowId = `win-${internal.windowIdCounter++}`;
+ internal.toplevelIdMap.set(toplevel, windowId);
+ }
+
+ currentWindows.add(windowId);
+
+ // Determine output
+ let outputName;
+
+ // Priority 1: Focused window matched to DWL output metadata
+ if (isFocused && (title || appId)) {
+ for (const oName in outputState) {
+ const os = outputState[oName];
+ if ((os.title || os.appId) && title === os.title && appId === os.appId) {
+ outputName = oName;
+ internal.windowOutputMap[windowId] = oName;
+ break;
+ }
+ }
+ }
+
+ // Priority 2: Remembered output
+ if (!outputName && internal.windowOutputMap[windowId]) {
+ outputName = internal.windowOutputMap[windowId];
+ }
+
+ // Priority 3: toplevel.screens (wlr-foreign-toplevel visible screens)
+ if (!outputName && toplevel.screens && toplevel.screens.length > 0) {
+ outputName = toplevel.screens[0].name;
+ }
+
+ // Fallback: selected monitor
+ if (!outputName) {
+ outputName = root.selectedMonitor || "DP-1";
+ }
+
+ // Determine tag
+ let tagId = null;
+
+ const os = outputState[outputName];
+ if (isFocused && os && !os.consumed && (os.title || os.appId) && title === os.title && appId === os.appId) {
+ // Focused window: assign to the active tag from DWL metadata
+ tagId = os.activeTagId;
+ internal.windowTagMap[windowId] = tagId;
+ // Consume so a second toplevel with identical title+appId cannot also claim focus
+ os.consumed = true;
+ } else if (internal.windowTagMap[windowId] !== undefined) {
+ // Previously seen window: use remembered tag
+ tagId = internal.windowTagMap[windowId];
+ }
+
+ if (tagId === null) {
+ // DWL only reports the focused window per output, so we can't
+ // determine the tag for unfocused windows until they gain focus.
+ continue;
+ }
+
+ // Convert to unique workspace ID
+ const outputIdx = internal.outputIndices[outputName];
+ if (outputIdx === undefined) {
+ Logger.e("MangoService", "No output index for", outputName);
+ continue;
+ }
+ const workspaceId = outputIdx * 100 + tagId;
+
+ windowList.push({
+ id: `${outputName}:${appId}:${title}:${i}`,
+ title: title,
+ appId: appId,
+ class: appId,
+ workspaceId: workspaceId,
+ isFocused: isFocused,
+ output: outputName,
+ handle: toplevel,
+ fullscreen: toplevel.fullscreen || false,
+ floating: toplevel.maximized === false && toplevel.fullscreen === false
+ });
+
+ if (isFocused) {
+ newFocusedIdx = windowList.length - 1;
+ }
+ }
+
+ // Clean up stale window tracking
+ if (Object.keys(internal.windowTagMap).length > toplevels.length + 20) {
+ const newTagMap = {};
+ const newOutputMap = {};
+ for (const windowId of currentWindows) {
+ if (internal.windowTagMap[windowId] !== undefined) {
+ newTagMap[windowId] = internal.windowTagMap[windowId];
+ }
+ if (internal.windowOutputMap[windowId] !== undefined) {
+ newOutputMap[windowId] = internal.windowOutputMap[windowId];
+ }
+ }
+ internal.windowTagMap = newTagMap;
+ internal.windowOutputMap = newOutputMap;
+ }
+
+ // Check if window list changed
+ const signature = JSON.stringify(windowList.map(w => w.id + w.workspaceId + w.isFocused));
+ if (signature !== internal.lastWindowSignature) {
+ internal.lastWindowSignature = signature;
+ root.windows = windowList;
+ root.windowListChanged();
+ }
+
+ if (newFocusedIdx !== root.focusedWindowIndex) {
+ root.focusedWindowIndex = newFocusedIdx;
+ root.activeWindowChanged();
+ }
+ }
+
+ // ===== PROCESS SCALES =====
+
+ function processScales(output) {
+ const lines = output.trim().split('\n');
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i].trim();
+ const match = line.match(scalePattern);
+
+ if (match) {
+ const outputName = match[1];
+ const scale = parseFloat(match[2]);
+ internal.monitorScales[outputName] = scale;
+ }
+ }
+
+ const scalesMap = {};
+ for (const name in internal.monitorScales) {
+ scalesMap[name] = {
+ name: name,
+ scale: internal.monitorScales[name] || 1.0,
+ width: 0,
+ height: 0,
+ x: 0,
+ y: 0
+ };
+ }
+
+ if (CompositorService && CompositorService.onDisplayScalesUpdated) {
+ CompositorService.onDisplayScalesUpdated(scalesMap);
+ }
+
+ root.displayScalesChanged();
+ }
+ }
+
+ // ===== DWL CONNECTIONS =====
+
+ // React to DWL frame events on each output (atomic state updates)
+ Instantiator {
+ model: DwlIpc.outputs
+ delegate: Connections {
+ required property DwlIpcOutput modelData
+ target: modelData
+
+ function onFrame() {
+ internal.rebuildWorkspaces();
+ internal.updateWindows();
+ }
+
+ function onKbLayoutChanged() {
+ if (KeyboardLayoutService) {
+ KeyboardLayoutService.setCurrentLayout(modelData.kbLayout);
+ }
+ }
+ }
+ }
+
+ // ===== PROCESSES =====
+
+ // Scale query (mmsg -g -A) - DWL doesn't provide scale info
+ property QtObject _scaleQuery: Process {
+ id: scaleQuery
+ command: ["mmsg", "-g", "-A"]
+
+ property string buffer: ""
+
+ stdout: SplitParser {
+ onRead: line => {
+ scaleQuery.buffer += line + "\n";
+ }
+ }
+
+ onExited: code => {
+ if (code === 0) {
+ internal.processScales(scaleQuery.buffer);
+ scaleQuery.buffer = "";
+ }
+ }
+ }
+
+ // ===== TOPLEVEL MANAGER CONNECTION =====
+
+ property QtObject _toplevelConnection: Connections {
+ target: ToplevelManager.toplevels
+
+ function onValuesChanged() {
+ internal.updateWindows();
+ }
+ }
+
+ // ===== PUBLIC FUNCTIONS =====
+
+ function initialize() {
+ if (initialized) {
+ return;
+ }
+
+ Logger.i("MangoService", "Initializing MangoWC/DWL compositor integration (DWL protocol)");
+
+ // Query display scales (only thing still needing mmsg)
+ scaleQuery.running = true;
+
+ // Initial build from DWL state
+ if (DwlIpc.available && DwlIpc.outputs.length > 0) {
+ internal.rebuildWorkspaces();
+ internal.updateWindows();
+ }
+
+ initialized = true;
+ }
+
+ function queryDisplayScales() {
+ scaleQuery.running = true;
+ }
+
+ function switchToWorkspace(workspace) {
+ const tagId = workspace.idx || workspace.id || 1;
+ const outputName = workspace.output || root.selectedMonitor || "";
+
+ // Use DWL protocol to switch tags
+ const dwlOutput = DwlIpc.outputForName(outputName);
+ if (dwlOutput) {
+ dwlOutput.setTags(1 << (tagId - 1)); // tagId is 1-based, bitmask is 0-based
+ } else {
+ // Fallback to mmsg
+ const cmd = ["mmsg", "-s", "-t", tagId.toString()];
+ if (outputName && Object.keys(internal.monitorScales).length > 1) {
+ cmd.push("-o", outputName);
+ }
+ Quickshell.execDetached(cmd);
+ }
+ }
+
+ function focusWindow(window) {
+ if (window && window.handle) {
+ window.handle.activate();
+ } else if (window.workspaceId) {
+ switchToWorkspace({
+ id: window.workspaceId,
+ output: window.output
+ });
+ }
+ }
+
+ function closeWindow(window) {
+ if (window && window.handle) {
+ window.handle.close();
+ } else {
+ Quickshell.execDetached(["mmsg", "-s", "-d", "killclient"]);
+ }
+ }
+
+ function turnOffMonitors() {
+ const screens = Quickshell.screens;
+ const cmds = [];
+ for (let i = 0; i < screens.length; i++) {
+ cmds.push("mmsg -s -d disable_monitor," + screens[i].name);
+ }
+ if (cmds.length > 0) {
+ Quickshell.execDetached(["sh", "-c", cmds.join(" && ")]);
+ }
+ }
+
+ function turnOnMonitors() {
+ const screens = Quickshell.screens;
+ const cmds = [];
+ for (let i = 0; i < screens.length; i++) {
+ cmds.push("mmsg -s -d enable_monitor," + screens[i].name);
+ }
+ if (cmds.length > 0) {
+ Quickshell.execDetached(["sh", "-c", cmds.join(" && ")]);
+ }
+ }
+
+ function logout() {
+ Quickshell.execDetached(["mmsg", "-s", "-q"]);
+ }
+
+ function cycleKeyboardLayout() {
+ Logger.w("MangoService", "Keyboard layout cycling not supported");
+ }
+
+ function getFocusedScreen() {
+ return null;
+ }
+
+ function spawn(command) {
+ try {
+ Quickshell.execDetached(["mmsg", "-s", "-d", "spawn_shell," + command.join(" ")]);
+ } catch (e) {
+ Logger.e("MangoService", "Failed to spawn command:", e);
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Compositor/NiriService.qml b/arch/.config/quickshell/noctalia-shell/Services/Compositor/NiriService.qml
new file mode 100644
index 0000000..a74efb0
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Compositor/NiriService.qml
@@ -0,0 +1,294 @@
+import QtQuick
+import Quickshell
+import Quickshell.Niri
+import qs.Commons
+import qs.Services.Keyboard
+
+Item {
+ id: root
+
+ property int floatingWindowPosition: Number.MAX_SAFE_INTEGER
+
+ property ListModel workspaces: ListModel {}
+ property var windows: []
+ property int focusedWindowIndex: -1
+
+ property bool overviewActive: false
+
+ property var keyboardLayouts: []
+
+ signal workspaceChanged
+ signal activeWindowChanged
+ signal windowListChanged
+ signal displayScalesChanged
+
+ property var outputCache: ({})
+ property var workspaceCache: ({})
+
+ function initialize() {
+ Niri.refreshOutputs();
+ Niri.refreshWorkspaces();
+ Niri.refreshWindows();
+
+ Qt.callLater(() => {
+ safeUpdateOutputs();
+ safeUpdateWorkspaces();
+ safeUpdateWindows();
+ queryDisplayScales();
+ });
+
+ Logger.i("NiriService", "Service started");
+ }
+
+ // Connections to the C++ Niri IPC module
+ Connections {
+ target: Niri
+ function onWorkspacesUpdated() {
+ safeUpdateWorkspaces();
+ workspaceChanged();
+ }
+ function onWindowsUpdated() {
+ safeUpdateWindows();
+ windowListChanged();
+ activeWindowChanged();
+ }
+ function onOutputsUpdated() {
+ safeUpdateOutputs();
+ queryDisplayScales();
+ }
+ function onOverviewActiveChanged() {
+ overviewActive = Niri.overviewActive;
+ }
+ function onKeyboardLayoutsChanged() {
+ keyboardLayouts = Niri.keyboardLayoutNames;
+ const layoutName = Niri.currentKeyboardLayoutName;
+ if (layoutName) {
+ KeyboardLayoutService.setCurrentLayout(layoutName);
+ }
+ Logger.d("NiriService", "Keyboard layouts changed:", keyboardLayouts.toString());
+ }
+ function onKeyboardLayoutSwitched() {
+ const layoutName = Niri.currentKeyboardLayoutName;
+ if (layoutName) {
+ KeyboardLayoutService.setCurrentLayout(layoutName);
+ }
+ Logger.d("NiriService", "Keyboard layout switched:", layoutName);
+ }
+ }
+
+ function safeUpdateOutputs() {
+ const niriOutputs = Niri.outputs.values;
+ outputCache = {};
+
+ for (var i = 0; i < niriOutputs.length; i++) {
+ const output = niriOutputs[i];
+ outputCache[output.name] = {
+ "name": output.name,
+ "connected": output.connected,
+ "scale": output.scale,
+ "width": output.width,
+ "height": output.height,
+ "x": output.x,
+ "y": output.y,
+ "physical_width": output.physicalWidth,
+ "physical_height": output.physicalHeight,
+ "refresh_rate": output.refreshRate,
+ "vrr_supported": output.vrrSupported,
+ "vrr_enabled": output.vrrEnabled,
+ "transform": output.transform
+ };
+ }
+ }
+
+ function safeUpdateWorkspaces() {
+ const niriWorkspaces = Niri.workspaces.values;
+ workspaceCache = {};
+
+ const workspacesList = [];
+ for (var i = 0; i < niriWorkspaces.length; i++) {
+ const ws = niriWorkspaces[i];
+ const wsData = {
+ "id": ws.id,
+ "idx": ws.idx,
+ "name": ws.name,
+ "output": ws.output,
+ "isFocused": ws.focused,
+ "isActive": ws.active,
+ "isUrgent": ws.urgent,
+ "isOccupied": ws.occupied
+ };
+ workspacesList.push(wsData);
+ workspaceCache[ws.id] = wsData;
+ }
+
+ // Workspaces come pre-sorted from C++ (by output then idx)
+ workspaces.clear();
+ for (var j = 0; j < workspacesList.length; j++) {
+ workspaces.append(workspacesList[j]);
+ }
+ }
+
+ function getWindowOutput(win) {
+ for (var i = 0; i < workspaces.count; i++) {
+ if (workspaces.get(i).id === win.workspaceId) {
+ return workspaces.get(i).output;
+ }
+ }
+ return null;
+ }
+
+ function toSortedWindowList(windowList) {
+ return windowList.map(win => {
+ const workspace = workspaceCache[win.workspaceId];
+ const output = (workspace && workspace.output) ? outputCache[workspace.output] : null;
+
+ return {
+ window: win,
+ workspaceIdx: workspace ? workspace.idx : 0,
+ outputX: output ? output.x : 0,
+ outputY: output ? output.y : 0
+ };
+ }).sort((a, b) => {
+ // Sort by output position first
+ if (a.outputX !== b.outputX) {
+ return a.outputX - b.outputX;
+ }
+ if (a.outputY !== b.outputY) {
+ return a.outputY - b.outputY;
+ }
+ // Then by workspace index
+ if (a.workspaceIdx !== b.workspaceIdx) {
+ return a.workspaceIdx - b.workspaceIdx;
+ }
+ // Then by window position
+ if (a.window.position.x !== b.window.position.x) {
+ return a.window.position.x - b.window.position.x;
+ }
+ if (a.window.position.y !== b.window.position.y) {
+ return a.window.position.y - b.window.position.y;
+ }
+ // Finally by window ID to ensure consistent ordering
+ return a.window.id - b.window.id;
+ }).map(info => info.window);
+ }
+
+ function safeUpdateWindows() {
+ const niriWindows = Niri.windows.values;
+ const windowsList = [];
+
+ for (var i = 0; i < niriWindows.length; i++) {
+ const win = niriWindows[i];
+ windowsList.push({
+ "id": win.id,
+ "title": win.title || "",
+ "appId": win.appId || "",
+ "workspaceId": win.workspaceId || -1,
+ "isFocused": win.focused,
+ "output": win.output || getWindowOutput(win) || "",
+ "position": {
+ "x": win.isFloating ? floatingWindowPosition : win.positionX,
+ "y": win.isFloating ? floatingWindowPosition : win.positionY
+ }
+ });
+ }
+
+ windows = toSortedWindowList(windowsList);
+ safeUpdateFocusedWindow();
+ }
+
+ function safeUpdateFocusedWindow() {
+ focusedWindowIndex = -1;
+ for (var i = 0; i < windows.length; i++) {
+ if (windows[i].isFocused) {
+ focusedWindowIndex = i;
+ break;
+ }
+ }
+ }
+
+ function queryDisplayScales() {
+ if (CompositorService && CompositorService.onDisplayScalesUpdated) {
+ CompositorService.onDisplayScalesUpdated(outputCache);
+ }
+ }
+
+ function switchToWorkspace(workspace) {
+ try {
+ Niri.dispatch(["focus-workspace", workspace.idx.toString()]);
+ } catch (e) {
+ Logger.e("NiriService", "Failed to switch workspace:", e);
+ }
+ }
+
+ function scrollWorkspaceContent(direction) {
+ try {
+ var action = direction < 0 ? "focus-column-left" : "focus-column-right";
+ Niri.dispatch([action]);
+ } catch (e) {
+ Logger.e("NiriService", "Failed to scroll workspace content:", e);
+ }
+ }
+
+ function focusWindow(window) {
+ try {
+ Niri.dispatch(["focus-window", "--id", window.id.toString()]);
+ } catch (e) {
+ Logger.e("NiriService", "Failed to switch window:", e);
+ }
+ }
+
+ function closeWindow(window) {
+ try {
+ Niri.dispatch(["close-window", "--id", window.id.toString()]);
+ } catch (e) {
+ Logger.e("NiriService", "Failed to close window:", e);
+ }
+ }
+
+ function turnOffMonitors() {
+ try {
+ Niri.dispatch(["power-off-monitors"]);
+ } catch (e) {
+ Logger.e("NiriService", "Failed to turn off monitors:", e);
+ }
+ }
+
+ function turnOnMonitors() {
+ try {
+ Niri.dispatch(["power-on-monitors"]);
+ } catch (e) {
+ Logger.e("NiriService", "Failed to turn on monitors:", e);
+ }
+ }
+
+ function logout() {
+ try {
+ Niri.dispatch(["quit", "--skip-confirmation"]);
+ } catch (e) {
+ Logger.e("NiriService", "Failed to logout:", e);
+ }
+ }
+
+ function cycleKeyboardLayout() {
+ try {
+ Niri.dispatch(["switch-layout", "next"]);
+ } catch (e) {
+ Logger.e("NiriService", "Failed to cycle keyboard layout:", e);
+ }
+ }
+
+ function getFocusedScreen() {
+ // On niri the code below only works when you have an actual app selected on that screen.
+ return null;
+ }
+
+ function spawn(command) {
+ try {
+ const niriArgs = ["spawn", "--"].concat(command);
+ Logger.d("NiriService", "Calling niri spawn: niri msg action " + niriArgs.join(" "));
+ Niri.dispatch(niriArgs);
+ } catch (e) {
+ Logger.e("NiriService", "Failed to spawn command:", e);
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Compositor/SwayService.qml b/arch/.config/quickshell/noctalia-shell/Services/Compositor/SwayService.qml
new file mode 100644
index 0000000..397c114
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Compositor/SwayService.qml
@@ -0,0 +1,593 @@
+import QtQuick
+import Quickshell
+import Quickshell.I3
+import Quickshell.Io
+import Quickshell.Wayland
+import qs.Commons
+import qs.Services.Keyboard
+
+Item {
+ id: root
+
+ // Configurable IPC command name (overridden to "scrollmsg" for Scroll)
+ property string msgCommand: "swaymsg"
+
+ // Properties that match the facade interface
+ property ListModel workspaces: ListModel {}
+ property var windows: []
+ property int focusedWindowIndex: -1
+
+ // Signals that match the facade interface
+ signal workspaceChanged
+ signal activeWindowChanged
+ signal windowListChanged
+ signal displayScalesChanged
+
+ // I3-specific properties
+ property bool initialized: false
+
+ // Cache for window-to-workspace mapping
+ property var windowWorkspaceMap: ({})
+
+ // Track window usage counts per workspace to handle duplicates
+ property var windowUsageCountsPerWorkspace: ({})
+
+ // Debounce timer for updates
+ Timer {
+ id: updateTimer
+ interval: 50
+ repeat: false
+ onTriggered: safeUpdate()
+ }
+
+ // Initialization
+ function initialize() {
+ if (initialized)
+ return;
+ try {
+ I3.refreshWorkspaces();
+ Qt.callLater(() => {
+ safeUpdateWorkspaces();
+ queryWindowWorkspaces();
+ queryDisplayScales();
+ queryKeyboardLayout();
+ });
+ initialized = true;
+ Logger.i("SwayService", "Service started");
+ } catch (e) {
+ Logger.e("SwayService", "Failed to initialize:", e);
+ }
+ }
+
+ // Query window-to-workspace mapping via IPC
+ function queryWindowWorkspaces() {
+ swayTreeProcess.running = true;
+ }
+
+ // Sway tree process for getting window workspace information
+ Process {
+ id: swayTreeProcess
+ running: false
+ command: [msgCommand, "-t", "get_tree", "-r"]
+
+ property string accumulatedOutput: ""
+
+ stdout: SplitParser {
+ onRead: function (line) {
+ swayTreeProcess.accumulatedOutput += line;
+ }
+ }
+
+ onExited: function (exitCode) {
+ if (exitCode !== 0 || !accumulatedOutput) {
+ Logger.e("SwayService", "Failed to query tree, exit code:", exitCode);
+ accumulatedOutput = "";
+ return;
+ }
+
+ try {
+ const treeData = JSON.parse(accumulatedOutput);
+ const newMap = {};
+ const workspaceWindows = {}; // Track windows per workspace
+
+ // Recursively find all windows and their workspaces
+ function traverseTree(node, workspaceNum) {
+ if (!node)
+ return;
+
+ // If this is a workspace node, update the workspace number
+ if (node.type === "workspace" && node.num !== undefined) {
+ workspaceNum = node.num;
+ if (!workspaceWindows[workspaceNum]) {
+ workspaceWindows[workspaceNum] = [];
+ }
+ }
+
+ // If this is a regular or floating container with app_id/class (i.e., a window)
+ if ((node.type === "con" || node.type === "floating_con") && (node.app_id || node.window_properties)) {
+ const appId = node.app_id || (node.window_properties ? node.window_properties.class : null);
+ const title = node.name || "";
+ const id = node.id;
+
+ if (appId && workspaceNum !== undefined && workspaceNum >= 0) {
+ // Store window info for this workspace
+ workspaceWindows[workspaceNum].push({
+ appId: appId,
+ title: title,
+ id: id
+ });
+ }
+ }
+
+ // Traverse children
+ if (node.nodes && node.nodes.length > 0) {
+ for (const child of node.nodes) {
+ traverseTree(child, workspaceNum);
+ }
+ }
+
+ // Traverse floating nodes
+ if (node.floating_nodes && node.floating_nodes.length > 0) {
+ for (const child of node.floating_nodes) {
+ traverseTree(child, workspaceNum);
+ }
+ }
+ }
+
+ traverseTree(treeData, -1);
+
+ // Now build the map with workspace-specific keys
+ for (const wsNum in workspaceWindows) {
+ const windows = workspaceWindows[wsNum];
+ const appTitleCounts = {}; // Count occurrences of each appId:title in this workspace
+
+ for (const win of windows) {
+ const baseKey = `${win.appId}:${win.title}`;
+
+ // Track how many times we've seen this appId:title combo in this workspace
+ if (!appTitleCounts[baseKey]) {
+ appTitleCounts[baseKey] = 0;
+ }
+ const occurrence = appTitleCounts[baseKey];
+ appTitleCounts[baseKey]++;
+
+ // Create unique key with workspace and occurrence index
+ const uniqueKey = `ws${wsNum}:${baseKey}[${occurrence}]`;
+ newMap[uniqueKey] = parseInt(wsNum);
+
+ // Also store by ID if available (most reliable)
+ if (win.id) {
+ newMap[`id:${win.id}`] = parseInt(wsNum);
+ }
+ }
+ }
+
+ windowWorkspaceMap = newMap;
+
+ // Update windows with new workspace information
+ Qt.callLater(safeUpdateWindows);
+ } catch (e) {
+ Logger.e("SwayService", "Failed to parse tree:", e);
+ } finally {
+ accumulatedOutput = "";
+ }
+ }
+ }
+
+ // Query display scales
+ function queryDisplayScales() {
+ swayOutputsProcess.running = true;
+ }
+
+ // Sway outputs process for display scale detection
+ Process {
+ id: swayOutputsProcess
+ running: false
+ command: [msgCommand, "-t", "get_outputs", "-r"]
+
+ property string accumulatedOutput: ""
+
+ stdout: SplitParser {
+ onRead: function (line) {
+ swayOutputsProcess.accumulatedOutput += line;
+ }
+ }
+
+ onExited: function (exitCode) {
+ if (exitCode !== 0 || !accumulatedOutput) {
+ Logger.e("SwayService", "Failed to query outputs, exit code:", exitCode);
+ accumulatedOutput = "";
+ return;
+ }
+
+ try {
+ const outputsData = JSON.parse(accumulatedOutput);
+ const scales = {};
+
+ for (const output of outputsData) {
+ if (output.name) {
+ scales[output.name] = {
+ "name": output.name,
+ "scale": output.scale || 1.0,
+ "width": output.current_mode ? output.current_mode.width : 0,
+ "height": output.current_mode ? output.current_mode.height : 0,
+ "refresh_rate": output.current_mode ? output.current_mode.refresh : 0,
+ "x": output.rect ? output.rect.x : 0,
+ "y": output.rect ? output.rect.y : 0,
+ "active": output.active || false,
+ "focused": output.focused || false,
+ "current_workspace": output.current_workspace || ""
+ };
+ }
+ }
+
+ // Notify CompositorService (it will emit displayScalesChanged)
+ if (CompositorService && CompositorService.onDisplayScalesUpdated) {
+ CompositorService.onDisplayScalesUpdated(scales);
+ }
+ } catch (e) {
+ Logger.e("SwayService", "Failed to parse outputs:", e);
+ } finally {
+ // Clear accumulated output for next query
+ accumulatedOutput = "";
+ }
+ }
+ }
+
+ function queryKeyboardLayout() {
+ swayInputsProcess.running = true;
+ }
+ // Sway inputs process for keyboard layout detection
+ Process {
+ id: swayInputsProcess
+ running: false
+ command: [msgCommand, "-t", "get_inputs", "-r"]
+
+ property string accumulatedOutput: ""
+
+ stdout: SplitParser {
+ onRead: function (line) {
+ // Accumulate lines instead of parsing each one
+ swayInputsProcess.accumulatedOutput += line;
+ }
+ }
+
+ onExited: function (exitCode) {
+ if (exitCode !== 0 || !accumulatedOutput) {
+ Logger.e("SwayService", "Failed to query inputs, exit code:", exitCode);
+ accumulatedOutput = "";
+ return;
+ }
+
+ try {
+ const inputsData = JSON.parse(accumulatedOutput);
+ for (const input of inputsData) {
+ if (input.type == "keyboard") {
+ const layoutName = input.xkb_active_layout_name;
+ KeyboardLayoutService.setCurrentLayout(layoutName);
+ Logger.d("SwayService", "Keyboard layout switched:", layoutName);
+ break;
+ }
+ }
+ } catch (e) {
+ Logger.e("SwayService", "Failed to parse inputs:", e);
+ } finally {
+ // Clear accumulated output for next query
+ accumulatedOutput = "";
+ }
+ }
+ }
+
+ // Safe update wrapper
+ function safeUpdate() {
+ queryWindowWorkspaces();
+ safeUpdateWorkspaces();
+ }
+
+ // Safe workspace update
+ function safeUpdateWorkspaces() {
+ try {
+ workspaces.clear();
+
+ if (!I3.workspaces || !I3.workspaces.values) {
+ return;
+ }
+
+ const hlWorkspaces = I3.workspaces.values;
+
+ for (var i = 0; i < hlWorkspaces.length; i++) {
+ const ws = hlWorkspaces[i];
+ if (!ws || ws.id < 1)
+ continue;
+ const wsData = {
+ "id": i,
+ "idx": ws.num,
+ "name": ws.name || "",
+ "output": (ws.monitor && ws.monitor.name) ? ws.monitor.name : "",
+ "isActive": ws.active === true,
+ "isFocused": ws.focused === true,
+ "isUrgent": ws.urgent === true,
+ "isOccupied": true,
+ "handle": ws
+ };
+
+ workspaces.append(wsData);
+ }
+ } catch (e) {
+ Logger.e("SwayService", "Error updating workspaces:", e);
+ }
+ }
+
+ // Safe window update
+ function safeUpdateWindows() {
+ try {
+ const windowsList = [];
+
+ // Reset usage counts per workspace before processing windows
+ windowUsageCountsPerWorkspace = {};
+
+ if (!ToplevelManager.toplevels || !ToplevelManager.toplevels.values) {
+ windows = [];
+ focusedWindowIndex = -1;
+ windowListChanged();
+ return;
+ }
+
+ const hlToplevels = ToplevelManager.toplevels.values;
+ let newFocusedIndex = -1;
+
+ for (var i = 0; i < hlToplevels.length; i++) {
+ const toplevel = hlToplevels[i];
+ if (!toplevel)
+ continue;
+ const windowData = extractWindowData(toplevel);
+ if (windowData) {
+ windowsList.push(windowData);
+
+ if (windowData.isFocused) {
+ newFocusedIndex = windowsList.length - 1;
+ }
+ }
+ }
+
+ windows = windowsList;
+
+ if (newFocusedIndex !== focusedWindowIndex) {
+ focusedWindowIndex = newFocusedIndex;
+ activeWindowChanged();
+ }
+
+ windowListChanged();
+ } catch (e) {
+ Logger.e("SwayService", "Error updating windows:", e);
+ }
+ }
+
+ // Extract window data safely from a toplevel
+ function extractWindowData(toplevel) {
+ if (!toplevel)
+ return null;
+
+ try {
+ // Safely extract properties
+ const appId = getAppId(toplevel);
+ const title = safeGetProperty(toplevel, "title", "");
+ const focused = toplevel.activated === true;
+
+ // Try to find workspace ID from our cached map by trying all workspaces
+ let workspaceId = -1;
+ let foundWorkspaceNum = -1;
+
+ // Build base key for this window
+ const baseKey = `${appId}:${title}`;
+
+ // Try to find this window in any workspace
+ for (var i = 0; i < workspaces.count; i++) {
+ const ws = workspaces.get(i);
+ if (!ws)
+ continue;
+
+ const wsNum = ws.idx;
+
+ // Initialize usage count for this workspace if needed
+ if (!windowUsageCountsPerWorkspace[wsNum]) {
+ windowUsageCountsPerWorkspace[wsNum] = {};
+ }
+
+ // Get current usage count for this appId:title in this workspace
+ if (!windowUsageCountsPerWorkspace[wsNum][baseKey]) {
+ windowUsageCountsPerWorkspace[wsNum][baseKey] = 0;
+ }
+
+ const occurrence = windowUsageCountsPerWorkspace[wsNum][baseKey];
+ const uniqueKey = `ws${wsNum}:${baseKey}[${occurrence}]`;
+
+ // Check if this key exists in our map
+ if (windowWorkspaceMap[uniqueKey] !== undefined) {
+ foundWorkspaceNum = windowWorkspaceMap[uniqueKey];
+ workspaceId = ws.id;
+
+ // Increment the usage count for this workspace
+ windowUsageCountsPerWorkspace[wsNum][baseKey]++;
+ break;
+ }
+ }
+
+ return {
+ "title": title,
+ "appId": appId,
+ "isFocused": focused,
+ "workspaceId": workspaceId,
+ "handle": toplevel
+ };
+ } catch (e) {
+ return null;
+ }
+ }
+
+ function getAppId(toplevel) {
+ if (!toplevel)
+ return "";
+
+ return toplevel.appId;
+ }
+
+ // Safe property getter
+ function safeGetProperty(obj, prop, defaultValue) {
+ try {
+ const value = obj[prop];
+ if (value !== undefined && value !== null) {
+ return String(value);
+ }
+ } catch (e)
+
+ // Property access failed
+ {}
+ return defaultValue;
+ }
+
+ function handleInputEvent(ev) {
+ try {
+ const eventData = JSON.parse(ev);
+ if (eventData.change == "xkb_layout" && eventData.input != null) {
+ const input = eventData.input;
+ if (input.type == "keyboard" && input.xkb_active_layout_name != null) {
+ const layoutName = input.xkb_active_layout_name;
+ KeyboardLayoutService.setCurrentLayout(layoutName);
+ Logger.d("SwayService", "Keyboard layout switched:", layoutName);
+ }
+ }
+ } catch (e) {
+ Logger.e("SwayService", "Error handling input event:", e);
+ }
+ }
+
+ // Connections to I3
+ Connections {
+ target: I3.workspaces
+ enabled: initialized
+ function onValuesChanged() {
+ safeUpdateWorkspaces();
+ workspaceChanged();
+ }
+ }
+
+ Connections {
+ target: ToplevelManager
+ enabled: initialized
+ function onActiveToplevelChanged() {
+ updateTimer.restart();
+ }
+ }
+
+ // Some programs change title of window dependent on content
+ Connections {
+ target: ToplevelManager ? ToplevelManager.activeToplevel : null
+ enabled: initialized
+ function onTitleChanged() {
+ updateTimer.restart();
+ }
+ }
+
+ Connections {
+ target: I3
+ enabled: initialized
+ function onRawEvent(event) {
+ safeUpdateWorkspaces();
+ workspaceChanged();
+ updateTimer.restart();
+
+ if (event.type === "output") {
+ Qt.callLater(queryDisplayScales);
+ }
+ }
+ }
+
+ I3IpcListener {
+ subscriptions: ["input"]
+ onIpcEvent: function (event) {
+ handleInputEvent(event.data);
+ }
+ }
+
+ // Public functions
+ function switchToWorkspace(workspace) {
+ try {
+ workspace.handle.activate();
+ } catch (e) {
+ Logger.e("SwayService", "Failed to switch workspace:", e);
+ }
+ }
+
+ function focusWindow(window) {
+ try {
+ window.handle.activate();
+ } catch (e) {
+ Logger.e("SwayService", "Failed to switch window:", e);
+ }
+ }
+
+ function closeWindow(window) {
+ try {
+ window.handle.close();
+ } catch (e) {
+ Logger.e("SwayService", "Failed to close window:", e);
+ }
+ }
+
+ function turnOffMonitors() {
+ try {
+ Quickshell.execDetached([msgCommand, "output", "*", "dpms", "off"]);
+ } catch (e) {
+ Logger.e("SwayService", "Failed to turn off monitors:", e);
+ }
+ }
+
+ function turnOnMonitors() {
+ try {
+ Quickshell.execDetached([msgCommand, "output", "*", "dpms", "on"]);
+ } catch (e) {
+ Logger.e("SwayService", "Failed to turn on monitors:", e);
+ }
+ }
+
+ function logout() {
+ try {
+ Quickshell.execDetached([msgCommand, "exit"]);
+ } catch (e) {
+ Logger.e("SwayService", "Failed to logout:", e);
+ }
+ }
+
+ function cycleKeyboardLayout() {
+ try {
+ Quickshell.execDetached([msgCommand, "input", "type:keyboard", "xkb_switch_layout", "next"]);
+ } catch (e) {
+ Logger.e("SwayService", "Failed to cycle keyboard layout:", e);
+ }
+ }
+
+ function getFocusedScreen() {
+ // de-activated until proper testing
+ return null;
+
+ // const i3Mon = I3.focusedMonitor;
+ // if (i3Mon) {
+ // const monitorName = i3Mon.name;
+ // for (let i = 0; i < Quickshell.screens.length; i++) {
+ // if (Quickshell.screens[i].name === monitorName) {
+ // return Quickshell.screens[i];
+ // }
+ // }
+ // }
+ // return null;
+ }
+
+ function spawn(command) {
+ try {
+ Quickshell.execDetached([msgCommand, "exec", "--"].concat(command));
+ } catch (e) {
+ Logger.e("SwayService", "Failed to spawn command:", e);
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Control/CurrentScreenDetector.qml b/arch/.config/quickshell/noctalia-shell/Services/Control/CurrentScreenDetector.qml
new file mode 100644
index 0000000..8ab719e
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Control/CurrentScreenDetector.qml
@@ -0,0 +1,163 @@
+import QtQuick
+import Quickshell
+import Quickshell.Wayland
+import qs.Commons
+import qs.Services.Compositor
+
+/**
+* Detects which screen the cursor is currently on by creating a temporary
+* invisible PanelWindow. Use withCurrentScreen() to get the screen asynchronously.
+*
+* Usage:
+* CurrentScreenDetector {
+* id: screenDetector
+* }
+*
+* function doSomething() {
+* screenDetector.withCurrentScreen(function(screen) {
+* // screen is the ShellScreen where cursor is
+* })
+* }
+*/
+Item {
+ id: root
+
+ // Pending callback to execute once screen is detected
+ property var pendingCallback: null
+ property bool pendingSkipBarCheck: false
+
+ // Detected screen
+ property var detectedScreen: null
+
+ // Signal emitted when screen is detected from the PanelWindow
+ signal screenDetected(var detectedScreen)
+
+ onScreenDetected: function (detectedScreen) {
+ root.detectedScreen = detectedScreen;
+ screenDetectorDebounce.restart();
+ }
+
+ /**
+ * Find a fallback screen that has a bar configured.
+ * Prioritizes the screen at position 0x0 (likely the primary screen).
+ */
+ function findScreenWithBar(): var {
+ const monitors = Settings.data.bar.monitors || [];
+ let primaryCandidate = null;
+ let firstWithBar = null;
+
+ for (let i = 0; i < Quickshell.screens.length; i++) {
+ const s = Quickshell.screens[i];
+ const hasBar = monitors.length === 0 || monitors.includes(s.name);
+
+ if (hasBar) {
+ // Check if this is at 0x0 (primary position)
+ if (s.x === 0 && s.y === 0) {
+ primaryCandidate = s;
+ }
+ // Track first screen with bar as fallback
+ if (!firstWithBar) {
+ firstWithBar = s;
+ }
+ }
+ }
+
+ // Prefer primary (0x0), then first with bar, then just first screen
+ return primaryCandidate || firstWithBar || Quickshell.screens[0];
+ }
+
+ /**
+ * Execute callback with the screen where the cursor currently is.
+ * On single-monitor setups, executes immediately.
+ * On multi-monitor setups, briefly opens an invisible window to detect the screen.
+ */
+ function withCurrentScreen(callback: var, skipBarCheck: bool) {
+ if (root.pendingCallback) {
+ Logger.w("CurrentScreenDetector", "Another detection is pending, ignoring new call");
+ return;
+ }
+
+ // Single monitor setup can execute immediately
+ if (Quickshell.screens.length === 1) {
+ callback(Quickshell.screens[0]);
+ return;
+ }
+
+ // Try compositor-specific focused monitor detection first
+ let screen = CompositorService.getFocusedScreen();
+
+ if (screen) {
+ // Apply the bar check if configured (skip for overlay launcher etc.)
+ if (!skipBarCheck && !Settings.data.general.allowPanelsOnScreenWithoutBar) {
+ const monitors = Settings.data.bar.monitors || [];
+ const hasBar = monitors.length === 0 || monitors.includes(screen.name);
+ if (!hasBar) {
+ screen = findScreenWithBar();
+ }
+ }
+ Logger.d("CurrentScreenDetector", "Using compositor-detected screen:", screen.name);
+ callback(screen);
+ return;
+ }
+
+ // Fallback: Multi-monitor setup needs async detection via invisible PanelWindow
+ root.detectedScreen = null;
+ root.pendingCallback = callback;
+ root.pendingSkipBarCheck = !!skipBarCheck;
+ screenDetectorLoader.active = true;
+ }
+
+ Timer {
+ id: screenDetectorDebounce
+ running: false
+ interval: 40
+ onTriggered: {
+ Logger.d("CurrentScreenDetector", "Screen debounced to:", root.detectedScreen?.name || "null");
+
+ // Execute pending callback if any
+ if (root.pendingCallback) {
+ if (!root.pendingSkipBarCheck && !Settings.data.general.allowPanelsOnScreenWithoutBar) {
+ // If we explicitly disabled panels on screen without bar, check if bar is configured
+ // for this screen, and fallback to primary screen if necessary
+ var monitors = Settings.data.bar.monitors || [];
+ const hasBar = monitors.length === 0 || monitors.includes(root.detectedScreen?.name);
+ if (!hasBar) {
+ root.detectedScreen = findScreenWithBar();
+ }
+ }
+
+ Logger.d("CurrentScreenDetector", "Executing callback on screen:", root.detectedScreen.name);
+ // Store callback locally and clear pendingCallback first to prevent deadlock
+ // if the callback throws an error
+ var callback = root.pendingCallback;
+ root.pendingCallback = null;
+ root.pendingSkipBarCheck = false;
+ try {
+ callback(root.detectedScreen);
+ } catch (e) {
+ Logger.e("CurrentScreenDetector", "Callback failed:", e);
+ }
+ }
+
+ // Clean up
+ screenDetectorLoader.active = false;
+ }
+ }
+
+ // Invisible dummy PanelWindow to detect which screen should receive the action
+ Loader {
+ id: screenDetectorLoader
+ active: false
+
+ sourceComponent: PanelWindow {
+ implicitWidth: 0
+ implicitHeight: 0
+ color: "transparent"
+ WlrLayershell.exclusionMode: ExclusionMode.Ignore
+ WlrLayershell.namespace: "noctalia-screen-detector"
+ mask: Region {}
+
+ onScreenChanged: root.screenDetected(screen)
+ }
+ }
+ }
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Control/CustomButtonIPCService.qml b/arch/.config/quickshell/noctalia-shell/Services/Control/CustomButtonIPCService.qml
new file mode 100644
index 0000000..85c5e8b
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Control/CustomButtonIPCService.qml
@@ -0,0 +1,318 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+import qs.Services.UI
+
+Singleton {
+ id: root
+
+ // Must be called at startup to force singleton instantiation,
+ // which registers the IpcHandler with the IPC system.
+ function init() {
+ Logger.i("CustomButtonIPCService", "Service started");
+ }
+
+ // Registry to store references to active custom buttons by their user-defined identifier
+ property var customButtonRegistry: ({})
+
+ // Register a custom button instance
+ function registerButton(button) {
+ if (!button || !button.ipcIdentifier) {
+ Logger.w("CustomButtonIPCService", "Cannot register button without ipcIdentifier");
+ return false;
+ }
+
+ customButtonRegistry[button.ipcIdentifier] = button;
+ Logger.d("CustomButtonIPCService", `Registered button with identifier: ${button.ipcIdentifier}`);
+ return true;
+ }
+
+ // Unregister a custom button instance
+ function unregisterButton(button) {
+ if (!button || !button.ipcIdentifier) {
+ return false;
+ }
+
+ if (customButtonRegistry[button.ipcIdentifier] === button) {
+ delete customButtonRegistry[button.ipcIdentifier];
+ Logger.d("CustomButtonIPCService", `Unregistered button with identifier: ${button.ipcIdentifier}`);
+ return true;
+ }
+ return false;
+ }
+
+ // Find a button by identifier
+ function findButton(identifier) {
+ return customButtonRegistry[identifier] || null;
+ }
+
+ // Find button config from Settings for when the live widget is not loaded
+ function findButtonConfig(identifier) {
+ var screens = Quickshell.screens;
+ for (var i = 0; i < screens.length; i++) {
+ var widgets = Settings.getBarWidgetsForScreen(screens[i].name);
+ var config = _searchWidgetsForIdentifier(widgets, identifier);
+ if (config)
+ return config;
+ }
+ // Also check global widgets as a final fallback
+ var globalConfig = _searchWidgetsForIdentifier(Settings.data.bar.widgets, identifier);
+ if (globalConfig)
+ return globalConfig;
+ return null;
+ }
+
+ function _searchWidgetsForIdentifier(widgets, identifier) {
+ var sections = ["left", "center", "right"];
+ for (var s = 0; s < sections.length; s++) {
+ var list = widgets[sections[s]];
+ if (!list)
+ continue;
+ for (var j = 0; j < list.length; j++) {
+ var w = list[j];
+ if (w.id === "CustomButton" && w.ipcIdentifier === identifier) {
+ return w;
+ }
+ }
+ }
+ return null;
+ }
+
+ // Resolve a command property from config with fallback to widgetMetadata defaults
+ function resolveCommand(config, prop) {
+ if (config[prop])
+ return config[prop];
+ var meta = BarWidgetRegistry.widgetMetadata["CustomButton"];
+ return meta ? (meta[prop] || "") : "";
+ }
+
+ // Substitute $delta expressions in a command string
+ function substituteWheelDelta(command, delta) {
+ var normalizedDelta = delta > 0 ? 1 : -1;
+ return command.replace(/\$delta([+\-*/]\d+)?/g, function (match, operation) {
+ if (operation) {
+ try {
+ var operator = operation.charAt(0);
+ var operand = parseInt(operation.substring(1));
+ var result;
+ switch (operator) {
+ case '+':
+ result = normalizedDelta + operand;
+ break;
+ case '-':
+ result = normalizedDelta - operand;
+ break;
+ case '*':
+ result = normalizedDelta * operand;
+ break;
+ case '/':
+ result = Math.floor(normalizedDelta / operand);
+ break;
+ default:
+ result = normalizedDelta;
+ }
+ return result.toString();
+ } catch (e) {
+ return normalizedDelta.toString();
+ }
+ } else {
+ return normalizedDelta.toString();
+ }
+ });
+ }
+
+ // IpcHandler for custom button commands using short alias 'cb'
+ IpcHandler {
+ target: "cb"
+
+ // Handle left click: cb left "identifier"
+ function left(identifier: string) {
+ const button = findButton(identifier);
+ if (button) {
+ if (button.leftClickExec || button.textCommand) {
+ button.clicked();
+ Logger.i("CustomButtonIPCService", `Triggered left click on button '${identifier}'`);
+ } else {
+ Logger.w("CustomButtonIPCService", `Button '${identifier}' has no left click action configured`);
+ }
+ return;
+ }
+
+ // Fallback: read from Settings
+ const config = findButtonConfig(identifier);
+ if (!config) {
+ Logger.w("CustomButtonIPCService", `Button with identifier '${identifier}' not found`);
+ return;
+ }
+ const cmd = resolveCommand(config, "leftClickExec");
+ if (cmd) {
+ Quickshell.execDetached(["sh", "-lc", cmd]);
+ Logger.i("CustomButtonIPCService", `Triggered left click on button '${identifier}' (from settings)`);
+ } else {
+ Logger.w("CustomButtonIPCService", `Button '${identifier}' has no left click action configured`);
+ }
+ }
+
+ // Handle right click: cb right "identifier"
+ function right(identifier: string) {
+ const button = findButton(identifier);
+ if (button) {
+ if (button.rightClickExec) {
+ button.rightClicked();
+ Logger.i("CustomButtonIPCService", `Triggered right click on button '${identifier}'`);
+ } else {
+ Logger.w("CustomButtonIPCService", `Button '${identifier}' has no right click action configured`);
+ }
+ return;
+ }
+
+ const config = findButtonConfig(identifier);
+ if (!config) {
+ Logger.w("CustomButtonIPCService", `Button with identifier '${identifier}' not found`);
+ return;
+ }
+ const cmd = resolveCommand(config, "rightClickExec");
+ if (cmd) {
+ Quickshell.execDetached(["sh", "-lc", cmd]);
+ Logger.i("CustomButtonIPCService", `Triggered right click on button '${identifier}' (from settings)`);
+ } else {
+ Logger.w("CustomButtonIPCService", `Button '${identifier}' has no right click action configured`);
+ }
+ }
+
+ // Handle middle click: cb middle "identifier"
+ function middle(identifier: string) {
+ const button = findButton(identifier);
+ if (button) {
+ if (button.middleClickExec) {
+ button.middleClicked();
+ Logger.i("CustomButtonIPCService", `Triggered middle click on button '${identifier}'`);
+ } else {
+ Logger.w("CustomButtonIPCService", `Button '${identifier}' has no middle click action configured`);
+ }
+ return;
+ }
+
+ const config = findButtonConfig(identifier);
+ if (!config) {
+ Logger.w("CustomButtonIPCService", `Button with identifier '${identifier}' not found`);
+ return;
+ }
+ const cmd = resolveCommand(config, "middleClickExec");
+ if (cmd) {
+ Quickshell.execDetached(["sh", "-lc", cmd]);
+ Logger.i("CustomButtonIPCService", `Triggered middle click on button '${identifier}' (from settings)`);
+ } else {
+ Logger.w("CustomButtonIPCService", `Button '${identifier}' has no middle click action configured`);
+ }
+ }
+
+ // Handle wheel up: cb up "identifier"
+ function up(identifier: string) {
+ const button = findButton(identifier);
+ if (button) {
+ if (button.wheelMode === "separate" && button.wheelUpExec) {
+ button.wheeled(1);
+ Logger.i("CustomButtonIPCService", `Triggered wheel up on button '${identifier}'`);
+ } else {
+ Logger.w("CustomButtonIPCService", `Button '${identifier}' has no separate wheel up action configured or is not in separate mode`);
+ }
+ return;
+ }
+
+ const config = findButtonConfig(identifier);
+ if (!config) {
+ Logger.w("CustomButtonIPCService", `Button with identifier '${identifier}' not found`);
+ return;
+ }
+ const mode = config.wheelMode || BarWidgetRegistry.widgetMetadata["CustomButton"].wheelMode;
+ const cmd = resolveCommand(config, "wheelUpExec");
+ if (mode === "separate" && cmd) {
+ const resolved = substituteWheelDelta(cmd, 1);
+ Quickshell.execDetached(["sh", "-lc", resolved]);
+ Logger.i("CustomButtonIPCService", `Triggered wheel up on button '${identifier}' (from settings)`);
+ } else {
+ Logger.w("CustomButtonIPCService", `Button '${identifier}' has no separate wheel up action configured or is not in separate mode`);
+ }
+ }
+
+ // Handle wheel down: cb down "identifier"
+ function down(identifier: string) {
+ const button = findButton(identifier);
+ if (button) {
+ if (button.wheelMode === "separate" && button.wheelDownExec) {
+ button.wheeled(-1);
+ Logger.i("CustomButtonIPCService", `Triggered wheel down on button '${identifier}'`);
+ } else {
+ Logger.w("CustomButtonIPCService", `Button '${identifier}' has no separate wheel down action configured or is not in separate mode`);
+ }
+ return;
+ }
+
+ const config = findButtonConfig(identifier);
+ if (!config) {
+ Logger.w("CustomButtonIPCService", `Button with identifier '${identifier}' not found`);
+ return;
+ }
+ const mode = config.wheelMode || BarWidgetRegistry.widgetMetadata["CustomButton"].wheelMode;
+ const cmd = resolveCommand(config, "wheelDownExec");
+ if (mode === "separate" && cmd) {
+ const resolved = substituteWheelDelta(cmd, -1);
+ Quickshell.execDetached(["sh", "-lc", resolved]);
+ Logger.i("CustomButtonIPCService", `Triggered wheel down on button '${identifier}' (from settings)`);
+ } else {
+ Logger.w("CustomButtonIPCService", `Button '${identifier}' has no separate wheel down action configured or is not in separate mode`);
+ }
+ }
+
+ // Handle wheel action: cb wheel "identifier"
+ function wheel(identifier: string) {
+ const button = findButton(identifier);
+ if (button) {
+ if (button.wheelMode === "unified" && button.wheelExec) {
+ button.wheeled(1);
+ Logger.i("CustomButtonIPCService", `Triggered wheel action on button '${identifier}'`);
+ } else {
+ Logger.w("CustomButtonIPCService", `Button '${identifier}' has no unified wheel action configured or is not in unified mode`);
+ }
+ return;
+ }
+
+ const config = findButtonConfig(identifier);
+ if (!config) {
+ Logger.w("CustomButtonIPCService", `Button with identifier '${identifier}' not found`);
+ return;
+ }
+ const mode = config.wheelMode || BarWidgetRegistry.widgetMetadata["CustomButton"].wheelMode;
+ const cmd = resolveCommand(config, "wheelExec");
+ if (mode === "unified" && cmd) {
+ const resolved = substituteWheelDelta(cmd, 1);
+ Quickshell.execDetached(["sh", "-lc", resolved]);
+ Logger.i("CustomButtonIPCService", `Triggered wheel action on button '${identifier}' (from settings)`);
+ } else {
+ Logger.w("CustomButtonIPCService", `Button '${identifier}' has no unified wheel action configured or is not in unified mode`);
+ }
+ }
+
+ // Handle refresh: cb refresh "identifier"
+ function refresh(identifier: string) {
+ const button = findButton(identifier);
+ if (!button) {
+ Logger.w("CustomButtonIPCService", `Button '${identifier}' is not currently loaded โ refresh requires a live widget instance`);
+ return;
+ }
+
+ if (button.textCommand && button.textCommand.length > 0 && !button.textStream) {
+ button.runTextCommand();
+ Logger.i("CustomButtonIPCService", `Triggered refresh (text command) on button '${identifier}'`);
+ } else if (button.textStream) {
+ Logger.w("CustomButtonIPCService", `Button '${identifier}' uses streaming, manual refresh disabled`);
+ } else {
+ Logger.w("CustomButtonIPCService", `Button '${identifier}' has no text command to refresh`);
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Control/HooksService.qml b/arch/.config/quickshell/noctalia-shell/Services/Control/HooksService.qml
new file mode 100644
index 0000000..c11c8c7
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Control/HooksService.qml
@@ -0,0 +1,326 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+import qs.Services.Power
+import qs.Services.Theming
+import qs.Services.UI
+
+Singleton {
+ id: root
+
+ // Hook connections for automatic script execution
+ Connections {
+ target: Settings.data.colorSchemes
+ function onDarkModeChanged() {
+ executeDarkModeHook(Settings.data.colorSchemes.darkMode);
+ }
+ }
+
+ // Pending wallpaper hook when waiting for color generation
+ property var pendingWallpaperHook: null
+
+ Connections {
+ target: WallpaperService
+ function onWallpaperChanged(screenName, path) {
+ // Check if we need to wait for color generation
+ if (Settings.data.colorSchemes.useWallpaperColors) {
+ var effectiveMonitor = Settings.data.colorSchemes.monitorForColors;
+ if (effectiveMonitor === "" || effectiveMonitor === undefined) {
+ effectiveMonitor = Screen.name;
+ }
+
+ if (screenName === effectiveMonitor) {
+ // Store pending hook and wait for colors to be generated
+ root.pendingWallpaperHook = {
+ path: path,
+ screenName: screenName
+ };
+ return;
+ }
+ }
+ // No color generation, execute immediately
+ executeWallpaperHook(path, screenName);
+ }
+ }
+
+ Connections {
+ target: TemplateProcessor
+ function onColorsGenerated() {
+ // Execute pending wallpaper hook after colors are ready
+ if (root.pendingWallpaperHook) {
+ const hook = root.pendingWallpaperHook;
+ root.pendingWallpaperHook = null;
+ executeWallpaperHook(hook.path, hook.screenName);
+ }
+ executeColorGenerationHook();
+ }
+ }
+
+ // Track lock screen state for unlock hook
+ property bool wasLocked: false
+
+ Connections {
+ target: PanelService
+ function onLockScreenChanged() {
+ if (PanelService.lockScreen) {
+ lockScreenActiveConnection.target = PanelService.lockScreen;
+ }
+ }
+ }
+
+ Connections {
+ id: lockScreenActiveConnection
+ target: PanelService.lockScreen
+ function onActiveChanged() {
+ // Detect lock: was unlocked, now locked
+ if (!wasLocked && PanelService.lockScreen.active) {
+ executeLockHook();
+ }
+ // Detect unlock: was locked, now not locked
+ if (wasLocked && !PanelService.lockScreen.active) {
+ executeUnlockHook();
+ }
+ wasLocked = PanelService.lockScreen.active;
+ }
+ }
+
+ // Track performance mode state for hooks
+ property bool wasPerformanceModeEnabled: false
+
+ Connections {
+ target: PowerProfileService
+ function onNoctaliaPerformanceModeChanged() {
+ const isEnabled = PowerProfileService.noctaliaPerformanceMode;
+
+ // Detect enabled: was disabled, now enabled
+ if (!wasPerformanceModeEnabled && isEnabled) {
+ executePerformanceModeEnabledHook();
+ }
+ // Detect disabled: was enabled, now disabled
+ if (wasPerformanceModeEnabled && !isEnabled) {
+ executePerformanceModeDisabledHook();
+ }
+ wasPerformanceModeEnabled = isEnabled;
+ }
+ }
+
+ // Execute wallpaper change hook
+ function executeWallpaperHook(wallpaperPath, screenName) {
+ if (!Settings.data.hooks?.enabled) {
+ return;
+ }
+
+ const script = Settings.data.hooks?.wallpaperChange;
+ if (!script || script === "") {
+ return;
+ }
+
+ try {
+ const theme = Settings.data.colorSchemes.darkMode ? "dark" : "light";
+ let command = script.replace(/\$1/g, wallpaperPath);
+ command = command.replace(/\$2/g, screenName || "");
+ command = command.replace(/\$3/g, theme);
+ Quickshell.execDetached(["sh", "-lc", command]);
+ Logger.d("HooksService", `Executed wallpaper hook: ${command}`);
+ } catch (e) {
+ Logger.e("HooksService", `Failed to execute wallpaper hook: ${e}`);
+ }
+ }
+
+ // Execute dark mode change hook
+ function executeDarkModeHook(isDarkMode) {
+ if (!Settings.data.hooks?.enabled) {
+ return;
+ }
+
+ const script = Settings.data.hooks?.darkModeChange;
+ if (!script || script === "") {
+ return;
+ }
+
+ try {
+ const command = script.replace(/\$1/g, isDarkMode ? "true" : "false");
+ Quickshell.execDetached(["sh", "-lc", command]);
+ Logger.d("HooksService", `Executed dark mode hook: ${command}`);
+ } catch (e) {
+ Logger.e("HooksService", `Failed to execute dark mode hook: ${e}`);
+ }
+ }
+
+ // Execute screen lock hook
+ function executeLockHook() {
+ if (!Settings.data.hooks?.enabled) {
+ return;
+ }
+
+ const script = Settings.data.hooks?.screenLock;
+ if (!script || script === "") {
+ return;
+ }
+
+ try {
+ // Pass "lock" as $1 via shell arguments so the script receives it
+ Quickshell.execDetached(["sh", "-lc", script, "lock-hook", "lock"]);
+ Logger.d("HooksService", `Executed screen lock hook: ${script}`);
+ } catch (e) {
+ Logger.e("HooksService", `Failed to execute screen lock hook: ${e}`);
+ }
+ }
+
+ // Execute screen unlock hook
+ function executeUnlockHook() {
+ if (!Settings.data.hooks?.enabled) {
+ return;
+ }
+
+ const script = Settings.data.hooks?.screenUnlock;
+ if (!script || script === "") {
+ return;
+ }
+
+ try {
+ // Pass "unlock" as $1 via shell arguments so the script receives it
+ Quickshell.execDetached(["sh", "-lc", script, "unlock-hook", "unlock"]);
+ Logger.d("HooksService", `Executed screen unlock hook: ${script}`);
+ } catch (e) {
+ Logger.e("HooksService", `Failed to execute screen unlock hook: ${e}`);
+ }
+ }
+
+ // Execute performance mode enabled hook
+ function executePerformanceModeEnabledHook() {
+ if (!Settings.data.hooks?.enabled) {
+ return;
+ }
+
+ const script = Settings.data.hooks?.performanceModeEnabled;
+ if (!script || script === "") {
+ return;
+ }
+
+ try {
+ Quickshell.execDetached(["sh", "-lc", script]);
+ } catch (e) {
+ Logger.e("HooksService", `Failed to execute performance mode enabled hook: ${e}`);
+ }
+ }
+
+ // Execute performance mode disabled hook
+ function executePerformanceModeDisabledHook() {
+ if (!Settings.data.hooks?.enabled) {
+ return;
+ }
+
+ const script = Settings.data.hooks?.performanceModeDisabled;
+ if (!script || script === "") {
+ return;
+ }
+
+ try {
+ Quickshell.execDetached(["sh", "-lc", script]);
+ } catch (e) {
+ Logger.e("HooksService", `Failed to execute performance mode disabled hook: ${e}`);
+ }
+ }
+
+ // Execute color generation hook
+ function executeColorGenerationHook() {
+ if (!Settings.data.hooks?.enabled) {
+ return;
+ }
+
+ const script = Settings.data.hooks?.colorGeneration;
+ if (!script || script === "") {
+ return;
+ }
+
+ try {
+ const theme = Settings.data.colorSchemes.darkMode ? "dark" : "light";
+ const command = script.replace(/\$1/g, theme);
+ Quickshell.execDetached(["sh", "-lc", command]);
+ Logger.d("HooksService", `Executed color generation hook: ${command}`);
+ } catch (e) {
+ Logger.e("HooksService", `Failed to execute color generation hook: ${e}`);
+ }
+ }
+
+ // Blocking power hook infrastructure
+ property var pendingPowerCallback: null
+
+ Process {
+ id: powerHookProcess
+ onExited: (exitCode, exitStatus) => {
+ if (exitCode !== 0) {
+ Logger.w("HooksService", `Power hook failed with exit code ${exitCode}`);
+ }
+
+ if (pendingPowerCallback !== null) {
+ const callback = pendingPowerCallback;
+ pendingPowerCallback = null;
+ callback();
+ }
+ }
+ }
+
+ function runPowerHook(script, callback) {
+ pendingPowerCallback = callback;
+ powerHookProcess.command = ["sh", "-lc", script];
+ powerHookProcess.running = true;
+ }
+
+ function executeSessionHook(action, callback) {
+ if (!Settings.data.hooks?.enabled) {
+ callback();
+
+ return;
+ }
+
+ const script = Settings.data.hooks?.session;
+ if (!script) {
+ callback();
+
+ return;
+ }
+
+ Logger.i("HooksService", `Executing session hook for ${action}`);
+ runPowerHook(`${script} ${action}`, callback);
+ }
+
+ // Execute startup hook
+ function executeStartupHook() {
+ if (!Settings.data.hooks?.enabled) {
+ return;
+ }
+
+ const script = Settings.data.hooks?.startup;
+ if (!script || script === "") {
+ return;
+ }
+
+ try {
+ Quickshell.execDetached(["sh", "-lc", script]);
+ Logger.d("HooksService", `Executed startup hook: ${script}`);
+ } catch (e) {
+ Logger.e("HooksService", `Failed to execute startup hook: ${e}`);
+ }
+ }
+
+ // Initialize the service
+ function init() {
+ Logger.i("HooksService", "Service started");
+ // Initialize lock screen state tracking
+ Qt.callLater(() => {
+ if (PanelService.lockScreen) {
+ wasLocked = PanelService.lockScreen.active;
+ lockScreenActiveConnection.target = PanelService.lockScreen;
+ }
+ // Initialize performance mode state tracking
+ wasPerformanceModeEnabled = PowerProfileService.noctaliaPerformanceMode;
+ // Execute startup hook
+ executeStartupHook();
+ });
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Control/IPCService.qml b/arch/.config/quickshell/noctalia-shell/Services/Control/IPCService.qml
new file mode 100644
index 0000000..f07bb7b
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Control/IPCService.qml
@@ -0,0 +1,940 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import Quickshell.Wayland
+import Quickshell.Widgets
+
+import qs.Commons
+import qs.Modules.Panels.Settings
+import qs.Services.Compositor
+import qs.Services.Hardware
+import qs.Services.Location
+import qs.Services.Media
+import qs.Services.Networking
+import qs.Services.Noctalia
+import qs.Services.Power
+import qs.Services.System
+import qs.Services.Theming
+import qs.Services.UI
+
+Singleton {
+ id: root
+
+ // Screen detector, set via init()
+ property var screenDetector: null
+
+ function init(detector) {
+ root.screenDetector = detector;
+ Logger.i("IPCService", "Service started");
+ }
+
+ // Helper for index-based notification lookups in IPC
+ function _getNotificationByIndex(index: string, funcName: string) {
+ var idx = index === "" ? 0 : parseInt(index);
+ if (isNaN(idx)) {
+ Logger.w("IPC", "Argument to ipc call '" + funcName + "' must be a number");
+ return null;
+ }
+ if (idx < 0 || idx >= NotificationService.popupModel.count) {
+ Logger.w("IPC", "Notification index out of range: " + idx);
+ return null;
+ }
+ return NotificationService.popupModel.get(idx);
+ }
+
+ IpcHandler {
+ target: "bar"
+ function toggle() {
+ BarService.toggleVisibility();
+ }
+ function hideBar() {
+ BarService.hide();
+ }
+ function showBar() {
+ BarService.show();
+ }
+ function peek() {
+ BarService.peek();
+ }
+ function setDisplayMode(mode: string, screen: string) {
+ if (mode === "always_visible" || mode === "non_exclusive" || mode === "auto_hide") {
+ if (!screen || screen === "all") {
+ Settings.data.bar.displayMode = mode;
+ } else {
+ Settings.setScreenOverride(screen, "displayMode", mode);
+ }
+ }
+ }
+ function setPosition(position: string, screen: string) {
+ var valid = position === "top" || position === "bottom" || position === "left" || position === "right";
+ if (!valid) {
+ Logger.w("IPC", "Invalid bar position: " + position + ". Valid: top, bottom, left, right");
+ return;
+ }
+ if (!screen || screen === "all") {
+ Settings.data.bar.position = position;
+ } else {
+ Settings.setScreenOverride(screen, "position", position);
+ }
+ }
+ }
+
+ // Settings IPC helpers (outside IpcHandler to avoid QVariant IPC warnings)
+ readonly property var _settingsTabMap: ({
+ "about": SettingsPanel.Tab.About,
+ "audio": SettingsPanel.Tab.Audio,
+ "bar": SettingsPanel.Tab.Bar,
+ "colorscheme": SettingsPanel.Tab.ColorScheme,
+ "lockscreen": SettingsPanel.Tab.LockScreen,
+ "controlcenter": SettingsPanel.Tab.ControlCenter,
+ "desktopwidgets": SettingsPanel.Tab.DesktopWidgets,
+ "osd": SettingsPanel.Tab.OSD,
+ "display": SettingsPanel.Tab.Display,
+ "dock": SettingsPanel.Tab.Dock,
+ "general": SettingsPanel.Tab.General,
+ "hooks": SettingsPanel.Tab.Hooks,
+ "launcher": SettingsPanel.Tab.Launcher,
+ "location": SettingsPanel.Tab.Location,
+ "connections": SettingsPanel.Tab.Connections,
+ "notifications": SettingsPanel.Tab.Notifications,
+ "plugins": SettingsPanel.Tab.Plugins,
+ "sessionmenu": SettingsPanel.Tab.SessionMenu,
+ "system": SettingsPanel.Tab.System,
+ "systemmonitor": SettingsPanel.Tab.System,
+ "userinterface": SettingsPanel.Tab.UserInterface,
+ "wallpaper": SettingsPanel.Tab.Wallpaper
+ })
+
+ function _parseSettingsTabArg(tabArg) {
+ var parts = tabArg.split("/");
+ var tabId = _resolveSettingsTab(parts[0]);
+ var subTabId = parts.length > 1 ? parseInt(parts[1]) : -1;
+ return {
+ "tab": tabId,
+ "subTab": isNaN(subTabId) ? -1 : subTabId
+ };
+ }
+
+ function _resolveSettingsTab(tabName) {
+ if (!tabName)
+ return SettingsPanel.Tab.General;
+ var key = tabName.toLowerCase().replace(/[-_]/g, "");
+ if (key in _settingsTabMap)
+ return _settingsTabMap[key];
+ Logger.w("IPC", "Unknown settings tab: " + tabName);
+ return SettingsPanel.Tab.General;
+ }
+
+ function _settingsToggle(tabId, subTabId) {
+ root.screenDetector.withCurrentScreen(screen => {
+ SettingsPanelService.toggle(tabId, subTabId, screen);
+ });
+ }
+
+ function _settingsOpen(tabId, subTabId) {
+ root.screenDetector.withCurrentScreen(screen => {
+ SettingsPanelService.openToTab(tabId, subTabId, screen);
+ });
+ }
+
+ IpcHandler {
+ target: "settings"
+
+ function toggle() {
+ root._settingsToggle(SettingsPanel.Tab.General, -1);
+ }
+
+ function toggleTab(tab: string) {
+ var parsed = root._parseSettingsTabArg(tab);
+ root._settingsToggle(parsed.tab, parsed.subTab);
+ }
+
+ function open() {
+ root._settingsOpen(SettingsPanel.Tab.General, -1);
+ }
+
+ function openTab(tab: string) {
+ var parsed = root._parseSettingsTabArg(tab);
+ root._settingsOpen(parsed.tab, parsed.subTab);
+ }
+ }
+
+ IpcHandler {
+ target: "calendar"
+ function toggle() {
+ root.screenDetector.withCurrentScreen(screen => {
+ var clockPanel = PanelService.getPanel("clockPanel", screen);
+ clockPanel?.toggle(null, "Clock");
+ });
+ }
+ }
+
+ IpcHandler {
+ target: "notifications"
+ function toggleHistory() {
+ // Will attempt to open the panel next to the bar button if any.
+ root.screenDetector.withCurrentScreen(screen => {
+ var notificationHistoryPanel = PanelService.getPanel("notificationHistoryPanel", screen);
+ notificationHistoryPanel.toggle(null, "NotificationHistory");
+ });
+ }
+ function toggleDND() {
+ NotificationService.doNotDisturb = !NotificationService.doNotDisturb;
+ }
+ function enableDND() {
+ NotificationService.doNotDisturb = true;
+ }
+ function disableDND() {
+ NotificationService.doNotDisturb = false;
+ }
+ function clear() {
+ NotificationService.clearHistory();
+ }
+
+ function dismissOldest() {
+ NotificationService.dismissOldestPopup();
+ }
+
+ function removeOldestHistory() {
+ NotificationService.removeOldestHistory();
+ }
+
+ function dismissAll() {
+ NotificationService.dismissAllPopups();
+ }
+
+ function getHistory(): string {
+ return JSON.stringify(NotificationService.getHistorySnapshot(), null, 2);
+ }
+
+ function removeFromHistory(id: string): bool {
+ return NotificationService.removeFromHistory(id);
+ }
+
+ function invokeDefault(index: string): bool {
+ var notif = root._getNotificationByIndex(index, "notifications invokeDefault");
+ if (!notif)
+ return false;
+
+ var actions = JSON.parse(notif.actionsJson || "[]");
+ if (actions.length === 0)
+ return false;
+
+ var actionId = actions.find(a => a.identifier === "default")?.identifier ?? actions[0].identifier;
+ return NotificationService.invokeAction(notif.id, actionId);
+ }
+
+ function invokeDefaultAndDismiss(index: string): bool {
+ var notif = root._getNotificationByIndex(index, "notifications invokeDefaultAndDismiss");
+ if (!notif)
+ return false;
+
+ var actions = JSON.parse(notif.actionsJson || "[]");
+ if (actions.length === 0) {
+ NotificationService.dismissPopup(notif.id);
+ return false;
+ }
+
+ var actionId = actions.find(a => a.identifier === "default")?.identifier ?? actions[0].identifier;
+ var result = NotificationService.invokeAction(notif.id, actionId);
+ NotificationService.dismissPopup(notif.id);
+ return result;
+ }
+
+ function invokeAction(id: string, actionId: string): bool {
+ if (!id || !actionId) {
+ Logger.w("IPC", "Both 'id' and 'actionId' are required for 'notifications invokeAction'");
+ return false;
+ }
+ return NotificationService.invokeAction(id, actionId);
+ }
+
+ function getActions(index: string): string {
+ var notif = root._getNotificationByIndex(index, "notifications getActions");
+ if (!notif)
+ return "[]";
+ return notif.actionsJson || "[]";
+ }
+ }
+
+ IpcHandler {
+ target: "toast"
+
+ function send(json: string) {
+ try {
+ var data = JSON.parse(json);
+ var title = data.title || "";
+ var body = data.body || "";
+ var icon = data.icon || "";
+ var type = data.type || "notice";
+ var duration = data.duration;
+
+ switch (type) {
+ case "warning":
+ ToastService.showWarning(title, body, duration ?? 4000);
+ break;
+ case "error":
+ ToastService.showError(title, body, duration ?? 6000);
+ break;
+ default:
+ ToastService.showNotice(title, body, icon, duration ?? 3000);
+ }
+ } catch (error) {
+ Logger.e("IPC", "Failed to parse toast JSON: " + error);
+ }
+ }
+
+ function dismiss() {
+ ToastService.dismissToast();
+ }
+ }
+
+ // Idle Inhibitor / Keep Awake
+ IpcHandler {
+ target: "idleInhibitor"
+ function toggle() {
+ IdleInhibitorService.manualToggle();
+ }
+ function enable() {
+ IdleInhibitorService.addManualInhibitor(null);
+ }
+ function disable() {
+ IdleInhibitorService.removeManualInhibitor();
+ }
+ function enableFor(seconds: string) {
+ var secs = parseInt(seconds);
+ if (isNaN(secs) || secs <= 0) {
+ Logger.w("IPC", "Argument to 'idleInhibitor enableFor' must be a positive number");
+ return;
+ }
+ IdleInhibitorService.addManualInhibitor(secs);
+ }
+ }
+
+ IpcHandler {
+ target: "launcher"
+ function toggle() {
+ root.screenDetector.withCurrentScreen(screen => {
+ var searchText = PanelService.getLauncherSearchText(screen);
+ var isInAppMode = !searchText.startsWith(">");
+ if (!PanelService.isLauncherOpen(screen)) {
+ // Closed -> open in app mode
+ PanelService.openLauncherWithSearch(screen, "");
+ } else if (isInAppMode) {
+ // Already in app mode -> close
+ PanelService.closeLauncher(screen);
+ } else {
+ // In another mode -> switch to app mode
+ PanelService.setLauncherSearchText(screen, "");
+ }
+ }, Settings.data.appLauncher.overviewLayer);
+ }
+ function clipboard() {
+ root.screenDetector.withCurrentScreen(screen => {
+ var searchText = PanelService.getLauncherSearchText(screen);
+ var isInClipMode = searchText.startsWith(">clip");
+ if (!PanelService.isLauncherOpen(screen)) {
+ // Closed -> open in clipboard mode
+ PanelService.openLauncherWithSearch(screen, ">clip ");
+ } else if (isInClipMode) {
+ // Already in clipboard mode -> close
+ PanelService.closeLauncher(screen);
+ } else {
+ // In another mode -> switch to clipboard mode
+ PanelService.setLauncherSearchText(screen, ">clip ");
+ }
+ }, Settings.data.appLauncher.overviewLayer);
+ }
+ function command() {
+ root.screenDetector.withCurrentScreen(screen => {
+ var searchText = PanelService.getLauncherSearchText(screen);
+ var isInCmdMode = searchText.startsWith(">cmd");
+ if (!PanelService.isLauncherOpen(screen)) {
+ PanelService.openLauncherWithSearch(screen, ">cmd ");
+ } else if (isInCmdMode) {
+ PanelService.closeLauncher(screen);
+ } else {
+ PanelService.setLauncherSearchText(screen, ">cmd ");
+ }
+ }, Settings.data.appLauncher.overviewLayer);
+ }
+ function emoji() {
+ root.screenDetector.withCurrentScreen(screen => {
+ var searchText = PanelService.getLauncherSearchText(screen);
+ var isInEmojiMode = searchText.startsWith(">emoji");
+ if (!PanelService.isLauncherOpen(screen)) {
+ // Closed -> open in emoji mode
+ PanelService.openLauncherWithSearch(screen, ">emoji ");
+ } else if (isInEmojiMode) {
+ // Already in emoji mode -> close
+ PanelService.closeLauncher(screen);
+ } else {
+ // In another mode -> switch to emoji mode
+ PanelService.setLauncherSearchText(screen, ">emoji ");
+ }
+ }, Settings.data.appLauncher.overviewLayer);
+ }
+ function windows() {
+ root.screenDetector.withCurrentScreen(screen => {
+ var searchText = PanelService.getLauncherSearchText(screen);
+ var isInWindowsMode = searchText.startsWith(">win");
+ if (!PanelService.isLauncherOpen(screen)) {
+ PanelService.openLauncherWithSearch(screen, ">win ");
+ } else if (isInWindowsMode) {
+ PanelService.closeLauncher(screen);
+ } else {
+ PanelService.setLauncherSearchText(screen, ">win ");
+ }
+ }, Settings.data.appLauncher.overviewLayer);
+ }
+ function settings() {
+ root.screenDetector.withCurrentScreen(screen => {
+ var searchText = PanelService.getLauncherSearchText(screen);
+ var isInSettingsMode = searchText.startsWith(">settings");
+ if (!PanelService.isLauncherOpen(screen)) {
+ PanelService.openLauncherWithSearch(screen, ">settings ");
+ } else if (isInSettingsMode) {
+ PanelService.closeLauncher(screen);
+ } else {
+ PanelService.setLauncherSearchText(screen, ">settings ");
+ }
+ }, Settings.data.appLauncher.overviewLayer);
+ }
+ }
+
+ IpcHandler {
+ target: "lockScreen"
+
+ // New preferred method - lock the screen
+ function lock() {
+ // Only lock if not already locked (prevents the red screen issue)
+ if (!PanelService.lockScreen.active) {
+ CompositorService.lock();
+ }
+ }
+ }
+
+ IpcHandler {
+ target: "brightness"
+ function increase() {
+ BrightnessService.increaseBrightness();
+ }
+ function decrease() {
+ BrightnessService.decreaseBrightness();
+ }
+ function set(value: string) {
+ var val = parseFloat(value);
+ if (isNaN(val))
+ return;
+
+ // Normalize logic: heuristic handling of 0-100 vs 0-1
+ if (val > 1.0)
+ val = val / 100.0;
+
+ // Clamp
+ val = Math.max(0.0, Math.min(1.0, val));
+
+ BrightnessService.setBrightness(val);
+ }
+ }
+
+ IpcHandler {
+ target: "monitors"
+ function on() {
+ CompositorService.turnOnMonitors();
+ }
+ function off() {
+ CompositorService.turnOffMonitors();
+ }
+ }
+
+ IpcHandler {
+ target: "darkMode"
+ function toggle() {
+ Settings.data.colorSchemes.darkMode = !Settings.data.colorSchemes.darkMode;
+ }
+ function setDark() {
+ Settings.data.colorSchemes.darkMode = true;
+ }
+ function setLight() {
+ Settings.data.colorSchemes.darkMode = false;
+ }
+ }
+
+ IpcHandler {
+ target: "nightLight"
+ function toggle() {
+ if (!ProgramCheckerService.wlsunsetAvailable) {
+ Logger.w("IPC", "wlsunset not available, cannot toggle night light");
+ return;
+ }
+
+ if (Settings.data.nightLight.forced) {
+ Settings.data.nightLight.forced = false;
+ } else {
+ if (Settings.data.nightLight.enabled) {
+ Settings.data.nightLight.enabled = false;
+ } else {
+ Settings.data.nightLight.forced = true;
+ Settings.data.nightLight.enabled = true;
+ }
+ }
+ }
+ }
+
+ IpcHandler {
+ target: "colorScheme"
+ function set(schemeName: string) {
+ ColorSchemeService.setPredefinedScheme(schemeName);
+ }
+ function setGenerationMethod(method: string) {
+ var valid = false;
+ for (var i = 0; i < TemplateProcessor.schemeTypes.length; i++) {
+ if (TemplateProcessor.schemeTypes[i].key === method) {
+ valid = true;
+ break;
+ }
+ }
+
+ if (valid) {
+ Settings.data.colorSchemes.generationMethod = method;
+ } else {
+ Logger.w("IPC", "Invalid generation method received: " + method);
+ }
+ }
+ }
+
+ IpcHandler {
+ target: "volume"
+ function increase() {
+ AudioService.increaseVolume();
+ }
+ function decrease() {
+ AudioService.decreaseVolume();
+ }
+ function muteOutput() {
+ AudioService.setOutputMuted(!AudioService.muted);
+ }
+ function increaseInput() {
+ AudioService.increaseInputVolume();
+ }
+ function decreaseInput() {
+ AudioService.decreaseInputVolume();
+ }
+ function muteInput() {
+ AudioService.setInputMuted(!AudioService.inputMuted);
+ }
+ function togglePanel() {
+ root.screenDetector.withCurrentScreen(screen => {
+ var panel = PanelService.getPanel("audioPanel", screen);
+ panel?.toggle(null, "Volume");
+ });
+ }
+ function openPanel() {
+ root.screenDetector.withCurrentScreen(screen => {
+ var panel = PanelService.getPanel("audioPanel", screen);
+ panel?.open(null, "Volume");
+ });
+ }
+ function closePanel() {
+ root.screenDetector.withCurrentScreen(screen => {
+ var panel = PanelService.getPanel("audioPanel", screen);
+ panel?.close(null, "Volume");
+ });
+ }
+ }
+
+ IpcHandler {
+ target: "sessionMenu"
+ function toggle() {
+ root.screenDetector.withCurrentScreen(screen => {
+ var sessionMenuPanel = PanelService.getPanel("sessionMenuPanel", screen);
+ // Session Menu is never open near the bar
+ sessionMenuPanel?.toggle();
+ });
+ }
+
+ function lock() {
+ if (!PanelService.lockScreen.active) {
+ CompositorService.lock();
+ }
+ }
+
+ function lockAndSuspend() {
+ // Only lock and suspend if not already locked
+ if (!PanelService.lockScreen.active) {
+ CompositorService.lockAndSuspend();
+ }
+ }
+ }
+
+ IpcHandler {
+ target: "controlCenter"
+ function toggle() {
+ root.screenDetector.withCurrentScreen(screen => {
+ var controlCenterPanel = PanelService.getPanel("controlCenterPanel", screen);
+ if (Settings.data.controlCenter.position === "close_to_bar_button") {
+ // Will attempt to open the panel next to the bar button if any.
+ controlCenterPanel?.toggle(null, "ControlCenter");
+ } else {
+ controlCenterPanel?.toggle();
+ }
+ });
+ }
+ }
+
+ IpcHandler {
+ target: "dock"
+ function toggle() {
+ Settings.data.dock.enabled = !Settings.data.dock.enabled;
+ }
+ }
+
+ // Wallpaper IPC: trigger a new random wallpaper
+ IpcHandler {
+ target: "wallpaper"
+ function toggle() {
+ if (Settings.data.wallpaper.enabled) {
+ root.screenDetector.withCurrentScreen(screen => {
+ var wallpaperPanel = PanelService.getPanel("wallpaperPanel", screen);
+ wallpaperPanel?.toggle();
+ });
+ }
+ }
+
+ function random(screen: string) {
+ if (Settings.data.wallpaper.enabled) {
+ if (!screen || screen === "all" || screen.trim().length === 0) {
+ screen = undefined;
+ }
+ WallpaperService.setRandomWallpaper(screen);
+ }
+ }
+
+ function get(screen: string): string {
+ if (screen === "all" || screen === "") {
+ if (Quickshell.screens.length > 1) {
+ return JSON.stringify(WallpaperService.getWallpapersEffectiveMap());
+ }
+ return WallpaperService.getWallpaper(Quickshell.screens[0].name) ?? "";
+ } else {
+ var found = Quickshell.screens.find(s => s.name === screen);
+ if (!found) {
+ Logger.w("IPC", "wallpaper get: unknown screen: " + screen);
+ return "";
+ }
+ return WallpaperService.getWallpaper(screen) ?? "";
+ }
+ }
+
+ function set(path: string, screen: string) {
+ if (screen === "all" || screen === "") {
+ screen = undefined;
+ }
+ WallpaperService.changeWallpaper(path, screen);
+ }
+
+ function refresh() {
+ WallpaperService.refreshWallpapersList();
+ }
+
+ function toggleAutomation() {
+ Settings.data.wallpaper.automationEnabled = !Settings.data.wallpaper.automationEnabled;
+ }
+ function disableAutomation() {
+ Settings.data.wallpaper.automationEnabled = false;
+ }
+ function enableAutomation() {
+ Settings.data.wallpaper.automationEnabled = true;
+ }
+ }
+
+ IpcHandler {
+ target: "wifi"
+ function toggle() {
+ NetworkService.setWifiEnabled(!NetworkService.wifiEnabled);
+ }
+ function enable() {
+ NetworkService.setWifiEnabled(true);
+ }
+ function disable() {
+ NetworkService.setWifiEnabled(false);
+ }
+ }
+
+ IpcHandler {
+ target: "network"
+ function togglePanel() {
+ root.screenDetector.withCurrentScreen(screen => {
+ var networkPanel = PanelService.getPanel("networkPanel", screen);
+ networkPanel?.toggle(null, "Network");
+ });
+ }
+ }
+
+ IpcHandler {
+ target: "bluetooth"
+ function toggle() {
+ BluetoothService.setBluetoothEnabled(!BluetoothService.enabled);
+ }
+ function enable() {
+ BluetoothService.setBluetoothEnabled(true);
+ }
+ function disable() {
+ BluetoothService.setBluetoothEnabled(false);
+ }
+ function togglePanel() {
+ root.screenDetector.withCurrentScreen(screen => {
+ var bluetoothPanel = PanelService.getPanel("bluetoothPanel", screen);
+ bluetoothPanel?.toggle(null, "Bluetooth");
+ });
+ }
+ function toggleAutoConnect() {
+ Settings.data.network.bluetoothAutoConnect = !Settings.data.network.bluetoothAutoConnect;
+ }
+ function enableAutoConnect() {
+ Settings.data.network.bluetoothAutoConnect = true;
+ }
+ function disableAutoConnect() {
+ Settings.data.network.bluetoothAutoConnect = false;
+ }
+ }
+
+ IpcHandler {
+ target: "airplaneMode"
+ function toggle() {
+ NetworkService.setAirplaneMode(!NetworkService.airplaneModeEnabled);
+ }
+ function enable() {
+ NetworkService.setAirplaneMode(true);
+ }
+ function disable() {
+ NetworkService.setAirplaneMode(false);
+ }
+ }
+
+ IpcHandler {
+ target: "battery"
+ function togglePanel() {
+ root.screenDetector.withCurrentScreen(screen => {
+ var batteryPanel = PanelService.getPanel("batteryPanel", screen);
+ batteryPanel?.toggle(null, "Battery");
+ });
+ }
+ }
+
+ IpcHandler {
+ target: "powerProfile"
+ function cycle() {
+ PowerProfileService.cycleProfile();
+ }
+
+ function cycleReverse() {
+ PowerProfileService.cycleProfileReverse();
+ }
+
+ function set(mode: string) {
+ switch (mode) {
+ case "performance":
+ PowerProfileService.setProfile(2);
+ break;
+ case "balanced":
+ PowerProfileService.setProfile(1);
+ break;
+ case "powersaver":
+ PowerProfileService.setProfile(0);
+ break;
+ }
+ }
+
+ function toggleNoctaliaPerformance() {
+ PowerProfileService.toggleNoctaliaPerformance();
+ }
+
+ function enableNoctaliaPerformance() {
+ PowerProfileService.setNoctaliaPerformance(true);
+ }
+
+ function disableNoctaliaPerformance() {
+ PowerProfileService.setNoctaliaPerformance(false);
+ }
+ }
+
+ IpcHandler {
+ target: "media"
+
+ function toggle() {
+ root.screenDetector.withCurrentScreen(screen => {
+ var panel = PanelService.getPanel("mediaPlayerPanel", screen);
+ panel?.toggle(null, "MediaMini");
+ });
+ }
+
+ function playPause() {
+ MediaService.playPause();
+ }
+
+ function play() {
+ MediaService.play();
+ }
+
+ function stop() {
+ MediaService.stop();
+ }
+
+ function pause() {
+ MediaService.pause();
+ }
+
+ function next() {
+ MediaService.next();
+ }
+
+ function previous() {
+ MediaService.previous();
+ }
+
+ function seekRelative(offset: string) {
+ var offsetVal = parseFloat(offset);
+ if (Number.isNaN(offsetVal)) {
+ Logger.w("Media", "Argument to ipc call 'media seekRelative' must be a number");
+ return;
+ }
+ MediaService.seekRelative(offsetVal);
+ }
+
+ function seekByRatio(position: string) {
+ var positionVal = parseFloat(position);
+ if (Number.isNaN(positionVal)) {
+ Logger.w("Media", "Argument to ipc call 'media seekByRatio' must be a number");
+ return;
+ }
+ MediaService.seekByRatio(positionVal);
+ }
+ }
+
+ IpcHandler {
+ target: "state"
+
+ // Returns all settings and shell state as JSON
+ function all(): string {
+ try {
+ var snapshot = ShellState.buildStateSnapshot();
+ if (!snapshot) {
+ throw new Error("State snapshot unavailable");
+ }
+ return JSON.stringify(snapshot, null, 2);
+ } catch (error) {
+ Logger.e("IPC", "Failed to serialize state:", error);
+ return JSON.stringify({
+ "error": "Failed to serialize state: " + error
+ }, null, 2);
+ }
+ }
+ }
+
+ IpcHandler {
+ target: "desktopWidgets"
+ function toggle() {
+ Settings.data.desktopWidgets.enabled = !Settings.data.desktopWidgets.enabled;
+ }
+ function disable() {
+ Settings.data.desktopWidgets.enabled = false;
+ }
+ function enable() {
+ Settings.data.desktopWidgets.enabled = true;
+ }
+ function edit() {
+ DesktopWidgetRegistry.editMode = !DesktopWidgetRegistry.editMode;
+ }
+ }
+
+ IpcHandler {
+ target: "location"
+ function get(): string {
+ return Settings.data.location.name;
+ }
+ function set(name: string) {
+ Settings.data.location.name = name;
+ LocationService.update();
+ }
+ }
+
+ IpcHandler {
+ target: "systemMonitor"
+ function toggle() {
+ root.screenDetector.withCurrentScreen(screen => {
+ var panel = PanelService.getPanel("systemStatsPanel", screen);
+ panel?.toggle(null, "SystemMonitor");
+ });
+ }
+ }
+
+ IpcHandler {
+ target: "plugin"
+ function openSettings(key: string) {
+ var manifest = PluginRegistry.getPluginManifest(key);
+ if (!manifest) {
+ Logger.w("IPC", "Plugin not found:", key);
+ return;
+ }
+ if (!manifest.entryPoints?.settings) {
+ Logger.w("IPC", "Plugin has no settings entry point:", key);
+ return;
+ }
+ root.screenDetector.withCurrentScreen(screen => {
+ BarService.openPluginSettings(screen, manifest);
+ });
+ }
+
+ function openPanel(key: string) {
+ var manifest = PluginRegistry.getPluginManifest(key);
+ if (!manifest) {
+ Logger.w("IPC", "Plugin not found:", key);
+ return;
+ }
+ if (!manifest.entryPoints?.panel) {
+ Logger.w("IPC", "Plugin has no panel entry point:", key);
+ return;
+ }
+ root.screenDetector.withCurrentScreen(screen => {
+ PluginService.openPluginPanel(key, screen, null);
+ });
+ }
+
+ function closePanel(key: string) {
+ var manifest = PluginRegistry.getPluginManifest(key);
+ if (!manifest) {
+ Logger.w("IPC", "Plugin not found:", key);
+ return;
+ }
+ if (!manifest.entryPoints?.panel) {
+ Logger.w("IPC", "Plugin has no panel entry point:", key);
+ return;
+ }
+ root.screenDetector.withCurrentScreen(screen => {
+ var api = PluginService.getPluginAPI(key);
+ if (api) {
+ api.closePanel(screen);
+ }
+ });
+ }
+
+ function togglePanel(key: string) {
+ var manifest = PluginRegistry.getPluginManifest(key);
+ if (!manifest) {
+ Logger.w("IPC", "Plugin not found:", key);
+ return;
+ }
+ if (!manifest.entryPoints?.panel) {
+ Logger.w("IPC", "Plugin has no panel entry point:", key);
+ return;
+ }
+ root.screenDetector.withCurrentScreen(screen => {
+ PluginService.togglePluginPanel(key, screen, null);
+ });
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Hardware/BatteryService.qml b/arch/.config/quickshell/noctalia-shell/Services/Hardware/BatteryService.qml
new file mode 100644
index 0000000..f615354
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Hardware/BatteryService.qml
@@ -0,0 +1,368 @@
+pragma Singleton
+import QtQml
+import QtQuick
+
+import Quickshell
+import Quickshell.Io
+import Quickshell.Services.UPower
+import qs.Commons
+import qs.Services.Networking
+import qs.Services.UI
+
+Singleton {
+ id: root
+
+ readonly property var primaryDevice: _laptopBattery || _bluetoothBattery || null // Primary battery device (prioritizes laptop over Bluetooth)
+ readonly property real batteryPercentage: getPercentage(primaryDevice)
+ readonly property bool batteryCharging: isCharging(primaryDevice)
+ readonly property bool batteryPluggedIn: isPluggedIn(primaryDevice)
+ readonly property bool batteryReady: isDeviceReady(primaryDevice)
+ readonly property bool batteryPresent: isDevicePresent(primaryDevice)
+ readonly property real warningThreshold: Settings.data.systemMonitor.batteryWarningThreshold
+ readonly property real criticalThreshold: Settings.data.systemMonitor.batteryCriticalThreshold
+ readonly property string batteryIcon: getIcon(batteryPercentage, batteryCharging, batteryPluggedIn, batteryReady)
+
+ readonly property var laptopBatteries: UPower.devices.values.filter(d => d.isLaptopBattery).sort((x, y) => {
+ // Force DisplayDevice to the top
+ if (x.nativePath.includes("DisplayDevice"))
+ return -1;
+ if (y.nativePath.includes("DisplayDevice"))
+ return 1;
+
+ // Standard string comparison works for BAT0 vs BAT1
+ return x.nativePath.localeCompare(y.nativePath, undefined, {
+ numeric: true
+ });
+ })
+
+ readonly property var bluetoothBatteries: {
+ var list = [];
+ var btArray = BluetoothService.devices?.values || [];
+ for (var i = 0; i < btArray.length; i++) {
+ var btd = btArray[i];
+ if (btd && btd.connected && btd.batteryAvailable) {
+ list.push(btd);
+ }
+ }
+ return list;
+ }
+
+ readonly property var _laptopBattery: UPower.displayDevice.isPresent ? UPower.displayDevice : (laptopBatteries.length > 0 ? laptopBatteries[0] : null)
+ readonly property var _bluetoothBattery: bluetoothBatteries.length > 0 ? bluetoothBatteries[0] : null
+
+ property var deviceModel: {
+ var model = [
+ {
+ "key": "__default__",
+ "name": I18n.tr("bar.battery.device-default")
+ }
+ ];
+ const devices = UPower.devices?.values || [];
+ for (let d of devices) {
+ if (!d || d.type === UPowerDeviceType.LinePower) {
+ continue;
+ }
+ model.push({
+ key: d.nativePath || "",
+ name: d.model || d.nativePath || I18n.tr("common.unknown")
+ });
+ }
+ return model;
+ }
+
+ property var _hasNotified: ({})
+
+ function findDevice(nativePath) {
+ if (!nativePath || nativePath === "__default__" || nativePath === "DisplayDevice") {
+ return _laptopBattery;
+ }
+
+ if (!UPower.devices) {
+ return null;
+ }
+
+ const devices = UPower.devices?.values || [];
+ for (let d of devices) {
+ if (d && d.nativePath === nativePath) {
+ if (d.type === UPowerDeviceType.LinePower) {
+ continue;
+ }
+ return d;
+ }
+ }
+ return null;
+ }
+
+ function isDevicePresent(device) {
+ if (!device) {
+ return false;
+ }
+
+ // Handle Bluetooth devices (identified by having batteryAvailable property)
+ if (device.batteryAvailable !== undefined) {
+ return device.connected === true;
+ }
+
+ // Handle UPower devices
+ if (device.type !== undefined) {
+ if (device.type === UPowerDeviceType.Battery && device.isPresent !== undefined) {
+ return device.isPresent === true;
+ }
+ // Fallback for non-battery UPower devices or if isPresent is missing
+ return device.ready && device.percentage !== undefined;
+ }
+ return false;
+ }
+
+ function isDeviceReady(device) {
+ if (!isDevicePresent(device)) {
+ return false;
+ }
+ if (device.batteryAvailable !== undefined) {
+ return device.battery !== undefined;
+ }
+ return device.ready && device.percentage !== undefined;
+ }
+
+ function getPercentage(device) {
+ if (!device) {
+ return -1;
+ }
+ if (device.batteryAvailable !== undefined) {
+ return Math.round((device.battery || 0) * 100);
+ }
+ return Math.round((device.percentage || 0) * 100);
+ }
+
+ function isCharging(device) {
+ if (!device || isBluetoothDevice(device)) {
+ // Tracking bluetooth devices can charge or not is a loop hole, none of my devices has it, even if it possible?!
+ return false; // Assuming not charging until someone/quickshell brings a way to do pretty unlikely.
+ }
+ if (device.state !== undefined) {
+ return device.state === UPowerDeviceState.Charging;
+ }
+ return false;
+ }
+
+ function isPluggedIn(device) {
+ if (!device || isBluetoothDevice(device)) {
+ // Tracking bluetooth devices can charge or not is a loop hole, none of my devices has it, even if it possible?!
+ return false; // Assuming not charging until someone/quickshell brings a way to do pretty unlikely.
+ }
+ if (device.state !== undefined) {
+ return device.state === UPowerDeviceState.FullyCharged || device.state === UPowerDeviceState.PendingCharge;
+ }
+ return false;
+ }
+
+ function isCriticalBattery(device) {
+ return (!isCharging(device) && !isPluggedIn(device)) && getPercentage(device) <= criticalThreshold;
+ }
+
+ function isLowBattery(device) {
+ return (!isCharging(device) && !isPluggedIn(device)) && getPercentage(device) <= warningThreshold && getPercentage(device) > criticalThreshold;
+ }
+
+ function isBluetoothDevice(device) {
+ if (!device) {
+ return false;
+ }
+ // Check for Quickshell Bluetooth device property
+ if (device.batteryAvailable !== undefined) {
+ return true;
+ }
+ // Check for UPower device path indicating it's a Bluetooth device
+ if (device.nativePath && (device.nativePath.includes("bluez") || device.nativePath.includes("bluetooth"))) {
+ return true;
+ }
+ return false;
+ }
+
+ function getDeviceName(device) {
+ if (!isDeviceReady(device)) {
+ return "";
+ }
+
+ if (!isBluetoothDevice(device) && device.isLaptopBattery) {
+ // If there is more than one battery explicitly name them
+ // Logger.e("BatteryDebug", "Available Battery count: " + laptopBatteries.length); // can be useful for debugging
+ if (laptopBatteries.length > 1 && device.nativePath) {
+ if (device.nativePath === "DisplayDevice") {
+ return I18n.tr("battery.all-batteries");
+ }
+ var match = device.nativePath.match(/(\d+)$/);
+ if (match) {
+ // In case of 2 batteries: bat0 => bat1 bat1 => bat2
+ return I18n.tr("common.battery") + " " + (parseInt(match[1]) + 1); // Append numbers
+ }
+ }
+ // Return Battery if there is only one
+ return I18n.tr("common.battery");
+ }
+
+ if (isBluetoothDevice(device) && device.name) {
+ return device.name;
+ }
+
+ if (device.model) {
+ return device.model;
+ }
+
+ return "";
+ }
+
+ function getIcon(percent, charging, pluggedIn, isReady) {
+ if (!isReady) {
+ return "battery-exclamation";
+ }
+ if (charging) {
+ return "battery-charging";
+ }
+ if (pluggedIn) {
+ return "battery-charging-2";
+ }
+
+ const icons = [
+ {
+ threshold: 86,
+ icon: "battery-4"
+ },
+ {
+ threshold: 56,
+ icon: "battery-3"
+ },
+ {
+ threshold: 31,
+ icon: "battery-2"
+ },
+ {
+ threshold: 11,
+ icon: "battery-1"
+ },
+ {
+ threshold: 0,
+ icon: "battery"
+ }
+ ];
+
+ const match = icons.find(tier => percent >= tier.threshold);
+ return match ? match.icon : "battery-off"; // New fallback icon clearly represent if nothing is true here.
+ }
+
+ function getRateText(device) {
+ if (!device || device.changeRate === undefined) {
+ return "";
+ }
+ const rate = Math.abs(device.changeRate);
+ if (device.timeToFull > 0) {
+ return I18n.tr("battery.charging-rate", {
+ "rate": rate.toFixed(2)
+ });
+ } else if (device.timeToEmpty > 0) {
+ return I18n.tr("battery.discharging-rate", {
+ "rate": rate.toFixed(2)
+ });
+ }
+ }
+
+ function getTimeRemainingText(device) {
+ if (!isDeviceReady(device)) {
+ return I18n.tr("battery.no-battery-detected");
+ }
+ if (isPluggedIn(device)) {
+ return I18n.tr("battery.plugged-in");
+ } else if (device.timeToFull > 0) {
+ return I18n.tr("battery.time-until-full", {
+ "time": Time.formatVagueHumanReadableDuration(device.timeToFull)
+ });
+ } else if (device.timeToEmpty > 0) {
+ return I18n.tr("battery.time-left", {
+ "time": Time.formatVagueHumanReadableDuration(device.timeToEmpty)
+ });
+ }
+ return I18n.tr("common.idle");
+ }
+
+ function checkDevice(device) {
+ if (!device || !isDeviceReady(device)) {
+ return;
+ }
+
+ const percentage = getPercentage(device);
+ const charging = isCharging(device);
+ const pluggedIn = isPluggedIn(device);
+ const level = isLowBattery(device) ? "low" : (isCriticalBattery(device) ? "critical" : "");
+ var deviceKey = device.nativePath;
+
+ if (!_hasNotified[deviceKey]) {
+ _hasNotified[deviceKey] = {
+ low: false,
+ critical: false
+ };
+ }
+
+ if (charging || pluggedIn) {
+ _hasNotified[deviceKey].low = false;
+ _hasNotified[deviceKey].critical = false;
+ }
+
+ if (percentage > warningThreshold) {
+ _hasNotified[deviceKey].low = false;
+ _hasNotified[deviceKey].critical = false;
+ } else if (percentage > criticalThreshold) {
+ _hasNotified[deviceKey].critical = false;
+ }
+
+ if (level) {
+ if (!_hasNotified[deviceKey][level]) {
+ notify(device, level);
+ _hasNotified[deviceKey][level] = true;
+ }
+ }
+ }
+
+ function notify(device, level) {
+ if (!Settings.data.notifications.enableBatteryToast) {
+ return;
+ }
+ var name = getDeviceName(device);
+ var titleKey = level === "critical" ? "toast.battery.critical" : "toast.battery.low";
+ var descKey = level === "critical" ? "toast.battery.critical-desc" : "toast.battery.low-desc";
+
+ var title = I18n.tr(titleKey);
+ var desc = I18n.tr(descKey, {
+ "percent": getPercentage(device)
+ });
+ var icon = level === "critical" ? "battery-exclamation" : "battery-charging-2";
+
+ if (device == _bluetoothBattery && name) {
+ title = title + " " + name;
+ }
+
+ // Only 'showNotice' supports custom icons
+ ToastService.showNotice(title, desc, icon, 6000);
+ }
+
+ Instantiator {
+ model: deviceModel
+ delegate: Connections {
+ required property var modelData
+ property var device: findDevice(modelData.key)
+ target: device
+
+ function onPercentageChanged() {
+ if (device.isLaptopBattery && modelData.key !== "__default__") {
+ return;
+ }
+ checkDevice(device);
+ }
+ function onStateChanged() {
+ if (device.isLaptopBattery && modelData.key !== "__default__") {
+ return;
+ }
+ checkDevice(device);
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Hardware/BrightnessService.qml b/arch/.config/quickshell/noctalia-shell/Services/Hardware/BrightnessService.qml
new file mode 100644
index 0000000..26847a5
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Hardware/BrightnessService.qml
@@ -0,0 +1,560 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+
+Singleton {
+ id: root
+
+ property list ddcMonitors: []
+ readonly property list monitors: variants.instances
+ property bool appleDisplayPresent: false
+ property list availableBacklightDevices: []
+
+ function getMonitorForScreen(screen: ShellScreen): var {
+ return monitors.find(m => m.modelData === screen);
+ }
+
+ // Signal emitted when a specific monitor's brightness changes, includes monitor context
+ signal monitorBrightnessChanged(var monitor, real newBrightness)
+
+ function getAvailableMethods(): list {
+ var methods = [];
+ if (Settings.data.brightness.enableDdcSupport && monitors.some(m => m.isDdc))
+ methods.push("ddcutil");
+ if (monitors.some(m => !m.isDdc))
+ methods.push("internal");
+ if (appleDisplayPresent)
+ methods.push("apple");
+ return methods;
+ }
+
+ // Global helpers for IPC and shortcuts
+ function increaseBrightness(): void {
+ monitors.forEach(m => m.increaseBrightness());
+ }
+
+ function decreaseBrightness(): void {
+ monitors.forEach(m => m.decreaseBrightness());
+ }
+
+ function setBrightness(value: real): void {
+ monitors.forEach(m => m.setBrightnessDebounced(value));
+ }
+
+ function getDetectedDisplays(): list {
+ return detectedDisplays;
+ }
+
+ function normalizeBacklightDevicePath(devicePath): string {
+ if (devicePath === undefined || devicePath === null)
+ return "";
+
+ var normalized = String(devicePath).trim();
+ if (normalized === "")
+ return "";
+
+ if (normalized.startsWith("/sys/class/backlight/"))
+ return normalized;
+
+ if (normalized.indexOf("/") === -1)
+ return "/sys/class/backlight/" + normalized;
+
+ return normalized;
+ }
+
+ function getBacklightDeviceName(devicePath): string {
+ var normalized = normalizeBacklightDevicePath(devicePath);
+ if (normalized === "")
+ return "";
+
+ var parts = normalized.split("/");
+ while (parts.length > 0 && parts[parts.length - 1] === "") {
+ parts.pop();
+ }
+ return parts.length > 0 ? parts[parts.length - 1] : "";
+ }
+
+ function getMappedBacklightDevice(outputName): string {
+ var normalizedOutput = String(outputName || "").trim();
+ if (normalizedOutput === "")
+ return "";
+
+ var mappings = Settings.data.brightness.backlightDeviceMappings || [];
+ for (var i = 0; i < mappings.length; i++) {
+ var mapping = mappings[i];
+ if (!mapping || typeof mapping !== "object")
+ continue;
+
+ if (String(mapping.output || "").trim() === normalizedOutput)
+ return normalizeBacklightDevicePath(mapping.device || "");
+ }
+
+ return "";
+ }
+
+ function setMappedBacklightDevice(outputName, devicePath): void {
+ var normalizedOutput = String(outputName || "").trim();
+ if (normalizedOutput === "")
+ return;
+
+ var normalizedDevicePath = normalizeBacklightDevicePath(devicePath);
+ var mappings = Settings.data.brightness.backlightDeviceMappings || [];
+ var nextMappings = [];
+ var replaced = false;
+
+ for (var i = 0; i < mappings.length; i++) {
+ var mapping = mappings[i];
+ if (!mapping || typeof mapping !== "object")
+ continue;
+
+ var mappingOutput = String(mapping.output || "").trim();
+ var mappingDevice = normalizeBacklightDevicePath(mapping.device || "");
+ if (mappingOutput === "" || mappingDevice === "")
+ continue;
+
+ if (mappingOutput === normalizedOutput) {
+ if (!replaced && normalizedDevicePath !== "") {
+ nextMappings.push({
+ "output": normalizedOutput,
+ "device": normalizedDevicePath
+ });
+ }
+ replaced = true;
+ } else {
+ nextMappings.push({
+ "output": mappingOutput,
+ "device": mappingDevice
+ });
+ }
+ }
+
+ if (!replaced && normalizedDevicePath !== "") {
+ nextMappings.push({
+ "output": normalizedOutput,
+ "device": normalizedDevicePath
+ });
+ }
+
+ Settings.data.brightness.backlightDeviceMappings = nextMappings;
+ }
+
+ function scanBacklightDevices(): void {
+ if (!scanBacklightProc.running)
+ scanBacklightProc.running = true;
+ }
+
+ reloadableId: "brightness"
+
+ Component.onCompleted: {
+ Logger.i("Brightness", "Service started");
+ scanBacklightDevices();
+ if (Settings.data.brightness.enableDdcSupport) {
+ ddcProc.running = true;
+ }
+ }
+
+ onMonitorsChanged: {
+ ddcMonitors = [];
+ scanBacklightDevices();
+ if (Settings.data.brightness.enableDdcSupport) {
+ ddcProc.running = true;
+ }
+ }
+
+ Connections {
+ target: Settings.data.brightness
+ function onEnableDdcSupportChanged() {
+ if (Settings.data.brightness.enableDdcSupport) {
+ // Re-detect DDC monitors when enabled
+ ddcMonitors = [];
+ ddcProc.running = true;
+ } else {
+ // Clear DDC monitors when disabled
+ ddcMonitors = [];
+ }
+ }
+ function onBacklightDeviceMappingsChanged() {
+ scanBacklightDevices();
+ for (var i = 0; i < monitors.length; i++) {
+ var m = monitors[i];
+ if (m && !m.isDdc && !m.isAppleDisplay)
+ m.initBrightness();
+ }
+ }
+ }
+
+ Variants {
+ id: variants
+ model: Quickshell.screens
+ Monitor {}
+ }
+
+ // Check for Apple Display support
+ Process {
+ running: true
+ command: ["sh", "-c", "which asdbctl >/dev/null 2>&1 && asdbctl get || echo ''"]
+ stdout: StdioCollector {
+ onStreamFinished: root.appleDisplayPresent = text.trim().length > 0
+ }
+ }
+
+ // Detect available internal backlight devices
+ Process {
+ id: scanBacklightProc
+ command: ["sh", "-c", "for dev in /sys/class/backlight/*; do if [ -f \"$dev/brightness\" ] && [ -f \"$dev/max_brightness\" ]; then echo \"$dev\"; fi; done"]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ var data = text.trim();
+ if (data === "") {
+ root.availableBacklightDevices = [];
+ return;
+ }
+
+ var lines = data.split("\n");
+ var found = [];
+ var seen = ({});
+ for (var i = 0; i < lines.length; i++) {
+ var path = root.normalizeBacklightDevicePath(lines[i]);
+ if (path === "" || seen[path])
+ continue;
+ seen[path] = true;
+ found.push(path);
+ }
+
+ root.availableBacklightDevices = found;
+ }
+ }
+ }
+
+ // Detect DDC monitors
+ Process {
+ id: ddcProc
+ property list ddcMonitors: []
+ command: ["ddcutil", "detect", "--enable-dynamic-sleep", "--sleep-multiplier=0.5"]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ var displays = text.trim().split("\n\n");
+ ddcProc.ddcMonitors = displays.map(d => {
+ var ddcModelMatch = d.match(/(This monitor does not support DDC\/CI|Invalid display)/);
+ var modelMatch = d.match(/Model:\s*(.*)/);
+ var busMatch = d.match(/I2C bus:[ ]*\/dev\/i2c-([0-9]+)/);
+ var connectorMatch = d.match(/DRM[_ ]connector:\s*card\d+-(.+)/);
+ var ddcModel = ddcModelMatch ? ddcModelMatch.length > 0 : false;
+ var model = modelMatch ? modelMatch[1] : "Unknown";
+ var bus = busMatch ? busMatch[1] : "Unknown";
+ var connector = connectorMatch ? connectorMatch[1].trim() : "";
+ Logger.i("Brightness", "Detected DDC Monitor:", model, "connector:", connector, "bus:", bus, "is DDC:", !ddcModel);
+ return {
+ "model": model,
+ "busNum": bus,
+ "connector": connector,
+ "isDdc": !ddcModel
+ };
+ });
+ root.ddcMonitors = ddcProc.ddcMonitors.filter(m => m.isDdc);
+ }
+ }
+ }
+
+ component Monitor: QtObject {
+ id: monitor
+
+ required property ShellScreen modelData
+ readonly property bool isDdc: Settings.data.brightness.enableDdcSupport && root.ddcMonitors.some(m => m.connector === modelData.name)
+ readonly property string busNum: root.ddcMonitors.find(m => m.connector === modelData.name)?.busNum ?? ""
+ readonly property bool isAppleDisplay: root.appleDisplayPresent && modelData.model.startsWith("StudioDisplay")
+ readonly property string method: isAppleDisplay ? "apple" : (isDdc ? "ddcutil" : "internal")
+
+ // Check if brightness control is available for this monitor
+ readonly property bool brightnessControlAvailable: {
+ if (isAppleDisplay)
+ return true;
+ if (isDdc)
+ return true;
+ // For internal displays, check if we have a brightness path
+ return brightnessPath !== "";
+ }
+
+ property real brightness
+ property real lastBrightness: 0
+ property real queuedBrightness: NaN
+ property bool commandRunning: false
+
+ // For internal displays - store the backlight device path
+ property string backlightDevice: ""
+ property string brightnessPath: ""
+ property string maxBrightnessPath: ""
+ property int maxBrightness: 100
+ property bool ignoreNextChange: false
+ property bool initInProgress: false
+
+ // Signal for brightness changes
+ signal brightnessUpdated(real newBrightness)
+
+ // Execute a system command to get the current brightness value directly
+ readonly property Process refreshProc: Process {
+ stdout: StdioCollector {
+ onStreamFinished: {
+ var dataText = text.trim();
+ if (dataText === "") {
+ return;
+ }
+
+ var newBrightness = NaN;
+
+ if (monitor.isAppleDisplay) {
+ // Apple display format: single integer (0-101)
+ var val = parseInt(dataText);
+ if (!isNaN(val)) {
+ newBrightness = val / 101;
+ }
+ } else if (monitor.isDdc) {
+ // DDC format: "VCP 10 C 100 100" (space-separated)
+ var parts = dataText.split(" ");
+ if (parts.length >= 4) {
+ var current = parseInt(parts[3]);
+ var max = parseInt(parts[4]);
+ if (!isNaN(current) && !isNaN(max) && max > 0) {
+ monitor.maxBrightness = max;
+ newBrightness = current / max;
+ }
+ }
+ } else {
+ // Internal display format: two lines (current\nmax)
+ var lines = dataText.split("\n");
+ if (lines.length >= 2) {
+ var current = parseInt(lines[0].trim());
+ var max = parseInt(lines[1].trim());
+ if (!isNaN(current) && !isNaN(max) && max > 0) {
+ newBrightness = current / max;
+ }
+ }
+ }
+
+ // Update if we got a valid brightness value
+ if (!isNaN(newBrightness) && (Math.abs(newBrightness - monitor.brightness) > 0.001 || monitor.brightness === 0)) {
+ monitor.brightness = newBrightness;
+ monitor.brightnessUpdated(monitor.brightness);
+ root.monitorBrightnessChanged(monitor, monitor.brightness);
+ Logger.d("Brightness", "Refreshed brightness from system:", monitor.modelData.name, monitor.brightness);
+ }
+ }
+ }
+ }
+
+ readonly property Process setBrightnessProc: Process {
+ stdout: StdioCollector {}
+ onExited: (exitCode, exitStatus) => {
+ monitor.commandRunning = false;
+ // If there's a queued brightness change, process it now
+ if (!isNaN(monitor.queuedBrightness)) {
+ Qt.callLater(() => {
+ monitor.setBrightness(monitor.queuedBrightness);
+ monitor.queuedBrightness = NaN;
+ });
+ }
+ }
+ }
+
+ // Function to actively refresh the brightness from system
+ function refreshBrightnessFromSystem() {
+ if (!monitor.isDdc && !monitor.isAppleDisplay) {
+ // For internal displays, query the system directly
+ refreshProc.command = ["sh", "-c", "cat " + monitor.brightnessPath + " && " + "cat " + monitor.maxBrightnessPath];
+ refreshProc.running = true;
+ } else if (monitor.isDdc && monitor.busNum !== "") {
+ // For DDC displays, get the current value
+ refreshProc.command = ["ddcutil", "-b", monitor.busNum, "--enable-dynamic-sleep", "--sleep-multiplier=0.05", "getvcp", "10", "--brief"];
+ refreshProc.running = true;
+ } else if (monitor.isAppleDisplay) {
+ // For Apple displays, get the current value
+ refreshProc.command = ["asdbctl", "get"];
+ refreshProc.running = true;
+ }
+ }
+
+ // FileView to watch for external brightness changes (internal displays only)
+ readonly property FileView brightnessWatcher: FileView {
+ id: brightnessWatcher
+ // Only set path for internal displays with a valid brightness path
+ path: (!monitor.isDdc && !monitor.isAppleDisplay && monitor.brightnessPath !== "") ? monitor.brightnessPath : ""
+ watchChanges: path !== ""
+ onFileChanged: {
+ // When a file change is detected, actively refresh from system
+ // to ensure we get the most up-to-date value
+ Qt.callLater(() => {
+ monitor.refreshBrightnessFromSystem();
+ });
+ }
+ }
+
+ // Initialize brightness
+ readonly property Process initProc: Process {
+ stdout: StdioCollector {
+ onStreamFinished: {
+ var dataText = text.trim();
+ if (dataText === "") {
+ return;
+ }
+
+ //Logger.i("Brightness", "Raw brightness data for", monitor.modelData.name + ":", dataText)
+ if (monitor.isAppleDisplay) {
+ var val = parseInt(dataText);
+ if (!isNaN(val)) {
+ monitor.brightness = val / 101;
+ Logger.d("Brightness", "Apple display brightness:", monitor.brightness);
+ }
+ } else if (monitor.isDdc) {
+ var parts = dataText.split(" ");
+ if (parts.length >= 4) {
+ var current = parseInt(parts[3]);
+ var max = parseInt(parts[4]);
+ if (!isNaN(current) && !isNaN(max) && max > 0) {
+ monitor.maxBrightness = max;
+ monitor.brightness = current / max;
+ Logger.d("Brightness", "DDC brightness:", current + "/" + max + " =", monitor.brightness);
+ }
+ }
+ } else {
+ // Internal backlight - parse the response which includes device path
+ var lines = dataText.split("\n");
+ if (lines.length >= 3) {
+ monitor.backlightDevice = lines[0];
+ monitor.brightnessPath = monitor.backlightDevice + "/brightness";
+ monitor.maxBrightnessPath = monitor.backlightDevice + "/max_brightness";
+
+ var current = parseInt(lines[1]);
+ var max = parseInt(lines[2]);
+ if (!isNaN(current) && !isNaN(max) && max > 0) {
+ monitor.maxBrightness = max;
+ monitor.brightness = current / max;
+ Logger.d("Brightness", "Internal brightness:", current + "/" + max + " =", monitor.brightness);
+ Logger.d("Brightness", "Using backlight device:", monitor.backlightDevice);
+ }
+ } else {
+ monitor.backlightDevice = "";
+ monitor.brightnessPath = "";
+ monitor.maxBrightnessPath = "";
+ }
+ }
+
+ monitor.initInProgress = false;
+ }
+ }
+ onExited: (exitCode, exitStatus) => {
+ monitor.initInProgress = false;
+ }
+ }
+
+ readonly property real stepSize: Settings.data.brightness.brightnessStep / 100.0
+ readonly property real minBrightnessValue: (Settings.data.brightness.enforceMinimum ? 0.01 : 0.0)
+
+ // Timer for debouncing rapid changes
+ readonly property Timer timer: Timer {
+ interval: monitor.isDdc ? 250 : 33
+ onTriggered: {
+ if (!isNaN(monitor.queuedBrightness)) {
+ monitor.setBrightness(monitor.queuedBrightness);
+ monitor.queuedBrightness = NaN;
+ }
+ }
+ }
+
+ function setBrightnessDebounced(value: real): void {
+ monitor.queuedBrightness = value;
+ timer.start();
+ }
+
+ function increaseBrightness(): void {
+ const value = !isNaN(monitor.queuedBrightness) ? monitor.queuedBrightness : monitor.brightness;
+ // Enforce minimum brightness if enabled
+ if (Settings.data.brightness.enforceMinimum && value < minBrightnessValue) {
+ setBrightnessDebounced(Math.max(stepSize, minBrightnessValue));
+ } else {
+ // Normal brightness increase
+ setBrightnessDebounced(value + stepSize);
+ }
+ }
+
+ function decreaseBrightness(): void {
+ const value = !isNaN(monitor.queuedBrightness) ? monitor.queuedBrightness : monitor.brightness;
+ setBrightnessDebounced(value - stepSize);
+ }
+
+ function setBrightness(value: real): void {
+ value = Math.max(minBrightnessValue, Math.min(1, value));
+ var rounded = Math.round(value * 100);
+
+ // Always update internal value and trigger UI feedback immediately
+ monitor.brightness = value;
+ monitor.brightnessUpdated(value);
+ root.monitorBrightnessChanged(monitor, monitor.brightness);
+
+ if (timer.running) {
+ monitor.queuedBrightness = value;
+ return;
+ }
+
+ // If a command is already running, queue this value
+ if (monitor.commandRunning) {
+ monitor.queuedBrightness = value;
+ return;
+ }
+
+ // Execute the brightness change command
+ if (isAppleDisplay) {
+ monitor.commandRunning = true;
+ monitor.ignoreNextChange = true;
+ setBrightnessProc.command = ["asdbctl", "set", rounded];
+ setBrightnessProc.running = true;
+ } else if (isDdc && busNum !== "") {
+ monitor.commandRunning = true;
+ monitor.ignoreNextChange = true;
+ var ddcValue = Math.round(value * monitor.maxBrightness);
+ var ddcBus = busNum;
+ Qt.callLater(() => {
+ setBrightnessProc.command = ["ddcutil", "-b", ddcBus, "--noverify", "--async", "--enable-dynamic-sleep", "--sleep-multiplier=0.05", "setvcp", "10", ddcValue];
+ setBrightnessProc.running = true;
+ });
+ } else if (!isDdc) {
+ monitor.commandRunning = true;
+ monitor.ignoreNextChange = true;
+ var backlightDeviceName = root.getBacklightDeviceName(monitor.backlightDevice);
+ if (backlightDeviceName !== "") {
+ setBrightnessProc.command = ["brightnessctl", "-d", backlightDeviceName, "s", rounded + "%"];
+ } else {
+ setBrightnessProc.command = ["brightnessctl", "s", rounded + "%"];
+ }
+ setBrightnessProc.running = true;
+ }
+ }
+
+ function initBrightness(): void {
+ monitor.initInProgress = true;
+ if (isAppleDisplay) {
+ initProc.command = ["asdbctl", "get"];
+ initProc.running = true;
+ } else if (isDdc && busNum !== "") {
+ initProc.command = ["ddcutil", "-b", busNum, "--enable-dynamic-sleep", "--sleep-multiplier=0.05", "getvcp", "10", "--brief"];
+ initProc.running = true;
+ } else if (!isDdc) {
+ // Internal backlight: first try explicit output mapping, then fall back to first available.
+ var preferredDevicePath = root.getMappedBacklightDevice(modelData.name);
+ var probeScript = ["preferred=\"$1\"", "if [ -n \"$preferred\" ] && [ ! -d \"$preferred\" ]; then preferred=\"/sys/class/backlight/$preferred\"; fi", "selected=\"\"",
+ "if [ -n \"$preferred\" ] && [ -f \"$preferred/brightness\" ] && [ -f \"$preferred/max_brightness\" ]; then selected=\"$preferred\"; else for dev in /sys/class/backlight/*; do if [ -f \"$dev/brightness\" ] && [ -f \"$dev/max_brightness\" ]; then selected=\"$dev\"; break; fi; done; fi",
+ "if [ -n \"$selected\" ]; then echo \"$selected\"; cat \"$selected/brightness\"; cat \"$selected/max_brightness\"; fi"].join("; ");
+ initProc.command = ["sh", "-c", probeScript, "sh", preferredDevicePath];
+ initProc.running = true;
+ } else {
+ monitor.initInProgress = false;
+ }
+ }
+
+ onBusNumChanged: initBrightness()
+ onIsDdcChanged: initBrightness()
+ Component.onCompleted: initBrightness()
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Keyboard/ClipboardService.qml b/arch/.config/quickshell/noctalia-shell/Services/Keyboard/ClipboardService.qml
new file mode 100644
index 0000000..3c04bd2
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Keyboard/ClipboardService.qml
@@ -0,0 +1,536 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+import qs.Services.UI
+
+// Clipboard history service using cliphist + local content cache
+Singleton {
+ id: root
+
+ // Public API
+ property bool active: Settings.data.appLauncher.enableClipboardHistory && cliphistAvailable
+ property bool loading: false
+ property var items: [] // [{id, preview, mime, isImage}]
+
+ // Check if cliphist is available on the system
+ property bool cliphistAvailable: false
+ property bool dependencyChecked: false
+
+ // Optional automatic watchers to feed cliphist DB
+ property bool autoWatch: true
+ property bool watchersStarted: false
+
+ // Expose decoded thumbnails by id and a revision to notify bindings
+ property var imageDataById: ({})
+ property var _imageDataInsertOrder: [] // insertion-order IDs for LRU eviction
+ readonly property int _imageDataMaxEntries: 20 // max decoded images held in RAM at once
+ property int revision: 0
+
+ // Local content cache - stores full text content by ID
+ // This avoids relying on cliphist decode which can be unreliable
+ property var contentCache: ({})
+
+ // Track the most recent clipboard content for instant access
+ property string _latestTextContent: ""
+ property string _latestTextId: ""
+
+ // Approximate first-seen timestamps for entries this session (seconds)
+ property var firstSeenById: ({})
+
+ // Internal: store callback for decode
+ property var _decodeCallback: null
+ property int _decodeRequestId: 0
+
+ // Queue for base64 decodes
+ property var _b64Queue: []
+ property var _b64CurrentCb: null
+ property string _b64CurrentMime: ""
+ property string _b64CurrentId: ""
+
+ signal listCompleted
+
+ // Check if cliphist is available
+ Component.onCompleted: {
+ checkCliphistAvailability();
+ }
+
+ // Check dependency availability
+ function checkCliphistAvailability() {
+ if (dependencyChecked)
+ return;
+ dependencyCheckProcess.command = ["sh", "-c", "command -v cliphist"];
+ dependencyCheckProcess.running = true;
+ }
+
+ // Process to check if cliphist is available
+ Process {
+ id: dependencyCheckProcess
+ stdout: StdioCollector {}
+ onExited: (exitCode, exitStatus) => {
+ root.dependencyChecked = true;
+ if (exitCode === 0) {
+ root.cliphistAvailable = true;
+ // Start watchers if feature is enabled
+ if (root.active) {
+ startWatchers();
+ }
+ } else {
+ root.cliphistAvailable = false;
+ // Show toast notification if feature is enabled but cliphist is missing
+ if (Settings.data.appLauncher.enableClipboardHistory) {
+ ToastService.showWarning(I18n.tr("toast.clipboard.unavailable"), I18n.tr("toast.clipboard.unavailable-desc"), 6000);
+ }
+ }
+ }
+ }
+
+ // Start/stop watchers when enabled changes
+ onActiveChanged: {
+ if (root.active) {
+ startWatchers();
+ } else {
+ stopWatchers();
+ loading = false;
+ items = [];
+ }
+ }
+
+ // Fallback: periodically refresh list so UI updates even if not in clip mode
+ Timer {
+ interval: 5000
+ repeat: true
+ running: root.active
+ onTriggered: list()
+ }
+
+ // Internal process objects
+ Process {
+ id: listProc
+ stdout: StdioCollector {}
+ onExited: (exitCode, exitStatus) => {
+ const out = String(stdout.text);
+ const lines = out.split('\n').filter(l => l.length > 0);
+ // cliphist list default format: " " or "\t"
+ const parsed = lines.map((l, i) => {
+ let id = "";
+ let preview = "";
+ const m = l.match(/^(\d+)\s+(.+)$/);
+ if (m) {
+ id = m[1];
+ preview = m[2];
+ } else {
+ const tab = l.indexOf('\t');
+ id = tab > -1 ? l.slice(0, tab) : l;
+ preview = tab > -1 ? l.slice(tab + 1) : "";
+ }
+ const lower = preview.toLowerCase();
+ const isImage = lower.startsWith("[image]") || lower.includes(" binary data ");
+ // Best-effort mime guess from preview
+ var mime = "text/plain";
+ if (isImage) {
+ if (lower.includes(" png"))
+ mime = "image/png";
+ else if (lower.includes(" jpg") || lower.includes(" jpeg"))
+ mime = "image/jpeg";
+ else if (lower.includes(" webp"))
+ mime = "image/webp";
+ else if (lower.includes(" gif"))
+ mime = "image/gif";
+ else
+ mime = "image/*";
+ }
+ // Record first seen time for new ids (approximate copy time)
+ if (!root.firstSeenById[id]) {
+ const assumedAge = i * 15 * 60;
+ root.firstSeenById[id] = Time.timestamp - assumedAge;
+ }
+ // Smart type detection
+ var contentType = "text";
+ if (isImage) {
+ contentType = "image";
+ } else {
+ const t = preview.trim();
+ const tLower = t.toLowerCase();
+ if (/^#([a-f0-9]{3}|[a-f0-9]{6}|[a-f0-9]{8})$/.test(tLower)) {
+ contentType = "color";
+ } else if (/^https?:\/\//i.test(t)) {
+ contentType = "link";
+ } else if (/^(\/|~\/|file:\/\/)/i.test(t) && !t.startsWith('//') && !t.includes('\n')) {
+ contentType = "file";
+ } else if ((t.includes('{') && t.includes('}') && (t.includes(';') || t.includes('='))) || t.includes('') || t.includes('/>') || t.includes('=>') || t.includes('===') || t.includes('!==') || t.includes('::') || t.includes('->') ||
+ /^(?:const|let|var|function|class|struct|interface|type|enum|import|export|func|fn|pub|def|using|namespace|property|public|private|protected)\b/i.test(t) || /^(?:#include|#define|#\[|@|\/\/|\/\*|<\?| {
+ if (item.isImage)
+ return true;
+ const p = item.preview;
+ // Skip UTF-16 encoded text (has null bytes between chars), chromium browser artifact
+ const nullCount = (p.match(/\x00/g) || []).length;
+ if (nullCount > p.length * 0.2)
+ return false;
+ // Skip browser-generated HTML wrapper, firefox
+ if (p.toLowerCase().startsWith(" root._imageDataMaxEntries) {
+ const evicted = root._imageDataInsertOrder.shift();
+ delete root.imageDataById[evicted];
+ }
+ root.revision += 1;
+ }
+ root._b64CurrentCb = null;
+ root._b64CurrentMime = "";
+ root._b64CurrentId = "";
+ Qt.callLater(root._startNextB64);
+ }
+ }
+
+ // Text watcher - stores to cliphist and triggers content capture
+ Process {
+ id: watchText
+ stdout: StdioCollector {}
+ onExited: (exitCode, exitStatus) => {
+ if (root.autoWatch && root.watchersStarted && Settings.data.appLauncher.clipboardWatchTextCommand.trim() !== "") {
+ watchTextRestartTimer.restart();
+ }
+ }
+ }
+
+ Timer {
+ id: watchTextRestartTimer
+ interval: 1000
+ repeat: false
+ onTriggered: {
+ if (root.autoWatch && root.watchersStarted)
+ watchText.running = true;
+ }
+ }
+
+ // Image watcher
+ Process {
+ id: watchImage
+ stdout: StdioCollector {}
+ onExited: (exitCode, exitStatus) => {
+ if (root.autoWatch && root.watchersStarted && Settings.data.appLauncher.clipboardWatchImageCommand.trim() !== "") {
+ watchImageRestartTimer.restart();
+ }
+ }
+ }
+
+ Timer {
+ id: watchImageRestartTimer
+ interval: 1000
+ repeat: false
+ onTriggered: {
+ if (root.autoWatch && root.watchersStarted)
+ watchImage.running = true;
+ }
+ }
+
+ // Capture current clipboard text when needed
+ Process {
+ id: captureTextProc
+ stdout: StdioCollector {}
+ onExited: (exitCode, exitStatus) => {
+ if (exitCode === 0) {
+ const content = String(stdout.text);
+ if (content.length > 0) {
+ root._latestTextContent = content;
+ // Associate with newest item if we have one
+ if (root.items.length > 0 && !root.items[0].isImage) {
+ const newestId = root.items[0].id;
+ if (!root.contentCache[newestId]) {
+ root.contentCache[newestId] = content;
+ root.revision++;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ function startWatchers() {
+ if (!root.active || !autoWatch || watchersStarted || !root.cliphistAvailable)
+ return;
+ watchersStarted = true;
+
+ // Text watcher
+ watchText.command = ["sh", "-c", Settings.data.appLauncher.clipboardWatchTextCommand];
+ watchText.running = true;
+
+ // Image watcher
+ watchImage.command = ["sh", "-c", Settings.data.appLauncher.clipboardWatchImageCommand];
+ watchImage.running = true;
+ }
+
+ function stopWatchers() {
+ if (!watchersStarted)
+ return;
+ watchText.running = false;
+ watchImage.running = false;
+ watchersStarted = false;
+ }
+
+ // Capture current clipboard text and cache it
+ function captureCurrentClipboard() {
+ if (captureTextProc.running)
+ return;
+ captureTextProc.command = ["wl-paste", "--no-newline"];
+ captureTextProc.running = true;
+ }
+
+ function list(maxPreviewWidth) {
+ if (!root.active || !root.cliphistAvailable) {
+ return;
+ }
+ if (listProc.running)
+ return;
+ loading = true;
+ const width = maxPreviewWidth || 100;
+ listProc.command = ["cliphist", "list", "-preview-width", String(width)];
+ listProc.running = true;
+ }
+
+ // Get content for an ID - uses cache first, falls back to cliphist decode
+ function getContent(id) {
+ if (root.contentCache[id]) {
+ return root.contentCache[id];
+ }
+ return null;
+ }
+
+ // Async decode - checks cache first, then falls back to cliphist
+ function decode(id, cb) {
+ if (!root.cliphistAvailable) {
+ if (cb)
+ cb("");
+ return;
+ }
+
+ // Check cache first
+ const cached = root.contentCache[id];
+ if (cached) {
+ if (cb)
+ cb(cached);
+ return;
+ }
+
+ // Fall back to cliphist decode
+ if (decodeProc.running) {
+ decodeProc.running = false;
+ }
+ root._decodeRequestId++;
+ decodeProc.requestId = root._decodeRequestId;
+ root._decodeCallback = function (content) {
+ // Cache the result if successful
+ if (content && content.trim()) {
+ root.contentCache[id] = content;
+ }
+ if (cb)
+ cb(content);
+ };
+ const idStr = String(id);
+ decodeProc.command = ["cliphist", "decode", idStr];
+ decodeProc.running = true;
+ }
+
+ function decodeToDataUrl(id, mime, cb) {
+ if (!root.cliphistAvailable) {
+ if (cb)
+ cb("");
+ return;
+ }
+ // If cached, return immediately
+ if (root.imageDataById[id]) {
+ if (cb)
+ cb(root.imageDataById[id]);
+ return;
+ }
+ // Queue request; ensures single process handles sequentially
+ root._b64Queue.push({
+ "id": id,
+ "mime": mime || "image/*",
+ "cb": cb
+ });
+ if (!decodeB64Proc.running && root._b64CurrentCb === null) {
+ _startNextB64();
+ }
+ }
+
+ function getImageData(id) {
+ if (id === undefined) {
+ return null;
+ }
+ return root.imageDataById[id];
+ }
+
+ function _startNextB64() {
+ if (root._b64Queue.length === 0 || !root.cliphistAvailable)
+ return;
+ const job = root._b64Queue.shift();
+ root._b64CurrentCb = job.cb;
+ root._b64CurrentMime = job.mime;
+ root._b64CurrentId = job.id;
+ decodeB64Proc.command = ["sh", "-c", `cliphist decode ${job.id} | base64 -w 0`];
+ decodeB64Proc.running = true;
+ }
+
+ function copyToClipboard(id) {
+ if (!root.cliphistAvailable) {
+ return;
+ }
+ copyProc.command = ["sh", "-c", `cliphist decode ${id} | wl-copy`];
+ copyProc.running = true;
+ }
+
+ function pasteFromClipboard(id, mime) {
+ if (!root.cliphistAvailable) {
+ return;
+ }
+ const isImage = mime && mime.startsWith("image/");
+ const typeArg = isImage ? ` --type ${mime}` : "";
+ const pasteKeys = isImage ? "wtype -M ctrl -k v" : "wtype -M ctrl -M shift v";
+ const cmd = `cliphist decode ${id} | wl-copy${typeArg} && ${pasteKeys}`;
+ pasteProc.command = ["sh", "-c", cmd];
+ pasteProc.running = true;
+ }
+
+ function pasteText(text) {
+ if (!text)
+ return;
+ const escaped = text.replace(/'/g, "'\\''");
+ const cmd = `printf '%s' '${escaped}' | wl-copy && wtype -M ctrl -M shift v`;
+ pasteProc.command = ["sh", "-c", cmd];
+ pasteProc.running = true;
+ }
+
+ function deleteById(id) {
+ if (!root.cliphistAvailable) {
+ return;
+ }
+ if (deleteProc.running) {
+ return;
+ }
+ const idStr = String(id).trim();
+ // Remove from caches
+ delete root.contentCache[idStr];
+ delete root.imageDataById[idStr];
+ const orderIdx = root._imageDataInsertOrder.indexOf(idStr);
+ if (orderIdx !== -1)
+ root._imageDataInsertOrder.splice(orderIdx, 1);
+ deleteProc.command = ["sh", "-c", `echo ${idStr} | cliphist delete`];
+ deleteProc.running = true;
+ }
+
+ function wipeAll() {
+ if (!root.cliphistAvailable) {
+ return;
+ }
+ // Clear caches
+ root.contentCache = {};
+ root.imageDataById = {};
+ root._imageDataInsertOrder = [];
+ root._latestTextContent = "";
+ root._latestTextId = "";
+
+ Quickshell.execDetached(["cliphist", "wipe"]);
+ revision++;
+ Qt.callLater(() => list());
+ }
+
+ // Parse image metadata from cliphist preview string
+ function parseImageMeta(preview) {
+ const re = /\[\[\s*binary data\s+([\d\.]+\s*(?:KiB|MiB|GiB|B))\s+(\w+)\s+(\d+)x(\d+)\s*\]\]/i;
+ const match = (preview || "").match(re);
+ if (!match)
+ return null;
+ return {
+ "size": match[1],
+ "fmt": (match[2] || "").toUpperCase(),
+ "w": Number(match[3]),
+ "h": Number(match[4])
+ };
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Keyboard/EmojiService.qml b/arch/.config/quickshell/noctalia-shell/Services/Keyboard/EmojiService.qml
new file mode 100644
index 0000000..ef818d2
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Keyboard/EmojiService.qml
@@ -0,0 +1,314 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+
+// Manages emoji data loading, searching, and clipboard operations
+Singleton {
+ id: root
+
+ property var emojis: []
+ property bool loaded: false
+
+ // Usage tracking for popular emojis
+ // Format: { "emoji": { count: number, lastUsed: timestamp } }
+ property var usageCounts: ({})
+
+ // File path for persisting usage data
+ readonly property string usageFilePath: Settings.cacheDir + "emoji_usage.json"
+
+ // Searches emojis based on query
+ function search(query) {
+ if (!loaded) {
+ return [];
+ }
+
+ if (!query || query.trim() === "") {
+ return emojis;
+ }
+
+ const terms = query.toLowerCase().split(" ").filter(t => t);
+ const results = emojis.filter(emoji => {
+ for (let term of terms) {
+ const emojiMatch = emoji.emoji.toLowerCase().includes(term);
+ const nameMatch = emoji.name.toLowerCase().includes(term);
+ const keywordMatch = emoji.keywords.some(kw => kw.toLowerCase().includes(term));
+ const categoryMatch = emoji.category.toLowerCase().includes(term);
+
+ if (!emojiMatch && !nameMatch && !keywordMatch && !categoryMatch) {
+ return false;
+ }
+ }
+ return true;
+ });
+
+ return results;
+ }
+
+ function _getPopularEmojis(limit) {
+ var emojisWithUsage = emojis.map(function (emoji) {
+ const usageData = usageCounts[emoji.emoji];
+ return {
+ emoji: emoji,
+ usageCount: usageData ? (usageData.count || 0) : 0,
+ lastUsed: usageData ? (usageData.lastUsed || 0) : 0
+ };
+ }).filter(function (item) {
+ return item.usageCount > 0;
+ });
+
+ // Sort by last used timestamp (descending - most recent first)
+ // Then by usage count if timestamps are equal
+ emojisWithUsage.sort(function (a, b) {
+ if (b.lastUsed !== a.lastUsed) {
+ return b.lastUsed - a.lastUsed;
+ }
+ if (b.usageCount !== a.usageCount) {
+ return b.usageCount - a.usageCount;
+ }
+ return a.emoji.name.localeCompare(b.emoji.name);
+ });
+
+ // Return the emoji objects limited by the specified count
+ return emojisWithUsage.slice(0, limit).map(function (item) {
+ return item.emoji;
+ });
+ }
+
+ function getCategoriesWithCounts() {
+ if (!loaded) {
+ return [];
+ }
+
+ var categoryCounts = {};
+
+ for (var i = 0; i < emojis.length; i++) {
+ var emoji = emojis[i];
+ var category = emoji.category || "other";
+ if (!categoryCounts[category]) {
+ categoryCounts[category] = 0;
+ }
+ categoryCounts[category]++;
+ }
+
+ var categories = [];
+ for (var cat in categoryCounts) {
+ categories.push({
+ name: cat,
+ count: categoryCounts[cat]
+ });
+ }
+
+ return categories;
+ }
+
+ function getEmojisByCategory(category) {
+ if (!loaded) {
+ return [];
+ }
+
+ if (category === "recent") {
+ return _getPopularEmojis(25);
+ }
+
+ return emojis.filter(function (emoji) {
+ return emoji.category === category;
+ });
+ }
+
+ // Record emoji usage
+ function recordUsage(emojiChar) {
+ if (emojiChar) {
+ const currentData = usageCounts[emojiChar] || {
+ count: 0,
+ lastUsed: 0
+ };
+ usageCounts[emojiChar] = {
+ count: currentData.count + 1,
+ lastUsed: Date.now()
+ };
+ _saveUsageData();
+ }
+ }
+
+ // Ensure usage file exists with default content
+ function _ensureUsageFileExists() {
+ Quickshell.execDetached(["sh", "-c", `mkdir -p "$(dirname "${root.usageFilePath}")" && echo '{}' > "${root.usageFilePath}"`]);
+ }
+
+ // File paths
+ readonly property string userEmojiPath: Settings.configDir + "emoji.json"
+ readonly property string builtinEmojiPath: `${Quickshell.shellDir}/Assets/Launcher/emoji.json`
+
+ // Internal data
+ property var _userEmojiData: []
+ property var _builtinEmojiData: []
+ property int _pendingLoads: 0
+
+ // Initialize on component completion
+ Component.onCompleted: {
+ _loadUsageData();
+ _loadEmojis();
+ }
+
+ // File loaders
+ FileView {
+ id: userEmojiFile
+ path: root.userEmojiPath
+ printErrors: false
+ watchChanges: false
+
+ onLoaded: {
+ try {
+ const content = text();
+ if (content) {
+ const parsed = JSON.parse(content);
+ _userEmojiData = Array.isArray(parsed) ? parsed : [];
+ } else {
+ _userEmojiData = [];
+ }
+ } catch (e) {
+ _userEmojiData = [];
+ }
+ _onLoadComplete();
+ }
+
+ onLoadFailed: function (error) {
+ _userEmojiData = [];
+ _onLoadComplete();
+ }
+ }
+
+ FileView {
+ id: builtinEmojiFile
+ path: root.builtinEmojiPath
+ printErrors: false
+ watchChanges: false
+
+ onLoaded: {
+ try {
+ const content = text();
+ if (content) {
+ const parsed = JSON.parse(content);
+ _builtinEmojiData = Array.isArray(parsed) ? parsed : [];
+ } else {
+ _builtinEmojiData = [];
+ }
+ } catch (e) {
+ _builtinEmojiData = [];
+ }
+ _onLoadComplete();
+ }
+
+ onLoadFailed: function (error) {
+ _builtinEmojiData = [];
+ _onLoadComplete();
+ }
+ }
+
+ // Load emoji files
+ function _loadEmojis() {
+ _pendingLoads = 2;
+ userEmojiFile.reload();
+ builtinEmojiFile.reload();
+ }
+
+ // Called when one file finishes loading
+ function _onLoadComplete() {
+ _pendingLoads--;
+ if (_pendingLoads <= 0) {
+ _finalizeEmojis();
+ }
+ }
+
+ // Merge and deduplicate emojis
+ function _finalizeEmojis() {
+ const emojiMap = new Map();
+
+ // Add built-in emojis first
+ for (const emoji of _builtinEmojiData) {
+ if (emoji && emoji.emoji) {
+ emojiMap.set(emoji.emoji, emoji);
+ }
+ }
+
+ // Add user emojis (override built-ins if duplicate)
+ for (const emoji of _userEmojiData) {
+ if (emoji && emoji.emoji) {
+ emojiMap.set(emoji.emoji, emoji);
+ }
+ }
+
+ emojis = Array.from(emojiMap.values());
+ loaded = true;
+ }
+
+ // FileView for usage data
+ FileView {
+ id: usageFile
+ path: root.usageFilePath
+ printErrors: false
+ watchChanges: false
+
+ onLoaded: {
+ try {
+ const content = text();
+ if (content && content.trim() !== "") {
+ const parsed = JSON.parse(content);
+ if (parsed && typeof parsed === 'object') {
+ root.usageCounts = parsed;
+ } else {
+ root.usageCounts = {};
+ }
+ } else {
+ root.usageCounts = {};
+ }
+ } catch (e) {
+ root.usageCounts = {};
+ }
+ }
+
+ onLoadFailed: function (error) {
+ root.usageCounts = {};
+ Qt.callLater(_ensureUsageFileExists);
+ }
+ }
+
+ // Timer for debouncing usage data saves
+ Timer {
+ id: saveTimer
+ interval: 1000
+ repeat: false
+ onTriggered: _doSaveUsageData()
+ }
+
+ // Load usage data
+ function _loadUsageData() {
+ usageFile.reload();
+ }
+
+ // Save usage data with debounce
+ function _saveUsageData() {
+ saveTimer.restart();
+ }
+
+ // Actually save usage data to file
+ function _doSaveUsageData() {
+ try {
+ const content = JSON.stringify(root.usageCounts);
+ Quickshell.execDetached(["sh", "-c", `mkdir -p "$(dirname "${root.usageFilePath}")" && echo '${content}' > "${root.usageFilePath}"`]);
+ } catch (e) {
+ Logger.e("EmojiService", "Failed to save usage data: " + e.message);
+ }
+ }
+
+ // Copies emoji to clipboard
+ function copy(emojiChar) {
+ if (emojiChar) {
+ recordUsage(emojiChar); // Record usage before copying
+ Quickshell.execDetached(["sh", "-c", `echo -n "${emojiChar}" | wl-copy`]);
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Keyboard/KeyboardLayoutService.qml b/arch/.config/quickshell/noctalia-shell/Services/Keyboard/KeyboardLayoutService.qml
new file mode 100644
index 0000000..f6e455f
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Keyboard/KeyboardLayoutService.qml
@@ -0,0 +1,319 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+import qs.Services.Compositor
+import qs.Services.UI
+
+Singleton {
+ id: root
+ property string currentLayout: I18n.tr("common.unknown")
+ property string fullLayoutName: I18n.tr("common.unknown")
+ property string previousLayout: ""
+ property bool isInitialized: false
+
+ // Updates current layout from various format strings. Called by compositors
+ function setCurrentLayout(layoutString) {
+ root.fullLayoutName = layoutString || I18n.tr("common.unknown");
+ root.currentLayout = extractLayoutCode(layoutString);
+ }
+
+ // Extract layout code from various format strings
+ // Priority: variant > country code > language lookup > fallback
+ function extractLayoutCode(layoutString) {
+ if (!layoutString)
+ return I18n.tr("common.unknown");
+
+ const str = layoutString.toLowerCase();
+
+ // If it's already a short code (2-3 chars), return uppercase
+ if (/^[a-z]{2,3}(\+.*)?$/.test(str)) {
+ return str.split('+')[0].toUpperCase();
+ }
+
+ // Check for layout variants first - these are more meaningful than country codes
+ // when distinguishing between multiple layouts of the same language
+ for (const [pattern, display] of Object.entries(variantMap)) {
+ if (str.includes(pattern)) {
+ return display;
+ }
+ }
+
+ // Extract short code from parentheses like "English (US)"
+ const shortCodeMatch = str.match(/\(([a-z]{2,3})\)/i);
+ if (shortCodeMatch) {
+ return shortCodeMatch[1].toUpperCase();
+ }
+
+ // Check for language/country names in the language map
+ // First, try to match at the start of the string (the primary language)
+ for (const [lang, code] of Object.entries(languageMap)) {
+ if (str.startsWith(lang)) {
+ return code.toUpperCase();
+ }
+ }
+ // Then try word boundary matching anywhere in the string
+ for (const [lang, code] of Object.entries(languageMap)) {
+ const regex = new RegExp(`\\b${lang}\\b`);
+ if (regex.test(str)) {
+ return code.toUpperCase();
+ }
+ }
+
+ // If nothing matches, try first 2-3 characters if they look like a code
+ const codeMatch = str.match(/^([a-z]{2,3})/);
+ return codeMatch ? codeMatch[1].toUpperCase() : I18n.tr("common.unknown");
+ }
+
+ // Watch for layout changes and show toast
+ onCurrentLayoutChanged: {
+ // Update previousLayout after checking for changes
+ const layoutChanged = isInitialized && currentLayout !== previousLayout && currentLayout !== I18n.tr("common.unknown") && previousLayout !== "" && previousLayout !== I18n.tr("common.unknown");
+
+ if (layoutChanged) {
+ if (Settings.data.notifications.enableKeyboardLayoutToast) {
+ const message = I18n.tr("toast.keyboard-layout.changed", {
+ "layout": fullLayoutName
+ });
+ ToastService.showNotice(I18n.tr("toast.keyboard-layout.title"), message, "", 2000);
+ }
+ Logger.d("KeyboardLayout", "Layout changed from", previousLayout, "to", currentLayout);
+ }
+
+ // Update previousLayout for next comparison
+ previousLayout = currentLayout;
+ }
+
+ Component.onCompleted: {
+ Logger.i("KeyboardLayout", "Service started");
+ // Mark as initialized after a delay to allow first layout update to complete
+ // This prevents showing a toast on the initial load
+ initializationTimer.start();
+ }
+
+ Timer {
+ id: initializationTimer
+ interval: 2000 // Wait 2 seconds for first layout update to complete
+ onTriggered: {
+ isInitialized = true;
+ // Set previousLayout to current value after initialization
+ previousLayout = currentLayout;
+ Logger.d("KeyboardLayout", "Service initialized, current layout:", currentLayout);
+ }
+ }
+
+ // Layout variants - checked BEFORE country codes
+ // These display the variant name when it's more meaningful than the country
+ property var variantMap: {
+ // Alternative keyboard layouts
+ "colemak": "Colemak",
+ "dvorak": "Dvorak",
+ "workman": "Workman",
+ "programmer dvorak": "Dvk-P",
+ "norman": "Norman",
+ // International variants
+ "intl": "Intl",
+ "international": "Intl",
+ "altgr-intl": "Intl",
+ "with dead keys": "Dead",
+ // Common variants
+ "phonetic": "Phon",
+ "extended": "Ext",
+ "ergonomic": "Ergo",
+ "legacy": "Legacy",
+ // Input methods
+ "pinyin": "Pinyin",
+ "cangjie": "Cangjie",
+ "romaji": "Romaji",
+ "kana": "Kana"
+ }
+
+ // Language/country name to ISO code mapping
+ property var languageMap: {
+ // English variants
+ "english": "us",
+ "american": "us",
+ "united states": "us",
+ "us english": "us",
+ "british": "gb",
+ "united kingdom": "gb",
+ "english (uk)": "gb",
+ "canadian": "ca",
+ "canada": "ca",
+ "canadian english": "ca",
+ "australian": "au",
+ "australia": "au",
+ // Nordic countries
+ "swedish": "se",
+ "svenska": "se",
+ "sweden": "se",
+ "norwegian": "no",
+ "norsk": "no",
+ "norway": "no",
+ "danish": "dk",
+ "dansk": "dk",
+ "denmark": "dk",
+ "finnish": "fi",
+ "suomi": "fi",
+ "finland": "fi",
+ "icelandic": "is",
+ "รญslenska": "is",
+ "iceland": "is",
+ // Western/Central European Germanic
+ "german": "de",
+ "deutsch": "de",
+ "germany": "de",
+ "austrian": "at",
+ "austria": "at",
+ "รถsterreich": "at",
+ "swiss": "ch",
+ "switzerland": "ch",
+ "schweiz": "ch",
+ "suisse": "ch",
+ "dutch": "nl",
+ "nederlands": "nl",
+ "netherlands": "nl",
+ "holland": "nl",
+ "belgian": "be",
+ "belgium": "be",
+ "belgiรซ": "be",
+ "belgique": "be",
+ // Romance languages (Western/Southern Europe)
+ "french": "fr",
+ "franรงais": "fr",
+ "france": "fr",
+ "canadian french": "ca",
+ "spanish": "es",
+ "espaรฑol": "es",
+ "spain": "es",
+ "castilian": "es",
+ "italian": "it",
+ "italiano": "it",
+ "italy": "it",
+ "portuguese": "pt",
+ "portuguรชs": "pt",
+ "portugal": "pt",
+ "catalan": "ad",
+ "catalร ": "ad",
+ "andorra": "ad",
+ // Eastern European Romance
+ "romanian": "ro",
+ "romรขnฤ": "ro",
+ "romania": "ro",
+ // Slavic languages (Eastern Europe)
+ "russian": "ru",
+ "ััััะบะธะน": "ru",
+ "russia": "ru",
+ "polish": "pl",
+ "polski": "pl",
+ "poland": "pl",
+ "czech": "cz",
+ "ฤeลกtina": "cz",
+ "czech republic": "cz",
+ "slovak": "sk",
+ "slovenฤina": "sk",
+ "slovakia": "sk",
+ // Ukrainian
+ "ukraine": "ua",
+ "ukrainian": "ua",
+ "ัะบัะฐัะฝััะบะฐ": "ua",
+ "bulgarian": "bg",
+ "ะฑัะปะณะฐััะบะธ": "bg",
+ "bulgaria": "bg",
+ "serbian": "rs",
+ "srpski": "rs",
+ "serbia": "rs",
+ "croatian": "hr",
+ "hrvatski": "hr",
+ "croatia": "hr",
+ "slovenian": "si",
+ "slovenลกฤina": "si",
+ "slovenia": "si",
+ "bosnian": "ba",
+ "bosanski": "ba",
+ "bosnia": "ba",
+ "macedonian": "mk",
+ "ะผะฐะบะตะดะพะฝัะบะธ": "mk",
+ "macedonia": "mk",
+ // Celtic languages (Western Europe)
+ "irish": "ie",
+ "gaeilge": "ie",
+ "ireland": "ie",
+ "welsh": "gb",
+ "cymraeg": "gb",
+ "wales": "gb",
+ "scottish": "gb",
+ "gร idhlig": "gb",
+ "scotland": "gb",
+ // Baltic languages (Northern Europe)
+ "estonian": "ee",
+ "eesti": "ee",
+ "estonia": "ee",
+ "latvian": "lv",
+ "latvieลกu": "lv",
+ "latvia": "lv",
+ "lithuanian": "lt",
+ "lietuviลณ": "lt",
+ "lithuania": "lt",
+ // Other European languages
+ "hungarian": "hu",
+ "magyar": "hu",
+ "hungary": "hu",
+ "greek": "gr",
+ "ฮตฮปฮปฮทฮฝฮนฮบฮฌ": "gr",
+ "greece": "gr",
+ "albanian": "al",
+ "shqip": "al",
+ "albania": "al",
+ "maltese": "mt",
+ "malti": "mt",
+ "malta": "mt",
+ // West/Southwest Asian languages
+ "turkish": "tr",
+ "tรผrkรงe": "tr",
+ "turkey": "tr",
+ "arabic": "ar",
+ "ุงูุนุฑุจูุฉ": "ar",
+ "arab": "ar",
+ "hebrew": "il",
+ "ืขืืจืืช": "il",
+ "israel": "il",
+ // South American languages
+ "brazilian": "br",
+ "brazilian portuguese": "br",
+ "brasil": "br",
+ "brazil": "br",
+ // East Asian languages
+ "japanese": "jp",
+ "ๆฅๆฌ่ช": "jp",
+ "japan": "jp",
+ "korean": "kr",
+ "ํ๊ตญ์ด": "kr",
+ "korea": "kr",
+ "south korea": "kr",
+ "chinese": "cn",
+ "ไธญๆ": "cn",
+ "china": "cn",
+ "simplified chinese": "cn",
+ "traditional chinese": "tw",
+ "taiwan": "tw",
+ "็น้ซไธญๆ": "tw",
+ // Southeast Asian languages
+ "thai": "th",
+ "เนเธเธข": "th",
+ "thailand": "th",
+ "vietnamese": "vn",
+ "tiแบฟng viแปt": "vn",
+ "vietnam": "vn",
+ // South Asian languages
+ "hindi": "in",
+ "เคนเคฟเคจเฅเคฆเฅ": "in",
+ "india": "in",
+ // African languages
+ "afrikaans": "za",
+ "south africa": "za",
+ "south african": "za"
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Keyboard/LockKeysService.qml b/arch/.config/quickshell/noctalia-shell/Services/Keyboard/LockKeysService.qml
new file mode 100644
index 0000000..463c2ed
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Keyboard/LockKeysService.qml
@@ -0,0 +1,151 @@
+pragma Singleton
+import Qt.labs.folderlistmodel 2.10
+import QtQml.Models
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+
+Singleton {
+ id: root
+
+ // Component registration - only poll when something needs lock key state
+ function registerComponent(componentId) {
+ root._registered[componentId] = true;
+ root._registered = Object.assign({}, root._registered);
+ Logger.d("LockKeys", "Component registered:", componentId, "- total:", root._registeredCount);
+ }
+
+ function unregisterComponent(componentId) {
+ delete root._registered[componentId];
+ root._registered = Object.assign({}, root._registered);
+ Logger.d("LockKeys", "Component unregistered:", componentId, "- total:", root._registeredCount);
+ }
+
+ property var _registered: ({})
+ readonly property int _registeredCount: Object.keys(_registered).length
+ readonly property bool shouldRun: _registeredCount > 0
+
+ property bool capsLockOn: false
+ property bool numLockOn: false
+ property bool scrollLockOn: false
+
+ signal capsLockChanged(bool active)
+ signal numLockChanged(bool active)
+ signal scrollLockChanged(bool active)
+
+ Instantiator {
+ model: FolderListModel {
+ id: folderModel
+ folder: Qt.resolvedUrl("/sys/class/leds")
+ showFiles: false
+ showOnlyReadable: true
+ }
+ delegate: Component {
+ FileView {
+ id: fileView
+ path: filePath + "/brightness"
+ // sysfs brightness can fail transiently (e.g. resume); omit console spam like other sysfs FileViews.
+ printErrors: false
+ watchChanges: false
+
+ function parseBrightnessLedOn(raw) {
+ var t = raw.trim();
+ if (t === "" || !/^[0-9]+$/.test(t))
+ return null;
+ return parseInt(t, 10) !== 0;
+ }
+
+ function applyLockState(kind, state, emitIfChanged) {
+ switch (kind) {
+ case "numlock":
+ if (emitIfChanged && root.numLockOn !== state) {
+ root.numLockOn = state;
+ root.numLockChanged(state);
+ Logger.i("LockKeysService", "Num Lock:", state, fileView.path);
+ } else if (!emitIfChanged) {
+ root.numLockOn = state;
+ }
+ break;
+ case "capslock":
+ if (emitIfChanged && root.capsLockOn !== state) {
+ root.capsLockOn = state;
+ root.capsLockChanged(state);
+ Logger.i("LockKeysService", "Caps Lock:", state, fileView.path);
+ } else if (!emitIfChanged) {
+ root.capsLockOn = state;
+ }
+ break;
+ case "scrolllock":
+ if (emitIfChanged && root.scrollLockOn !== state) {
+ root.scrollLockOn = state;
+ root.scrollLockChanged(state);
+ Logger.i("LockKeysService", "Scroll Lock:", state, fileView.path);
+ } else if (!emitIfChanged) {
+ root.scrollLockOn = state;
+ }
+ break;
+ }
+ }
+
+ // Only apply after a successful read โ failed reloads must not update UI (empty text is not "off").
+ onLoaded: {
+ if (!fileView.isWanted)
+ return;
+ var state = fileView.parseBrightnessLedOn(fileView.text());
+ if (state === null)
+ return;
+
+ var kind = fileName.split("::")[1];
+
+ // First read after polling starts: sync bar/UI from sysfs without firing
+ // *Changed signals (OSD listens to those and would flash on startup).
+ if (!fileView.initialCheckDone) {
+ fileView.initialCheckDone = true;
+ fileView.applyLockState(kind, state, false);
+ return;
+ }
+
+ fileView.applyLockState(kind, state, true);
+ }
+
+ // FolderListModel only provides filters for file names, not folders
+ property bool isWanted: {
+ if (fileName.startsWith("input") && fileName.includes("::")) {
+ switch (fileName.split("::")[1]) {
+ case "numlock":
+ case "capslock":
+ case "scrolllock":
+ return true;
+ }
+ }
+ return false;
+ }
+
+ // After shouldRun becomes true, first brightness read updates properties only (no *Changed signals).
+ property bool initialCheckDone: false
+ property variant connections: Connections {
+ target: root
+ function onShouldRunChanged() {
+ if (root.shouldRun) {
+ fileView.initialCheckDone = false;
+ }
+ }
+ }
+
+ // sysfs does not provide change notifications
+ property variant refreshTimer: Timer {
+ interval: 200
+ running: root.shouldRun && fileView.isWanted
+ repeat: true
+ onTriggered: fileView.reload()
+ }
+ }
+ }
+ }
+
+ Component.onCompleted: {
+ Logger.i("LockKeysService", "Service started (polling deferred until a consumer registers).");
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Location/Calendar/EvolutionDataServer.qml b/arch/.config/quickshell/noctalia-shell/Services/Location/Calendar/EvolutionDataServer.qml
new file mode 100644
index 0000000..830a243
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Location/Calendar/EvolutionDataServer.qml
@@ -0,0 +1,148 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+import qs.Services.Location
+
+Singleton {
+ id: root
+
+ // Python scripts
+ readonly property string checkCalendarAvailableScript: Quickshell.shellDir + '/Scripts/python/src/calendar/check-calendar.py'
+ readonly property string listCalendarsScript: Quickshell.shellDir + '/Scripts/python/src/calendar/list-calendars.py'
+ readonly property string calendarEventsScript: Quickshell.shellDir + '/Scripts/python/src/calendar/calendar-events.py'
+
+ function init() {
+ availabilityCheckProcess.running = true;
+ }
+ function loadCalendars() {
+ listCalendarsProcess.running = true;
+ }
+ function loadEvents(daysAhead = 31, daysBehind = 14) {
+ CalendarService.loading = true;
+ CalendarService.lastError = "";
+
+ const now = new Date();
+ const startDate = new Date(now.getTime() - (daysBehind * 24 * 60 * 60 * 1000));
+ const endDate = new Date(now.getTime() + (daysAhead * 24 * 60 * 60 * 1000));
+
+ loadEventsProcess.startTime = Math.floor(startDate.getTime() / 1000);
+ loadEventsProcess.endTime = Math.floor(endDate.getTime() / 1000);
+ loadEventsProcess.running = true;
+
+ Logger.d("Calendar", `Loading events (${daysBehind} days behind, ${daysAhead} days ahead): ${startDate.toLocaleDateString()} to ${endDate.toLocaleDateString()}`);
+ }
+
+ // Process to check for evolution-data-server libraries
+ Process {
+ id: availabilityCheckProcess
+ running: false
+ command: ["sh", "-c", "command -v python3 >/dev/null 2>&1 && python3 " + root.checkCalendarAvailableScript + " || echo 'unavailable: python3 not installed'"]
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const result = text.trim();
+ if (result === "available") {
+ CalendarService.dataProvider = CalendarService.dataProvider || root;
+ CalendarService.available = true;
+ }
+
+ if (CalendarService.available) {
+ Logger.i("Calendar", "EDS libraries available");
+ loadCalendars();
+ } else {
+ Logger.w("Calendar", "EDS libraries not available: " + result);
+ CalendarService.lastError = "Evolution Data Server libraries not installed";
+ }
+ }
+ }
+
+ stderr: StdioCollector {
+ onStreamFinished: {
+ if (text.trim()) {
+ Logger.d("Calendar", "Availability check error: " + text);
+ CalendarService.lastError = "Failed to check library availability";
+ }
+ }
+ }
+ }
+
+ // Process to list available calendars
+ Process {
+ id: listCalendarsProcess
+ running: false
+ command: ["python3", root.listCalendarsScript]
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try {
+ const result = JSON.parse(text.trim());
+ CalendarService.setCalendars(result);
+ Logger.d("Calendar", `Found ${result.length} calendar(s)`);
+
+ // Auto-load events after discovering calendars
+ if (result.length > 0) {
+ loadEvents();
+ }
+ } catch (e) {
+ Logger.d("Calendar", "Failed to parse calendars: " + e);
+ CalendarService.lastError = "Failed to parse calendar list";
+ }
+ }
+ }
+
+ stderr: StdioCollector {
+ onStreamFinished: {
+ if (text.trim()) {
+ Logger.d("Calendar", "List calendars error: " + text);
+ CalendarService.lastError = text.trim();
+ }
+ }
+ }
+ }
+
+ // Process to load events
+ Process {
+ id: loadEventsProcess
+ running: false
+ property int startTime: 0
+ property int endTime: 0
+
+ command: ["python3", root.calendarEventsScript, startTime.toString(), endTime.toString()]
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ CalendarService.loading = false;
+
+ try {
+ const events = JSON.parse(text.trim());
+ CalendarService.setEvents(events);
+ Logger.d("Calendar", `Loaded ${events.length} events(s)`);
+ } catch (e) {
+ Logger.d("Calendar", "Failed to parse events: " + e);
+ CalendarService.lastError = "Failed to parse events";
+ // Fall back to cached events
+ CalendarService.loadCachedEvents();
+ Logger.d("Calendar", "Using cached events");
+ }
+ }
+ }
+
+ stderr: StdioCollector {
+ onStreamFinished: {
+ CalendarService.loading = false;
+
+ if (text.trim()) {
+ Logger.d("Calendar", "Load events error: " + text);
+ CalendarService.lastError = text.trim();
+
+ // Fall back to cached events
+ CalendarService.loadCachedEvents();
+ Logger.d("Calendar", "Using cached events");
+ }
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Location/Calendar/Khal.qml b/arch/.config/quickshell/noctalia-shell/Services/Location/Calendar/Khal.qml
new file mode 100644
index 0000000..2e14e23
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Location/Calendar/Khal.qml
@@ -0,0 +1,182 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+import qs.Services.Location
+
+Singleton {
+ id: root
+
+ readonly property string khalEventsScript: Quickshell.shellDir + '/Scripts/python/src/calendar/khal-events.py'
+
+ function init() {
+ availabilityCheckProcess.running = true;
+ }
+ function loadCalendars() {
+ listCalendarsProcess.running = true;
+ }
+ function loadEvents(daysAhead = 31, daysBehind = 14) {
+ CalendarService.loading = true;
+ CalendarService.lastError = "";
+
+ const now = new Date();
+ const startDate = new Date(now.getTime() - (daysBehind * 24 * 60 * 60 * 1000));
+
+ loadEventsProcess.startTime = formatDateYMD(startDate);
+ loadEventsProcess.duration = `${daysAhead + daysBehind}d`;
+ loadEventsProcess.running = true;
+
+ Logger.d("Calendar", `Loading events (${daysBehind} days behind, ${daysAhead} days ahead): ${loadEventsProcess.startTime} ${loadEventsProcess.duration}`);
+ }
+
+ // Process to check for khal installation
+ Process {
+ id: availabilityCheckProcess
+ running: false
+ command: ["sh", "-c", "command -v khal >/dev/null 2>&1 && command -v python3 >/dev/null 2>&1"]
+ onExited: function (exitCode) {
+ if (exitCode === 0) {
+ CalendarService.available = true;
+ CalendarService.dataProvider = CalendarService.dataProvider || root;
+ }
+
+ if (CalendarService.available) {
+ Logger.i("Calendar", "Khal available");
+ loadCalendars();
+ } else {
+ Logger.w("Calendar", "Khal not available");
+ CalendarService.lastError = "khal binary not installed";
+ }
+ }
+ }
+
+ // Process to list available calendars
+ Process {
+ id: listCalendarsProcess
+ running: false
+ command: ["khal", "printcalendars"]
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try {
+ const calendars = text.trim().split("\n");
+ CalendarService.setCalendars(calendars);
+
+ Logger.d("Calendar", `Found ${calendars.length} calendar(s)`);
+
+ // Auto-load events after discovering calendars
+ if (calendars.length > 0) {
+ loadEvents();
+ }
+ } catch (e) {
+ Logger.d("Calendar", "Failed to parse calendars: " + e);
+ CalendarService.lastError = "Failed to parse calendar list";
+ }
+ }
+ }
+
+ stderr: StdioCollector {
+ onStreamFinished: {
+ if (text.trim()) {
+ Logger.d("Calendar", "List calendars error: " + text);
+ CalendarService.lastError = text.trim();
+ }
+ }
+ }
+ }
+
+ // Process to load events
+ Process {
+ id: loadEventsProcess
+ running: false
+ property string startTime: ""
+ property string duration: ""
+
+ command: ["python3", root.khalEventsScript, startTime, duration]
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ CalendarService.loading = false;
+
+ try {
+ const events = parseEvents(text.trim());
+ CalendarService.setEvents(events);
+ Logger.d("Calendar", `Loaded ${events.length} events(s)`);
+ } catch (e) {
+ Logger.d("Calendar", "Failed to parse events: " + e);
+ CalendarService.lastError = "Failed to parse events";
+ // Fall back to cached events
+ CalendarService.loadCachedEvents();
+ Logger.d("Calendar", "Using cached events");
+ }
+ }
+ }
+
+ stderr: StdioCollector {
+ onStreamFinished: {
+ CalendarService.loading = false;
+
+ if (text.trim()) {
+ Logger.d("Calendar", "Load events error: " + text);
+ CalendarService.lastError = text.trim();
+ // Fall back to cached events
+ CalendarService.loadCachedEvents();
+ Logger.d("Calendar", "Using cached events");
+ }
+ }
+ }
+ }
+
+ function parseEvents(text) {
+ const result = [];
+ const duplicates = new Set();
+
+ for (const line of text.split("\n")) {
+ if (!line.trim())
+ continue;
+ const dayEvents = JSON.parse(line);
+ for (const event of dayEvents) {
+ if (event["repeat-pattern"] !== "") {
+ // if there is a repeat pattern, the event must be included each time
+ result.push({
+ uid: event.uid,
+ calendar: event.calendar,
+ summary: event.title,
+ start: parseTimestamp(event["start-long-full"]),
+ end: parseTimestamp(event["end-long-full"]),
+ location: event.location,
+ description: event.description
+ });
+ } else if (!duplicates.has(event.uid)) {
+ // in any other cases, we must remove duplicates using the uid of the event
+ result.push({
+ uid: event.uid,
+ calendar: event.calendar,
+ summary: event.title,
+ start: parseTimestamp(event["start-long-full"]),
+ end: parseTimestamp(event["end-long-full"]),
+ location: event.location,
+ description: event.description
+ });
+ duplicates.add(event.uid);
+ }
+ }
+ }
+
+ return result;
+ }
+
+ function parseTimestamp(timeStr) {
+ // expects ISO8601 format
+ return Math.floor((Date.parse(timeStr)).valueOf() / 1000);
+ }
+
+ function formatDateYMD(date) {
+ const y = date.getFullYear();
+ const m = String(date.getMonth() + 1).padStart(2, "0");
+ const d = String(date.getDate()).padStart(2, "0");
+ return y + "-" + m + "-" + d;
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Location/CalendarService.qml b/arch/.config/quickshell/noctalia-shell/Services/Location/CalendarService.qml
new file mode 100644
index 0000000..7ef3b5f
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Location/CalendarService.qml
@@ -0,0 +1,136 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+import qs.Services.Location.Calendar
+import qs.Services.System
+
+Singleton {
+ id: root
+
+ // Core state
+ property var events: ([])
+ property bool loading: false
+ property bool available: false
+ property string lastError: ""
+ property var calendars: ([])
+
+ property var dataProvider: null
+
+ // Persistent cache
+ property string cacheFile: Settings.cacheDir + "calendar.json"
+
+ // Cache file handling
+ FileView {
+ id: cacheFileView
+ path: root.cacheFile
+ printErrors: false
+
+ JsonAdapter {
+ id: cacheAdapter
+ property var cachedEvents: ([])
+ property var cachedCalendars: ([])
+ property string lastUpdate: ""
+ }
+
+ onLoadFailed: {
+ cacheAdapter.cachedEvents = ([]);
+ cacheAdapter.cachedCalendars = ([]);
+ cacheAdapter.lastUpdate = "";
+ }
+
+ onLoaded: {
+ loadFromCache();
+ }
+ }
+
+ Component.onCompleted: {
+ Logger.i("Calendar", "Service started");
+ loadFromCache();
+ checkAvailability();
+ }
+
+ // Save cache with debounce
+ Timer {
+ id: saveDebounce
+ interval: 1000
+ onTriggered: cacheFileView.writeAdapter()
+ }
+
+ function setEvents(newEvents) {
+ root.events = newEvents;
+ cacheAdapter.cachedEvents = newEvents;
+ cacheAdapter.lastUpdate = new Date().toISOString();
+ saveCache();
+ }
+
+ function setCalendars(newCalendars) {
+ root.calendars = newCalendars;
+ cacheAdapter.cachedCalendars = newCalendars;
+ saveCache();
+ }
+
+ function loadCachedEvents() {
+ if (cacheAdapter.cachedEvents.length > 0) {
+ root.events = cacheAdapter.cachedEvents;
+ }
+ }
+
+ function saveCache() {
+ saveDebounce.restart();
+ }
+
+ // Load events and calendars from cache
+ function loadFromCache() {
+ if (cacheAdapter.cachedEvents && cacheAdapter.cachedEvents.length > 0) {
+ root.events = cacheAdapter.cachedEvents;
+ Logger.d("Calendar", `Loaded ${cacheAdapter.cachedEvents.length} cached event(s)`);
+ }
+
+ if (cacheAdapter.cachedCalendars && cacheAdapter.cachedCalendars.length > 0) {
+ root.calendars = cacheAdapter.cachedCalendars;
+ Logger.d("Calendar", `Loaded ${cacheAdapter.cachedCalendars.length} cached calendar(s)`);
+ }
+
+ if (cacheAdapter.lastUpdate) {
+ Logger.d("Calendar", `Cache last updated: ${cacheAdapter.lastUpdate}`);
+ }
+ }
+
+ // Auto-refresh timer (every 5 minutes)
+ Timer {
+ id: refreshTimer
+ interval: 300000
+ running: true
+ repeat: true
+ onTriggered: loadEvents()
+ }
+
+ // Core functions
+ function checkAvailability() {
+ if (!Settings.data.location.showCalendarEvents) {
+ root.available = false;
+ return;
+ }
+
+ Khal.init();
+ EvolutionDataServer.init();
+ }
+
+ function loadCalendars() {
+ if (!root.available || !dataProvider) {
+ return;
+ }
+
+ dataProvider.loadCalendars();
+ }
+ function loadEvents(daysAhead = 31, daysBehind = 14) {
+ if (!root.available || !dataProvider) {
+ return;
+ }
+
+ dataProvider.loadEvents(daysAhead, daysBehind);
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Location/DarkModeService.qml b/arch/.config/quickshell/noctalia-shell/Services/Location/DarkModeService.qml
new file mode 100644
index 0000000..5eb9f5d
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Location/DarkModeService.qml
@@ -0,0 +1,195 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import qs.Commons
+
+Singleton {
+ id: root
+
+ property bool initComplete: false
+ property bool nextDarkModeState: false
+
+ Connections {
+ target: LocationService.data
+ enabled: Settings.data.colorSchemes.schedulingMode == "location"
+ function onWeatherChanged() {
+ if (LocationService.data.weather !== null) {
+ const changes = root.collectWeatherChanges(LocationService.data.weather);
+ if (!root.initComplete) {
+ root.initComplete = true;
+ root.applyCurrentMode(changes);
+ }
+ root.scheduleNextMode(changes);
+ }
+ }
+ }
+
+ Connections {
+ target: Settings.data.colorSchemes
+ enabled: Settings.data.colorSchemes.schedulingMode == "manual"
+ function onManualSunriseChanged() {
+ const changes = root.collectManualChanges();
+ root.applyCurrentMode(changes);
+ root.scheduleNextMode(changes);
+ }
+ function onManualSunsetChanged() {
+ const changes = root.collectManualChanges();
+ root.applyCurrentMode(changes);
+ root.scheduleNextMode(changes);
+ }
+ }
+
+ Connections {
+ target: Settings.data.colorSchemes
+ function onSchedulingModeChanged() {
+ root.update();
+ }
+ }
+
+ Connections {
+ target: Time
+ function onResumed() {
+ Logger.i("DarkModeService", "System resumed - re-evaluating dark mode");
+ root.update();
+ resumeRetryTimer.restart();
+ }
+ }
+
+ Timer {
+ id: timer
+ onTriggered: {
+ Settings.data.colorSchemes.darkMode = root.nextDarkModeState;
+ root.update();
+ }
+ }
+
+ Timer {
+ id: resumeRetryTimer
+ interval: 2000
+ repeat: false
+ onTriggered: {
+ Logger.i("DarkModeService", "Resume retry - re-evaluating dark mode again");
+ root.update();
+ }
+ }
+
+ function init() {
+ Logger.i("DarkModeService", "Service started");
+ root.update();
+ }
+
+ function update() {
+ if (Settings.data.colorSchemes.schedulingMode == "manual") {
+ const changes = collectManualChanges();
+ initComplete = true;
+ applyCurrentMode(changes);
+ scheduleNextMode(changes);
+ } else if (Settings.data.colorSchemes.schedulingMode == "location" && LocationService.data.weather) {
+ const changes = collectWeatherChanges(LocationService.data.weather);
+ initComplete = true;
+ applyCurrentMode(changes);
+ scheduleNextMode(changes);
+ }
+ }
+
+ function parseTime(timeString) {
+ const parts = timeString.split(":").map(Number);
+ return {
+ "hour": parts[0],
+ "minute": parts[1]
+ };
+ }
+
+ function collectManualChanges() {
+ const sunriseTime = parseTime(Settings.data.colorSchemes.manualSunrise);
+ const sunsetTime = parseTime(Settings.data.colorSchemes.manualSunset);
+
+ const now = new Date();
+ const year = now.getFullYear();
+ const month = now.getMonth();
+ const day = now.getDate();
+
+ const yesterdaysSunset = new Date(year, month, day - 1, sunsetTime.hour, sunsetTime.minute);
+ const todaysSunrise = new Date(year, month, day, sunriseTime.hour, sunriseTime.minute);
+ const todaysSunset = new Date(year, month, day, sunsetTime.hour, sunsetTime.minute);
+ const tomorrowsSunrise = new Date(year, month, day + 1, sunriseTime.hour, sunriseTime.minute);
+
+ return [
+ {
+ "time": yesterdaysSunset.getTime(),
+ "darkMode": true
+ },
+ {
+ "time": todaysSunrise.getTime(),
+ "darkMode": false
+ },
+ {
+ "time": todaysSunset.getTime(),
+ "darkMode": true
+ },
+ {
+ "time": tomorrowsSunrise.getTime(),
+ "darkMode": false
+ }
+ ];
+ }
+
+ function collectWeatherChanges(weather) {
+ const changes = [];
+
+ if (Date.now() < Date.parse(weather.daily.sunrise[0])) {
+ // The sun has not risen yet
+ changes.push({
+ "time": Date.now() - 1,
+ "darkMode": true
+ });
+ }
+
+ for (var i = 0; i < weather.daily.sunrise.length; i++) {
+ changes.push({
+ "time": Date.parse(weather.daily.sunrise[i]),
+ "darkMode": false
+ });
+ changes.push({
+ "time": Date.parse(weather.daily.sunset[i]),
+ "darkMode": true
+ });
+ }
+
+ return changes;
+ }
+
+ function applyCurrentMode(changes) {
+ const now = Date.now();
+ Logger.i("DarkModeService", `Applying mode at ${new Date(now).toLocaleString()} (${now})`);
+
+ // changes.findLast(change => change.time < now) // not available in QML...
+ let lastChange = null;
+ for (var i = 0; i < changes.length; i++) {
+ Logger.d("DarkModeService", `Checking change: time=${changes[i].time} (${new Date(changes[i].time).toLocaleString()}), darkMode=${changes[i].darkMode}`);
+ if (changes[i].time < now) {
+ lastChange = changes[i];
+ }
+ }
+
+ if (lastChange) {
+ Logger.i("DarkModeService", `Selected change: time=${lastChange.time}, darkMode=${lastChange.darkMode}`);
+ Settings.data.colorSchemes.darkMode = lastChange.darkMode;
+ Logger.d("DarkModeService", `Reset: darkmode=${lastChange.darkMode}`);
+ } else {
+ Logger.w("DarkModeService", "No suitable change found for current time!");
+ }
+ }
+
+ function scheduleNextMode(changes) {
+ const now = Date.now();
+ const nextChange = changes.find(change => change.time > now);
+ if (nextChange) {
+ root.nextDarkModeState = nextChange.darkMode;
+ timer.interval = nextChange.time - now;
+ timer.restart();
+ Logger.d("DarkModeService", `Scheduled: darkmode=${nextChange.darkMode} in ${timer.interval} ms`);
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Location/LocationService.qml b/arch/.config/quickshell/noctalia-shell/Services/Location/LocationService.qml
new file mode 100644
index 0000000..b48baf8
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Location/LocationService.qml
@@ -0,0 +1,397 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+
+// Location and weather service with decoupled geocoding and weather fetching.
+Singleton {
+ id: root
+
+ property string locationFile: Quickshell.env("NOCTALIA_WEATHER_FILE") || (Settings.cacheDir + "location.json")
+ property int weatherUpdateFrequency: 30 * 60
+ property bool isFetchingWeather: false
+
+ // Talia weather
+ readonly property int taliaMascotWeatherMonth: 3
+ readonly property int taliaMascotWeatherDay: 1
+
+ readonly property bool taliaWeatherMascotDayActive: {
+ const d = Time.now;
+ return d.getMonth() === root.taliaMascotWeatherMonth && d.getDate() === root.taliaMascotWeatherDay;
+ }
+
+ readonly property bool taliaWeatherMascotActive: taliaWeatherMascotDayActive || Settings.data.location.weatherTaliaMascotAlways
+
+ readonly property alias data: adapter
+
+ // True when the user has set a location name or enabled auto-locate
+ readonly property bool locationConfigured: Settings.data.location.name !== "" || Settings.data.location.autoLocate
+
+ // Stable UI properties - only updated when location is successfully geocoded
+ property bool coordinatesReady: false
+ property string stableLatitude: ""
+ property string stableLongitude: ""
+ property string stableName: ""
+
+ FileView {
+ id: locationFileView
+ path: locationFile
+ printErrors: false
+ onAdapterUpdated: saveTimer.start()
+ onLoaded: {
+ Logger.d("Location", "Loaded cached data");
+ if (adapter.latitude !== "" && adapter.longitude !== "" && adapter.weatherLastFetch > 0) {
+ root.stableLatitude = adapter.latitude;
+ root.stableLongitude = adapter.longitude;
+ root.stableName = adapter.name;
+ root.coordinatesReady = true;
+ Logger.i("Location", "Coordinates ready");
+ }
+ update();
+ }
+ onLoadFailed: function (error) {
+ update();
+ }
+
+ JsonAdapter {
+ id: adapter
+ property string latitude: ""
+ property string longitude: ""
+ property string name: ""
+ property int weatherLastFetch: 0
+ property var weather: null
+ }
+ }
+
+ // Formatted coordinates for UI display
+ readonly property string displayCoordinates: {
+ if (!root.coordinatesReady || root.stableLatitude === "" || root.stableLongitude === "") {
+ return "";
+ }
+ const lat = parseFloat(root.stableLatitude).toFixed(4);
+ const lon = parseFloat(root.stableLongitude).toFixed(4);
+ return `${lat}, ${lon}`;
+ }
+
+ // Auto-geolocate timer - periodically updates location via IP geolocation
+ Timer {
+ id: autoLocateTimer
+ interval: 30 * 60 * 1000
+ running: Settings.data.location.autoLocate
+ repeat: true
+ triggeredOnStart: true
+ onTriggered: root.geolocateAndApply()
+ }
+
+ // Update timer runs when weather is enabled or location-based scheduling is active
+ Timer {
+ id: updateTimer
+ interval: 20 * 1000
+ running: Settings.data.location.weatherEnabled || Settings.data.colorSchemes.schedulingMode == "location"
+ repeat: true
+ onTriggered: {
+ update();
+ }
+ }
+
+ Timer {
+ id: saveTimer
+ running: false
+ interval: 1000
+ onTriggered: locationFileView.writeAdapter()
+ }
+
+ function init() {
+ Logger.i("Location", "Service started");
+ }
+
+ function resetWeather() {
+ Logger.i("Location", "Resetting location and weather data");
+
+ root.coordinatesReady = false;
+ root.stableLatitude = "";
+ root.stableLongitude = "";
+ root.stableName = "";
+
+ adapter.latitude = "";
+ adapter.longitude = "";
+ adapter.name = "";
+ adapter.weatherLastFetch = 0;
+ adapter.weather = null;
+ isFetchingWeather = false;
+ update();
+ }
+
+ // Main update function - geocodes location if needed, then fetches weather if enabled
+ function update() {
+ updateLocation();
+
+ if (Settings.data.location.weatherEnabled) {
+ updateWeatherData();
+ }
+ }
+
+ // Runs independently of weather toggle
+ function updateLocation() {
+ const locationChanged = adapter.name !== Settings.data.location.name;
+ const needsGeocoding = (adapter.latitude === "") || (adapter.longitude === "") || locationChanged;
+
+ if (!needsGeocoding) {
+ return;
+ }
+
+ if (isFetchingWeather) {
+ return;
+ }
+
+ isFetchingWeather = true;
+
+ if (locationChanged) {
+ root.coordinatesReady = false;
+ Logger.d("Location", "Location changed from", adapter.name, "to", Settings.data.location.name);
+ }
+
+ geocodeLocation(Settings.data.location.name, function (latitude, longitude, name, country) {
+ adapter.name = Settings.data.location.name;
+ adapter.latitude = latitude.toString();
+ adapter.longitude = longitude.toString();
+ root.stableLatitude = adapter.latitude;
+ root.stableLongitude = adapter.longitude;
+ root.stableName = `${name}, ${country}`;
+ root.coordinatesReady = true;
+
+ isFetchingWeather = false;
+ Logger.i("Location", `Geocoded ${Settings.data.location.name}: ${root.stableLatitude}, ${root.stableLongitude}`);
+
+ if (locationChanged) {
+ adapter.weatherLastFetch = 0;
+ updateWeatherData();
+ }
+ }, errorCallback);
+ }
+
+ // Fetch weather data if enabled and coordinates are available
+ function updateWeatherData() {
+ if (!Settings.data.location.weatherEnabled) {
+ return;
+ }
+
+ if (isFetchingWeather) {
+ return;
+ }
+
+ if (adapter.latitude === "" || adapter.longitude === "") {
+ Logger.w("Location", "Cannot fetch weather without coordinates");
+ return;
+ }
+ const needsWeatherUpdate = (adapter.weatherLastFetch === "") || (adapter.weather === null) || (Time.timestamp >= adapter.weatherLastFetch + weatherUpdateFrequency);
+
+ if (needsWeatherUpdate) {
+ isFetchingWeather = true;
+ fetchWeatherData(adapter.latitude, adapter.longitude, errorCallback);
+ }
+ }
+
+ // Query geocoding API to convert location name to coordinates
+ function geocodeLocation(locationName, callback, errorCallback) {
+ if (locationName === "") {
+ isFetchingWeather = false;
+ return;
+ }
+
+ Logger.d("Location", "Geocoding location name");
+ var geoUrl = "https://api.noctalia.dev/geocode?city=" + encodeURIComponent(locationName);
+ var xhr = new XMLHttpRequest();
+ xhr.onreadystatechange = function () {
+ if (xhr.readyState === XMLHttpRequest.DONE) {
+ if (xhr.status === 200) {
+ try {
+ var geoData = JSON.parse(xhr.responseText);
+ if (geoData.lat != null) {
+ callback(geoData.lat, geoData.lng, geoData.name, geoData.country);
+ } else {
+ errorCallback("Location", "could not resolve location name");
+ }
+ } catch (e) {
+ errorCallback("Location", "Failed to parse geocoding data: " + e);
+ }
+ } else {
+ errorCallback("Location", `Geocoding error: ${xhr.status} ${xhr.responseText}`);
+ }
+ }
+ };
+ xhr.open("GET", geoUrl);
+ xhr.send();
+ }
+
+ // Fetch weather data from Open-Meteo API
+ function fetchWeatherData(latitude, longitude, errorCallback) {
+ Logger.d("Location", "Fetching weather from api.open-meteo.com");
+ var url = "https://api.open-meteo.com/v1/forecast?latitude=" + latitude + "&longitude=" + longitude + "¤t_weather=true¤t=relativehumidity_2m,surface_pressure,is_day&daily=temperature_2m_max,temperature_2m_min,weathercode,sunset,sunrise&timezone=auto";
+ var xhr = new XMLHttpRequest();
+ xhr.onreadystatechange = function () {
+ if (xhr.readyState === XMLHttpRequest.DONE) {
+ if (xhr.status === 200) {
+ try {
+ var weatherData = JSON.parse(xhr.responseText);
+ //console.log(JSON.stringify(weatherData))
+
+ // Save core data
+ data.weather = weatherData;
+ data.weatherLastFetch = Time.timestamp;
+
+ // Update stable display values only when complete and successful
+ root.stableLatitude = data.latitude = weatherData.latitude.toString();
+ root.stableLongitude = data.longitude = weatherData.longitude.toString();
+ root.coordinatesReady = true;
+
+ isFetchingWeather = false;
+ Logger.d("Location", "Cached weather to disk - stable coordinates updated");
+ } catch (e) {
+ errorCallback("Location", "Failed to parse weather data");
+ }
+ } else {
+ errorCallback("Location", `Weather error: ${xhr.status} ${xhr.responseText}`);
+ }
+ }
+ };
+ xhr.open("GET", url);
+ xhr.send();
+ }
+
+ // Geolocate via IP address using the Noctalia API
+ function geolocate(callback, errorCallback) {
+ Logger.d("Location", "Geolocating via IP");
+ var url = "https://api.noctalia.dev/geolocate";
+ var xhr = new XMLHttpRequest();
+ xhr.onreadystatechange = function () {
+ if (xhr.readyState === XMLHttpRequest.DONE) {
+ if (xhr.status === 200) {
+ try {
+ var data = JSON.parse(xhr.responseText);
+ if (data.lat != null) {
+ callback(data.lat, data.lng, data.city, data.country);
+ } else {
+ errorCallback("Location", "Geolocate: no coordinates returned");
+ }
+ } catch (e) {
+ errorCallback("Location", "Failed to parse geolocate data: " + e);
+ }
+ } else {
+ errorCallback("Location", `Geolocate error: ${xhr.status} ${xhr.responseText}`);
+ }
+ }
+ };
+ xhr.open("GET", url);
+ xhr.send();
+ }
+
+ // Geolocate via IP and apply the result as the current location
+ function geolocateAndApply() {
+ if (isFetchingWeather) {
+ Logger.w("Location", "Geolocate skipped, fetch already in progress");
+ return;
+ }
+ geolocate(function (lat, lng, city, country) {
+ Logger.i("Location", "Geolocated to", city + ",", country + ":", lat + "," + lng);
+
+ const locationChanged = adapter.name !== city;
+ Settings.data.location.name = city;
+ adapter.name = city;
+ adapter.latitude = lat.toString();
+ adapter.longitude = lng.toString();
+ root.stableLatitude = adapter.latitude;
+ root.stableLongitude = adapter.longitude;
+ root.stableName = `${city}, ${country}`;
+ root.coordinatesReady = true;
+
+ if (locationChanged) {
+ adapter.weatherLastFetch = 0;
+ adapter.weather = null;
+ }
+
+ if (Settings.data.location.weatherEnabled) {
+ updateWeatherData();
+ }
+ }, errorCallback);
+ }
+
+ // --------------------------------
+ function errorCallback(module, message) {
+ Logger.w(module, message);
+ isFetchingWeather = false;
+ }
+
+ // --------------------------------
+ function weatherSymbolFromCode(code) {
+ var isDay = data.weather ? data.weather.current_weather.is_day : true;
+ if (code === 0)
+ return isDay ? "weather-sun" : "weather-moon";
+ if (code === 1 || code === 2)
+ return isDay ? "weather-cloud-sun" : "weather-moon-stars";
+ if (code === 3)
+ return "weather-cloud";
+ if (code >= 45 && code <= 48)
+ return "weather-cloud-haze";
+ if (code >= 51 && code <= 67)
+ return "weather-cloud-rain";
+ if (code >= 80 && code <= 82)
+ return "weather-cloud-rain";
+ if (code >= 71 && code <= 77)
+ return "weather-cloud-snow";
+ if (code >= 71 && code <= 77)
+ return "weather-cloud-snow";
+ if (code >= 85 && code <= 86)
+ return "weather-cloud-snow";
+ if (code >= 95 && code <= 99)
+ return "weather-cloud-lightning";
+ return "weather-cloud";
+ }
+
+ // --------------------------------
+ function taliaWeatherImageFromCode(code) {
+ var isDay = data.weather ? data.weather.current_weather.is_day : true;
+ if (code >= 40 && code <= 49)
+ return Quickshell.shellDir + "/Assets/Talia/TaliaDazed.png";
+ if (code >= 95 && code <= 99)
+ return Quickshell.shellDir + "/Assets/Talia/TaliaFear.png";
+ var wet = (code >= 51 && code <= 67) || (code >= 80 && code <= 82) || (code >= 71 && code <= 77) || (code >= 85 && code <= 86);
+ if (wet)
+ return Quickshell.shellDir + "/Assets/Talia/TaliaSob.png";
+ if ((code === 0 || code === 1 || code === 2) && isDay === false)
+ return Quickshell.shellDir + "/Assets/Talia/TaliaVampire.png";
+ if ((code === 0 && isDay === true) || code === 1 || code === 2)
+ return Quickshell.shellDir + "/Assets/Talia/TaliaParty.png";
+ return Quickshell.shellDir + "/Assets/Talia/TaliaBlank.png";
+ }
+
+ // --------------------------------
+ function weatherDescriptionFromCode(code) {
+ if (code === 0)
+ return "Clear sky";
+ if (code === 1)
+ return "Mainly clear";
+ if (code === 2)
+ return "Partly cloudy";
+ if (code === 3)
+ return "Overcast";
+ if (code === 45 || code === 48)
+ return "Fog";
+ if (code >= 51 && code <= 67)
+ return "Drizzle";
+ if (code >= 71 && code <= 77)
+ return "Snow";
+ if (code >= 80 && code <= 82)
+ return "Rain showers";
+ if (code >= 95 && code <= 99)
+ return "Thunderstorm";
+ return "Unknown";
+ }
+
+ // --------------------------------
+ function celsiusToFahrenheit(celsius) {
+ return 32 + celsius * 1.8;
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Location/NightLightService.qml b/arch/.config/quickshell/noctalia-shell/Services/Location/NightLightService.qml
new file mode 100644
index 0000000..16f6a0f
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Location/NightLightService.qml
@@ -0,0 +1,290 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+import qs.Services.UI
+
+Singleton {
+ id: root
+
+ // Night Light properties - directly bound to settings
+ readonly property var params: Settings.data.nightLight
+ property var lastCommand: []
+
+ // Crash tracking for auto-restart
+ property int _crashCount: 0
+ property int _maxCrashes: 5
+
+ // Manual schedule tracking
+ property bool _manualNightPhase: false
+
+ // Kill any stale wlsunset processes on startup to prevent issues after shell restart
+ Component.onCompleted: {
+ killStaleProcess.running = true;
+ }
+
+ Process {
+ id: killStaleProcess
+ running: false
+ command: ["pkill", "-x", "wlsunset"]
+ onExited: function (code, status) {
+ if (code === 0) {
+ Logger.i("NightLight", "Killed stale wlsunset process from previous session");
+ }
+ // Now apply the settings after cleanup
+ root.apply();
+ }
+ }
+
+ Timer {
+ id: restartTimer
+ interval: 2000
+ repeat: false
+ onTriggered: {
+ if (root.params.enabled && !runner.running) {
+ Logger.w("NightLight", "Restarting after crash...");
+ if (root.isManualMode()) {
+ root.applyManualSchedule();
+ } else {
+ runner.running = true;
+ }
+ }
+ }
+ }
+
+ Timer {
+ id: manualScheduleTimer
+ repeat: false
+ onTriggered: {
+ Logger.i("NightLight", "Manual schedule: phase boundary reached");
+ root.applyManualSchedule();
+ }
+ }
+
+ function timeToMinutes(timeStr) {
+ var parts = timeStr.split(":").map(Number);
+ return parts[0] * 60 + parts[1];
+ }
+
+ function isManualMode() {
+ return !params.forced && !params.autoSchedule;
+ }
+
+ function isCurrentlyNight() {
+ var now = new Date();
+ var nowMin = now.getHours() * 60 + now.getMinutes();
+ var sunsetMin = timeToMinutes(params.manualSunset);
+ var sunriseMin = timeToMinutes(params.manualSunrise);
+
+ if (sunsetMin < sunriseMin) {
+ // Inverted: e.g. sunset=03:00, sunrise=07:00 โ night is [03:00, 07:00)
+ return nowMin >= sunsetMin && nowMin < sunriseMin;
+ } else {
+ // Normal: e.g. sunset=18:00, sunrise=06:00 โ night is [18:00, 06:00)
+ return nowMin >= sunsetMin || nowMin < sunriseMin;
+ }
+ }
+
+ function msUntilNextBoundary() {
+ var now = new Date();
+ var nowMin = now.getHours() * 60 + now.getMinutes();
+ var sunsetMin = timeToMinutes(params.manualSunset);
+ var sunriseMin = timeToMinutes(params.manualSunrise);
+
+ var targetMin = isCurrentlyNight() ? sunriseMin : sunsetMin;
+ var diffMin = targetMin - nowMin;
+ if (diffMin <= 0)
+ diffMin += 1440;
+
+ return diffMin * 60 * 1000 - now.getSeconds() * 1000 - now.getMilliseconds();
+ }
+
+ function applyManualSchedule() {
+ if (!params.enabled) {
+ manualScheduleTimer.stop();
+ runner.running = false;
+ return;
+ }
+
+ var night = isCurrentlyNight();
+ _manualNightPhase = night;
+
+ if (night) {
+ var cmd = ["wlsunset"];
+ cmd.push("-t", `${params.nightTemp}`, "-T", `${params.dayTemp}`);
+ cmd.push("-S", "23:59");
+ cmd.push("-s", "00:00");
+ cmd.push("-d", 1);
+
+ if (JSON.stringify(cmd) !== JSON.stringify(lastCommand) || !runner.running) {
+ lastCommand = cmd;
+ runner.command = cmd;
+ runner.running = false;
+ runner.running = true;
+ }
+ Logger.i("NightLight", "Manual schedule: night phase - wlsunset forced on");
+ } else {
+ lastCommand = [];
+ runner.running = false;
+ Logger.i("NightLight", "Manual schedule: day phase - wlsunset stopped");
+ }
+
+ var ms = msUntilNextBoundary();
+ manualScheduleTimer.interval = Math.max(ms, 1000);
+ manualScheduleTimer.restart();
+ Logger.i("NightLight", "Manual schedule: next boundary in " + Math.round(ms / 1000) + "s");
+ }
+
+ function apply(force = false) {
+ // If using LocationService, wait for it to be ready
+ if (!params.forced && params.autoSchedule && !LocationService.coordinatesReady) {
+ return;
+ }
+
+ // Manual mode: handle scheduling ourselves
+ if (isManualMode() && params.enabled) {
+ _crashCount = 0;
+ restartTimer.stop();
+ applyManualSchedule();
+ return;
+ }
+
+ // Not in manual mode - clean up manual timer
+ manualScheduleTimer.stop();
+
+ var command = buildCommand();
+
+ // Compare with previous command to avoid unnecessary restart
+ if (force || JSON.stringify(command) !== JSON.stringify(lastCommand)) {
+ lastCommand = command;
+ runner.command = command;
+
+ // Set running to false so it may restart below if still enabled
+ runner.running = false;
+ }
+ runner.running = params.enabled;
+ }
+
+ function buildCommand() {
+ var cmd = ["wlsunset"];
+ if (params.forced) {
+ // Force immediate full night temperature regardless of time
+ // Keep distinct day/night temps but set times so we're effectively always in "night"
+ cmd.push("-t", `${params.nightTemp}`, "-T", `${params.dayTemp}`);
+ // Night spans from sunset (00:00) to sunrise (23:59) covering almost the full day
+ cmd.push("-S", "23:59"); // sunrise very late
+ cmd.push("-s", "00:00"); // sunset at midnight
+ // Near-instant transition
+ cmd.push("-d", 1);
+ } else if (params.autoSchedule) {
+ cmd.push("-t", `${params.nightTemp}`, "-T", `${params.dayTemp}`);
+ cmd.push("-l", `${LocationService.stableLatitude}`, "-L", `${LocationService.stableLongitude}`);
+ cmd.push("-d", 60 * 15); // 15min progressive fade at sunset/sunrise
+ }
+ return cmd;
+ }
+
+ // Observe setting changes and location readiness
+ Connections {
+ target: Settings.data.nightLight
+ function onEnabledChanged() {
+ apply();
+ // Toast: night light toggled
+ const enabled = !!Settings.data.nightLight.enabled;
+ ToastService.showNotice(I18n.tr("common.night-light"), enabled ? I18n.tr("common.enabled") : I18n.tr("common.disabled"), enabled ? "nightlight-on" : "nightlight-off");
+ }
+ function onForcedChanged() {
+ apply();
+ if (Settings.data.nightLight.enabled) {
+ ToastService.showNotice(I18n.tr("common.night-light"), Settings.data.nightLight.forced ? I18n.tr("toast.night-light.forced") : I18n.tr("toast.night-light.normal"), Settings.data.nightLight.forced ? "nightlight-forced" : "nightlight-on");
+ }
+ }
+ function onNightTempChanged() {
+ apply();
+ }
+ function onDayTempChanged() {
+ apply();
+ }
+ function onManualSunriseChanged() {
+ apply();
+ }
+ function onManualSunsetChanged() {
+ apply();
+ }
+ function onAutoScheduleChanged() {
+ apply();
+ }
+ }
+
+ Connections {
+ target: LocationService
+ function onCoordinatesReadyChanged() {
+ if (LocationService.coordinatesReady) {
+ root.apply();
+ }
+ }
+ }
+
+ Timer {
+ id: resumeRetryTimer
+ interval: 2000
+ repeat: false
+ onTriggered: {
+ Logger.i("NightLight", "Resume retry - re-applying night light again");
+ root.apply(true);
+ }
+ }
+
+ Connections {
+ target: Time
+ function onResumed() {
+ Logger.i("NightLight", "System resumed - re-applying night light");
+ root.apply(true);
+ resumeRetryTimer.restart();
+ }
+ }
+
+ // Foreground process runner
+ Process {
+ id: runner
+ running: false
+ onStarted: {
+ Logger.i("NightLight", "Wlsunset started:", runner.command);
+ // Reset crash count on successful start
+ if (root._crashCount > 0) {
+ root._crashCount = 0;
+ }
+ }
+ onExited: function (code, status) {
+ if (root.params.enabled && root.isManualMode()) {
+ // Manual mode: only treat as crash if we're in the night phase
+ if (root._manualNightPhase) {
+ root._crashCount++;
+ if (root._crashCount <= root._maxCrashes) {
+ Logger.w("NightLight", "Wlsunset exited unexpectedly during manual night phase (code: " + code + "), restarting in 2s... (attempt " + root._crashCount + "/" + root._maxCrashes + ")");
+ restartTimer.start();
+ } else {
+ Logger.e("NightLight", "Wlsunset crashed too many times (" + root._maxCrashes + "), giving up");
+ }
+ } else {
+ Logger.i("NightLight", "Wlsunset exited (manual day phase):", code, status);
+ root._crashCount = 0;
+ }
+ } else if (root.params.enabled) {
+ // Non-manual mode: any exit while enabled is a crash
+ root._crashCount++;
+ if (root._crashCount <= root._maxCrashes) {
+ Logger.w("NightLight", "Wlsunset exited unexpectedly (code: " + code + "), restarting in 2s... (attempt " + root._crashCount + "/" + root._maxCrashes + ")");
+ restartTimer.start();
+ } else {
+ Logger.e("NightLight", "Wlsunset crashed too many times (" + root._maxCrashes + "), giving up");
+ }
+ } else {
+ Logger.i("NightLight", "Wlsunset exited (disabled):", code, status);
+ root._crashCount = 0;
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Media/AudioService.qml b/arch/.config/quickshell/noctalia-shell/Services/Media/AudioService.qml
new file mode 100644
index 0000000..fffdb1c
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Media/AudioService.qml
@@ -0,0 +1,1002 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import Quickshell.Services.Pipewire
+import qs.Commons
+import qs.Services.System
+
+Singleton {
+ id: root
+
+ // Rate limiting for volume feedback (minimum 100ms between sounds)
+ property var lastVolumeFeedbackTime: 0
+ readonly property int minVolumeFeedbackInterval: 100
+
+ // Track the last sink that produced volume feedback so we can suppress the
+ // initial onVolumeChanged that fires on startup and device switches.
+ property PwNode _lastFeedbackSink: null
+
+ // Devices
+ readonly property var sink: Pipewire.ready ? Pipewire.defaultAudioSink : null
+ readonly property var source: validatedSource
+ readonly property bool hasInput: !!source
+ readonly property list sinks: deviceNodes.sinks
+ readonly property list sources: deviceNodes.sources
+ readonly property real maxVolume: Settings.data.audio.volumeOverdrive ? 1.5 : 1.0
+ readonly property real epsilon: 0.005
+
+ // Fallback state sourced from wpctl when PipeWire node values go stale.
+ property bool wpctlAvailable: false
+ property bool wpctlStateValid: false
+ property real wpctlOutputVolume: 0
+ property bool wpctlOutputMuted: true
+ property bool wpctlInputStateValid: false
+ property real wpctlInputVolume: 0
+ property bool wpctlInputMuted: true
+
+ signal volumeAtMaximum
+ signal volumeAtMinimum
+
+ function clampOutputVolume(vol: real): real {
+ if (vol === undefined || isNaN(vol)) {
+ return 0;
+ }
+ return Math.max(0, Math.min(root.maxVolume, vol));
+ }
+
+ function refreshWpctlOutputState(): void {
+ if (!wpctlAvailable || wpctlStateProcess.running) {
+ return;
+ }
+ wpctlStateProcess.command = ["wpctl", "get-volume", "@DEFAULT_AUDIO_SINK@"];
+ wpctlStateProcess.running = true;
+ }
+
+ function refreshWpctlInputState(): void {
+ if (!wpctlAvailable || wpctlInputStateProcess.running) {
+ return;
+ }
+ wpctlInputStateProcess.command = ["wpctl", "get-volume", "@DEFAULT_AUDIO_SOURCE@"];
+ wpctlInputStateProcess.running = true;
+ }
+
+ function applyWpctlOutputState(raw: string): bool {
+ const text = String(raw || "").trim();
+ const match = text.match(/Volume:\s*([0-9]*\.?[0-9]+)/i);
+ if (!match || match.length < 2) {
+ return false;
+ }
+
+ const parsedVolume = Number(match[1]);
+ if (isNaN(parsedVolume)) {
+ return false;
+ }
+
+ wpctlOutputVolume = clampOutputVolume(parsedVolume);
+ wpctlOutputMuted = /\[MUTED\]/i.test(text);
+ wpctlStateValid = true;
+ return true;
+ }
+
+ function applyWpctlInputState(raw: string): bool {
+ const text = String(raw || "").trim();
+ const match = text.match(/Volume:\s*([0-9]*\.?[0-9]+)/i);
+ if (!match || match.length < 2) {
+ return false;
+ }
+
+ const parsedVolume = Number(match[1]);
+ if (isNaN(parsedVolume)) {
+ return false;
+ }
+
+ wpctlInputVolume = Math.max(0, Math.min(root.maxVolume, parsedVolume));
+ wpctlInputMuted = /\[MUTED\]/i.test(text);
+ wpctlInputStateValid = true;
+ return true;
+ }
+
+ // Output volume (prefer wpctl state when available)
+ readonly property real volume: {
+ if (wpctlAvailable && wpctlStateValid) {
+ return clampOutputVolume(wpctlOutputVolume);
+ }
+
+ if (!sink?.audio) {
+ return 0;
+ }
+ return clampOutputVolume(sink.audio.volume);
+ }
+ readonly property bool muted: {
+ if (wpctlAvailable && wpctlStateValid) {
+ return wpctlOutputMuted;
+ }
+ return sink?.audio?.muted ?? true;
+ }
+
+ // Input volume (prefer wpctl state when available โ matches set-volume % round-trip)
+ readonly property real inputVolume: {
+ if (wpctlAvailable && wpctlInputStateValid) {
+ return Math.max(0, Math.min(root.maxVolume, wpctlInputVolume));
+ }
+ if (!source?.audio) {
+ return 0;
+ }
+ const vol = source.audio.volume;
+ if (vol === undefined || isNaN(vol)) {
+ return 0;
+ }
+ return Math.max(0, Math.min(root.maxVolume, vol));
+ }
+ readonly property bool inputMuted: {
+ if (wpctlAvailable && wpctlInputStateValid) {
+ return wpctlInputMuted;
+ }
+ return source?.audio?.muted ?? true;
+ }
+
+ // Allow callers to skip the next OSD notification when they are already
+ // presenting volume state (e.g. the Audio Panel UI). We track this as a short
+ // time window so suppression applies to every monitor, not just the first one
+ // that receives the signal.
+ property double outputOSDSuppressedUntilMs: 0
+ property double inputOSDSuppressedUntilMs: 0
+
+ function suppressOutputOSD(durationMs = 400) {
+ const target = Date.now() + durationMs;
+ outputOSDSuppressedUntilMs = Math.max(outputOSDSuppressedUntilMs, target);
+ }
+
+ function suppressInputOSD(durationMs = 400) {
+ const target = Date.now() + durationMs;
+ inputOSDSuppressedUntilMs = Math.max(inputOSDSuppressedUntilMs, target);
+ }
+
+ function consumeOutputOSDSuppression(): bool {
+ return Date.now() < outputOSDSuppressedUntilMs;
+ }
+
+ function consumeInputOSDSuppression(): bool {
+ return Date.now() < inputOSDSuppressedUntilMs;
+ }
+
+ readonly property real stepVolume: Settings.data.audio.volumeStep / 100.0
+
+ // Filtered device nodes (non-stream sinks and sources)
+ readonly property var deviceNodes: Pipewire.ready ? Pipewire.nodes.values.reduce((acc, node) => {
+ if (!node.isStream) {
+ // Filter out quickshell nodes (unlikely to be devices, but for consistency)
+ const name = node.name || "";
+ const mediaName = (node.properties && node.properties["media.name"]) || "";
+ if (name === "quickshell" || mediaName === "quickshell") {
+ return acc;
+ }
+
+ if (node.isSink) {
+ acc.sinks.push(node);
+ } else if (node.audio) {
+ acc.sources.push(node);
+ }
+ }
+ return acc;
+ }, {
+ "sources": [],
+ "sinks": []
+ }) : {
+ "sources": [],
+ "sinks": []
+ }
+
+ // Validated source (ensures it's a proper audio source, not a sink)
+ readonly property var validatedSource: {
+ if (!Pipewire.ready) {
+ return null;
+ }
+ const raw = Pipewire.defaultAudioSource;
+ if (!raw || raw.isSink || !raw.audio) {
+ return null;
+ }
+ // Optional: check type if available (type reflects media.class per docs)
+ if (raw.type && typeof raw.type === "string" && !raw.type.startsWith("Audio/Source")) {
+ return null;
+ }
+ return raw;
+ }
+
+ // Internal state for feedback loop prevention
+ property bool isSettingOutputVolume: false
+ property bool isSettingInputVolume: false
+
+ // Bind default sink and source to ensure their properties are available
+ PwObjectTracker {
+ id: sinkTracker
+ objects: root.sink ? [root.sink] : []
+ }
+
+ PwObjectTracker {
+ id: sourceTracker
+ objects: root.source ? [root.source] : []
+ }
+
+ // Track links to the default sink to find active streams
+ PwNodeLinkTracker {
+ id: sinkLinkTracker
+ }
+
+ onSinkChanged: {
+ if (root.sink) {
+ sinkLinkTracker.node = root.sink;
+ }
+ }
+
+ // Track all streams globally to prevent binding loops for filtered out streams
+ readonly property var streamNodes: Pipewire.ready ? Pipewire.nodes.values.filter(n => n && n.isStream) : []
+
+ // Find application streams that are connected to the default sink
+ readonly property var appStreams: {
+ if (!Pipewire.ready || !root.sink) {
+ return [];
+ }
+
+ var connectedStreamIds = {};
+ var connectedStreams = [];
+
+ // Use PwNodeLinkTracker to get properly bound link groups
+ if (!sinkLinkTracker.linkGroups) {
+ return [];
+ }
+
+ var linkGroupsCount = 0;
+ if (sinkLinkTracker.linkGroups.length !== undefined) {
+ linkGroupsCount = sinkLinkTracker.linkGroups.length;
+ } else if (sinkLinkTracker.linkGroups.count !== undefined) {
+ linkGroupsCount = sinkLinkTracker.linkGroups.count;
+ } else {
+ return [];
+ }
+
+ if (linkGroupsCount === 0) {
+ return [];
+ }
+
+ var intermediateNodeIds = {};
+ var nodesToCheck = [];
+
+ for (var i = 0; i < linkGroupsCount; i++) {
+ var linkGroup;
+ if (sinkLinkTracker.linkGroups.get) {
+ linkGroup = sinkLinkTracker.linkGroups.get(i);
+ } else {
+ linkGroup = sinkLinkTracker.linkGroups[i];
+ }
+
+ if (!linkGroup || !linkGroup.source) {
+ continue;
+ }
+
+ var sourceNode = linkGroup.source;
+
+ // Filter out quickshell
+ const name = sourceNode.name || "";
+ const mediaName = (sourceNode.properties && sourceNode.properties["media.name"]) || "";
+ if (name === "quickshell" || mediaName === "quickshell") {
+ continue;
+ }
+
+ // Filter out filter (intermediate) streams
+ const isVirtual = (sourceNode.properties && sourceNode.properties["node.virtual"]) || "";
+ // If it's an application stream node, add it directly
+ if (sourceNode.isStream && sourceNode.audio && !isVirtual) {
+ if (!connectedStreamIds[sourceNode.id]) {
+ connectedStreamIds[sourceNode.id] = true;
+ connectedStreams.push(sourceNode);
+ }
+ } else {
+ // Not a stream - this is an intermediate node, track it
+ intermediateNodeIds[sourceNode.id] = true;
+ nodesToCheck.push(sourceNode);
+ }
+ }
+
+ // If we found intermediate nodes, we need to find streams connected to them
+ if (nodesToCheck.length > 0 || connectedStreams.length === 0) {
+ try {
+ var allNodes = Pipewire.nodes.values || [];
+
+ // Find all stream nodes
+ for (var j = 0; j < allNodes.length; j++) {
+ var node = allNodes[j];
+ if (!node || !node.isStream || !node.audio) {
+ continue;
+ }
+
+ // Filter out quickshell
+ const nodeName = node.name || "";
+ const nodeMediaName = (node.properties && node.properties["media.name"]) || "";
+ if (nodeName === "quickshell" || nodeMediaName === "quickshell") {
+ continue;
+ }
+
+ // Filter out filter streams
+ const nodeIsVirtual = (node.properties && node.properties["node.virtual"]) || "";
+ if (nodeIsVirtual) {
+ continue;
+ }
+
+ var streamId = node.id;
+ if (connectedStreamIds[streamId]) {
+ continue;
+ }
+
+ if (Object.keys(intermediateNodeIds).length > 0) {
+ connectedStreamIds[streamId] = true;
+ connectedStreams.push(node);
+ } else if (connectedStreams.length === 0) {
+ connectedStreamIds[streamId] = true;
+ connectedStreams.push(node);
+ }
+ }
+ } catch (e) {}
+ }
+
+ return connectedStreams;
+ }
+
+ // Bind all devices to ensure their properties are available
+ PwObjectTracker {
+ objects: [...root.sinks, ...root.sources]
+ }
+
+ // Per-stream volume overrides (app + media identity) so concurrent browser streams do not share one entry.
+ property var appVolumeOverrides: ({})
+ property var _knownAppStreamIds: ({})
+ property bool _isApplyingAppOverride: false
+
+ PwObjectTracker {
+ objects: root.streamNodes
+ }
+
+ // Keep appVolumeOverrides aligned with PipeWire when apps change volume/mute.
+ Item {
+ width: 0
+ height: 0
+ visible: false
+
+ Repeater {
+ model: root.appStreams
+
+ delegate: Item {
+ required property var modelData
+
+ Connections {
+ target: modelData?.audio ?? null
+
+ function onVolumeChanged() {
+ if (root._isApplyingAppOverride || !modelData?.audio) {
+ return;
+ }
+ var key = root.getAppKey(modelData);
+ if (key) {
+ root.setAppStreamVolume(key, modelData.audio.volume);
+ }
+ }
+
+ function onMutedChanged() {
+ if (root._isApplyingAppOverride || !modelData?.audio) {
+ return;
+ }
+ var key = root.getAppKey(modelData);
+ if (key) {
+ root.setAppStreamMuted(key, modelData.audio.muted);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ function getAppKey(node): string {
+ if (!node || !node.properties) {
+ return "";
+ }
+ var props = node.properties;
+ var base = "";
+ var binary = props["application.process.binary"] || "";
+ if (binary) {
+ var parts = binary.split("/");
+ base = parts[parts.length - 1].toLowerCase();
+ }
+ if (!base) {
+ var appName = props["application.name"] || "";
+ if (appName) {
+ base = appName.toLowerCase();
+ }
+ }
+ if (!base) {
+ var appId = props["application.id"] || "";
+ if (appId) {
+ base = appId.toLowerCase();
+ }
+ }
+ if (!base) {
+ return "";
+ }
+
+ var mediaName = (props["media.name"] || "").trim().toLowerCase();
+ var mediaRole = (props["media.role"] || "").trim().toLowerCase();
+ var tagParts = [];
+ if (mediaName) {
+ tagParts.push(mediaName);
+ }
+ if (mediaRole) {
+ tagParts.push(mediaRole);
+ }
+ if (tagParts.length > 0) {
+ return base + "\u001f" + tagParts.join("\u001e");
+ }
+ return base + "\u001f" + String(node.id);
+ }
+
+ function setAppStreamVolume(appKey: string, volume: real): void {
+ if (!appKey) {
+ return;
+ }
+ var o = appVolumeOverrides;
+ if (!o[appKey]) {
+ o[appKey] = {};
+ }
+
+ o[appKey].volume = volume;
+ appVolumeOverrides = o;
+ }
+
+ function setAppStreamMuted(appKey: string, muted: bool): void {
+ if (!appKey) {
+ return;
+ }
+ var o = appVolumeOverrides;
+ if (!o[appKey]) {
+ o[appKey] = {};
+ }
+ o[appKey].muted = muted;
+ appVolumeOverrides = o;
+ }
+
+ function getAppVolumeOverride(appKey: string) {
+ return appKey ? (appVolumeOverrides[appKey] || null) : null;
+ }
+
+ function _applyAppOverrides(): void {
+ var streams = root.appStreams;
+ if (!streams) {
+ return;
+ }
+
+ var prevKnown = root._knownAppStreamIds;
+ var currentIds = {};
+ _isApplyingAppOverride = true;
+ for (var i = 0; i < streams.length; i++) {
+ var s = streams[i];
+ if (!s) {
+ continue;
+ }
+
+ currentIds[s.id] = true;
+ var key = getAppKey(s);
+ var ov = key ? appVolumeOverrides[key] : null;
+
+ // New stream node (reload, app restart, etc.): adopt PipeWire state into
+ // overrides so we do not force an outdated Noctalia-only value.
+ if (key && s.audio && !prevKnown[s.id]) {
+ var seeded = appVolumeOverrides;
+ if (!seeded[key]) {
+ seeded[key] = {};
+ }
+ seeded[key].volume = s.audio.volume;
+ seeded[key].muted = s.audio.muted;
+ appVolumeOverrides = seeded;
+ ov = seeded[key];
+ }
+
+ if (!ov || !s.audio) {
+ continue;
+ }
+ if (ov.volume !== undefined && Math.abs(s.audio.volume - ov.volume) > root.epsilon) {
+ s.audio.volume = ov.volume;
+ }
+ if (ov.muted !== undefined && s.audio.muted !== ov.muted) {
+ s.audio.muted = ov.muted;
+ }
+ }
+ _knownAppStreamIds = currentIds;
+ _isApplyingAppOverride = false;
+ }
+
+ Connections {
+ target: root
+ function onAppStreamsChanged() {
+ _appOverrideTimer.restart();
+ }
+ }
+
+ Timer {
+ id: _appOverrideTimer
+ interval: 50
+ onTriggered: root._applyAppOverrides()
+ }
+
+ Timer {
+ id: _appOverrideEnforcer
+ interval: 1000
+ running: Object.keys(root.appVolumeOverrides).length > 0 && root.appStreams.length > 0
+ repeat: true
+ onTriggered: root._applyAppOverrides()
+ }
+
+ Component.onCompleted: {
+ wpctlAvailabilityProcess.running = true;
+ }
+
+ Connections {
+ target: root
+ function onSinkChanged() {
+ if (root.wpctlAvailable) {
+ root.refreshWpctlOutputState();
+ }
+ }
+
+ function onSourceChanged() {
+ if (root.wpctlAvailable) {
+ root.refreshWpctlInputState();
+ }
+ }
+ }
+
+ Timer {
+ id: wpctlPollTimer
+ // Safety net only; regular updates are event-driven from sink audio signals.
+ interval: 20000
+ running: root.wpctlAvailable
+ repeat: true
+ onTriggered: {
+ root.refreshWpctlOutputState();
+ root.refreshWpctlInputState();
+ }
+ }
+
+ Process {
+ id: wpctlAvailabilityProcess
+ command: ["sh", "-c", "command -v wpctl"]
+ running: false
+
+ onExited: function (code) {
+ root.wpctlAvailable = (code === 0);
+ root.wpctlStateValid = false;
+ root.wpctlInputStateValid = false;
+ if (root.wpctlAvailable) {
+ root.refreshWpctlOutputState();
+ root.refreshWpctlInputState();
+ }
+ }
+ }
+
+ Process {
+ id: wpctlStateProcess
+ running: false
+
+ onExited: function (code) {
+ if (code !== 0 || !root.applyWpctlOutputState(stdout.text)) {
+ root.wpctlStateValid = false;
+ }
+ }
+
+ stdout: StdioCollector {}
+ }
+
+ Process {
+ id: wpctlSetVolumeProcess
+ running: false
+
+ onExited: function (code) {
+ root.isSettingOutputVolume = false;
+ if (code !== 0) {
+ Logger.w("AudioService", "wpctl set-volume failed, falling back to PipeWire node audio");
+ if (root.sink?.audio) {
+ root.sink.audio.muted = false;
+ root.sink.audio.volume = root.clampOutputVolume(root.wpctlOutputVolume);
+ }
+ }
+ root.refreshWpctlOutputState();
+ }
+ }
+
+ Process {
+ id: wpctlSetMuteProcess
+ running: false
+
+ onExited: function (_code) {
+ root.refreshWpctlOutputState();
+ }
+ }
+
+ Process {
+ id: wpctlInputStateProcess
+ running: false
+
+ onExited: function (code) {
+ if (code !== 0 || !root.applyWpctlInputState(stdout.text)) {
+ root.wpctlInputStateValid = false;
+ }
+ }
+
+ stdout: StdioCollector {}
+ }
+
+ Process {
+ id: wpctlSetInputVolumeProcess
+ running: false
+
+ onExited: function (code) {
+ root.isSettingInputVolume = false;
+ if (code !== 0) {
+ Logger.w("AudioService", "wpctl set-volume failed for default source, falling back to PipeWire node audio");
+ if (root.source?.audio) {
+ root.source.audio.muted = false;
+ root.source.audio.volume = Math.max(0, Math.min(root.maxVolume, root.wpctlInputVolume));
+ }
+ }
+ root.refreshWpctlInputState();
+ }
+ }
+
+ Process {
+ id: wpctlSetInputMuteProcess
+ running: false
+
+ onExited: function (_code) {
+ root.refreshWpctlInputState();
+ }
+ }
+
+ // Watch output device changes for clamping
+ Connections {
+ target: sink?.audio ?? null
+
+ function onVolumeChanged() {
+ if (root.wpctlAvailable) {
+ root.refreshWpctlOutputState();
+ }
+
+ // Ignore volume changes if we're the one setting it (to prevent feedback loop)
+ if (root.isSettingOutputVolume) {
+ return;
+ }
+
+ if (!root.sink?.audio) {
+ return;
+ }
+
+ const vol = root.sink.audio.volume;
+ if (vol === undefined || isNaN(vol)) {
+ return;
+ }
+
+ if (root.sink !== root._lastFeedbackSink) {
+ root._lastFeedbackSink = root.sink;
+ } else {
+ playVolumeFeedback(clampOutputVolume(vol));
+ }
+
+ // If volume exceeds max, clamp it (but only if we didn't just set it)
+ if (vol > root.maxVolume) {
+ root.isSettingOutputVolume = true;
+ Qt.callLater(() => {
+ if (root.sink?.audio && root.sink.audio.volume > root.maxVolume) {
+ root.sink.audio.volume = root.maxVolume;
+ }
+ root.isSettingOutputVolume = false;
+ });
+ }
+ }
+
+ function onMutedChanged() {
+ if (root.wpctlAvailable) {
+ root.refreshWpctlOutputState();
+ }
+ }
+ }
+
+ // Watch input device changes for clamping
+ Connections {
+ target: source?.audio ?? null
+
+ function onVolumeChanged() {
+ if (root.wpctlAvailable) {
+ root.refreshWpctlInputState();
+ }
+
+ // Ignore volume changes if we're the one setting it (to prevent feedback loop)
+ if (root.isSettingInputVolume) {
+ return;
+ }
+
+ if (!root.source?.audio) {
+ return;
+ }
+
+ const vol = root.source.audio.volume;
+ if (vol === undefined || isNaN(vol)) {
+ return;
+ }
+
+ // If volume exceeds max, clamp it (but only if we didn't just set it)
+ if (vol > root.maxVolume) {
+ root.isSettingInputVolume = true;
+ Qt.callLater(() => {
+ if (root.source?.audio && root.source.audio.volume > root.maxVolume) {
+ root.source.audio.volume = root.maxVolume;
+ }
+ root.isSettingInputVolume = false;
+ });
+ }
+ }
+
+ function onMutedChanged() {
+ if (root.wpctlAvailable) {
+ root.refreshWpctlInputState();
+ }
+ }
+ }
+
+ // Output Control
+ function increaseVolume() {
+ if (!Pipewire.ready || (!sink?.audio && !wpctlAvailable)) {
+ return;
+ }
+ if (volume >= root.maxVolume) {
+ volumeAtMaximum();
+ return;
+ }
+ setVolume(Math.min(root.maxVolume, volume + stepVolume));
+ }
+
+ function decreaseVolume() {
+ if (!Pipewire.ready || (!sink?.audio && !wpctlAvailable)) {
+ return;
+ }
+ if (volume <= 0) {
+ volumeAtMinimum();
+ return;
+ }
+ setVolume(Math.max(0, volume - stepVolume));
+ }
+
+ function setVolume(newVolume: real) {
+ if (!Pipewire.ready || (!sink?.audio && !wpctlAvailable)) {
+ Logger.w("AudioService", "No sink available or not ready");
+ return;
+ }
+
+ const clampedVolume = clampOutputVolume(newVolume);
+ const delta = Math.abs(clampedVolume - volume);
+ if (delta < root.epsilon) {
+ return;
+ }
+
+ if (wpctlAvailable) {
+ if (wpctlSetVolumeProcess.running) {
+ return;
+ }
+
+ isSettingOutputVolume = true;
+ wpctlOutputMuted = false;
+ wpctlOutputVolume = clampedVolume;
+ wpctlStateValid = true;
+
+ const volumePct = Math.round(clampedVolume * 10000) / 100;
+ wpctlSetVolumeProcess.command = ["sh", "-c", "wpctl set-mute @DEFAULT_AUDIO_SINK@ 0 && wpctl set-volume @DEFAULT_AUDIO_SINK@ " + volumePct + "%"];
+ wpctlSetVolumeProcess.running = true;
+
+ playVolumeFeedback(clampedVolume);
+ return;
+ }
+
+ if (!sink?.ready || !sink?.audio) {
+ Logger.w("AudioService", "No sink available or not ready");
+ return;
+ }
+
+ // Set flag to prevent feedback loop, then set the actual volume
+ isSettingOutputVolume = true;
+ sink.audio.muted = false;
+ sink.audio.volume = clampedVolume;
+
+ playVolumeFeedback(clampedVolume);
+
+ // Clear flag after a short delay to allow external changes to be detected
+ Qt.callLater(() => {
+ isSettingOutputVolume = false;
+ });
+ }
+
+ function setOutputMuted(muted: bool) {
+ if (!Pipewire.ready || (!sink?.audio && !wpctlAvailable)) {
+ Logger.w("AudioService", "No sink available or Pipewire not ready");
+ return;
+ }
+
+ if (wpctlAvailable) {
+ if (wpctlSetMuteProcess.running) {
+ return;
+ }
+
+ wpctlOutputMuted = muted;
+ wpctlStateValid = true;
+ wpctlSetMuteProcess.command = ["wpctl", "set-mute", "@DEFAULT_AUDIO_SINK@", muted ? "1" : "0"];
+ wpctlSetMuteProcess.running = true;
+ return;
+ }
+
+ sink.audio.muted = muted;
+ }
+
+ function getOutputIcon() {
+ if (muted) {
+ return "volume-mute";
+ }
+
+ const clampedVolume = Math.max(0, Math.min(volume, root.maxVolume));
+
+ // Show volume-x icon when volume is effectively 0% (within rounding threshold)
+ if (clampedVolume < root.epsilon) {
+ return "volume-x";
+ }
+ if (clampedVolume <= 0.5) {
+ return "volume-low";
+ }
+ return "volume-high";
+ }
+
+ // Input Control
+ function increaseInputVolume() {
+ if (!Pipewire.ready || (!source?.audio && !wpctlAvailable)) {
+ return;
+ }
+ if (inputVolume >= root.maxVolume) {
+ return;
+ }
+ setInputVolume(Math.min(root.maxVolume, inputVolume + stepVolume));
+ }
+
+ function decreaseInputVolume() {
+ if (!Pipewire.ready || (!source?.audio && !wpctlAvailable)) {
+ return;
+ }
+ setInputVolume(Math.max(0, inputVolume - stepVolume));
+ }
+
+ function setInputVolume(newVolume: real) {
+ if (!Pipewire.ready) {
+ return;
+ }
+
+ const clampedVolume = Math.max(0, Math.min(root.maxVolume, newVolume));
+ var currentVol = 0;
+ if (wpctlAvailable && wpctlInputStateValid) {
+ currentVol = wpctlInputVolume;
+ } else if (source?.audio && source.audio.volume !== undefined && !isNaN(source.audio.volume)) {
+ currentVol = source.audio.volume;
+ }
+ const delta = Math.abs(clampedVolume - currentVol);
+ if (delta < root.epsilon) {
+ return;
+ }
+
+ if (wpctlAvailable) {
+ if (wpctlSetInputVolumeProcess.running) {
+ return;
+ }
+
+ isSettingInputVolume = true;
+ wpctlInputMuted = false;
+ wpctlInputVolume = clampedVolume;
+ wpctlInputStateValid = true;
+
+ const volumePct = Math.round(clampedVolume * 10000) / 100;
+ wpctlSetInputVolumeProcess.command = ["sh", "-c", "wpctl set-mute @DEFAULT_AUDIO_SOURCE@ 0 && wpctl set-volume @DEFAULT_AUDIO_SOURCE@ " + volumePct + "%"];
+ wpctlSetInputVolumeProcess.running = true;
+ return;
+ }
+
+ if (!source?.ready || !source?.audio) {
+ Logger.w("AudioService", "No source available or not ready");
+ return;
+ }
+
+ isSettingInputVolume = true;
+ source.audio.muted = false;
+ source.audio.volume = clampedVolume;
+
+ Qt.callLater(() => {
+ isSettingInputVolume = false;
+ });
+ }
+
+ function playVolumeFeedback(currentVolume: real) {
+ if (!SoundService.multimediaAvailable) {
+ return;
+ }
+
+ if (!Settings.data.audio.volumeFeedback) {
+ return;
+ }
+
+ const now = Date.now();
+ if (now - lastVolumeFeedbackTime < minVolumeFeedbackInterval) {
+ return;
+ }
+ lastVolumeFeedbackTime = now;
+
+ const feedbackVolume = currentVolume;
+ const configuredSoundFile = Settings.data.audio.volumeFeedbackSoundFile;
+ const soundFile = (configuredSoundFile && configuredSoundFile.trim() !== "") ? configuredSoundFile : "volume-change.wav";
+
+ SoundService.playSound(soundFile, {
+ volume: feedbackVolume,
+ fallback: false,
+ repeat: false
+ });
+ }
+
+ function setInputMuted(muted: bool) {
+ if (!Pipewire.ready) {
+ Logger.w("AudioService", "Pipewire not ready");
+ return;
+ }
+
+ if (wpctlAvailable) {
+ if (wpctlSetInputMuteProcess.running) {
+ return;
+ }
+
+ wpctlInputMuted = muted;
+ wpctlInputStateValid = true;
+ wpctlSetInputMuteProcess.command = ["wpctl", "set-mute", "@DEFAULT_AUDIO_SOURCE@", muted ? "1" : "0"];
+ wpctlSetInputMuteProcess.running = true;
+ return;
+ }
+
+ if (!source?.audio) {
+ Logger.w("AudioService", "No source available");
+ return;
+ }
+
+ source.audio.muted = muted;
+ }
+
+ function getInputIcon() {
+ if (inputMuted || inputVolume <= Number.EPSILON) {
+ return "microphone-mute";
+ }
+ return "microphone";
+ }
+
+ // Device Selection
+ function setAudioSink(newSink: PwNode): void {
+ if (!Pipewire.ready) {
+ Logger.w("AudioService", "Pipewire not ready");
+ return;
+ }
+ Pipewire.preferredDefaultAudioSink = newSink;
+ }
+
+ function setAudioSource(newSource: PwNode): void {
+ if (!Pipewire.ready) {
+ Logger.w("AudioService", "Pipewire not ready");
+ return;
+ }
+ Pipewire.preferredDefaultAudioSource = newSource;
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Media/MediaService.qml b/arch/.config/quickshell/noctalia-shell/Services/Media/MediaService.qml
new file mode 100644
index 0000000..e30762d
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Media/MediaService.qml
@@ -0,0 +1,356 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Services.Mpris
+import qs.Commons
+
+Singleton {
+ id: root
+
+ function formatTime(seconds) {
+ if (isNaN(seconds) || seconds < 0)
+ return "0:00";
+ var h = Math.floor(seconds / 3600);
+ var m = Math.floor((seconds % 3600) / 60);
+ var s = Math.floor(seconds % 60);
+ var pad = function (n) {
+ return (n < 10) ? ("0" + n) : n;
+ };
+
+ if (h > 0) {
+ return h + ":" + pad(m) + ":" + pad(s);
+ } else {
+ return m + ":" + pad(s);
+ }
+ }
+
+ property var currentPlayer: null
+ property string playerIdentity: currentPlayer ? (currentPlayer.identity || "") : ""
+ property real currentPosition: 0
+ property bool isSeeking: false
+ property int selectedPlayerIndex: 0
+ property bool isPlaying: currentPlayer ? (currentPlayer.playbackState === MprisPlaybackState.Playing || currentPlayer.isPlaying) : false
+ property string trackTitle: currentPlayer ? (currentPlayer.trackTitle !== undefined ? currentPlayer.trackTitle.replace(/(\r\n|\n|\r)/g, "") : "") : ""
+ property string trackArtist: currentPlayer ? (currentPlayer.trackArtist || "") : ""
+ property string trackAlbum: currentPlayer ? (currentPlayer.trackAlbum || "") : ""
+ property string trackArtUrl: currentPlayer ? (currentPlayer.trackArtUrl || "") : ""
+ property real trackLength: currentPlayer ? ((currentPlayer.length < infiniteTrackLength) ? currentPlayer.length : 0) : 0
+ property bool canPlay: currentPlayer ? currentPlayer.canPlay : false
+ property bool canPause: currentPlayer ? currentPlayer.canPause : false
+ property bool canGoNext: currentPlayer ? currentPlayer.canGoNext : false
+ property bool canGoPrevious: currentPlayer ? currentPlayer.canGoPrevious : false
+ property bool canSeek: currentPlayer ? currentPlayer.canSeek : false
+ property string positionString: formatTime(currentPosition)
+ property string lengthString: formatTime(trackLength)
+ property real infiniteTrackLength: 922337203685
+
+ Component.onCompleted: {
+ updateCurrentPlayer();
+ }
+
+ function getAvailablePlayers() {
+ if (!Mpris.players || !Mpris.players.values) {
+ return [];
+ }
+
+ let allPlayers = Mpris.players.values;
+ let finalPlayers = [];
+ const genericBrowsers = ["firefox", "chromium", "chrome"];
+ const blacklist = (Settings.data.audio && Settings.data.audio.mprisBlacklist) ? Settings.data.audio.mprisBlacklist : [];
+
+ // Separate players into specific and generic lists
+ let specificPlayers = [];
+ let genericPlayers = [];
+ for (var i = 0; i < allPlayers.length; i++) {
+ if (!allPlayers[i])
+ continue;
+ const identity = String(allPlayers[i].identity || "").toLowerCase();
+ const dbusName = String(allPlayers[i].dbusName || "").toLowerCase();
+ const match = blacklist.find(b => {
+ const s = String(b || "").toLowerCase();
+ return s && (identity.includes(s) || dbusName.includes(s));
+ });
+ if (match)
+ continue;
+ if (genericBrowsers.some(b => identity.includes(b))) {
+ genericPlayers.push(allPlayers[i]);
+ } else {
+ specificPlayers.push(allPlayers[i]);
+ }
+ }
+
+ let matchedGenericIndices = {};
+
+ // For each specific player, try to find and pair it with a generic partner
+ for (var i = 0; i < specificPlayers.length; i++) {
+ let specificPlayer = specificPlayers[i];
+ let title1 = String(specificPlayer.trackTitle || "").trim();
+ let wasMatched = false;
+
+ if (title1) {
+ for (var j = 0; j < genericPlayers.length; j++) {
+ if (matchedGenericIndices[j])
+ continue;
+ let genericPlayer = genericPlayers[j];
+ let title2 = String(genericPlayer.trackTitle || "").trim();
+
+ if (title2 && (title1.includes(title2) || title2.includes(title1))) {
+ let dataPlayer = genericPlayer;
+ let identityPlayer = specificPlayer;
+
+ let scoreSpecific = (specificPlayer.trackArtUrl ? 1 : 0);
+ let scoreGeneric = (genericPlayer.trackArtUrl ? 1 : 0);
+ if (scoreSpecific > scoreGeneric) {
+ dataPlayer = specificPlayer;
+ }
+
+ let virtualPlayer = {
+ "identity": identityPlayer.identity,
+ "desktopEntry": identityPlayer.desktopEntry,
+ "trackTitle": dataPlayer.trackTitle,
+ "trackArtist": dataPlayer.trackArtist,
+ "trackAlbum": dataPlayer.trackAlbum,
+ "trackArtUrl": dataPlayer.trackArtUrl,
+ "length": dataPlayer.length || 0,
+ "position": dataPlayer.position || 0,
+ "playbackState": dataPlayer.playbackState,
+ "isPlaying": dataPlayer.isPlaying || false,
+ "canPlay": dataPlayer.canPlay || false,
+ "canPause": dataPlayer.canPause || false,
+ "canGoNext": dataPlayer.canGoNext || false,
+ "canGoPrevious": dataPlayer.canGoPrevious || false,
+ "canSeek": dataPlayer.canSeek || false,
+ "canControl": dataPlayer.canControl || false,
+ "_stateSource": dataPlayer,
+ "_controlTarget": identityPlayer
+ };
+ finalPlayers.push(virtualPlayer);
+ matchedGenericIndices[j] = true;
+ wasMatched = true;
+ break;
+ }
+ }
+ }
+ if (!wasMatched) {
+ finalPlayers.push(specificPlayer);
+ }
+ }
+
+ // Add any generic players that were not matched
+ for (var i = 0; i < genericPlayers.length; i++) {
+ if (!matchedGenericIndices[i]) {
+ finalPlayers.push(genericPlayers[i]);
+ }
+ }
+
+ // Filter for controllable players
+ let controllablePlayers = [];
+ for (var i = 0; i < finalPlayers.length; i++) {
+ let player = finalPlayers[i];
+ if (player && player.canPlay) {
+ controllablePlayers.push(player);
+ }
+ }
+ return controllablePlayers;
+ }
+
+ function findActivePlayer() {
+ let availablePlayers = getAvailablePlayers();
+ if (availablePlayers.length === 0) {
+ //Logger.i("Media", "No active player found")
+ return null;
+ }
+
+ // Prioritize the actively playing player ---
+ for (var i = 0; i < availablePlayers.length; i++) {
+ if (availablePlayers[i] && availablePlayers[i].playbackState === MprisPlaybackState.Playing) {
+ Logger.d("Media", "Found actively playing player: " + availablePlayers[i].identity);
+ selectedPlayerIndex = i;
+ return availablePlayers[i];
+ }
+ }
+
+ // fallback if nothing is playing)
+ const preferred = (Settings.data.audio.preferredPlayer || "");
+ if (preferred !== "") {
+ for (var i = 0; i < availablePlayers.length; i++) {
+ const p = availablePlayers[i];
+ const identity = String(p.identity || "").toLowerCase();
+ const pref = preferred.toLowerCase();
+ if (identity.includes(pref)) {
+ selectedPlayerIndex = i;
+ return p;
+ }
+ }
+ }
+
+ if (selectedPlayerIndex < availablePlayers.length) {
+ return availablePlayers[selectedPlayerIndex];
+ } else {
+ selectedPlayerIndex = 0;
+ return availablePlayers[0];
+ }
+ }
+
+ property bool autoSwitchingPaused: false
+
+ function switchToPlayer(index) {
+ let availablePlayers = getAvailablePlayers();
+ if (index >= 0 && index < availablePlayers.length) {
+ let newPlayer = availablePlayers[index];
+ if (newPlayer !== currentPlayer) {
+ currentPlayer = newPlayer;
+ selectedPlayerIndex = index;
+ currentPosition = currentPlayer ? currentPlayer.position : 0;
+ Logger.d("Media", "Manually switched to player " + currentPlayer.identity);
+ }
+ }
+ }
+
+ // Switch to the most recently active player
+ function updateCurrentPlayer() {
+ let newPlayer = findActivePlayer();
+ if (newPlayer !== currentPlayer) {
+ currentPlayer = newPlayer;
+ currentPosition = currentPlayer ? currentPlayer.position : 0;
+ Logger.d("Media", "Switching player");
+ }
+ }
+
+ function playPause() {
+ if (currentPlayer) {
+ let stateSource = currentPlayer._stateSource || currentPlayer;
+ let controlTarget = currentPlayer._controlTarget || currentPlayer;
+ if (stateSource.playbackState === MprisPlaybackState.Playing) {
+ controlTarget.pause();
+ } else {
+ controlTarget.play();
+ }
+ }
+ }
+
+ function play() {
+ let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null;
+ if (target && target.canPlay) {
+ target.play();
+ }
+ }
+
+ function stop() {
+ let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null;
+ if (target) {
+ target.stop();
+ }
+ }
+
+ function pause() {
+ let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null;
+ if (target && target.canPause) {
+ target.pause();
+ }
+ }
+
+ function next() {
+ let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null;
+ if (target && target.canGoNext) {
+ target.next();
+ }
+ }
+
+ function previous() {
+ let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null;
+ if (target && target.canGoPrevious) {
+ target.previous();
+ }
+ }
+
+ function seek(position) {
+ let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null;
+ if (target && target.canSeek) {
+ target.position = position;
+ currentPosition = position;
+ }
+ }
+
+ function seekRelative(offset) {
+ let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null;
+ if (target && target.canSeek && target.length > 0) {
+ let seekPosition = target.position + offset;
+ target.position = seekPosition;
+ currentPosition = seekPosition;
+ }
+ }
+
+ // Seek to position based on ratio (0.0 to 1.0)
+ function seekByRatio(ratio) {
+ let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null;
+ if (target && target.canSeek && target.length > 0) {
+ let seekPosition = ratio * target.length;
+ target.position = seekPosition;
+ currentPosition = seekPosition;
+ }
+ }
+
+ // Update progress bar every second while playing
+ Timer {
+ id: positionTimer
+ interval: 1000
+ running: currentPlayer && !root.isSeeking && currentPlayer.isPlaying && currentPlayer.length > 0 && currentPlayer.playbackState === MprisPlaybackState.Playing
+ repeat: true
+ onTriggered: {
+ if (currentPlayer && !root.isSeeking && currentPlayer.isPlaying && currentPlayer.playbackState === MprisPlaybackState.Playing) {
+ currentPosition = currentPlayer.position;
+ } else {
+ running = false;
+ }
+ }
+ }
+
+ // Avoid overwriting currentPosition while seeking due to backend position changes
+ Connections {
+ target: currentPlayer
+ function onPositionChanged() {
+ if (!root.isSeeking && currentPlayer) {
+ currentPosition = currentPlayer.position;
+ }
+ }
+ function onPlaybackStateChanged() {
+ if (!root.isSeeking && currentPlayer) {
+ currentPosition = currentPlayer.position;
+ }
+ }
+ }
+
+ // Reset position when switching to inactive player
+ onCurrentPlayerChanged: {
+ if (!currentPlayer || !currentPlayer.isPlaying || currentPlayer.playbackState !== MprisPlaybackState.Playing) {
+ currentPosition = 0;
+ }
+ }
+
+ Timer {
+ id: playerStateMonitor
+ interval: 2000 // Check every 2 seconds
+ repeat: true
+ running: true
+ onTriggered: {
+ //Logger.d("MediaService", "playerStateMonitor triggered. autoSwitchingPaused: " + root.autoSwitchingPaused)
+ if (autoSwitchingPaused)
+ return;
+ // Only update if we don't have a playing player or if current player is paused
+ if (!currentPlayer || !currentPlayer.isPlaying || currentPlayer.playbackState !== MprisPlaybackState.Playing) {
+ updateCurrentPlayer();
+ }
+ }
+ }
+
+ // Update current player when available players change
+ Connections {
+ target: Mpris.players
+ function onValuesChanged() {
+ Logger.d("Media", "Players changed");
+ updateCurrentPlayer();
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Media/SpectrumService.qml b/arch/.config/quickshell/noctalia-shell/Services/Media/SpectrumService.qml
new file mode 100644
index 0000000..63c2c3e
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Media/SpectrumService.qml
@@ -0,0 +1,81 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Services.Pipewire
+import qs.Commons
+import qs.Services.UI
+
+Singleton {
+ id: root
+
+ // TODO Remove in may 2026
+ Component.onCompleted: {
+ _setBandsCount();
+ }
+
+ // Register a component that needs audio data, call this when a visualizer becomes active.
+ // Pass a unique identifier (e.g., "lockscreen", "controlcenter:screen1", "plugin:fancy-audiovisualizer")
+ function registerComponent(componentId) {
+ root._registeredComponents[componentId] = true;
+ root._registeredComponents = Object.assign({}, root._registeredComponents);
+ Logger.d("Spectrum", "Component registered:", componentId, "- total:", root._registeredCount);
+ }
+
+ // Unregister a component when it no longer needs audio data.
+ function unregisterComponent(componentId) {
+ delete root._registeredComponents[componentId];
+ root._registeredComponents = Object.assign({}, root._registeredComponents);
+ Logger.d("Spectrum", "Component unregistered:", componentId, "- total:", root._registeredCount);
+ }
+
+ // Check if a component is registered
+ function isRegistered(componentId) {
+ return root._registeredComponents[componentId] === true;
+ }
+
+ // Component registration - any component needing audio data registers here
+ property var _registeredComponents: ({})
+ readonly property int _registeredCount: Object.keys(_registeredComponents).length
+ property bool _shouldRun: _registeredCount > 0
+
+ property var values: []
+ property bool isIdle: true
+
+ PwAudioSpectrum {
+ id: spectrum
+ node: Pipewire.defaultAudioSink
+ enabled: root._shouldRun
+ // TODO Uncomment this in may 2026
+ // bandCount: Settings.data.audio.spectrumMirrored ? 32 : 64
+ frameRate: Settings.data.audio.spectrumFrameRate
+ lowerCutoff: 50
+ upperCutoff: 12000
+ noiseReduction: 0.77
+ smoothing: true
+
+ onValuesChanged: {
+ root.values = spectrum.values;
+ }
+
+ onIdleChanged: {
+ root.isIdle = spectrum.idle;
+ }
+ }
+
+ // TODO Remove in may 2026 - temporary until noctalia-qs is fully propagated
+ Connections {
+ target: Settings.data.audio
+ function onSpectrumMirroredChanged() {
+ _setBandsCount();
+ }
+ }
+ function _setBandsCount() {
+ const bandCount = Settings.data.audio.spectrumMirrored ? 32 : 64;
+ if (spectrum.bandCount !== undefined) {
+ spectrum.bandCount = bandCount;
+ } else if (spectrum.barCount !== undefined) {
+ spectrum.barCount = bandCount;
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Networking/BluetoothRssi.qml b/arch/.config/quickshell/noctalia-shell/Services/Networking/BluetoothRssi.qml
new file mode 100644
index 0000000..cc22127
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Networking/BluetoothRssi.qml
@@ -0,0 +1,72 @@
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import "../../Helpers/BluetoothUtils.js" as BluetoothUtils
+
+QtObject {
+ id: root
+
+ // Controls
+ property bool enabled: false
+ property int intervalMs: 10000
+ property var connectedDevices: []
+
+ // Output cache and version for bindings
+ property var cache: ({}) // addr -> percent (0..100)
+ property int version: 0
+
+ // Internal rotation state
+ property int _index: 0
+ property string _currentAddr: ""
+
+ // Single process reused for RSSI queries
+ property Process rssiProcess: Process {
+ id: proc
+ running: false
+ stdout: StdioCollector {
+ id: out
+ }
+ onExited: function (exitCode, exitStatus) {
+ try {
+ var text = out.text || "";
+ var dbm = BluetoothUtils.parseRssiOutput(text);
+ if (root._currentAddr !== "" && dbm !== null) {
+ var pct = BluetoothUtils.dbmToPercent(dbm);
+ if (pct !== null) {
+ root.cache[root._currentAddr] = pct;
+ root.version++;
+ }
+ }
+ } catch (e) {} finally {
+ root._currentAddr = "";
+ }
+ }
+ }
+
+ // Periodic RSSI polling timer
+ property Timer rssiTimer: Timer {
+ interval: root.intervalMs
+ repeat: true
+ running: root.enabled
+ onTriggered: {
+ var list = root.connectedDevices || [];
+ if (!list || list.length === 0)
+ return;
+ if (root._index >= list.length)
+ root._index = 0;
+ var dev = list[root._index++];
+ if (!dev)
+ return;
+ var addr = BluetoothUtils.macFromDevice(dev);
+ if (!addr || addr.length < 7)
+ return;
+ if (proc.running)
+ return; // avoid overlap
+ root._currentAddr = addr;
+ proc.command = ["sh", "-c", `bluetoothctl info "${addr}"`];
+ try {
+ proc.running = true;
+ } catch (e) {}
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Networking/BluetoothService.qml b/arch/.config/quickshell/noctalia-shell/Services/Networking/BluetoothService.qml
new file mode 100644
index 0000000..5635918
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Networking/BluetoothService.qml
@@ -0,0 +1,542 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Bluetooth
+import Quickshell.Io
+import "../../Helpers/BluetoothUtils.js" as BluetoothUtils
+import qs.Commons
+import qs.Services.System
+import qs.Services.UI
+
+Singleton {
+ id: root
+
+ readonly property BluetoothAdapter adapter: Bluetooth.defaultAdapter
+
+ // Power/availability state
+ readonly property bool bluetoothAvailable: !!adapter
+ readonly property bool enabled: adapter?.enabled ?? false
+ readonly property bool blocked: adapter?.state === BluetoothAdapter.Blocked
+
+ // Exposed scanning flag for UI button state; reflects adapter discovery when available
+ readonly property bool scanningActive: adapter?.discovering ?? false
+
+ // Adapter discoverability (advertising) flag
+ readonly property bool discoverable: adapter?.discoverable ?? false
+ readonly property var devices: adapter ? adapter.devices : null
+ readonly property var connectedDevices: {
+ if (!adapter || !adapter.devices) {
+ return [];
+ }
+ return adapter.devices.values.filter(dev => dev && dev.connected);
+ }
+
+ // Experimental: bestโeffort RSSI polling for connected devices (without root)
+ // Enabled in debug mode or via user setting in Settings > Network
+ property bool rssiPollingEnabled: Settings?.data?.network?.bluetoothRssiPollingEnabled || Settings?.isDebug || false
+ // Interval can be configured from Settings; defaults to 60s
+ property int rssiPollIntervalMs: Settings?.data?.network?.bluetoothRssiPollIntervalMs || 60000
+ // RSSI helper subโcomponent
+ property BluetoothRssi rssi: BluetoothRssi {
+ enabled: root.enabled && root.rssiPollingEnabled
+ intervalMs: root.rssiPollIntervalMs
+ connectedDevices: root.connectedDevices
+ }
+
+ // Tunables for CLI pairing/connect flow
+ property int pairWaitSeconds: 45
+ property int connectAttempts: 5
+ property int connectRetryIntervalMs: 2000
+
+ // Interaction state
+ property bool pinRequired: false
+
+ // Internal variables
+ property bool _discoveryWasRunning: false
+ property bool _ctlInit: false
+ property var _autoConnectQueue: []
+
+ // Persistent cache for per-device auto-connect toggle
+ property string cacheFile: Settings.cacheDir + "bluetooth_devices.json"
+
+ FileView {
+ id: cacheFileView
+ path: root.cacheFile
+ printErrors: false
+
+ JsonAdapter {
+ id: cacheAdapter
+ property var autoConnectSettings: ({})
+ }
+ }
+
+ // Handle system wakeup to force-poll and ensure state is up-to-date
+ Connections {
+ target: Time
+ function onResumed() {
+ Logger.i("Bluetooth", "System resumed - forcing state poll");
+ ctlPollTimer.restart();
+ }
+ }
+
+ // Track adapter state changes
+ Connections {
+ target: adapter
+ function onStateChanged() {
+ if (!adapter || adapter.state === BluetoothAdapter.Enabling || adapter.state === BluetoothAdapter.Disabling) {
+ return;
+ }
+ checkAirplaneMode();
+ }
+ function onEnabledChanged() {
+ if (adapter && adapter.enabled && Settings.data.network.bluetoothAutoConnect) {
+ autoConnectTimer.restart();
+ }
+ }
+ }
+
+ Connections {
+ target: Settings.data.network
+ function onBluetoothAutoConnectChanged() {
+ if (Settings.data.network.bluetoothAutoConnect && adapter && adapter.enabled) {
+ autoConnectTimer.restart();
+ } else {
+ autoConnectTimer.stop();
+ }
+ }
+ }
+
+ Component.onCompleted: {
+ Logger.i("Bluetooth", "Service started");
+ autoConnectTimer.restart();
+ }
+
+ Timer {
+ id: autoConnectTimer
+ interval: 1500
+ repeat: false
+ onTriggered: attemptAutoConnect()
+ }
+
+ Timer {
+ id: autoConnectStepTimer
+ interval: 500
+ repeat: false
+ onTriggered: {
+ var device = root._autoConnectQueue.shift();
+ if (device && device.paired && !device.connected && !device.blocked) {
+ Logger.i("Bluetooth", "Auto-connecting to:", device.name || device.deviceName);
+ connectDeviceWithTrust(device);
+ }
+ if (root._autoConnectQueue.length > 0) {
+ autoConnectStepTimer.restart();
+ }
+ }
+ }
+
+ Timer {
+ id: ctlPollTimer
+ interval: 250
+ running: false
+ onTriggered: {
+ if (!adapter || !ProgramCheckerService.bluetoothctlAvailable) {
+ return;
+ }
+ ctlPollProcess.running = true;
+ }
+ }
+
+ // Adapter power (enable/disable) via bluetoothctl
+ function setBluetoothEnabled(state) {
+ if (!adapter) {
+ Logger.d("Bluetooth", "Enable/Disable skipped: no adapter");
+ return;
+ }
+ try {
+ adapter.enabled = state;
+ Logger.i("Bluetooth", "SetBluetoothEnabled", state);
+ } catch (e) {
+ Logger.w("Bluetooth", "Enable/Disable failed", e);
+ ToastService.showWarning(I18n.tr("common.bluetooth"), I18n.tr("toast.bluetooth.state-change-failed"));
+ }
+ }
+
+ // Check if airplane mode has been toggled
+ function checkAirplaneMode() {
+ var isAirplaneModeActive = !NetworkService.wifiEnabled && adapter.state === BluetoothAdapter.Blocked;
+ if (isAirplaneModeActive && !NetworkService.airplaneModeEnabled) {
+ NetworkService.airplaneModeToggled = true;
+ NetworkService.airplaneModeEnabled = true;
+ ToastService.showNotice(I18n.tr("toast.airplane-mode.title"), I18n.tr("common.enabled"), "plane");
+ Logger.i("AirplaneMode", "Enabled");
+ } else if (!isAirplaneModeActive && NetworkService.airplaneModeEnabled) {
+ NetworkService.airplaneModeToggled = true;
+ NetworkService.airplaneModeEnabled = false;
+ ToastService.showNotice(I18n.tr("toast.airplane-mode.title"), I18n.tr("common.disabled"), "plane-off");
+ Logger.i("AirplaneMode", "Disabled");
+ } else if (adapter.enabled) {
+ ToastService.showNotice(I18n.tr("common.bluetooth"), I18n.tr("common.enabled"), "bluetooth");
+ Logger.d("Bluetooth", "Adapter enabled");
+ } else {
+ ToastService.showNotice(I18n.tr("common.bluetooth"), I18n.tr("common.disabled"), "bluetooth-off");
+ Logger.d("Bluetooth", "Adapter disabled");
+ }
+ }
+
+ // Unify discovery controls
+ function setScanActive(active) {
+ if (!adapter) {
+ Logger.d("Bluetooth", "Scan request ignored: adapter unavailable");
+ return;
+ }
+ try {
+ if (active || adapter.discovering) { // Only attempt to set if activating, or if deactivating and currently currently discovering
+ adapter.discovering = active;
+ }
+ } catch (e) {
+ Logger.e("Bluetooth", "setScanActive failed", e);
+ }
+ }
+
+ // Toggle adapter discoverability (advertising visibility) via bluetoothctl
+ function setDiscoverable(state) {
+ if (!adapter) {
+ Logger.d("Bluetooth", "Discoverable change skipped: no adapter");
+ return;
+ }
+ try {
+ adapter.discoverable = state;
+ Logger.i("Bluetooth", "Discoverable state set to:", state);
+ } catch (e) {
+ Logger.w("Bluetooth", "Failed to change discoverable state", e);
+ ToastService.showWarning(I18n.tr("common.bluetooth"), I18n.tr("toast.bluetooth.discoverable-change-failed"));
+ }
+ }
+
+ function sortDevices(devices) {
+ return devices.sort(function (a, b) {
+ var aName = a.name || a.deviceName || "";
+ var bName = b.name || b.deviceName || "";
+
+ var aHasRealName = aName.indexOf(" ") !== -1 && aName.length > 3;
+ var bHasRealName = bName.indexOf(" ") !== -1 && bName.length > 3;
+
+ if (aHasRealName && !bHasRealName) {
+ return -1;
+ }
+ if (!aHasRealName && bHasRealName) {
+ return 1;
+ }
+
+ var aSignal = (a.signalStrength !== undefined && a.signalStrength > 0) ? a.signalStrength : 0;
+ var bSignal = (b.signalStrength !== undefined && b.signalStrength > 0) ? b.signalStrength : 0;
+ return bSignal - aSignal;
+ });
+ }
+
+ function getDeviceIcon(device) {
+ if (!device) {
+ return "bt-device-generic";
+ }
+ return BluetoothUtils.deviceIcon(device.name || device.deviceName, device.icon);
+ }
+
+ function canConnect(device) {
+ if (!device) {
+ return false;
+ }
+ return !device.connected && (device.paired || device.trusted) && !device.pairing && !device.blocked;
+ }
+
+ function canDisconnect(device) {
+ if (!device) {
+ return false;
+ }
+ return device.connected && !device.pairing && !device.blocked;
+ }
+
+ // Textual signal quality (translated)
+ function getSignalStrength(device) {
+ var p = getSignalPercent(device);
+ if (p === null) {
+ return I18n.tr("bluetooth.panel.signal-text-unknown");
+ }
+ if (p >= 80) {
+ return I18n.tr("bluetooth.panel.signal-text-excellent");
+ }
+ if (p >= 60) {
+ return I18n.tr("bluetooth.panel.signal-text-good");
+ }
+ if (p >= 40) {
+ return I18n.tr("bluetooth.panel.signal-text-fair");
+ }
+ if (p >= 20) {
+ return I18n.tr("bluetooth.panel.signal-text-poor");
+ }
+ return I18n.tr("bluetooth.panel.signal-text-very-poor");
+ }
+
+ // Numeric helpers for UI rendering
+ function getSignalPercent(device) {
+ // Establish binding dependency so UI updates when RSSI cache changes
+ var _v = rssi.version;
+ return BluetoothUtils.signalPercent(device, rssi.cache, _v);
+ }
+
+ function getBatteryPercent(device) {
+ return BluetoothUtils.batteryPercent(device);
+ }
+
+ function getSignalIcon(device) {
+ var p = getSignalPercent(device);
+ return BluetoothUtils.signalIcon(p);
+ }
+
+ function isDeviceBusy(device) {
+ if (!device) {
+ return false;
+ }
+ return device.pairing || device.state === BluetoothDevice.Disconnecting || device.state === BluetoothDevice.Connecting;
+ }
+
+ // Return a stable unique key for a device (prefer MAC address)
+ function deviceKey(device) {
+ return BluetoothUtils.deviceKey(device);
+ }
+
+ // Deduplicate a list of devices using the stable key
+ function dedupeDevices(devList) {
+ return BluetoothUtils.dedupeDevices(devList);
+ }
+
+ // Separate capability helpers
+ function canPair(device) {
+ if (!device) {
+ return false;
+ }
+ return !device.connected && !device.paired && !device.trusted && !device.pairing && !device.blocked;
+ }
+
+ // Pairing and unpairing helpers
+ function pairDevice(device) {
+ if (!device) {
+ return;
+ }
+ ToastService.showNotice(I18n.tr("common.bluetooth"), I18n.tr("common.pairing"), "bluetooth");
+ try {
+ pairWithBluetoothctl(device);
+ } catch (e) {
+ Logger.w("Bluetooth", "pairDevice failed", e);
+ ToastService.showWarning(I18n.tr("common.bluetooth"), I18n.tr("toast.bluetooth.pair-failed"));
+ }
+ }
+
+ function submitPin(pin) {
+ if (pairingProcess.running) {
+ pairingProcess.write(pin + "\n");
+ root.pinRequired = false;
+ }
+ }
+
+ function cancelPairing() {
+ if (pairingProcess.running) {
+ pairingProcess.running = false;
+ }
+ root.pinRequired = false;
+ }
+
+ // Pair using bluetoothctl which registers its own BlueZ agent internally.
+ function pairWithBluetoothctl(device) {
+ if (!device) {
+ return;
+ }
+ var addr = BluetoothUtils.macFromDevice(device);
+ if (!addr || addr.length < 7) {
+ Logger.w("Bluetooth", "pairWithBluetoothctl: no valid address for device");
+ return;
+ }
+
+ Logger.i("Bluetooth", "pairWithBluetoothctl", addr);
+
+ if (pairingProcess.running) {
+ pairingProcess.running = false;
+ }
+ root.pinRequired = false;
+
+ const pairWait = Math.max(5, Number(root.pairWaitSeconds) | 0);
+ const attempts = Math.max(1, Number(root.connectAttempts) | 0);
+ const intervalMs = Math.max(500, Number(root.connectRetryIntervalMs) | 0);
+ const intervalSec = Math.max(1, Math.round(intervalMs / 1000));
+
+ // Temporarily pause discovery during pair/connect to reduce HCI churn
+ root._discoveryWasRunning = root.scanningActive;
+ if (root.scanningActive) {
+ root.setScanActive(false);
+ }
+
+ const scriptPath = Quickshell.shellDir + "/Scripts/python/src/network/bluetooth-pair.py";
+ pairingProcess.command = ["python3", scriptPath, String(addr), String(pairWait), String(attempts), String(intervalSec)];
+ pairingProcess.running = true;
+ }
+
+ // Helper to run bluetoothctl and scripts with consistent error logging
+ function btExec(args) {
+ try {
+ Quickshell.execDetached(args);
+ } catch (e) {
+ Logger.w("Bluetooth", "btExec failed", e);
+ }
+ }
+
+ // Status key for a device (untranslated)
+ function getStatusKey(device) {
+ if (!device) {
+ return "";
+ }
+ try {
+ if (device.pairing)
+ return "pairing";
+ if (device.blocked)
+ return "blocked";
+ if (device.state === BluetoothDevice.Connecting)
+ return "connecting";
+ if (device.state === BluetoothDevice.Disconnecting)
+ return "disconnecting";
+ } catch (_) {}
+ return "";
+ }
+
+ function unpairDevice(device) {
+ forgetDevice(device);
+ }
+
+ function getDeviceAutoConnect(device) {
+ if (!device || !device.address || !cacheAdapter.autoConnectSettings) {
+ return false;
+ }
+ const mac = device.address;
+ const settings = cacheAdapter.autoConnectSettings[mac];
+ return settings ? !!settings.autoConnect : false;
+ }
+
+ function setDeviceAutoConnect(device, enabled) {
+ if (!device || !device.address) {
+ return;
+ }
+ const mac = device.address;
+ let settings = cacheAdapter.autoConnectSettings || ({});
+ if (enabled) {
+ settings[mac] = {
+ autoConnect: true,
+ deviceName: device.name || device.deviceName || ""
+ };
+ } else {
+ delete settings[mac];
+ }
+ cacheAdapter.autoConnectSettings = settings;
+ cacheFileView.writeAdapter();
+ }
+
+ function attemptAutoConnect() {
+ if (NetworkService.airplaneModeEnabled || !adapter || !adapter.enabled || !Settings.data.network.bluetoothAutoConnect) {
+ return;
+ }
+
+ _autoConnectQueue = adapter.devices.values.filter(dev => dev && dev.paired && !dev.connected && !dev.blocked && getDeviceAutoConnect(dev) === true);
+
+ if (root._autoConnectQueue.length > 0) {
+ autoConnectStepTimer.restart();
+ }
+ }
+
+ function connectDeviceWithTrust(device) {
+ if (!device) {
+ return;
+ }
+ try {
+ device.trusted = true;
+ device.connect();
+ } catch (e) {
+ Logger.w("Bluetooth", "connectDeviceWithTrust failed", e);
+ ToastService.showWarning(I18n.tr("common.bluetooth"), I18n.tr("toast.bluetooth.connect-failed"));
+ }
+ }
+
+ function disconnectDevice(device) {
+ if (!device) {
+ return;
+ }
+ try {
+ device.disconnect();
+ } catch (e) {
+ Logger.w("Bluetooth", "disconnectDevice failed", e);
+ ToastService.showWarning(I18n.tr("common.bluetooth"), I18n.tr("toast.bluetooth.disconnect-failed"));
+ }
+ }
+
+ function forgetDevice(device) {
+ if (!device) {
+ return;
+ }
+ try {
+ device.trusted = false;
+ device.forget();
+ } catch (e) {
+ Logger.w("Bluetooth", "forgetDevice failed", e);
+ ToastService.showWarning(I18n.tr("common.bluetooth"), I18n.tr("toast.bluetooth.forget-failed"));
+ }
+ }
+
+ // Poll Bluetooth power state with bluetoothctl to handle a Quickshell bug on resume after suspend
+ Process {
+ id: ctlPollProcess
+ command: ["bluetoothctl", "show"]
+ running: false
+ stdout: StdioCollector {
+ onStreamFinished: {
+ var powered = false;
+ var mp = text.match(/\bPowered:\s*(yes|no)\b/i);
+ if (mp) {
+ powered = mp[1].toLowerCase() === 'yes';
+ }
+ if (adapter.enabled !== powered) {
+ adapter.enabled = powered;
+ }
+ }
+ }
+ stderr: StdioCollector {
+ onStreamFinished: {
+ if (text.trim()) {
+ Logger.d("Bluetooth", "Failed to parse bluetoothctl show output" + text);
+ }
+ }
+ }
+ }
+
+ // Interactive pairing process
+ Process {
+ id: pairingProcess
+ stdout: SplitParser {
+ onRead: data => {
+ Logger.d("Bluetooth", data);
+ if (data.indexOf("PIN_REQUIRED") !== -1) {
+ root.pinRequired = true;
+ Logger.i("Bluetooth", "PIN required for pairing");
+ }
+ }
+ }
+ onExited: {
+ root.pinRequired = false;
+ Logger.i("Bluetooth", "Pairing process exited.");
+ // Restore discovery if we paused it
+ if (root._discoveryWasRunning) {
+ root.setScanActive(true);
+ }
+ root._discoveryWasRunning = false;
+ }
+ environment: ({
+ "LC_ALL": "C"
+ })
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Networking/NetworkService.qml b/arch/.config/quickshell/noctalia-shell/Services/Networking/NetworkService.qml
new file mode 100644
index 0000000..8f1f106
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Networking/NetworkService.qml
@@ -0,0 +1,1161 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import Quickshell.Networking
+import qs.Commons
+import qs.Services.System
+import qs.Services.UI
+
+Singleton {
+ id: root
+ // Shared core (read-only) properties
+ readonly property bool wifiAvailable: _wifiAvailable
+ readonly property bool ethernetAvailable: _ethernetAvailable
+ readonly property bool internetConnectivity: _internetConnectivity
+ readonly property string networkConnectivity: _networkConnectivity
+
+ // Supported Wi-Fi security types
+ readonly property var supportedSecurityTypes: [
+ {
+ key: "open",
+ name: I18n.tr("wifi.panel.security-open")
+ },
+ {
+ key: "wep",
+ name: I18n.tr("wifi.panel.security-wep")
+ },
+ {
+ key: "wpa-psk",
+ name: I18n.tr("wifi.panel.security-wpa")
+ },
+ {
+ key: "wpa2-psk",
+ name: I18n.tr("wifi.panel.security-wpa23")
+ },
+ {
+ key: "sae",
+ name: I18n.tr("wifi.panel.security-wpa3")
+ },
+ {
+ key: "wpa-eap",
+ name: I18n.tr("wifi.panel.security-wpa-ent")
+ },
+ {
+ key: "wpa2-eap",
+ name: I18n.tr("wifi.panel.security-wpa2-ent")
+ },
+ {
+ key: "wpa3-eap",
+ name: I18n.tr("wifi.panel.security-wpa3-ent")
+ }
+ ]
+
+ // Core properties
+ property bool _wifiAvailable: false
+ property bool _ethernetAvailable: false
+ property string _networkConnectivity: "unknown"
+ property bool _internetConnectivity: false
+ property string lastError: ""
+ property int activeDetailsTtlMs: 10000
+
+ // Ethernet properties
+ property var ethernetInterfaces: ([])
+ property var activeEthernetDetails: ({})
+ property bool ethernetConnected: false
+ property string activeEthernetIf: ""
+ property bool ethernetDetailsLoading: false
+ property double activeEthernetDetailsTimestamp: 0
+
+ // Wi-Fi properties
+ readonly property bool wifiEnabled: Networking.wifiEnabled
+ property var networks: ({})
+ property var activeWifiDetails: ({})
+ property bool wifiConnected: false
+ property string activeWifiIf: ""
+ property bool wifiDetailsLoading: false
+ property double activeWifiDetailsTimestamp: 0
+ property bool wifiInit: false
+
+ // Wi-Fi adapter/connection properties
+ property bool connecting: false
+ property string connectingTo: ""
+ property string disconnectingFrom: ""
+ property string forgettingNetwork: ""
+ property bool scanPending: false
+ property bool scanningActive: false
+ property var existingProfiles: ({})
+
+ // Airplane mode status
+ property bool airplaneModeEnabled: false
+ property bool airplaneModeToggled: false
+
+ Connections {
+ target: root
+ function onWifiEnabledChanged() {
+ if (!root.wifiInit) {
+ return;
+ }
+ wifiDebounce.restart();
+ }
+ }
+
+ // Start initial checks when nmcli becomes available
+ Connections {
+ target: ProgramCheckerService
+ function onNmcliAvailableChanged() {
+ if (ProgramCheckerService.nmcliAvailable) {
+ deviceStatusProcess.running = true;
+ connectivityCheckProcess.running = true;
+ }
+ }
+ }
+
+ Component.onCompleted: {
+ Logger.i("Network", "Service started");
+ wifiInitTimer.running = true;
+
+ // Ensure initial detection if nmcli is already available at startup
+ if (ProgramCheckerService.nmcliAvailable) {
+ deviceStatusProcess.running = true;
+ connectivityCheckProcess.running = true;
+ }
+ }
+
+ // Prevent an initial "Wi-Fi enabled" toast and trigger initial scan
+ Timer {
+ id: wifiInitTimer
+ interval: 500
+ onTriggered: {
+ root.wifiInit = true;
+ if (root.wifiEnabled) {
+ scan();
+ }
+ if (!root.wifiEnabled && BluetoothService.blocked) {
+ root.airplaneModeEnabled = true;
+ }
+ }
+ }
+
+ // Debounce to prevent multiple toast notifications from transient states
+ Timer {
+ id: wifiDebounce
+ interval: 300
+ onTriggered: {
+ if (!ProgramCheckerService.nmcliAvailable) {
+ return;
+ }
+ if (root.airplaneModeToggled) {
+ root.airplaneModeToggled = false;
+ if (root.wifiEnabled) {
+ scan();
+ } else {
+ root.networks = ({});
+ }
+ return;
+ }
+ var isAirplaneModeActive = !root.wifiEnabled && BluetoothService.blocked;
+ // Extra check for Airplane Mode if Bluetooth has been blocked before Wi-Fi
+ if (isAirplaneModeActive && !root.airplaneModeEnabled) {
+ root.airplaneModeEnabled = true;
+ ToastService.showNotice(I18n.tr("toast.airplane-mode.title"), I18n.tr("common.enabled"), "plane");
+ Logger.i("AirplaneMode", "Enabled");
+ root.networks = ({});
+ return;
+ }
+ // Extra check for Airplane Mode if Wi-Fi has been unblocked before Bluetooth
+ if (!isAirplaneModeActive && root.airplaneModeEnabled) {
+ root.airplaneModeEnabled = false;
+ ToastService.showNotice(I18n.tr("toast.airplane-mode.title"), I18n.tr("common.disabled"), "plane-off");
+ Logger.i("AirplaneMode", "Disabled");
+ scan();
+ return;
+ }
+ if (root.wifiEnabled) {
+ ToastService.showNotice(I18n.tr("common.wifi"), I18n.tr("common.enabled"), "wifi");
+ scan();
+ } else {
+ ToastService.showNotice(I18n.tr("common.wifi"), I18n.tr("common.disabled"), "wifi-off");
+ root.networks = ({});
+ }
+ }
+ }
+
+ // Internet connectivity check timer
+ Timer {
+ id: connectivityCheckTimer
+ interval: 15000
+ running: ProgramCheckerService.nmcliAvailable && (root.ethernetConnected || root.wifiConnected)
+ repeat: true
+ onTriggered: connectivityCheckProcess.running = true
+ }
+
+ // Delayed scan timer
+ Timer {
+ id: delayedScanTimer
+ interval: 7000
+ onTriggered: scan()
+ }
+
+ // Core functions
+ function setWifiEnabled(enabled) {
+ if (!ProgramCheckerService.nmcliAvailable) {
+ return;
+ }
+ Logger.i("Wi-Fi", "SetWifiEnabled", enabled);
+ Networking.wifiEnabled = enabled;
+ }
+
+ function setAirplaneMode(state) {
+ if (state) {
+ Quickshell.execDetached(["rfkill", "block", "all"]);
+ } else {
+ Quickshell.execDetached(["rfkill", "unblock", "all"]);
+ }
+ }
+
+ function scan() {
+ if (!ProgramCheckerService.nmcliAvailable || !root.wifiEnabled) {
+ return;
+ }
+ lastError = "";
+
+ // If scanning in progress, mark as pending to trigger another scan when current when finished.
+ if (profileCheckProcess.running || scanProcess.running) {
+ root.scanPending = true;
+ return;
+ }
+
+ // Get existing profiles first, then scan
+ profileCheckProcess.running = true;
+ root.scanningActive = true;
+ Logger.d("Network", "Scanning Wi-Fi networks...");
+ }
+
+ function connect(ssid, password = "", isHidden = false, securityKey = "", identity = "", enterpriseConfig = {}) {
+ if (!ProgramCheckerService.nmcliAvailable || connecting) {
+ return;
+ }
+
+ const isSaved = (networks[ssid] && networks[ssid].existing);
+ const isEnt = securityKey ? isEnterprise(securityKey) : isEnterprise(networks[ssid] ? networks[ssid].security : "");
+
+ connecting = true;
+ connectingTo = ssid;
+ lastError = "";
+
+ connectProcess.ssid = ssid;
+ connectProcess.password = password;
+ connectProcess.isHidden = isHidden;
+
+ if (isSaved) {
+ connectProcess.mode = "saved";
+ } else if (isEnt || securityKey === "wep" || (securityKey && securityKey !== "open" && securityKey !== "wpa-psk" && securityKey !== "wpa2-psk")) {
+ connectProcess.mode = "manual";
+ connectProcess.securityKey = securityKey || (networks[ssid] ? networks[ssid].security : "wpa-psk");
+ connectProcess.identity = identity;
+ connectProcess.eap = enterpriseConfig.eap || "peap";
+ connectProcess.phase2 = enterpriseConfig.phase2 || "mschapv2";
+ connectProcess.anonIdentity = enterpriseConfig.anonIdentity || "";
+ connectProcess.caCert = enterpriseConfig.caCert || "";
+ } else {
+ connectProcess.mode = "new";
+ }
+
+ connectProcess.running = true;
+ }
+
+ function disconnect(ssid) {
+ if (!ProgramCheckerService.nmcliAvailable) {
+ return;
+ }
+ disconnectingFrom = ssid;
+ disconnectProcess.ssid = ssid;
+ disconnectProcess.running = true;
+ }
+
+ function forget(ssid) {
+ if (!ProgramCheckerService.nmcliAvailable) {
+ return;
+ }
+ forgettingNetwork = ssid;
+
+ // Remove from system
+ forgetProcess.ssid = ssid;
+ forgetProcess.running = true;
+ }
+
+ // Refresh details for the currently active WiโFi link
+ function refreshActiveWifiDetails() {
+ const now = Date.now();
+ if (wifiDetailsLoading || (activeWifiIf && wifiConnected && activeWifiDetails && (now - activeWifiDetailsTimestamp) < activeDetailsTtlMs)) {
+ return;
+ }
+ if (wifiConnected && activeWifiIf) {
+ wifiDetailsLoading = true;
+ deviceStatusProcess.running = true;
+ }
+ }
+
+ // Refresh details for the currently active Ethernet link
+ function refreshActiveEthernetDetails() {
+ const now = Date.now();
+ if (ethernetDetailsLoading || activeEthernetIf && activeEthernetDetails && (now - activeEthernetDetailsTimestamp) < activeDetailsTtlMs) {
+ return;
+ }
+ if (ethernetConnected && activeEthernetIf) {
+ ethernetDetailsLoading = true;
+ deviceStatusProcess.running = true;
+ }
+ }
+
+ // Helper function to immediately update network status
+ function updateNetworkStatus(ssid, connected) {
+ let nets = networks;
+
+ // Update all networks connected status
+ for (let key in nets) {
+ if (nets[key].connected && key !== ssid) {
+ nets[key].connected = false;
+ }
+ }
+ // Update the target network if it exists
+ if (nets[ssid]) {
+ nets[ssid].connected = connected;
+ nets[ssid].existing = true;
+ } else if (connected) {
+ // Create a temporary entry if network doesn't exist yet
+ nets[ssid] = {
+ "ssid": ssid,
+ "security": "--",
+ "signal": 100,
+ "connected": true,
+ "existing": true
+ };
+ }
+ // Trigger property change notification
+ networks = ({});
+ networks = nets;
+ }
+
+ // Helper functions
+ function getSignalInfo(signal, isConnected) {
+ let icon = "";
+ if (isConnected) {
+ if (root._networkConnectivity === "limited") {
+ icon = "wifi-exclamation";
+ } else if (root._networkConnectivity === "portal" || root._networkConnectivity === "unknown") {
+ icon = "wifi-question";
+ }
+ }
+ const label = signal >= 80 ? I18n.tr("wifi.signal.excellent") : signal >= 60 ? I18n.tr("wifi.signal.good") : signal >= 35 ? I18n.tr("wifi.signal.fair") : signal >= 15 ? I18n.tr("wifi.signal.poor") : I18n.tr("wifi.signal.weak");
+ if (!icon) {
+ icon = signal >= 80 ? "wifi" : signal >= 60 ? "wifi-3" : signal >= 35 ? "wifi-2" : signal >= 15 ? "wifi-1" : "wifi-0";
+ }
+ return {
+ icon,
+ label
+ };
+ }
+
+ function isSecured(security) {
+ return security && security !== "--" && security.trim() !== "";
+ }
+
+ function isEnterprise(security) {
+ if (!security) {
+ return false;
+ }
+ const s = security.toUpperCase();
+ return s.indexOf("802.1X") !== -1 || s.indexOf("EAP") !== -1 || s.indexOf("ENTERPRISE") !== -1;
+ }
+
+ function parseIpDetails(text) {
+ const details = {
+ connectionName: "",
+ ipv4: "",
+ gateway4: "",
+ dns4: [],
+ ipv6: [],
+ gateway6: [],
+ dns6: [],
+ hwAddr: "",
+ speed: ""
+ };
+ const addUnique = (arr, val) => {
+ if (val && arr.indexOf(val) === -1) {
+ arr.push(val);
+ }
+ };
+ const handlers = {
+ "GENERAL.CONNECTION": v => {
+ details.connectionName = v;
+ },
+ "GENERAL.HWADDR": v => {
+ details.hwAddr = v;
+ },
+ "CAPABILITIES.SPEED": v => {
+ if (v && v !== "unknown") {
+ details.speed = v;
+ }
+ },
+ "IP4.ADDRESS": v => {
+ details.ipv4 = v.split("/")[0];
+ },
+ "IP4.GATEWAY": v => {
+ details.gateway4 = v;
+ },
+ "IP6.ADDRESS": v => {
+ addUnique(details.ipv6, v.split("/")[0]);
+ },
+ "IP6.GATEWAY": v => {
+ addUnique(details.gateway6, v);
+ },
+ "IP4.DNS": v => {
+ addUnique(details.dns4, v);
+ },
+ "IP6.DNS": v => {
+ addUnique(details.dns6, v);
+ }
+ };
+ const lines = text.split("\n");
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i].trim();
+ if (!line) {
+ continue;
+ }
+ const idx = line.indexOf(":");
+ if (idx === -1) {
+ continue;
+ }
+ const key = line.substring(0, idx).replace(/\[\d+\]$/, "");
+ const val = line.substring(idx + 1).trim();
+ if (handlers[key]) {
+ handlers[key](val);
+ }
+ }
+ return details;
+ }
+
+ // Functions used in /Modules/Panels/ControlCenter/Widgets/Network.qml & /Modules/Bar/Widgets/Network.qml
+ function getStatusText(showSpeed = false) {
+ // This variable can be tied to a toggle
+ if (root.connecting) {
+ return root.connectingTo ? I18n.tr("common.connecting") + " " + root.connectingTo : I18n.tr("common.connecting");
+ }
+
+ if (NetworkService.airplaneModeEnabled) {
+ return I18n.tr("toast.airplane-mode.title");
+ }
+ if (!root.wifiEnabled) {
+ return "";
+ }
+
+ // Ethernet
+ if (root.ethernetConnected) {
+ const eth = root.activeEthernetDetails;
+ const name = eth.connectionName || (root.ethernetInterfaces.length > 0 ? root.ethernetInterfaces[0].connectionName : "") || "";
+ const speed = eth.speed || "";
+ return (name + (showSpeed && speed ? " - " + speed : ""));
+ }
+
+ // Wi-Fi
+ if (root.wifiConnected) {
+ const wl = root.activeWifiDetails;
+ const speed = wl.rateShort || wl.rate || "";
+ const connectedNet = Object.values(root.networks).find(net => net.connected);
+ const name = connectedNet ? connectedNet.ssid : (wl.connectionName || "");
+ return (name + (showSpeed && speed ? " - " + speed : ""));
+ }
+ return "";
+ }
+
+ function getIcon(forceEthernet = false) {
+ if (NetworkService.airplaneModeEnabled && !forceEthernet) {
+ return "plane";
+ }
+
+ // 1. Ethernet Priority: Show Ethernet icon if connected OR if specifically requested (Panel)
+ if (root.ethernetConnected || forceEthernet) {
+ switch (root._networkConnectivity) {
+ case "limited":
+ return "ethernet-exclamation";
+ case "portal":
+ case "unknown":
+ return "ethernet-question";
+ case "full":
+ return "ethernet";
+ default:
+ return "ethernet-off";
+ }
+ }
+
+ // 2. Wi-Fi Fallback
+ if (root.wifiAvailable || !forceEthernet) {
+ const networkCount = Object.values(root.networks).length;
+ if (!root.wifiEnabled) {
+ return "wifi-off";
+ }
+ if (root.wifiConnected) {
+ let s = (root.activeWifiDetails && root.activeWifiDetails.signal !== undefined && root.activeWifiDetails.signal !== "") ? root.activeWifiDetails.signal : 0;
+ return root.getSignalInfo(s, true).icon;
+ }
+ if (root.connecting || networkCount > 0) {
+ return "wifi-question";
+ }
+ }
+ return (root.ethernetAvailable || root.ethernetConnected) ? "ethernet-off" : root.wifiAvailable ? "wifi-0" : "wifi-off";
+ }
+
+ // Processes
+ // Discover connected interface[s] and fetch details [1]
+ Process {
+ id: deviceStatusProcess
+ running: false
+ command: ["sh", "-c", "nmcli -t -f GENERAL.DEVICE,GENERAL.TYPE,GENERAL.STATE,GENERAL.CONNECTION,GENERAL.HWADDR,IP4.ADDRESS,IP4.GATEWAY,IP4.DNS,IP6.ADDRESS,IP6.GATEWAY,IP6.DNS,CAPABILITIES.SPEED device show; echo \"------\"; nmcli -t -f IN-USE,SIGNAL,RATE,CHAN,FREQ,BANDWIDTH device wifi list"]
+ environment: ({
+ "LC_ALL": "C"
+ })
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const outputParts = text.split("------");
+ const deviceText = outputParts[0];
+ const wifiText = outputParts[1] || "";
+
+ let lines = deviceText.split("\n");
+ let deviceBlocks = [];
+ let currentBlock = [];
+
+ for (let i = 0; i < lines.length; i++) {
+ let line = lines[i].trim();
+ if (!line) {
+ continue;
+ }
+ if (line.startsWith("GENERAL.DEVICE:")) {
+ if (currentBlock.length > 0) {
+ deviceBlocks.push(currentBlock);
+ }
+ currentBlock = [line];
+ } else if (currentBlock.length > 0) {
+ currentBlock.push(line);
+ }
+ }
+ if (currentBlock.length > 0) {
+ deviceBlocks.push(currentBlock);
+ }
+
+ let activeEthIf = "";
+ let activeWifiIf = "";
+ let wifiAvailable = false;
+ let ethernetAvailable = false;
+ let ethList = [];
+
+ let newActiveWifiDetails = ({});
+ let newActiveEthernetDetails = ({});
+
+ for (let b = 0; b < deviceBlocks.length; b++) {
+ let block = deviceBlocks[b];
+ let blockText = block.join("\n");
+ let details = root.parseIpDetails(blockText);
+
+ let name = "";
+ let type = "";
+ let stateStr = "";
+
+ for (let l = 0; l < block.length; l++) {
+ let line = block[l];
+ if (line.startsWith("GENERAL.DEVICE:")) {
+ name = line.substring(15).trim();
+ } else if (line.startsWith("GENERAL.TYPE:")) {
+ type = line.substring(13).trim();
+ } else if (line.startsWith("GENERAL.STATE:")) {
+ stateStr = line.substring(14).trim();
+ }
+ }
+
+ if (stateStr.indexOf("(unmanaged)") !== -1) {
+ continue;
+ }
+ let isConnected = stateStr.indexOf("(connected)") !== -1;
+
+ if (type === "ethernet") {
+ ethernetAvailable = true;
+ let stateName = stateStr.split(" ")[1] ? stateStr.split(" ")[1].replace(/[()]/g, "") : stateStr;
+ ethList.push({
+ ifname: name,
+ state: stateName,
+ connected: isConnected,
+ connectionName: details.connectionName
+ });
+ if (isConnected && !activeEthIf) {
+ activeEthIf = name;
+ newActiveEthernetDetails = details;
+ newActiveEthernetDetails.ifname = name;
+ }
+ } else if (type === "wifi") {
+ wifiAvailable = true;
+ if (isConnected && !activeWifiIf) {
+ activeWifiIf = name;
+ newActiveWifiDetails = details;
+ newActiveWifiDetails.ifname = name;
+ }
+ }
+ }
+
+ // Parse Wi-Fi details if active
+ if (activeWifiIf && wifiText) {
+ let rate = "";
+ let freq = "";
+ let channel = "";
+ let width = "";
+ let signal = "";
+
+ const wifiLines = wifiText.split("\n");
+ for (let i = 0; i < wifiLines.length; i++) {
+ const line = wifiLines[i].trim();
+ if (line.startsWith("*")) {
+ const parts = line.split(":");
+ if (parts.length >= 6) {
+ signal = parts[1];
+ rate = parts[2];
+ channel = parts[3];
+ freq = parts[4].replace(" MHz", "");
+ width = parts[5];
+ }
+ break;
+ }
+ }
+
+ let band = "";
+ if (freq) {
+ const f = +freq;
+ if (f) {
+ switch (true) {
+ case (f >= 5925 && f < 7125):
+ band = "6 GHz";
+ break;
+ case (f >= 5150 && f < 5925):
+ band = "5 GHz";
+ break;
+ case (f >= 2400 && f < 2500):
+ band = "2.4 GHz";
+ break;
+ default:
+ band = `${f} MHz`;
+ }
+ }
+ }
+
+ let rateShort = "";
+ if (rate) {
+ var rparts = rate.trim().split(" ");
+ var compact = [];
+ for (var i = 0; i < rparts.length; i++) {
+ if (rparts[i]) {
+ compact.push(rparts[i]);
+ }
+ }
+ var unitIdx = -1;
+ for (var j = 0; j < compact.length; j++) {
+ var token = compact[j].toLowerCase();
+ if (token === "mbit/s" || token === "mb/s" || token === "mbits/s") {
+ unitIdx = j;
+ break;
+ }
+ }
+ if (unitIdx > 0) {
+ var num = compact[unitIdx - 1];
+ var parsed = parseFloat(num);
+ if (!isNaN(parsed)) {
+ rateShort = parsed + " Mbit/s";
+ }
+ }
+ if (!rateShort) {
+ rateShort = compact.slice(0, 2).join(" ");
+ }
+ }
+
+ let enhancedBand = band;
+ if (channel && width && width !== "0 MHz") {
+ enhancedBand = `${band} / ${channel} (${width})`;
+ } else if (channel) {
+ enhancedBand = `${band} / ${channel}`;
+ }
+
+ if (newActiveWifiDetails.speed) {
+ newActiveWifiDetails.rate = newActiveWifiDetails.speed.replace(/Mb\/s/i, "Mbit/s");
+ newActiveWifiDetails.rateShort = newActiveWifiDetails.rate;
+ } else {
+ newActiveWifiDetails.rate = rate;
+ newActiveWifiDetails.rateShort = rateShort;
+ }
+ newActiveWifiDetails.band = enhancedBand;
+ newActiveWifiDetails.channel = channel;
+ newActiveWifiDetails.width = width;
+ newActiveWifiDetails.signal = signal;
+ }
+
+ root._wifiAvailable = wifiAvailable;
+ root._ethernetAvailable = ethernetAvailable;
+ root.ethernetConnected = (activeEthIf !== "");
+ root.wifiConnected = (activeWifiIf !== "");
+
+ Logger.d("Network", "Device sync: wifiAvailable: " + wifiAvailable + ", ethAvailable: " + ethernetAvailable + ", wifiConnected: " + root.wifiConnected + " (" + activeWifiIf + "), ethConnected: " + root.ethernetConnected + " (" + activeEthIf + ")");
+
+ ethList.sort((a, b) => (a.connected !== b.connected) ? (a.connected ? -1 : 1) : a.ifname.localeCompare(b.ifname));
+ root.ethernetInterfaces = ethList;
+
+ root.activeEthernetIf = activeEthIf;
+ root.activeEthernetDetails = newActiveEthernetDetails;
+ root.activeEthernetDetailsTimestamp = Date.now();
+ root.ethernetDetailsLoading = false;
+
+ root.activeWifiIf = activeWifiIf;
+ root.activeWifiDetails = newActiveWifiDetails;
+ root.activeWifiDetailsTimestamp = Date.now();
+ root.wifiDetailsLoading = false;
+ }
+ }
+ stderr: StdioCollector {
+ onStreamFinished: {
+ if (text && text.trim()) {
+ Logger.w("Network", "nmcli device show stderr:", text.trim());
+ }
+ root.ethernetDetailsLoading = false;
+ root.wifiDetailsLoading = false;
+ }
+ }
+ }
+
+ // Process to check the internet connectivity of the connected network
+ Process {
+ id: connectivityCheckProcess
+ running: false
+ command: ["nmcli", "networking", "connectivity", "check"]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const r = text.trim();
+ if (!r) {
+ return;
+ }
+ root._networkConnectivity = (r === "none") ? "unknown" : r;
+ root._internetConnectivity = (r === "full");
+ }
+ }
+ stderr: StdioCollector {
+ onStreamFinished: {
+ if (text.trim()) {
+ Logger.w("Network", "Connectivity check error: " + text);
+ }
+ }
+ }
+ }
+
+ // Helper process to get existing profiles
+ Process {
+ id: profileCheckProcess
+ running: false
+ command: ["nmcli", "-t", "-f", "NAME", "connection", "show"]
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ var profiles = {};
+ var lines = text.split("\n");
+ for (var i = 0; i < lines.length; i++) {
+ var l = lines[i];
+ if (l && l.trim()) {
+ profiles[l.trim()] = true;
+ }
+ }
+ root.existingProfiles = profiles;
+ scanProcess.running = true;
+ }
+ }
+ stderr: StdioCollector {
+ onStreamFinished: {
+ if (text && text.trim()) {
+ Logger.w("Network", "Profile check stderr:", text.trim());
+ if (root.scanningActive) {
+ if (root.scanPending) {
+ root.scanPending = false;
+ delayedScanTimer.interval = 3000;
+ } else {
+ delayedScanTimer.interval = 5000;
+ }
+ delayedScanTimer.restart();
+ }
+ }
+ }
+ }
+ }
+
+ // Scan for Wi-Fi networks
+ Process {
+ id: scanProcess
+ running: false
+ command: ["nmcli", "-t", "-f", "SSID,SECURITY,SIGNAL,IN-USE", "device", "wifi", "list", "--rescan", "yes"]
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const lines = text.trim().split("\n");
+ const networksMap = {};
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i].trim();
+ if (!line) {
+ continue;
+ }
+
+ // Parse SSID:SECURITY:SIGNAL:IN-USE
+ const parts = line.split(":");
+ if (parts.length < 4) {
+ continue;
+ }
+
+ const inUse = parts[parts.length - 1];
+ const signal = parseInt(parts[parts.length - 2]) || 0;
+ let security = parts[parts.length - 3];
+ if (security) {
+ security = security.replace("WPA2 WPA3", "WPA2/WPA3").replace("WPA1 WPA2", "WPA1/WPA2");
+ }
+ const ssid = parts.slice(0, parts.length - 3).join(":");
+
+ if (ssid) {
+ const isConnected = (inUse === "*");
+ if (!networksMap[ssid]) {
+ networksMap[ssid] = {
+ "ssid": ssid,
+ "security": security || "--",
+ "signal": signal,
+ "connected": isConnected,
+ "existing": !!root.existingProfiles[ssid]
+ };
+ } else {
+ if (isConnected) {
+ networksMap[ssid].connected = true;
+ networksMap[ssid].signal = signal;
+ connectivityCheckProcess.running = true;
+ } else if (!networksMap[ssid].connected && signal > networksMap[ssid].signal) {
+ networksMap[ssid].signal = signal;
+ }
+ }
+ }
+ }
+
+ // Logging & Diffing
+ const oldSSIDs = Object.keys(root.networks);
+ const newSSIDs = Object.keys(networksMap);
+ const newNetworks = newSSIDs.filter(s => oldSSIDs.indexOf(s) === -1);
+ const lostNetworks = oldSSIDs.filter(s => newSSIDs.indexOf(s) === -1);
+
+ // Always update networks, this makes more reflective of state/signal.
+ root.networks = networksMap;
+
+ if (newNetworks.length > 0 || lostNetworks.length > 0) {
+ if (newNetworks.length > 0) {
+ Logger.d("Network", "New Wi-Fi network appeared:", newNetworks.join(", "));
+ }
+ if (lostNetworks.length > 0) {
+ Logger.d("Network", "Wi-Fi network disappeared:", lostNetworks.join(", "));
+ }
+ Logger.d("Network", "Total Wi-Fi networks:", Object.keys(networksMap).length);
+ }
+
+ if (Object.values(networksMap).some(n => n.connected)) {
+ root.refreshActiveWifiDetails();
+ }
+
+ if (root.scanPending) {
+ root.scanPending = false;
+ delayedScanTimer.interval = 100;
+ delayedScanTimer.restart();
+ }
+ root.scanningActive = false;
+ }
+ }
+
+ stderr: StdioCollector {
+ onStreamFinished: {
+ if (text.trim()) {
+ Logger.w("Network", "Scan error: " + text);
+
+ // Even on error, if a scan was pending, try again
+ if (root.scanPending) {
+ root.scanPending = false;
+ delayedScanTimer.interval = 3000;
+ } else if (root.scanningActive) {
+ delayedScanTimer.interval = 10000;
+ }
+ delayedScanTimer.restart();
+ }
+ root.scanningActive = false;
+ }
+ }
+ }
+
+ // Connect to Wi-Fi network
+ Process {
+ id: connectProcess
+ property string mode: "new" // "saved", "new", or "manual"
+ property string ssid: ""
+ property string password: ""
+ property bool isHidden: false
+ // Manual properties
+ property string securityKey: ""
+ property string identity: ""
+ property string eap: "peap"
+ property string phase2: "mschapv2"
+ property string anonIdentity: ""
+ property string caCert: ""
+ running: false
+
+ command: {
+ if (mode === "saved") {
+ return ["nmcli", "-t", "connection", "up", "id", ssid];
+ } else if (mode === "manual") {
+ const nmArgs = ["connection", "add", "type", "wifi", "con-name", ssid, "ssid", ssid, "--", "802-11-wireless.hidden", isHidden ? "yes" : "no"];
+
+ if (securityKey === "wpa-psk" || securityKey === "wpa2-psk") {
+ nmArgs.push("wifi-sec.key-mgmt", "wpa-psk", "wifi-sec.psk", password);
+ } else if (securityKey === "sae") {
+ nmArgs.push("wifi-sec.key-mgmt", "sae", "wifi-sec.psk", password);
+ } else if (securityKey === "wep") {
+ nmArgs.push("wifi-sec.key-mgmt", "none", "wifi-sec.wep-key0", password);
+ } else if (securityKey && securityKey.indexOf("-eap") !== -1) {
+ nmArgs.push("wifi-sec.key-mgmt", "wpa-eap", "802-1x.eap", eap, "802-1x.phase2-auth", phase2, "802-1x.identity", identity, "802-1x.password", password);
+ if (anonIdentity) {
+ nmArgs.push("802-1x.anonymous-identity", anonIdentity);
+ }
+ if (caCert) {
+ nmArgs.push("802-1x.ca-cert", caCert);
+ }
+ }
+
+ const script = `
+ SSID="$1"
+ shift
+ # Find existing profile by Name and Type
+ UUID=$(nmcli -t -f NAME,UUID,TYPE connection show | awk -F: -v target="$SSID" '$1 == target && $3 == "802-11-wireless" { print $2; exit }')
+
+ if [ -n "$UUID" ]; then
+ echo "Using existing profile: $UUID"
+ nmcli connection delete uuid "$UUID" 2>/dev/null || true
+ else
+ echo "Creating new profile for $SSID"
+ fi
+ nmcli "$@"
+ nmcli connection up id "$SSID"
+ `;
+
+ return ["sh", "-c", script, "--", ssid].concat(nmArgs);
+ } else {
+ var cmd = ["nmcli", "-t", "device", "wifi", "connect", ssid];
+ if (isHidden) {
+ cmd.push("hidden", "yes");
+ }
+ if (password) {
+ cmd.push("password", password);
+ }
+ if (root.activeWifiIf) {
+ cmd.push("ifname", root.activeWifiIf);
+ }
+ return cmd;
+ }
+ }
+
+ environment: ({
+ "LC_ALL": "C"
+ })
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const output = text.trim();
+ if (!output || (output.indexOf("successfully activated") === -1 && output.indexOf("Connection successfully") === -1)) {
+ return;
+ }
+
+ root.wifiConnected = true;
+ root.updateNetworkStatus(connectProcess.ssid, true);
+ root.refreshActiveWifiDetails(); // This needs wifiConnected true.
+
+ root.connecting = false;
+ root.connectingTo = "";
+ Logger.i("Network", "Connected to network: '" + connectProcess.ssid + "' (" + connectProcess.mode + ")");
+ ToastService.showNotice(I18n.tr("common.wifi"), I18n.tr("toast.wifi.connected", {
+ "ssid": connectProcess.ssid
+ }), root.getIcon(false));
+
+ delayedScanTimer.interval = 5000;
+ delayedScanTimer.restart();
+ }
+ }
+
+ stderr: StdioCollector {
+ onStreamFinished: {
+ if (text.trim()) {
+ root.connecting = false;
+ root.connectingTo = "";
+
+ if (text.indexOf("Secrets were required") !== -1 || text.indexOf("no secrets provided") !== -1) {
+ root.lastError = I18n.tr("toast.wifi.incorrect-password");
+ forget(connectProcess.ssid);
+ } else if (text.indexOf("No network with SSID") !== -1) {
+ root.lastError = I18n.tr("toast.wifi.network-not-found");
+ } else if (text.indexOf("Timeout") !== -1) {
+ root.lastError = I18n.tr("toast.wifi.connection-timeout");
+ } else {
+ root.lastError = I18n.tr("toast.wifi.connection-failed");
+ }
+
+ Logger.w("Network", "Connect error (" + connectProcess.mode + "): " + text);
+ ToastService.showWarning(I18n.tr("common.wifi"), root.lastError || I18n.tr("toast.wifi.connection-failed"), "wifi-exclamation");
+ wifiConnected = false;
+ }
+ }
+ }
+ }
+
+ // Disconnect from Wi-Fi network
+ Process {
+ id: disconnectProcess
+ property string ssid: ""
+ running: false
+ command: ["nmcli", "connection", "down", "id", ssid]
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ Logger.i("Network", "Disconnected from network: '" + disconnectProcess.ssid + "'");
+ root.wifiConnected = false;
+ ToastService.showNotice(I18n.tr("common.wifi"), I18n.tr("toast.wifi.disconnected", {
+ "ssid": disconnectProcess.ssid
+ }), "wifi-off");
+
+ // Immediately update UI on successful disconnect
+ root.updateNetworkStatus(disconnectProcess.ssid, false);
+ root.disconnectingFrom = "";
+
+ // Do a scan to refresh the list
+ delayedScanTimer.interval = 3000;
+ delayedScanTimer.restart();
+ }
+ }
+
+ stderr: StdioCollector {
+ onStreamFinished: {
+ root.disconnectingFrom = "";
+ if (text.trim()) {
+ Logger.w("Network", "Disconnect error: " + text);
+ }
+ // Still trigger a scan even on error
+ delayedScanTimer.interval = 5000;
+ delayedScanTimer.restart();
+ }
+ }
+ }
+
+ // Forget given Wi-Fi network
+ Process {
+ id: forgetProcess
+ property string ssid: ""
+ running: false
+ environment: ({
+ "LC_ALL": "C"
+ })
+
+ // Try multiple common profile name patterns
+ command: {
+ var script = `
+ ssid="$1"
+ deleted=false
+
+ # Find existing profile by Name and Type
+ UUID=$(nmcli -t -f NAME,UUID,TYPE connection show | awk -F: -v target="$ssid" '$1 == target && $3 == "802-11-wireless" { print $2; exit }')
+
+ if [ -n "$UUID" ]; then
+ if nmcli connection delete uuid "$UUID" 2>/dev/null; then
+ echo "Deleted profile: $ssid ($UUID)"
+ deleted=true
+ fi
+ fi
+
+ # Fallback: try common patterns if UUID lookup failed
+ if [ "$deleted" = "false" ]; then
+ # Try "Auto $ssid" pattern
+ if nmcli connection delete id "Auto $ssid" 2>/dev/null; then
+ echo "Deleted profile: Auto $ssid"
+ deleted=true
+ fi
+
+ # Try "$ssid 1", "$ssid 2", etc. patterns
+ for i in 1 2 3; do
+ if nmcli connection delete id "$ssid $i" 2>/dev/null; then
+ echo "Deleted profile: $ssid $i"
+ deleted=true
+ fi
+ done
+ fi
+
+ if [ "$deleted" = "false" ]; then
+ echo "No profiles found for SSID: $ssid"
+ fi
+ `;
+
+ return ["sh", "-c", script, "--", ssid];
+ }
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ Logger.i("Network", "Forget network: \"" + forgetProcess.ssid + "\"");
+ Logger.d("Network", text.trim().replace(/[\r\n]/g, " "));
+
+ // Update existing status immediately
+ let nets = root.networks;
+ if (nets[forgetProcess.ssid]) {
+ nets[forgetProcess.ssid].existing = false;
+ // Trigger property change
+ root.networks = ({});
+ root.networks = nets;
+ }
+
+ root.forgettingNetwork = "";
+
+ // Scan to verify the profile is gone
+ delayedScanTimer.interval = 5000;
+ delayedScanTimer.restart();
+ }
+ }
+
+ stderr: StdioCollector {
+ onStreamFinished: {
+ root.forgettingNetwork = "";
+ if (text.trim() && text.indexOf("No profiles found") === -1) {
+ Logger.w("Network", "Forget error: " + text);
+ }
+ // Still Trigger a scan even on error
+ delayedScanTimer.interval = 5000;
+ delayedScanTimer.restart();
+ }
+ }
+ }
+
+ // Listen to NetworkManager events in real-time (roaming, auto-connect) -- ~9mb Memory usage.
+ Process {
+ id: networkMonitorProcess
+ running: ProgramCheckerService.nmcliAvailable
+ command: ["nmcli", "-t", "monitor"]
+ environment: ({
+ "LC_ALL": "C"
+ })
+ stdout: SplitParser {
+ onRead: data => {
+ if (data.endsWith(": connected") || data.endsWith(": disconnected")) {
+ Logger.d("Network", "State changed: " + data);
+ deviceStatusProcess.running = true;
+ connectivityCheckProcess.running = true;
+ }
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Networking/VPNService.qml b/arch/.config/quickshell/noctalia-shell/Services/Networking/VPNService.qml
new file mode 100644
index 0000000..d867307
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Networking/VPNService.qml
@@ -0,0 +1,287 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+import qs.Services.UI
+
+Singleton {
+ id: root
+
+ property var connections: ({})
+ property bool refreshing: false
+ property bool connecting: false
+ property bool disconnecting: false
+ property string connectingUuid: ""
+ property string disconnectingUuid: ""
+ property string lastError: ""
+ property bool refreshPending: false
+
+ readonly property var activeConnections: {
+ const result = [];
+ const map = connections;
+ for (const key in map) {
+ const conn = map[key];
+ if (conn && conn.active) {
+ result.push(conn);
+ }
+ }
+ return result;
+ }
+
+ readonly property var inactiveConnections: {
+ const result = [];
+ const map = connections;
+ for (const key in map) {
+ const conn = map[key];
+ if (conn && !conn.active) {
+ result.push(conn);
+ }
+ }
+ return result;
+ }
+
+ readonly property bool hasActiveConnection: activeConnections.length > 0
+
+ Timer {
+ id: refreshTimer
+ interval: 5000
+ running: true
+ repeat: true
+ onTriggered: refresh()
+ }
+
+ Timer {
+ id: delayedRefreshTimer
+ interval: 1000
+ repeat: false
+ onTriggered: refresh()
+ }
+
+ Component.onCompleted: {
+ Logger.i("VPN", "Service started");
+ refresh();
+ }
+
+ function refresh() {
+ if (refreshing) {
+ refreshPending = true;
+ return;
+ }
+ refreshing = true;
+ lastError = "";
+ refreshProcess.running = true;
+ }
+
+ function connect(uuid) {
+ if (connecting || !uuid) {
+ return;
+ }
+ const conn = connections[uuid];
+ if (!conn) {
+ return;
+ }
+ connecting = true;
+ connectingUuid = uuid;
+ lastError = "";
+ connectProcess.uuid = uuid;
+ connectProcess.name = conn.name;
+ connectProcess.running = true;
+ }
+
+ function disconnect(uuid) {
+ if (disconnecting || !uuid) {
+ return;
+ }
+ const conn = connections[uuid];
+ if (!conn) {
+ return;
+ }
+ disconnecting = true;
+ disconnectingUuid = uuid;
+ lastError = "";
+ disconnectProcess.uuid = uuid;
+ disconnectProcess.name = conn.name;
+ disconnectProcess.running = true;
+ }
+
+ function toggle(uuid) {
+ const conn = connections[uuid];
+ if (!conn) {
+ return;
+ }
+ if (conn.active) {
+ disconnect(uuid);
+ } else {
+ connect(uuid);
+ }
+ }
+
+ function setConnection(uuid, data) {
+ if (!uuid) {
+ return;
+ }
+ const map = Object.assign({}, connections);
+ if (map[uuid]) {
+ map[uuid] = Object.assign({}, map[uuid], data);
+ connections = map;
+ }
+ }
+
+ function scheduleRefresh(interval) {
+ delayedRefreshTimer.interval = interval;
+ delayedRefreshTimer.restart();
+ }
+
+ Process {
+ id: refreshProcess
+ running: false
+ command: ["nmcli", "-t", "-f", "NAME,UUID,TYPE,DEVICE", "connection", "show"]
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const lines = text.split("\n");
+ const map = {};
+ for (let i = 0; i < lines.length; ++i) {
+ const line = lines[i].trim();
+ if (!line) {
+ continue;
+ }
+ const lastColonIdx = line.lastIndexOf(":");
+ if (lastColonIdx === -1) {
+ continue;
+ }
+ const device = line.substring(lastColonIdx + 1);
+ const remaining = line.substring(0, lastColonIdx);
+ const secondLastColonIdx = remaining.lastIndexOf(":");
+ if (secondLastColonIdx === -1) {
+ continue;
+ }
+ const type = remaining.substring(secondLastColonIdx + 1);
+ if (type !== "vpn" && type !== "wireguard") {
+ continue;
+ }
+ const remaining2 = remaining.substring(0, secondLastColonIdx);
+ const thirdLastColonIdx = remaining2.lastIndexOf(":");
+ if (thirdLastColonIdx === -1) {
+ continue;
+ }
+ const uuid = remaining2.substring(thirdLastColonIdx + 1);
+ const name = remaining2.substring(0, thirdLastColonIdx);
+ if (!uuid || !name) {
+ continue;
+ }
+ const active = device && device !== "--";
+ map[uuid] = {
+ "uuid": uuid,
+ "name": name,
+ "device": device,
+ "active": active
+ };
+ }
+ connections = map;
+ const pending = refreshPending;
+ refreshing = false;
+ refreshPending = false;
+ if (pending) {
+ scheduleRefresh(200);
+ }
+ }
+ }
+
+ stderr: StdioCollector {
+ onStreamFinished: {
+ const pending = refreshPending;
+ refreshing = false;
+ refreshPending = false;
+ if (text.trim()) {
+ lastError = text.split("\n")[0].trim();
+ Logger.w("VPN", "Refresh error: " + text);
+ }
+ if (pending) {
+ scheduleRefresh(2000);
+ }
+ }
+ }
+ }
+
+ Process {
+ id: connectProcess
+ property string uuid: ""
+ property string name: ""
+ running: false
+ command: ["nmcli", "connection", "up", "uuid", uuid]
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const output = text.trim();
+ if (!output || (!output.includes("successfully activated") && !output.includes("Connection successfully"))) {
+ return;
+ }
+ setConnection(connectProcess.uuid, {
+ "active": true
+ });
+ connecting = false;
+ connectingUuid = "";
+ lastError = "";
+ Logger.i("VPN", "Connected to " + connectProcess.name);
+ ToastService.showNotice(connectProcess.name, I18n.tr("toast.vpn.connected", {
+ "name": connectProcess.name
+ }), "shield-lock");
+ scheduleRefresh(1000);
+ }
+ }
+
+ stderr: StdioCollector {
+ onStreamFinished: {
+ const trimmed = text.trim();
+ if (trimmed) {
+ lastError = trimmed.split("\n")[0].trim();
+ Logger.w("VPN", "Connect error: " + trimmed);
+ ToastService.showWarning(connectProcess.name, lastError);
+ }
+ connecting = false;
+ connectingUuid = "";
+ }
+ }
+ }
+
+ Process {
+ id: disconnectProcess
+ property string uuid: ""
+ property string name: ""
+ running: false
+ command: ["nmcli", "connection", "down", "uuid", uuid]
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ Logger.i("VPN", "Disconnected from " + disconnectProcess.name);
+ setConnection(disconnectProcess.uuid, {
+ "active": false,
+ "device": ""
+ });
+ disconnecting = false;
+ disconnectingUuid = "";
+ lastError = "";
+ ToastService.showNotice(disconnectProcess.name, I18n.tr("toast.vpn.disconnected", {
+ "name": disconnectProcess.name
+ }), "shield-off");
+ scheduleRefresh(1000);
+ }
+ }
+
+ stderr: StdioCollector {
+ onStreamFinished: {
+ const trimmed = text.trim();
+ if (trimmed) {
+ lastError = trimmed.split("\n")[0].trim();
+ Logger.w("VPN", "Disconnect error: " + trimmed);
+ ToastService.showWarning(disconnectProcess.name, lastError);
+ }
+ disconnecting = false;
+ disconnectingUuid = "";
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Noctalia/GitHubService.qml b/arch/.config/quickshell/noctalia-shell/Services/Noctalia/GitHubService.qml
new file mode 100644
index 0000000..d23a4d2
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Noctalia/GitHubService.qml
@@ -0,0 +1,336 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+import qs.Services.UI
+
+// GitHub API logic for contributors
+Singleton {
+ id: root
+
+ property string githubDataFile: Quickshell.env("NOCTALIA_GITHUB_FILE") || (Settings.cacheDir + "github.json")
+ property int githubUpdateFrequency: 60 * 60 // 1 hour expressed in seconds
+ property bool isFetchingData: false
+ readonly property alias data: adapter // Used to access via GitHubService.data.xxx.yyy
+
+ // Public properties for easy access
+ property string latestVersion: I18n.tr("common.unknown")
+ property string latestQSVersion: I18n.tr("common.unknown")
+ property var contributors: []
+
+ // Avatar caching properties (simplified - uses ImageCacheService)
+ property var cachedAvatars: ({}) // username โ file:// path
+ property bool avatarsCached: false // Track if we've already processed avatars
+
+ property bool isInitialized: false
+
+ FileView {
+ id: githubDataFileView
+ path: githubDataFile
+ printErrors: false
+ watchChanges: false // Disable to prevent reload on our own writes
+ Component.onCompleted: {
+ // Data loading handled by FileView onLoaded
+ }
+ onLoaded: {
+ if (!root.isInitialized) {
+ root.isInitialized = true;
+ loadFromCache();
+ }
+ }
+ onLoadFailed: function (error) {
+ if (error.toString().includes("No such file") || error === 2) {
+ // No cache file exists, fetch fresh data
+ root.isInitialized = true;
+ fetchFromGitHub();
+ }
+ }
+
+ JsonAdapter {
+ id: adapter
+
+ property string version: I18n.tr("common.unknown")
+ property string qsVersion: I18n.tr("common.unknown")
+ property var contributors: []
+ property real timestamp: 0
+ }
+ }
+
+ // --------------------------------
+ function init() {
+ Logger.i("GitHub", "Service started");
+ // FileView will handle loading automatically via onLoaded
+ }
+
+ // --------------------------------
+ function loadFromCache() {
+ const now = Time.timestamp;
+ var needsRefetch = false;
+
+ Logger.i("GitHub", "Checking cache - timestamp:", data.timestamp, "now:", now, "age:", data.timestamp ? Math.round((now - data.timestamp) / 60) : "N/A", "minutes");
+
+ if (!data.timestamp || (now >= data.timestamp + githubUpdateFrequency)) {
+ needsRefetch = true;
+ Logger.i("GitHub", "Cache expired or missing, scheduling fetch (update frequency:", Math.round(githubUpdateFrequency / 60), "minutes)");
+ } else {
+ Logger.i("GitHub", "Cache is fresh, using cached data (age:", Math.round((now - data.timestamp) / 60) + " minutes)");
+ }
+
+ if (data.version) {
+ root.latestVersion = data.version;
+ }
+ if (data.qsVersion) {
+ root.latestQSVersion = data.qsVersion;
+ }
+ if (data.contributors && data.contributors.length > 0) {
+ root.contributors = data.contributors;
+ Logger.d("GitHub", "Loaded", data.contributors.length, "contributors from cache");
+ }
+
+ if (needsRefetch) {
+ fetchFromGitHub();
+ }
+ }
+
+ // --------------------------------
+ function fetchFromGitHub() {
+ if (isFetchingData) {
+ Logger.d("GitHub", "GitHub data is still fetching");
+ return;
+ }
+
+ isFetchingData = true;
+ versionProcess.running = true;
+ qsVersionProcess.running = true;
+ contributorsProcess.running = true;
+ }
+
+ // --------------------------------
+ function saveData() {
+ data.timestamp = Time.timestamp;
+ Logger.d("GitHub", "Saving data to cache file:", githubDataFile, "with timestamp:", data.timestamp);
+ Logger.d("GitHub", "Data to save - version:", data.version, "qsVersion:", data.qsVersion, "contributors:", data.contributors.length);
+
+ // Ensure cache directory exists
+ Quickshell.execDetached(["mkdir", "-p", Settings.cacheDir]);
+
+ try {
+ // Write immediately instead of Qt.callLater to ensure it completes
+ githubDataFileView.writeAdapter();
+ Logger.d("GitHub", "Cache file written successfully");
+ } catch (error) {
+ Logger.e("GitHub", "Failed to write cache file:", error);
+ }
+ }
+
+ // --------------------------------
+ function checkAndSaveData() {
+ // Only save when all processes are finished
+ if (!versionProcess.running && !qsVersionProcess.running && !contributorsProcess.running) {
+ root.isFetchingData = false;
+
+ // Check results
+ var anySucceeded = versionProcess.fetchSucceeded || qsVersionProcess.fetchSucceeded || contributorsProcess.fetchSucceeded;
+ var wasRateLimited = versionProcess.wasRateLimited || qsVersionProcess.wasRateLimited || contributorsProcess.wasRateLimited;
+
+ if (anySucceeded) {
+ root.saveData();
+ Logger.d("GitHub", "Successfully fetched data from GitHub");
+ } else if (wasRateLimited) {
+ root.saveData();
+ Logger.w("GitHub", "API rate limited - using cached data (retry in", Math.round(githubUpdateFrequency / 60), "minutes)");
+ } else {
+ Logger.w("GitHub", "API request failed - using cached data without updating timestamp");
+ }
+
+ // Reset fetch flags for next time
+ versionProcess.fetchSucceeded = false;
+ versionProcess.wasRateLimited = false;
+ qsVersionProcess.fetchSucceeded = false;
+ qsVersionProcess.wasRateLimited = false;
+ contributorsProcess.fetchSucceeded = false;
+ contributorsProcess.wasRateLimited = false;
+ }
+ }
+
+ // --------------------------------
+ function resetCache() {
+ data.version = I18n.tr("common.unknown");
+ data.qsVersion = I18n.tr("common.unknown");
+ data.contributors = [];
+ data.timestamp = 0;
+
+ // Try to fetch immediately
+ fetchFromGitHub();
+ }
+
+ // --------------------------------
+ // Avatar Caching Functions (simplified - uses ImageCacheService)
+ // --------------------------------
+
+ function getAvatarPath(username) {
+ return cachedAvatars[username] || "";
+ }
+
+ function cacheTopContributorAvatars() {
+ if (contributors.length === 0)
+ return;
+
+ avatarsCached = true;
+
+ for (var i = 0; i < Math.min(contributors.length, 20); i++) {
+ var contributor = contributors[i];
+ var username = contributor.login;
+ var avatarUrl = contributor.avatar_url;
+
+ // Use closure to capture username
+ (function (uname, url) {
+ ImageCacheService.getCircularAvatar(url, uname, function (cachedPath, success) {
+ if (success) {
+ cachedAvatars[uname] = "file://" + cachedPath;
+ cachedAvatarsChanged();
+ }
+ });
+ })(username, avatarUrl);
+ }
+ }
+
+ // --------------------------------
+ // Hook into contributors change - only process once
+ onContributorsChanged: {
+ if (contributors.length > 0 && !avatarsCached && ImageCacheService.initialized) {
+ Qt.callLater(cacheTopContributorAvatars);
+ }
+ }
+
+ // Also watch for ImageCacheService to become initialized
+ Connections {
+ target: ImageCacheService
+ function onInitializedChanged() {
+ if (ImageCacheService.initialized && contributors.length > 0 && !avatarsCached) {
+ Qt.callLater(cacheTopContributorAvatars);
+ }
+ }
+ }
+
+ Process {
+ id: versionProcess
+
+ property bool fetchSucceeded: false
+ property bool wasRateLimited: false
+
+ command: ["curl", "-s", "https://api.github.com/repos/noctalia-dev/noctalia-shell/releases/latest"]
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try {
+ const response = text;
+ if (response && response.trim()) {
+ const data = JSON.parse(response);
+ if (data.tag_name) {
+ const version = data.tag_name;
+ root.data.version = version;
+ root.latestVersion = version;
+ versionProcess.fetchSucceeded = true;
+ Logger.d("GitHub", "Latest version fetched:", version);
+ } else if (data.message) {
+ // Check if it's a rate limit error
+ if (data.message.includes("rate limit")) {
+ versionProcess.wasRateLimited = true;
+ } else {
+ Logger.w("GitHub", "Version API error:", data.message);
+ }
+ }
+ }
+ } catch (e) {
+ Logger.e("GitHub", "Failed to parse version response:", e);
+ }
+
+ // Check if all processes are done
+ checkAndSaveData();
+ }
+ }
+ }
+
+ Process {
+ id: qsVersionProcess
+
+ property bool fetchSucceeded: false
+ property bool wasRateLimited: false
+
+ command: ["curl", "-s", "https://api.github.com/repos/noctalia-dev/noctalia-qs/releases/latest"]
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try {
+ const response = text;
+ if (response && response.trim()) {
+ const data = JSON.parse(response);
+ if (data.tag_name) {
+ const version = data.tag_name;
+ root.data.qsVersion = version;
+ root.latestQSVersion = version;
+ qsVersionProcess.fetchSucceeded = true;
+ Logger.d("GitHub", "Latest QS version fetched:", version);
+ } else if (data.message) {
+ // Check if it's a rate limit error
+ if (data.message.includes("rate limit")) {
+ qsVersionProcess.wasRateLimited = true;
+ } else {
+ Logger.w("GitHub", "QS Version API error:", data.message);
+ }
+ }
+ }
+ } catch (e) {
+ Logger.e("GitHub", "Failed to parse QS version response:", e);
+ }
+
+ // Check if all processes are done
+ checkAndSaveData();
+ }
+ }
+ }
+
+ Process {
+ id: contributorsProcess
+
+ property bool fetchSucceeded: false
+ property bool wasRateLimited: false
+
+ command: ["curl", "-s", "https://api.github.com/repos/noctalia-dev/noctalia-shell/contributors?per_page=100"]
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try {
+ const response = text;
+ Logger.d("GitHub", "Raw contributors response length:", response ? response.length : 0);
+ if (response && response.trim()) {
+ const data = JSON.parse(response);
+ Logger.d("GitHub", "Parsed contributors data type:", typeof data, "length:", Array.isArray(data) ? data.length : "not array");
+ // Only update if we got a valid array
+ if (Array.isArray(data)) {
+ root.data.contributors = data;
+ root.contributors = root.data.contributors;
+ contributorsProcess.fetchSucceeded = true;
+ Logger.d("GitHub", "Contributors fetched:", root.contributors.length);
+ } else if (data.message) {
+ // Check if it's a rate limit error
+ if (data.message.includes("rate limit")) {
+ contributorsProcess.wasRateLimited = true;
+ } else {
+ Logger.w("GitHub", "Contributors API error:", data.message);
+ }
+ }
+ }
+ } catch (e) {
+ Logger.e("GitHub", "Failed to parse contributors response:", e);
+ }
+
+ // Check if all processes are done
+ checkAndSaveData();
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Noctalia/PluginRegistry.qml b/arch/.config/quickshell/noctalia-shell/Services/Noctalia/PluginRegistry.qml
new file mode 100644
index 0000000..4364ea0
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Noctalia/PluginRegistry.qml
@@ -0,0 +1,620 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import "../../Helpers/sha256.js" as Crypto
+import qs.Commons
+
+Singleton {
+ id: root
+
+ readonly property string pluginsDir: Settings.configDir + "plugins"
+ readonly property string pluginsFile: Settings.configDir + "plugins.json"
+
+ readonly property int currentVersion: 2
+ // Main source URL - plugins from this source keep plain IDs
+ readonly property string mainSourceUrl: "https://github.com/noctalia-dev/noctalia-plugins"
+
+ Component.onCompleted: {
+ ensurePluginsDirectory();
+ ensurePluginsFile();
+ }
+
+ // Generate a short hash (6 characters) from a source URL
+ function generateSourceHash(sourceUrl) {
+ var hash = Crypto.sha256(sourceUrl);
+ return hash.substring(0, 6);
+ }
+
+ // Check if a source is the main Noctalia plugins repository
+ function isMainSource(sourceUrl) {
+ return sourceUrl === root.mainSourceUrl;
+ }
+
+ // Generate composite key: plain ID for official, "hash:id" for custom
+ function generateCompositeKey(pluginId, sourceUrl) {
+ if (!sourceUrl || isMainSource(sourceUrl)) {
+ return pluginId;
+ }
+ var hash = generateSourceHash(sourceUrl);
+ return hash + ":" + pluginId;
+ }
+
+ // Parse composite key back to components
+ function parseCompositeKey(compositeKey) {
+ var colonIndex = compositeKey.indexOf(":");
+ // If no colon or colon is after position 6 (hash length), it's a plain ID
+ if (colonIndex === -1 || colonIndex > 6) {
+ return {
+ sourceHash: null,
+ pluginId: compositeKey,
+ isOfficial: true
+ };
+ }
+ // Has hash prefix (custom source plugin)
+ return {
+ sourceHash: compositeKey.substring(0, colonIndex),
+ pluginId: compositeKey.substring(colonIndex + 1),
+ isOfficial: false
+ };
+ }
+
+ // Get source name by URL
+ function getSourceNameByUrl(sourceUrl) {
+ for (var i = 0; i < root.pluginSources.length; i++) {
+ if (root.pluginSources[i].url === sourceUrl) {
+ return root.pluginSources[i].name;
+ }
+ }
+ return null;
+ }
+
+ // Get source name by hash
+ function getSourceNameByHash(hash) {
+ for (var i = 0; i < root.pluginSources.length; i++) {
+ if (generateSourceHash(root.pluginSources[i].url) === hash) {
+ return root.pluginSources[i].name;
+ }
+ }
+ return null;
+ }
+
+ // Get source URL from plugin state
+ function getPluginSourceUrl(compositeKey) {
+ var state = root.pluginStates[compositeKey];
+ return state?.sourceUrl || root.mainSourceUrl;
+ }
+
+ // Signals
+ signal pluginsChanged
+
+ // In-memory plugin cache (populated by scanning disk)
+ property var installedPlugins: ({}) // { pluginId: manifest }
+ property var pluginStates: ({}) // { pluginId: { enabled: bool } }
+ property var pluginSources: [] // Array of { name, url }
+ property var pluginLoadVersions: ({}) // { pluginId: versionNumber } - for cache busting
+
+ // Track async loading
+ property int pendingManifests: 0
+
+ // File storage (minimal - only states and sources)
+ property FileView pluginsFileView: FileView {
+ id: pluginsFileView
+ path: root.pluginsFile
+
+ adapter: JsonAdapter {
+ id: adapter
+ property int version: root.currentVersion
+ property var states: ({})
+ property list sources: []
+ }
+
+ onLoaded: {
+ Logger.i("PluginRegistry", "Loaded plugin states from:", path);
+ root.pluginStates = adapter.states || {};
+ root.pluginSources = adapter.sources || [];
+
+ // Ensure default repo is in sources
+ if (root.pluginSources.length === 0) {
+ root.pluginSources = [
+ {
+ "name": "Noctalia Plugins",
+ "url": "https://github.com/noctalia-dev/noctalia-plugins",
+ "enabled": true
+ }
+ ];
+ root.save();
+ }
+
+ // Migrate from v1 to v2 (add sourceUrl to states)
+ root.migratePluginData();
+
+ // Scan plugin folder to discover installed plugins
+ scanPluginFolder();
+ }
+
+ onLoadFailed: function (error) {
+ Logger.w("PluginRegistry", "Failed to load plugins.json, will create it:", error);
+ // Initialize defaults and continue
+ root.pluginStates = {};
+ root.pluginSources = [
+ {
+ "name": "Noctalia Plugins",
+ "url": "https://github.com/noctalia-dev/noctalia-plugins",
+ "enabled": true
+ }
+ ];
+ // Scan for installed plugins
+ root.scanPluginFolder();
+ }
+ }
+
+ function init() {
+ Logger.d("PluginRegistry", "Initialized");
+ // Force instantiation of PluginService to set up signal listener
+ PluginService.initialized;
+ }
+
+ // Migrate plugin data from older versions
+ function migratePluginData() {
+ var needsSave = false;
+
+ // Migration v1 -> v2: add sourceUrl to states
+ for (var pluginId in root.pluginStates) {
+ if (root.pluginStates[pluginId].sourceUrl === undefined) {
+ Logger.i("PluginRegistry", "Migrating plugin data to v2 (adding sourceUrl)");
+
+ var newStates = {};
+ for (var id in root.pluginStates) {
+ // For v1 -> v2 migration, we assume plugins are from main source
+ // Custom plugins installed before this feature need to be reinstalled
+ newStates[id] = {
+ enabled: root.pluginStates[id].enabled,
+ sourceUrl: root.mainSourceUrl
+ };
+ }
+ root.pluginStates = newStates;
+ needsSave = true;
+ break;
+ }
+ }
+
+ // Migration: rename "Official Noctalia Plugins" -> "Noctalia Plugins"
+ var newSources = [];
+ var sourcesChanged = false;
+ for (var i = 0; i < root.pluginSources.length; i++) {
+ var source = root.pluginSources[i];
+ if (source.name === "Official Noctalia Plugins") {
+ newSources.push({
+ name: "Noctalia Plugins",
+ url: source.url,
+ enabled: source.enabled
+ });
+ sourcesChanged = true;
+ Logger.i("PluginRegistry", "Migrating source name: 'Official Noctalia Plugins' -> 'Noctalia Plugins'");
+ } else {
+ newSources.push(source);
+ }
+ }
+ if (sourcesChanged) {
+ root.pluginSources = newSources;
+ needsSave = true;
+ }
+
+ if (needsSave) {
+ root.save();
+ Logger.i("PluginRegistry", "Migration complete");
+ }
+ }
+
+ // Ensure plugins directory exists
+ function ensurePluginsDirectory() {
+ var mkdirProcess = Qt.createQmlObject(`
+ import QtQuick
+ import Quickshell.Io
+ Process {
+ command: ["mkdir", "-p", "${root.pluginsDir}"]
+ }
+ `, root, "MkdirPlugins");
+
+ mkdirProcess.exited.connect(function (exitCode) {
+ if (exitCode === 0) {
+ Logger.d("PluginRegistry", "Plugins directory ensured:", root.pluginsDir);
+ } else {
+ Logger.e("PluginRegistry", "Failed to create plugins directory");
+ }
+ mkdirProcess.destroy();
+ });
+
+ mkdirProcess.running = true;
+ }
+
+ // Ensure plugins.json exists (create minimal one if it doesn't)
+ function ensurePluginsFile() {
+ var checkProcess = Qt.createQmlObject(`
+ import QtQuick
+ import Quickshell.Io
+ Process {
+ command: ["sh", "-c", "test -f '${root.pluginsFile}' || echo '{\\"version\\":${root.currentVersion},\\"states\\":{},\\"sources\\":[]}' > '${root.pluginsFile}'"]
+ }
+ `, root, "EnsurePluginsFile");
+
+ checkProcess.exited.connect(function (exitCode) {
+ if (exitCode === 0) {
+ Logger.d("PluginRegistry", "Plugins file ensured:", root.pluginsFile);
+ }
+ checkProcess.destroy();
+ });
+
+ checkProcess.running = true;
+ }
+
+ // Scan plugin folder to discover installed plugins (single process reads all manifests)
+ function scanPluginFolder() {
+ Logger.i("PluginRegistry", "Scanning plugin folder:", root.pluginsDir);
+
+ var scanProcess = Qt.createQmlObject(`
+ import QtQuick
+ import Quickshell.Io
+ Process {
+ command: ["sh", "-c", "for d in '${root.pluginsDir}'/*/; do [ -d \\"$d\\" ] || continue; [ -f \\"$d/manifest.json\\" ] || continue; echo \\"@@PLUGIN@@$(basename \\"$d\\")\\" ; cat \\"$d/manifest.json\\" ; done"]
+ stdout: StdioCollector {}
+ running: true
+ }
+ `, root, "ScanAllPlugins");
+
+ scanProcess.exited.connect(function (exitCode) {
+ var output = String(scanProcess.stdout.text || "");
+ var sections = output.split("@@PLUGIN@@");
+ var loadedCount = 0;
+
+ for (var i = 1; i < sections.length; i++) {
+ var section = sections[i];
+ var newlineIdx = section.indexOf('\n');
+ if (newlineIdx === -1)
+ continue;
+
+ var pluginId = section.substring(0, newlineIdx).trim();
+ var manifestJson = section.substring(newlineIdx + 1).trim();
+
+ if (!pluginId || !manifestJson)
+ continue;
+
+ try {
+ var manifest = JSON.parse(manifestJson);
+ var validation = validateManifest(manifest);
+
+ if (validation.valid) {
+ manifest.compositeKey = pluginId;
+ root.installedPlugins[pluginId] = manifest;
+ Logger.i("PluginRegistry", "Loaded plugin:", pluginId, "-", manifest.name);
+
+ if (!root.pluginStates[pluginId]) {
+ root.pluginStates[pluginId] = {
+ enabled: false
+ };
+ }
+ loadedCount++;
+ } else {
+ Logger.e("PluginRegistry", "Invalid manifest for", pluginId + ":", validation.error);
+ }
+ } catch (e) {
+ Logger.e("PluginRegistry", "Failed to parse manifest for", pluginId + ":", e.toString());
+ }
+ }
+
+ Logger.i("PluginRegistry", "All plugin manifests loaded. Total plugins:", loadedCount);
+ root.pluginsChanged();
+ scanProcess.destroy();
+ });
+ }
+
+ // Load a single plugin's manifest from disk
+ function loadPluginManifest(pluginId) {
+ var manifestPath = root.pluginsDir + "/" + pluginId + "/manifest.json";
+
+ var catProcess = Qt.createQmlObject(`
+ import QtQuick
+ import Quickshell.Io
+ Process {
+ command: ["cat", "${manifestPath}"]
+ stdout: StdioCollector {}
+ running: true
+ }
+ `, root, "LoadManifest_" + pluginId);
+
+ catProcess.exited.connect(function (exitCode) {
+ var output = String(catProcess.stdout.text || "");
+ if (exitCode === 0 && output) {
+ try {
+ var manifest = JSON.parse(output);
+ var validation = validateManifest(manifest);
+
+ if (validation.valid) {
+ manifest.compositeKey = pluginId;
+ root.installedPlugins[pluginId] = manifest;
+ Logger.i("PluginRegistry", "Loaded plugin:", pluginId, "-", manifest.name);
+
+ // Ensure state exists (default to disabled)
+ if (!root.pluginStates[pluginId]) {
+ root.pluginStates[pluginId] = {
+ enabled: false
+ };
+ }
+ } else {
+ Logger.e("PluginRegistry", "Invalid manifest for", pluginId + ":", validation.error);
+ }
+ } catch (e) {
+ Logger.e("PluginRegistry", "Failed to parse manifest for", pluginId + ":", e.toString());
+ }
+ } else {
+ Logger.d("PluginRegistry", "No manifest found for:", pluginId);
+ }
+
+ // Decrement pending count and emit signal when all are done
+ root.pendingManifests--;
+ Logger.d("PluginRegistry", "Pending manifests remaining:", root.pendingManifests);
+ if (root.pendingManifests === 0) {
+ var installedIds = Object.keys(root.installedPlugins);
+ Logger.i("PluginRegistry", "All plugin manifests loaded. Total plugins:", installedIds.length);
+ Logger.d("PluginRegistry", "Installed plugin IDs:", JSON.stringify(installedIds));
+ root.pluginsChanged();
+ }
+
+ catProcess.destroy();
+ });
+ }
+
+ // Save registry to disk (only states and sources)
+ function save() {
+ adapter.version = root.currentVersion;
+ adapter.states = root.pluginStates;
+ adapter.sources = root.pluginSources;
+
+ Qt.callLater(() => {
+ pluginsFileView.writeAdapter();
+ Logger.d("PluginRegistry", "Plugin states saved");
+ });
+ }
+
+ // Enable/disable a plugin
+ function setPluginEnabled(pluginId, enabled) {
+ if (!root.installedPlugins[pluginId]) {
+ Logger.w("PluginRegistry", "Cannot set state for non-existent plugin:", pluginId);
+ return;
+ }
+
+ if (!root.pluginStates[pluginId]) {
+ root.pluginStates[pluginId] = {
+ enabled: enabled
+ };
+ } else {
+ root.pluginStates[pluginId].enabled = enabled;
+ }
+
+ save();
+ root.pluginsChanged();
+ Logger.i("PluginRegistry", "Plugin", pluginId, enabled ? "enabled" : "disabled");
+ }
+
+ // Check if plugin is enabled
+ function isPluginEnabled(pluginId) {
+ return root.pluginStates[pluginId]?.enabled || false;
+ }
+
+ // Check if plugin is downloaded/installed
+ function isPluginDownloaded(pluginId) {
+ return pluginId in root.installedPlugins;
+ }
+
+ // Get plugin manifest from cache
+ function getPluginManifest(pluginId) {
+ return root.installedPlugins[pluginId] || null;
+ }
+
+ // Get ALL installed plugin IDs (discovered from disk)
+ function getAllInstalledPluginIds() {
+ return Object.keys(root.installedPlugins);
+ }
+
+ // Get enabled plugin IDs only
+ function getEnabledPluginIds() {
+ return Object.keys(root.pluginStates).filter(function (id) {
+ return root.pluginStates[id].enabled === true;
+ });
+ }
+
+ // Register a plugin (add to installed plugins after download)
+ // sourceUrl is required for new plugins to generate composite key
+ function registerPlugin(manifest, sourceUrl) {
+ var compositeKey = generateCompositeKey(manifest.id, sourceUrl);
+ manifest.compositeKey = compositeKey;
+ root.installedPlugins[compositeKey] = manifest;
+
+ // Ensure state exists (default to disabled, store sourceUrl)
+ if (!root.pluginStates[compositeKey]) {
+ root.pluginStates[compositeKey] = {
+ enabled: false,
+ sourceUrl: sourceUrl || root.mainSourceUrl
+ };
+ } else {
+ // Preserve enabled state but update sourceUrl
+ root.pluginStates[compositeKey].sourceUrl = sourceUrl || root.mainSourceUrl;
+ }
+
+ save();
+ root.pluginsChanged();
+ Logger.i("PluginRegistry", "Registered plugin:", compositeKey);
+ return compositeKey;
+ }
+
+ // Unregister a plugin (remove from registry)
+ function unregisterPlugin(pluginId) {
+ delete root.pluginStates[pluginId];
+ delete root.installedPlugins[pluginId];
+ save();
+ root.pluginsChanged();
+ Logger.i("PluginRegistry", "Unregistered plugin:", pluginId);
+ }
+
+ // Increment plugin load version (for cache busting when plugin is updated)
+ function incrementPluginLoadVersion(pluginId) {
+ var versions = Object.assign({}, root.pluginLoadVersions);
+ versions[pluginId] = (versions[pluginId] || 0) + 1;
+ root.pluginLoadVersions = versions;
+ Logger.d("PluginRegistry", "Incremented load version for", pluginId, "to", versions[pluginId]);
+ return versions[pluginId];
+ }
+
+ // Remove plugin state (call after deleting plugin folder)
+ function removePluginState(pluginId) {
+ delete root.pluginStates[pluginId];
+ delete root.installedPlugins[pluginId];
+ save();
+ root.pluginsChanged();
+ Logger.i("PluginRegistry", "Removed plugin state:", pluginId);
+ }
+
+ // Add a plugin source
+ function addPluginSource(name, url) {
+ for (var i = 0; i < root.pluginSources.length; i++) {
+ if (root.pluginSources[i].url === url) {
+ Logger.w("PluginRegistry", "Source already exists:", url);
+ return false;
+ }
+ }
+
+ // Create a new array to trigger property change notification
+ var newSources = root.pluginSources.slice();
+ newSources.push({
+ name: name,
+ url: url,
+ enabled: true
+ });
+ root.pluginSources = newSources;
+ save();
+ Logger.i("PluginRegistry", "Added plugin source:", name);
+ return true;
+ }
+
+ // Remove a plugin source
+ function removePluginSource(url) {
+ var newSources = [];
+ for (var i = 0; i < root.pluginSources.length; i++) {
+ if (root.pluginSources[i].url !== url) {
+ newSources.push(root.pluginSources[i]);
+ }
+ }
+
+ if (newSources.length === root.pluginSources.length) {
+ Logger.w("PluginRegistry", "Source not found:", url);
+ return false;
+ }
+
+ root.pluginSources = newSources;
+ save();
+ Logger.i("PluginRegistry", "Removed plugin source:", url);
+ return true;
+ }
+
+ // Set source enabled/disabled state
+ function setSourceEnabled(url, enabled) {
+ var newSources = [];
+ var found = false;
+ for (var i = 0; i < root.pluginSources.length; i++) {
+ if (root.pluginSources[i].url === url) {
+ newSources.push({
+ name: root.pluginSources[i].name,
+ url: root.pluginSources[i].url,
+ enabled: enabled
+ });
+ found = true;
+ } else {
+ newSources.push(root.pluginSources[i]);
+ }
+ }
+
+ if (!found) {
+ Logger.w("PluginRegistry", "Source not found:", url);
+ return false;
+ }
+
+ root.pluginSources = newSources;
+ save();
+ Logger.i("PluginRegistry", "Source", url, enabled ? "enabled" : "disabled");
+ return true;
+ }
+
+ // Check if source is enabled
+ function isSourceEnabled(url) {
+ for (var i = 0; i < root.pluginSources.length; i++) {
+ if (root.pluginSources[i].url === url) {
+ return root.pluginSources[i].enabled !== false; // Default to true if not set
+ }
+ }
+ return false;
+ }
+
+ // Get enabled sources only
+ function getEnabledSources() {
+ var enabledSources = [];
+ for (var i = 0; i < root.pluginSources.length; i++) {
+ if (root.pluginSources[i].enabled !== false) {
+ enabledSources.push(root.pluginSources[i]);
+ }
+ }
+ return enabledSources;
+ }
+
+ // Get plugin directory path
+ function getPluginDir(pluginId) {
+ return root.pluginsDir + "/" + pluginId;
+ }
+
+ // Get plugin settings file path
+ function getPluginSettingsFile(pluginId) {
+ return getPluginDir(pluginId) + "/settings.json";
+ }
+
+ // Validate manifest
+ function validateManifest(manifest) {
+ if (!manifest) {
+ return {
+ valid: false,
+ error: "Manifest is null or undefined"
+ };
+ }
+
+ var required = ["id", "name", "version", "author", "description"];
+ for (var i = 0; i < required.length; i++) {
+ if (!manifest[required[i]]) {
+ return {
+ valid: false,
+ error: "Missing required field: " + required[i]
+ };
+ }
+ }
+
+ if (!manifest.entryPoints) {
+ return {
+ valid: false,
+ error: "Missing 'entryPoints' field"
+ };
+ }
+
+ // Check version format (simple x.y.z check)
+ var versionRegex = /^\d+\.\d+\.\d+$/;
+ if (!versionRegex.test(manifest.version)) {
+ return {
+ valid: false,
+ error: "Invalid version format (must be x.y.z)"
+ };
+ }
+
+ return {
+ valid: true,
+ error: null
+ };
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Noctalia/PluginService.qml b/arch/.config/quickshell/noctalia-shell/Services/Noctalia/PluginService.qml
new file mode 100644
index 0000000..7b01744
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Noctalia/PluginService.qml
@@ -0,0 +1,2087 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+import qs.Modules.Panels.Settings
+import qs.Services.Noctalia
+import qs.Services.UI
+
+Singleton {
+ id: root
+
+ signal pluginLoaded(string pluginId)
+ signal pluginUnloaded(string pluginId)
+ signal pluginEnabled(string pluginId)
+ signal pluginDisabled(string pluginId)
+ signal pluginReloaded(string pluginId)
+ signal availablePluginsUpdated
+ signal allPluginsLoaded
+
+ // When available plugins are updated, check if we should perform update check
+ onAvailablePluginsUpdated: {
+ if (shouldCheckUpdatesAfterFetch && Object.keys(activeFetches).length === 0) {
+ Logger.d("PluginService", "All registry fetches complete, performing update check");
+ performUpdateCheck();
+ }
+ }
+
+ // Loaded plugin instances
+ property var loadedPlugins: ({}) // { pluginId: { component, instance, api } }
+
+ // Available plugins from all sources (fetched from registries)
+ property var availablePlugins: ([]) // Array of plugin metadata from all sources
+
+ // Plugin updates available: { pluginId: { currentVersion, availableVersion } }
+ property var pluginUpdates: ({})
+
+ // Plugin updates that require a newer Noctalia version: { pluginId: { currentVersion, availableVersion, minNoctaliaVersion } }
+ property var pluginUpdatesPending: ({})
+
+ // Plugin load errors: { pluginId: { error: string, entryPoint: string, timestamp: date } }
+ property var pluginErrors: ({})
+ signal pluginLoadError(string pluginId, string entryPoint, string error)
+
+ // Track currently installing plugins: { pluginId: true }
+ property var installingPlugins: ({})
+
+ // Hot reload: file watchers for plugin directories
+ property var pluginFileWatchers: ({}) // { pluginId: FileView }
+ property list pluginHotReloadEnabled: [] // List of pluginIds that have hot reload enabled
+
+ // Track active fetches
+ property var activeFetches: ({})
+
+ property bool initialized: false
+ property bool pluginsFullyLoaded: false
+
+ // Plugin container from shell.qml (for placing Main instances in graphics scene)
+ property var pluginContainer: null
+
+ // Screen detector from shell.qml (for withCurrentScreen in plugin API)
+ property var screenDetector: null
+
+ // Track if we need to initialize once container is ready
+ property bool needsInit: false
+
+ // Watch for pluginContainer to be set
+ onPluginContainerChanged: {
+ if (root.pluginContainer && root.needsInit) {
+ Logger.d("PluginService", "Plugin container now available, initializing plugins");
+ root.needsInit = false;
+ root.init();
+ }
+ }
+
+ // Listen for PluginRegistry to finish loading
+ Connections {
+ target: PluginRegistry
+
+ function onPluginsChanged() {
+ if (!root.initialized) {
+ if (root.pluginContainer) {
+ // Container already available, init now
+ root.init();
+ } else {
+ // Container not ready, wait for it
+ Logger.d("PluginService", "Deferring plugin init until container is ready");
+ root.needsInit = true;
+ }
+ }
+ }
+ }
+
+ // When debug mode is disabled, tear down all hot reload watchers
+ Connections {
+ target: Settings
+
+ function onIsDebugChanged() {
+ if (!Settings.isDebug && root.pluginHotReloadEnabled.length > 0) {
+ Logger.i("PluginService", "Debug mode disabled, removing all hot reload watchers");
+ // Remove watchers for all hot-reload-enabled plugins
+ var plugins = root.pluginHotReloadEnabled.slice(); // copy since we mutate
+ for (var i = 0; i < plugins.length; i++) {
+ removePluginFileWatcher(plugins[i]);
+ }
+ root.pluginHotReloadEnabled = [];
+ }
+ }
+ }
+
+ // Listen for language changes to reload plugin translations
+ Connections {
+ target: I18n
+
+ function onLanguageChanged() {
+ Logger.d("PluginService", "Language changed to:", I18n.langCode, "- reloading plugin translations");
+
+ // Reload translations for all loaded plugins
+ for (var pluginId in root.loadedPlugins) {
+ // Use IIFE to capture current loop values (avoid closure bug)
+ (function (id, plugin) {
+ if (plugin && plugin.api && plugin.manifest) {
+ // Update current language
+ plugin.api.currentLanguage = I18n.langCode;
+
+ // Reload translations
+ loadPluginTranslationsAsync(id, plugin.manifest, I18n.langCode, function (translations) {
+ plugin.api.pluginTranslations = translations;
+
+ // Reload English fallback for non-English languages
+ if (I18n.langCode !== "en") {
+ loadPluginTranslationsAsync(id, plugin.manifest, "en", function (fallbackTranslations) {
+ plugin.api.pluginFallbackTranslations = fallbackTranslations;
+ plugin.api.translationVersion++;
+ Logger.d("PluginService", "Reloaded translations for plugin:", id);
+ });
+ } else {
+ plugin.api.pluginFallbackTranslations = {};
+ plugin.api.translationVersion++;
+ Logger.d("PluginService", "Reloaded translations for plugin:", id);
+ }
+ });
+ }
+ })(pluginId, root.loadedPlugins[pluginId]);
+ }
+
+ // Update translation file watchers to watch the new language's files
+ if (root.pluginHotReloadEnabled.length > 0) {
+ updateTranslationWatchers();
+ }
+ }
+ }
+
+ // Track pending plugin loads for init completion
+ property int _pendingPluginLoads: 0
+
+ function init() {
+ if (root.initialized) {
+ Logger.d("PluginService", "Already initialized, skipping");
+ return;
+ }
+
+ Logger.i("PluginService", "Initializing plugin system");
+ root.initialized = true;
+
+ // Debug: Check what's in PluginRegistry
+ var allInstalled = PluginRegistry.getAllInstalledPluginIds();
+ Logger.d("PluginService", "All installed plugins:", JSON.stringify(allInstalled));
+ Logger.d("PluginService", "Plugin states:", JSON.stringify(PluginRegistry.pluginStates));
+
+ // Load all enabled plugins
+ var enabledIds = PluginRegistry.getEnabledPluginIds();
+ Logger.i("PluginService", "Found", enabledIds.length, "enabled plugins:", JSON.stringify(enabledIds));
+
+ // Count plugins that need to be loaded
+ var pluginsToLoad = [];
+ for (var i = 0; i < enabledIds.length; i++) {
+ var manifest = PluginRegistry.getPluginManifest(enabledIds[i]);
+ if (manifest) {
+ pluginsToLoad.push(enabledIds[i]);
+ } else {
+ Logger.w("PluginService", "Plugin", enabledIds[i], "is enabled but not found on disk - install");
+ var sourceUrl = PluginRegistry.getPluginSourceUrl(enabledIds[i]);
+ root.installPlugin({
+ id: enabledIds[i],
+ source: {
+ url: sourceUrl
+ }
+ }, false, function (success, error, registeredKey) {
+ if (success) {
+ ToastService.showNotice(I18n.tr("panels.plugins.title"), I18n.tr("panels.plugins.install-success", {
+ "plugin": registeredKey
+ }));
+ // Load the plugin since it was already enabled (state persisted but files were missing)
+ loadPlugin(registeredKey);
+
+ // Add plugin widget to bar if it provides one
+ var manifest = PluginRegistry.getPluginManifest(registeredKey);
+ if (manifest && manifest.entryPoints && manifest.entryPoints.barWidget) {
+ var widgetId = "plugin:" + registeredKey;
+ addWidgetToBar(widgetId, "right");
+ }
+ } else {
+ ToastService.showError(I18n.tr("panels.plugins.title"), I18n.tr("panels.plugins.install-error", {
+ "error": error || "Unknown error"
+ }));
+ }
+ });
+ }
+ }
+
+ // If no plugins to load, mark as complete immediately
+ if (pluginsToLoad.length === 0) {
+ root.pluginsFullyLoaded = true;
+ Logger.i("PluginService", "No plugins to load");
+ root.allPluginsLoaded();
+ root._isStartupCheck = true;
+ refreshAvailablePlugins();
+ return;
+ }
+
+ // Track pending loads
+ root._pendingPluginLoads = pluginsToLoad.length;
+
+ // Load all plugins (async - they will call _onPluginLoadComplete when done)
+ for (var j = 0; j < pluginsToLoad.length; j++) {
+ Logger.d("PluginService", "Attempting to load plugin:", pluginsToLoad[j]);
+ loadPlugin(pluginsToLoad[j]);
+ }
+ }
+
+ // Called when a plugin finishes loading (success or failure)
+ function _onPluginLoadComplete() {
+ root._pendingPluginLoads--;
+
+ if (root._pendingPluginLoads <= 0) {
+ // All plugins finished loading
+ root.pluginsFullyLoaded = true;
+ Logger.i("PluginService", "All plugins loaded");
+ root.allPluginsLoaded();
+
+ // Fetch available plugins from all sources
+ root._isStartupCheck = true;
+ refreshAvailablePlugins();
+ }
+ }
+
+ // Refresh available plugins from all sources
+ function refreshAvailablePlugins() {
+ // If fetches are already in progress, don't start new ones
+ if (Object.keys(activeFetches).length > 0) {
+ Logger.d("PluginService", "Refresh already in progress, skipping duplicate refresh");
+ return;
+ }
+
+ Logger.i("PluginService", "Refreshing available plugins");
+ root.availablePlugins = [];
+
+ // Signal that we want to check for updates after refresh completes
+ shouldCheckUpdatesAfterFetch = true;
+
+ var enabledSources = PluginRegistry.getEnabledSources();
+ Logger.d("PluginService", "Fetching from", enabledSources.length, "enabled sources");
+ for (var i = 0; i < enabledSources.length; i++) {
+ fetchPluginRegistry(enabledSources[i]);
+ }
+ }
+
+ // Fetch plugin registry from a source using git sparse-checkout
+ function fetchPluginRegistry(source) {
+ var repoUrl = source.url;
+
+ Logger.d("PluginService", "Fetching registry from:", repoUrl);
+
+ // Use git sparse-checkout to fetch only registry.json (--no-cone for single file)
+ // GIT_TERMINAL_PROMPT=0 prevents hanging on private repos that need auth
+ var fetchCmd = "temp_dir=$(mktemp -d) && GIT_TERMINAL_PROMPT=0 git clone --filter=blob:none --sparse --depth=1 --quiet '" + repoUrl + "' \"$temp_dir\" 2>/dev/null && cd \"$temp_dir\" && git sparse-checkout set --no-cone /registry.json 2>/dev/null && cat \"$temp_dir/registry.json\"; rm -rf \"$temp_dir\"";
+
+ var fetchProcess = Qt.createQmlObject('import QtQuick; import Quickshell.Io; Process { command: ["sh", "-c", "' + fetchCmd.replace(/"/g, '\\"') + '"]; stdout: StdioCollector {} }', root, "FetchRegistry_" + Date.now());
+
+ activeFetches[source.url] = fetchProcess;
+
+ fetchProcess.stdout.onStreamFinished.connect(function () {
+ var response = fetchProcess.stdout.text;
+
+ // Debug: log the raw response
+ Logger.d("PluginService", "Registry response length:", response ? response.length : 0);
+
+ if (!response || response.trim() === "") {
+ Logger.e("PluginService", "Empty response from", source.name);
+ delete activeFetches[source.url];
+ fetchProcess.destroy();
+ return;
+ }
+
+ try {
+ var registry = JSON.parse(response);
+
+ if (registry && registry.plugins && Array.isArray(registry.plugins)) {
+ // Add source info to each plugin
+ for (var i = 0; i < registry.plugins.length; i++) {
+ var plugin = registry.plugins[i];
+ plugin.source = source;
+
+ // Check if already downloaded - use composite key
+ var compositeKey = PluginRegistry.generateCompositeKey(plugin.id, source.url);
+ plugin.downloaded = PluginRegistry.isPluginDownloaded(compositeKey);
+ plugin.enabled = PluginRegistry.isPluginEnabled(compositeKey);
+
+ root.availablePlugins.push(plugin);
+ }
+
+ Logger.i("PluginService", `Parsed ${registry.plugins.length} plugins manifest from '${source.name}'`);
+
+ // Remove from active fetches BEFORE emitting signal so handler sees correct count
+ delete activeFetches[source.url];
+ fetchProcess.destroy();
+
+ root.availablePluginsUpdated();
+ return;
+ }
+ } catch (e) {
+ Logger.e("PluginService", "Failed to parse registry from", source.name, ":", e);
+ Logger.e("PluginService", "Response was:", response ? response.substring(0, 200) : "null");
+ }
+
+ // Clean up on error or empty response
+ delete activeFetches[source.url];
+ fetchProcess.destroy();
+ });
+
+ fetchProcess.exited.connect(function (exitCode) {
+ if (exitCode !== 0) {
+ Logger.e("PluginService", "Failed to fetch registry from", source.name, "- exit code:", exitCode);
+ delete activeFetches[source.url];
+ fetchProcess.destroy();
+ }
+ });
+
+ fetchProcess.running = true;
+ }
+
+ // Check for plugin ID collision before installation
+ function checkPluginCollision(pluginMetadata) {
+ var sourceUrl = pluginMetadata.source.url;
+ var compositeKey = PluginRegistry.generateCompositeKey(pluginMetadata.id, sourceUrl);
+
+ // Check if this exact composite key already exists
+ if (PluginRegistry.isPluginDownloaded(compositeKey)) {
+ return {
+ collision: true,
+ reason: "already_installed",
+ existingKey: compositeKey,
+ message: I18n.tr("panels.plugins.collision-already-installed")
+ };
+ }
+
+ // For official plugins, also check if any custom version with same base ID exists
+ if (PluginRegistry.isMainSource(sourceUrl)) {
+ var allInstalled = PluginRegistry.getAllInstalledPluginIds();
+ for (var i = 0; i < allInstalled.length; i++) {
+ var parsed = PluginRegistry.parseCompositeKey(allInstalled[i]);
+ if (parsed.pluginId === pluginMetadata.id && !parsed.isOfficial) {
+ var sourceName = PluginRegistry.getSourceNameByHash(parsed.sourceHash) || I18n.tr("panels.plugins.source-custom");
+ return {
+ collision: true,
+ reason: "custom_version_exists",
+ existingKey: allInstalled[i],
+ message: I18n.tr("panels.plugins.collision-custom-version-exists", {
+ source: sourceName
+ })
+ };
+ }
+ }
+ }
+
+ // For custom plugins, check if official version exists
+ if (!PluginRegistry.isMainSource(sourceUrl)) {
+ if (PluginRegistry.isPluginDownloaded(pluginMetadata.id)) {
+ return {
+ collision: true,
+ reason: "official_version_exists",
+ existingKey: pluginMetadata.id,
+ message: I18n.tr("panels.plugins.collision-official-version-exists")
+ };
+ }
+ }
+
+ return {
+ collision: false
+ };
+ }
+
+ // Download and install a plugin using git sparse-checkout
+ // skipCollisionCheck: set to true when updating an existing plugin
+ function installPlugin(pluginMetadata, skipCollisionCheck, callback) {
+ var pluginId = pluginMetadata.id;
+ // Do not include hash for 3rd party plugins
+ var pluginIdRegex = /^[a-f0-9]{6}:/;
+ if (pluginIdRegex.test(pluginId)) {
+ pluginId = pluginId.substring(7);
+ }
+
+ var source = pluginMetadata.source;
+
+ // Check for collision first (skip when updating)
+ if (!skipCollisionCheck) {
+ var collision = checkPluginCollision(pluginMetadata);
+ if (collision.collision) {
+ Logger.w("PluginService", "Plugin collision detected:", collision.message);
+ ToastService.showError(I18n.tr("panels.plugins.title"), collision.message);
+ if (callback)
+ callback(false, collision.message);
+ return;
+ }
+
+ // Check Noctalia version compatibility (skip when updating - that's handled in performUpdateCheck)
+ if (pluginMetadata.minNoctaliaVersion) {
+ var noctaliaVersion = UpdateService.baseVersion;
+ if (compareVersions(pluginMetadata.minNoctaliaVersion, noctaliaVersion) > 0) {
+ var incompatibleMsg = I18n.tr("panels.plugins.install-incompatible", {
+ "plugin": pluginMetadata.name,
+ "version": pluginMetadata.minNoctaliaVersion
+ });
+ Logger.w("PluginService", "Plugin incompatible:", incompatibleMsg);
+ if (callback)
+ callback(false, incompatibleMsg);
+ return;
+ }
+ }
+ }
+
+ // Generate composite key for the plugin folder
+ var compositeKey = PluginRegistry.generateCompositeKey(pluginId, source.url);
+ Logger.i("PluginService", "Installing plugin:", compositeKey, "from", source.name);
+
+ var pluginDir = PluginRegistry.getPluginDir(compositeKey);
+ var repoUrl = source.url;
+
+ // Use git sparse-checkout to clone only the plugin subfolder
+ // GIT_TERMINAL_PROMPT=0 prevents hanging on private repos that need auth
+ // Note: We download from the original pluginId folder in the repo, but save to compositeKey folder
+ var downloadCmd = "temp_dir=$(mktemp -d) && GIT_TERMINAL_PROMPT=0 git clone --filter=blob:none --sparse --depth=1 --quiet '" + repoUrl + "' \"$temp_dir\" 2>/dev/null && cd \"$temp_dir\" && git sparse-checkout set '" + pluginId + "' 2>/dev/null && mkdir -p '" + pluginDir + "' && rm -f \"$temp_dir/" + pluginId + "/settings.json\" && cp -r \"$temp_dir/" + pluginId
+ + "/.\" '" + pluginDir + "/'; exit_code=$?; rm -rf \"$temp_dir\"; exit $exit_code";
+
+ // Mark as installing
+ var newInstalling = Object.assign({}, root.installingPlugins);
+ newInstalling[pluginId] = true;
+ root.installingPlugins = newInstalling;
+
+ var downloadProcess = Qt.createQmlObject('import QtQuick; import Quickshell.Io; Process { command: ["sh", "-c", "' + downloadCmd.replace(/"/g, '\\"') + '"] }', root, "DownloadPlugin_" + pluginId);
+
+ downloadProcess.exited.connect(function (exitCode) {
+ // Mark as finished (remove from installing)
+ var currentInstalling = Object.assign({}, root.installingPlugins);
+ delete currentInstalling[pluginId];
+ root.installingPlugins = currentInstalling;
+
+ if (exitCode === 0) {
+ Logger.i("PluginService", "Downloaded plugin:", compositeKey);
+
+ // Load and validate manifest
+ var manifestPath = pluginDir + "/manifest.json";
+ loadManifest(manifestPath, function (success, manifest) {
+ if (success) {
+ var validation = PluginRegistry.validateManifest(manifest);
+ if (validation.valid) {
+ // Register plugin with source URL
+ var registeredKey = PluginRegistry.registerPlugin(manifest, source.url);
+ Logger.i("PluginService", "Installed plugin:", registeredKey);
+
+ // Update available plugins list
+ updatePluginInAvailable(pluginId, {
+ downloaded: true
+ });
+
+ if (callback)
+ callback(true, null, registeredKey);
+ } else {
+ Logger.e("PluginService", "Invalid manifest:", validation.error);
+ if (callback)
+ callback(false, "Invalid manifest: " + validation.error);
+ }
+ } else {
+ Logger.e("PluginService", "Failed to load manifest for:", compositeKey);
+ if (callback)
+ callback(false, "Failed to load manifest");
+ }
+ });
+ } else {
+ Logger.e("PluginService", "Failed to download plugin:", compositeKey);
+ if (callback)
+ callback(false, "Download failed");
+ }
+
+ downloadProcess.destroy();
+ });
+
+ downloadProcess.running = true;
+ }
+
+ // Uninstall a plugin (compositeKey is the full key like "abc123:my-plugin" or plain "my-plugin")
+ function uninstallPlugin(compositeKey, callback) {
+ Logger.i("PluginService", "Uninstalling plugin:", compositeKey);
+
+ // Disable and unload first
+ if (PluginRegistry.isPluginEnabled(compositeKey)) {
+ disablePlugin(compositeKey);
+ }
+
+ var pluginDir = PluginRegistry.getPluginDir(compositeKey);
+
+ var removeProcess = Qt.createQmlObject(`
+ import QtQuick
+ import Quickshell.Io
+ Process {
+ command: ["rm", "-rf", "${pluginDir}"]
+ }
+ `, root, "RemovePlugin_" + compositeKey);
+
+ removeProcess.exited.connect(function (exitCode) {
+ if (exitCode === 0) {
+ PluginRegistry.unregisterPlugin(compositeKey);
+ Logger.i("PluginService", "Uninstalled plugin:", compositeKey);
+
+ // Update available plugins list (use plain ID to match against availablePlugins)
+ var parsed = PluginRegistry.parseCompositeKey(compositeKey);
+ updatePluginInAvailable(parsed.pluginId, {
+ downloaded: false,
+ enabled: false
+ });
+
+ if (callback)
+ callback(true, null);
+ } else {
+ Logger.e("PluginService", "Failed to uninstall plugin:", pluginId);
+ if (callback)
+ callback(false, "Failed to remove plugin files");
+ }
+
+ removeProcess.destroy();
+ });
+
+ removeProcess.running = true;
+ }
+
+ // Enable a plugin (compositeKey is the full key like "abc123:my-plugin" or plain "my-plugin")
+ function enablePlugin(compositeKey, skipAddToBar) {
+ if (PluginRegistry.isPluginEnabled(compositeKey)) {
+ Logger.w("PluginService", "Plugin already enabled:", compositeKey);
+ return true;
+ }
+
+ if (!PluginRegistry.isPluginDownloaded(compositeKey)) {
+ Logger.e("PluginService", "Cannot enable: plugin not downloaded:", compositeKey);
+ return false;
+ }
+
+ PluginRegistry.setPluginEnabled(compositeKey, true);
+ loadPlugin(compositeKey);
+
+ // Add plugin widget to bar if it provides one (unless we're restoring from backup)
+ if (!skipAddToBar) {
+ var manifest = PluginRegistry.getPluginManifest(compositeKey);
+ if (manifest && manifest.entryPoints && manifest.entryPoints.barWidget) {
+ var widgetId = "plugin:" + compositeKey;
+ addWidgetToBar(widgetId, "right");
+ }
+ }
+
+ // Update available plugins list (use plain ID to match against availablePlugins)
+ var parsed = PluginRegistry.parseCompositeKey(compositeKey);
+ updatePluginInAvailable(parsed.pluginId, {
+ enabled: true
+ });
+ root.pluginEnabled(compositeKey);
+
+ return true;
+ }
+
+ // Helper function to add a widget to the bar (global + all screen overrides)
+ function addWidgetToBar(widgetId, section) {
+ section = section || "right"; // Default to right section
+
+ // Check if widget already exists in any section (global)
+ var sections = ["left", "center", "right"];
+ for (var s = 0; s < sections.length; s++) {
+ var widgets = Settings.data.bar.widgets[sections[s]] || [];
+ for (var i = 0; i < widgets.length; i++) {
+ if (widgets[i].id === widgetId) {
+ Logger.d("PluginService", "Widget already in bar:", widgetId);
+ return false;
+ }
+ }
+ }
+
+ // Add to global
+ var globalWidgets = Settings.data.bar.widgets[section] || [];
+ globalWidgets.push({
+ id: widgetId
+ });
+ Settings.data.bar.widgets[section] = globalWidgets;
+
+ // Also add to any screen overrides that have widget configurations
+ var overrides = Settings.data.bar.screenOverrides || [];
+ for (var o = 0; o < overrides.length; o++) {
+ if (overrides[o] && overrides[o].widgets) {
+ var overrideWidgets = overrides[o].widgets;
+ var sectionWidgets = overrideWidgets[section] || [];
+ // Check if widget already exists in this override
+ var alreadyExists = false;
+ for (var j = 0; j < sections.length; j++) {
+ var owSec = overrideWidgets[sections[j]] || [];
+ for (var k = 0; k < owSec.length; k++) {
+ if (owSec[k].id === widgetId) {
+ alreadyExists = true;
+ break;
+ }
+ }
+ if (alreadyExists)
+ break;
+ }
+ if (!alreadyExists) {
+ sectionWidgets.push({
+ id: widgetId
+ });
+ overrideWidgets[section] = sectionWidgets;
+ Settings.setScreenOverride(overrides[o].name, "widgets", overrideWidgets);
+ }
+ }
+ }
+
+ Logger.i("PluginService", "Added widget", widgetId, "to bar section:", section);
+ return true;
+ }
+
+ // Disable a plugin (compositeKey is the full key like "abc123:my-plugin" or plain "my-plugin")
+ function disablePlugin(compositeKey) {
+ if (!PluginRegistry.isPluginEnabled(compositeKey)) {
+ Logger.w("PluginService", "Plugin already disabled:", compositeKey);
+ return true;
+ }
+
+ // Remove plugin widget from bar before unloading
+ var widgetId = "plugin:" + compositeKey;
+ removeWidgetFromBar(widgetId);
+
+ PluginRegistry.setPluginEnabled(compositeKey, false);
+ unloadPlugin(compositeKey);
+
+ // Update available plugins list (use plain ID to match against availablePlugins)
+ var parsed = PluginRegistry.parseCompositeKey(compositeKey);
+ updatePluginInAvailable(parsed.pluginId, {
+ enabled: false
+ });
+ root.pluginDisabled(compositeKey);
+ return true;
+ }
+
+ // Helper function to remove a widget from all bar sections (global + screen overrides)
+ function removeWidgetFromBar(widgetId) {
+ var sections = ["left", "center", "right"];
+ var changed = false;
+
+ // Remove from global
+ for (var s = 0; s < sections.length; s++) {
+ var section = sections[s];
+ var widgets = Settings.data.bar.widgets[section] || [];
+ var newWidgets = [];
+
+ for (var i = 0; i < widgets.length; i++) {
+ if (widgets[i].id !== widgetId) {
+ newWidgets.push(widgets[i]);
+ } else {
+ changed = true;
+ Logger.i("PluginService", "Removed widget", widgetId, "from bar section:", section);
+ }
+ }
+
+ if (changed) {
+ Settings.data.bar.widgets[section] = newWidgets;
+ }
+ }
+
+ // Also remove from any screen overrides that have widget configurations
+ var overrides = Settings.data.bar.screenOverrides || [];
+ for (var o = 0; o < overrides.length; o++) {
+ if (overrides[o] && overrides[o].widgets) {
+ var overrideWidgets = overrides[o].widgets;
+ var overrideChanged = false;
+ for (var s2 = 0; s2 < sections.length; s2++) {
+ var sec = sections[s2];
+ var owWidgets = overrideWidgets[sec] || [];
+ var owNew = [];
+ for (var j = 0; j < owWidgets.length; j++) {
+ if (owWidgets[j].id !== widgetId) {
+ owNew.push(owWidgets[j]);
+ } else {
+ overrideChanged = true;
+ changed = true;
+ Logger.i("PluginService", "Removed widget", widgetId, "from screen override:", overrides[o].name, "section:", sec);
+ }
+ }
+ if (overrideChanged) {
+ overrideWidgets[sec] = owNew;
+ }
+ }
+ if (overrideChanged) {
+ Settings.setScreenOverride(overrides[o].name, "widgets", overrideWidgets);
+ }
+ }
+ }
+
+ // Signal the bar to refresh if widgets were removed
+ if (changed) {
+ BarService.widgetsRevision++;
+ }
+
+ return changed;
+ }
+
+ // Remove plugin desktop widgets from all monitors' saved settings
+ function removePluginDesktopWidgetsFromSettings(pluginId) {
+ var widgetId = "plugin:" + pluginId;
+ var monitorWidgets = Settings.data.desktopWidgets.monitorWidgets || [];
+ var changed = false;
+
+ for (var m = 0; m < monitorWidgets.length; m++) {
+ var monitor = monitorWidgets[m];
+ var widgets = monitor.widgets || [];
+ var newWidgets = [];
+
+ for (var i = 0; i < widgets.length; i++) {
+ if (widgets[i].id !== widgetId) {
+ newWidgets.push(widgets[i]);
+ } else {
+ changed = true;
+ Logger.i("PluginService", "Removed desktop widget", widgetId, "from monitor:", monitor.name);
+ }
+ }
+
+ if (newWidgets.length !== widgets.length) {
+ monitorWidgets[m].widgets = newWidgets;
+ }
+ }
+
+ if (changed) {
+ Settings.data.desktopWidgets.monitorWidgets = monitorWidgets;
+ }
+
+ return changed;
+ }
+
+ // Load plugin settings and translations before instantiating components
+ // This ensures pluginApi is fully populated before being passed to createObject()
+ function loadPluginData(pluginId, manifest, callback) {
+ // Load settings first
+ loadPluginSettings(pluginId, function (settings) {
+ // Then load translations
+ loadPluginTranslationsAsync(pluginId, manifest, I18n.langCode, function (translations) {
+ // Load English fallback for non-English languages
+ if (I18n.langCode !== "en") {
+ loadPluginTranslationsAsync(pluginId, manifest, "en", function (fallbackTranslations) {
+ callback(settings, translations, fallbackTranslations);
+ });
+ } else {
+ callback(settings, translations, {});
+ }
+ });
+ });
+ }
+
+ // Load a plugin
+ function loadPlugin(pluginId) {
+ if (root.loadedPlugins[pluginId]) {
+ Logger.w("PluginService", "Plugin already loaded:", pluginId);
+ return;
+ }
+
+ var manifest = PluginRegistry.getPluginManifest(pluginId);
+ if (!manifest) {
+ Logger.e("PluginService", "Cannot load: manifest not found for:", pluginId);
+ return;
+ }
+
+ var pluginDir = PluginRegistry.getPluginDir(pluginId);
+
+ Logger.i("PluginService", "Loading plugin:", pluginId);
+
+ // Load settings and translations FIRST, then create API and instantiate components
+ loadPluginData(pluginId, manifest, function (settings, translations, fallbackTranslations) {
+ // Create plugin API object with pre-loaded data
+ var pluginApi = createPluginAPI(pluginId, manifest, settings, translations, fallbackTranslations);
+
+ // Initialize plugin entry with API and manifest
+ root.loadedPlugins[pluginId] = {
+ barWidget: null,
+ desktopWidget: null,
+ launcherProvider: null,
+ mainInstance: null,
+ api: pluginApi,
+ manifest: manifest
+ };
+
+ // Clear any previous errors for this plugin
+ root.clearPluginError(pluginId);
+
+ // Load Main.qml entry point if it exists
+ if (manifest.entryPoints && manifest.entryPoints.main) {
+ var mainPath = pluginDir + "/" + manifest.entryPoints.main;
+ var loadVersion = PluginRegistry.pluginLoadVersions[pluginId] || 0;
+ var mainComponent = Qt.createComponent("file://" + mainPath + "?v=" + loadVersion);
+
+ if (mainComponent.status === Component.Ready) {
+ // Get the plugin container from shell.qml (must be in graphics scene)
+ if (!root.pluginContainer) {
+ Logger.e("PluginService", "Plugin container not set. Shell must set PluginService.pluginContainer.");
+ return;
+ }
+
+ // Instantiate Main.qml with pluginApi passed directly in createObject
+ var mainInstance = mainComponent.createObject(root.pluginContainer, {
+ pluginApi: pluginApi
+ });
+
+ if (mainInstance) {
+ root.loadedPlugins[pluginId].mainInstance = mainInstance;
+ pluginApi.mainInstance = mainInstance;
+ Logger.i("PluginService", "Loaded Main.qml for plugin:", pluginId);
+ } else {
+ root.recordPluginError(pluginId, "main", "Failed to instantiate Main.qml");
+ }
+ } else if (mainComponent.status === Component.Error) {
+ root.recordPluginError(pluginId, "main", mainComponent.errorString());
+ }
+ }
+
+ // Load bar widget component if provided (don't instantiate - BarWidgetRegistry will do that)
+ if (manifest.entryPoints && manifest.entryPoints.barWidget) {
+ var widgetPath = pluginDir + "/" + manifest.entryPoints.barWidget;
+ var widgetLoadVersion = PluginRegistry.pluginLoadVersions[pluginId] || 0;
+ var widgetComponent = Qt.createComponent("file://" + widgetPath + "?v=" + widgetLoadVersion);
+
+ if (widgetComponent.status === Component.Ready) {
+ root.loadedPlugins[pluginId].barWidget = widgetComponent;
+ pluginApi.barWidget = widgetComponent;
+
+ // Register with BarWidgetRegistry
+ BarWidgetRegistry.registerPluginWidget(pluginId, widgetComponent, manifest.metadata);
+ Logger.i("PluginService", "Loaded bar widget for plugin:", pluginId);
+
+ // Now that the widget is registered, bump widgetsRevision so the bar can render it
+ BarService.widgetsRevision++;
+ } else if (widgetComponent.status === Component.Error) {
+ root.recordPluginError(pluginId, "barWidget", widgetComponent.errorString());
+ }
+ }
+
+ // Load desktop widget component if provided (don't instantiate - DesktopWidgetRegistry will do that)
+ if (manifest.entryPoints && manifest.entryPoints.desktopWidget) {
+ var desktopWidgetPath = pluginDir + "/" + manifest.entryPoints.desktopWidget;
+ var desktopWidgetLoadVersion = PluginRegistry.pluginLoadVersions[pluginId] || 0;
+ var desktopWidgetComponent = Qt.createComponent("file://" + desktopWidgetPath + "?v=" + desktopWidgetLoadVersion);
+
+ if (desktopWidgetComponent.status === Component.Ready) {
+ root.loadedPlugins[pluginId].desktopWidget = desktopWidgetComponent;
+ pluginApi.desktopWidget = desktopWidgetComponent;
+
+ // Register with DesktopWidgetRegistry
+ DesktopWidgetRegistry.registerPluginWidget(pluginId, desktopWidgetComponent, manifest.metadata);
+ Logger.i("PluginService", "Loaded desktop widget for plugin:", pluginId);
+ } else if (desktopWidgetComponent.status === Component.Error) {
+ root.recordPluginError(pluginId, "desktopWidget", desktopWidgetComponent.errorString());
+ }
+ }
+
+ // Load launcher provider component if provided (don't instantiate - Launcher will do that)
+ if (manifest.entryPoints && manifest.entryPoints.launcherProvider) {
+ var launcherProviderPath = pluginDir + "/" + manifest.entryPoints.launcherProvider;
+ var launcherProviderLoadVersion = PluginRegistry.pluginLoadVersions[pluginId] || 0;
+ var launcherProviderComponent = Qt.createComponent("file://" + launcherProviderPath + "?v=" + launcherProviderLoadVersion);
+
+ if (launcherProviderComponent.status === Component.Ready) {
+ root.loadedPlugins[pluginId].launcherProvider = launcherProviderComponent;
+ pluginApi.launcherProvider = launcherProviderComponent;
+
+ // Register with LauncherProviderRegistry
+ LauncherProviderRegistry.registerPluginProvider(pluginId, launcherProviderComponent, manifest.metadata);
+ Logger.i("PluginService", "Loaded launcher provider for plugin:", pluginId);
+ } else if (launcherProviderComponent.status === Component.Error) {
+ root.recordPluginError(pluginId, "launcherProvider", launcherProviderComponent.errorString());
+ }
+ }
+
+ // Load control center widget component if provided
+ if (manifest.entryPoints && manifest.entryPoints.controlCenterWidget) {
+ var ccWidgetPath = pluginDir + "/" + manifest.entryPoints.controlCenterWidget;
+ var ccWidgetLoadVersion = PluginRegistry.pluginLoadVersions[pluginId] || 0;
+ var ccWidgetComponent = Qt.createComponent("file://" + ccWidgetPath + "?v=" + ccWidgetLoadVersion);
+
+ if (ccWidgetComponent.status === Component.Ready) {
+ root.loadedPlugins[pluginId].controlCenterWidget = ccWidgetComponent;
+ pluginApi.controlCenterWidget = ccWidgetComponent;
+
+ // Register with ControlCenterWidgetRegistry
+ ControlCenterWidgetRegistry.registerPluginWidget(pluginId, ccWidgetComponent, manifest.metadata);
+ Logger.i("PluginService", "Loaded control center widget for plugin:", pluginId);
+ } else if (ccWidgetComponent.status === Component.Error) {
+ root.recordPluginError(pluginId, "controlCenterWidget", ccWidgetComponent.errorString());
+ }
+ }
+
+ Logger.i("PluginService", "Plugin loaded:", pluginId);
+ root.pluginLoaded(pluginId);
+
+ // Set up hot reload watcher if enabled
+ setupPluginFileWatcher(pluginId);
+
+ // Notify that this plugin finished loading (for init tracking)
+ root._onPluginLoadComplete();
+ });
+ }
+
+ // Unload a plugin
+ // preserveSettings: if true, don't remove desktop widget settings (used for hot reload)
+ function unloadPlugin(pluginId, preserveSettings) {
+ var plugin = root.loadedPlugins[pluginId];
+ if (!plugin) {
+ Logger.w("PluginService", "Plugin not loaded:", pluginId);
+ return;
+ }
+
+ Logger.i("PluginService", "Unloading plugin:", pluginId);
+
+ // Remove hot reload watcher
+ removePluginFileWatcher(pluginId);
+
+ // Unregister from BarWidgetRegistry
+ if (plugin.manifest.entryPoints && plugin.manifest.entryPoints.barWidget) {
+ BarWidgetRegistry.unregisterPluginWidget(pluginId);
+ }
+
+ // Unregister from DesktopWidgetRegistry
+ if (plugin.manifest.entryPoints && plugin.manifest.entryPoints.desktopWidget) {
+ // Only remove settings when uninstalling, not during hot reload
+ if (!preserveSettings) {
+ removePluginDesktopWidgetsFromSettings(pluginId);
+ }
+ DesktopWidgetRegistry.unregisterPluginWidget(pluginId);
+ }
+
+ // Unregister from LauncherProviderRegistry
+ if (plugin.manifest.entryPoints && plugin.manifest.entryPoints.launcherProvider) {
+ LauncherProviderRegistry.unregisterPluginProvider(pluginId);
+ }
+
+ // Unregister from ControlCenterWidgetRegistry
+ if (plugin.manifest.entryPoints && plugin.manifest.entryPoints.controlCenterWidget) {
+ ControlCenterWidgetRegistry.unregisterPluginWidget(pluginId);
+ }
+
+ // Destroy Main instance if any
+ if (plugin.mainInstance) {
+ plugin.mainInstance.destroy();
+ }
+
+ delete root.loadedPlugins[pluginId];
+ root.pluginUnloaded(pluginId);
+ Logger.i("PluginService", "Unloaded plugin:", pluginId);
+ }
+
+ // Create plugin API object with pre-loaded settings and translations
+ function createPluginAPI(pluginId, manifest, settings, translations, fallbackTranslations) {
+ var pluginDir = PluginRegistry.getPluginDir(pluginId);
+
+ var api = Qt.createQmlObject(`
+ import QtQuick
+
+ QtObject {
+ // Plugin-specific
+ readonly property string pluginId: "${pluginId}"
+ readonly property string pluginDir: "${pluginDir}"
+ property var pluginSettings: ({})
+ property var manifest: ({})
+
+ // Instance references (set after loading)
+ property var mainInstance: null
+ property var barWidget: null
+ property var desktopWidget: null
+ property var launcherProvider: null
+ property var controlCenterWidget: null
+
+ // Panel state: which screen the plugin's panel is currently open on (null if closed)
+ property var panelOpenScreen: null
+
+ // IPC handlers storage
+ property var ipcHandlers: ({})
+
+ // Translation storage
+ property var pluginTranslations: ({})
+ property var pluginFallbackTranslations: ({}) // English fallback for missing keys
+ property string currentLanguage: ""
+ property int translationVersion: 0 // Increments when translations change - plugins should depend on this
+
+ // Functions will be bound below
+ property var saveSettings: null
+ property var openPanel: null
+ property var closePanel: null
+ property var togglePanel: null
+ property var openLauncher: null
+ property var closeLauncher: null
+ property var toggleLauncher: null
+ property var withCurrentScreen: null
+ property var tr: null
+ property var trp: null
+ property var hasTranslation: null
+ }
+ `, root, "PluginAPI_" + pluginId);
+
+ // Set manifest
+ api.manifest = manifest;
+
+ // Set current language (can't use binding in Qt.createQmlObject string)
+ api.currentLanguage = I18n.langCode;
+
+ // Merge manifest defaults with loaded settings (user settings take priority)
+ var defaults = (manifest.metadata && manifest.metadata.defaultSettings) || {};
+ api.pluginSettings = Object.assign({}, defaults, settings || {});
+ api.pluginTranslations = translations || {};
+ api.pluginFallbackTranslations = fallbackTranslations || {};
+
+ // ----------------------------------------
+ // Helper function to get nested property by dot notation
+ var getNestedProperty = function (obj, path) {
+ var keys = path.split('.');
+ var current = obj;
+ for (var i = 0; i < keys.length; i++) {
+ if (current === undefined || current === null) {
+ return undefined;
+ }
+ current = current[keys[i]];
+ }
+ return current;
+ };
+
+ // ----------------------------------------
+ // Bind functions
+ // ----------------------------------------
+ api.saveSettings = function () {
+ savePluginSettings(pluginId, api.pluginSettings);
+
+ // Replace the entire pluginSettings object to trigger QML property bindings
+ // Make a shallow copy so bindings detect the change
+ api.pluginSettings = Object.assign({}, api.pluginSettings);
+ };
+
+ // ----------------------------------------
+ api.togglePanel = function (screen, buttonItem) {
+ // Toggle this plugin's panel on the specified screen
+ // buttonItem: optional, if provided the panel will position near this button
+ if (!screen) {
+ Logger.w("PluginAPI", "No screen available for toggling panel");
+ return false;
+ }
+ return togglePluginPanel(pluginId, screen, buttonItem);
+ };
+
+ // ----------------------------------------
+ api.openPanel = function (screen, buttonItem) {
+ // Open this plugin's panel on the specified screen
+ // buttonItem: optional, if provided the panel will position near this button
+ if (!screen) {
+ Logger.w("PluginAPI", "No screen available for opening panel");
+ return false;
+ }
+ return openPluginPanel(pluginId, screen, buttonItem);
+ };
+
+ // ----------------------------------------
+ api.closePanel = function (screen) {
+ // Close this plugin's panel (find which slot it's in and close it)
+ for (var slotNum = 1; slotNum <= 2; slotNum++) {
+ var panelName = "pluginPanel" + slotNum;
+ var panel = PanelService.getPanel(panelName, screen);
+ if (panel && panel.currentPluginId === pluginId) {
+ panel.close();
+ return true;
+ }
+ }
+ return false;
+ };
+
+ // ----------------------------------------
+ // Launcher provider methods
+ // ----------------------------------------
+
+ // Get the search prefix for this plugin's launcher provider
+ var getSearchPrefix = function () {
+ var metadata = LauncherProviderRegistry.getProviderMetadata("plugin:" + pluginId);
+ var prefix = (metadata && metadata.commandPrefix) ? metadata.commandPrefix : pluginId;
+ return ">" + prefix + " ";
+ };
+
+ api.openLauncher = function (screen) {
+ // Open the launcher with this plugin's provider active
+ if (!screen) {
+ Logger.w("PluginAPI", "No screen available for opening launcher");
+ return;
+ }
+ PanelService.openLauncherWithSearch(screen, getSearchPrefix());
+ };
+
+ api.closeLauncher = function (screen) {
+ // Close the launcher
+ if (!screen) {
+ Logger.w("PluginAPI", "No screen available for closing launcher");
+ return;
+ }
+ PanelService.closeLauncher(screen);
+ };
+
+ api.toggleLauncher = function (screen) {
+ // Toggle the launcher with this plugin's provider active
+ if (!screen) {
+ Logger.w("PluginAPI", "No screen available for toggling launcher");
+ return;
+ }
+ var searchPrefix = getSearchPrefix();
+ var searchText = PanelService.getLauncherSearchText(screen);
+ var isInThisMode = searchText.startsWith(searchPrefix);
+ if (!PanelService.isLauncherOpen(screen)) {
+ PanelService.openLauncherWithSearch(screen, searchPrefix);
+ } else if (isInThisMode) {
+ PanelService.closeLauncher(screen);
+ } else {
+ PanelService.setLauncherSearchText(screen, searchPrefix);
+ }
+ };
+
+ // ----------------------------------------
+ api.withCurrentScreen = function (callback) {
+ // Detect which screen the cursor is on and call callback with that screen
+ if (!root.screenDetector) {
+ Logger.w("PluginAPI", "Screen detector not available, using primary screen");
+ callback(Quickshell.screens[0]);
+ return;
+ }
+ root.screenDetector.withCurrentScreen(callback);
+ };
+
+ // ----------------------------------------
+ // Translation function
+ api.tr = function (key, interpolations) {
+ if (typeof interpolations === 'undefined') {
+ interpolations = {};
+ }
+
+ var translation = getNestedProperty(api.pluginTranslations, key);
+
+ // Fallback to English if not found in current language
+ if (translation === undefined || translation === null || typeof translation !== 'string') {
+ translation = getNestedProperty(api.pluginFallbackTranslations, key);
+ }
+
+ // Return formatted key if translation not found in any language
+ if (translation === undefined || translation === null) {
+ return `!!${key}!!`;
+ }
+
+ // Ensure translation is a string
+ if (typeof translation !== 'string') {
+ return `!!${key}!!`;
+ }
+
+ // Handle interpolations (e.g., "Hello {name}!")
+ var result = translation;
+ for (var placeholder in interpolations) {
+ var regex = new RegExp('\\{' + placeholder + '\\}', 'g');
+ result = result.replace(regex, interpolations[placeholder]);
+ }
+
+ return result;
+ };
+
+ // ----------------------------------------
+ // Plural translation function
+ api.trp = function (key, count, interpolations) {
+ if (typeof interpolations === 'undefined') {
+ interpolations = {};
+ }
+
+ // Use key for singular, key-plural for plural
+ const realKey = count === 1 ? key : `${key}-plural`;
+
+ // Merge interpolations with count
+ var finalInterpolations = {
+ 'count': count
+ };
+ for (var prop in interpolations) {
+ finalInterpolations[prop] = interpolations[prop];
+ }
+
+ // Use tr() to look up the translation
+ return api.tr(realKey, finalInterpolations);
+ };
+
+ // ----------------------------------------
+ // Check if translation exists
+ api.hasTranslation = function (key) {
+ return getNestedProperty(api.pluginTranslations, key) !== undefined || getNestedProperty(api.pluginFallbackTranslations, key) !== undefined;
+ };
+
+ return api;
+ }
+
+ // Load plugin translations asynchronously
+ function loadPluginTranslationsAsync(pluginId, manifest, language, callback) {
+ var pluginDir = PluginRegistry.getPluginDir(pluginId);
+ var translationFile = pluginDir + "/i18n/" + language + ".json";
+
+ var readProcess = Qt.createQmlObject(`
+ import QtQuick
+ import Quickshell.Io
+ Process {
+ command: ["cat", "${translationFile}"]
+ stdout: StdioCollector {}
+ }
+ `, root, "ReadTranslation_" + pluginId + "_" + language);
+
+ readProcess.exited.connect(function (exitCode) {
+ var translations = {};
+
+ if (exitCode === 0) {
+ try {
+ translations = JSON.parse(readProcess.stdout.text);
+ Logger.d("PluginService", "Loaded translations for", pluginId, "language:", language);
+ } catch (e) {
+ Logger.w("PluginService", "Failed to parse translations for", pluginId, "language:", language);
+ }
+ } else {
+ Logger.d("PluginService", "No translation file for", pluginId, "language:", language);
+ }
+
+ if (callback) {
+ callback(translations);
+ }
+
+ readProcess.destroy();
+ });
+
+ readProcess.running = true;
+ }
+
+ // Load plugin settings
+ function loadPluginSettings(pluginId, callback) {
+ var settingsFile = PluginRegistry.getPluginSettingsFile(pluginId);
+
+ var readProcess = Qt.createQmlObject(`
+ import QtQuick
+ import Quickshell.Io
+ Process {
+ command: ["cat", "${settingsFile}"]
+ stdout: StdioCollector {}
+ }
+ `, root, "ReadSettings_" + pluginId);
+
+ readProcess.exited.connect(function (exitCode) {
+ if (exitCode === 0) {
+ try {
+ var settings = JSON.parse(readProcess.stdout.text);
+ callback(settings);
+ } catch (e) {
+ Logger.w("PluginService", "Failed to parse settings for", pluginId, "- using defaults");
+ callback({});
+ }
+ } else {
+ // File doesn't exist - use defaults
+ callback({});
+ }
+
+ readProcess.destroy();
+ });
+
+ readProcess.running = true;
+ }
+
+ // Save plugin settings
+ function savePluginSettings(pluginId, settings) {
+ var settingsFile = PluginRegistry.getPluginSettingsFile(pluginId);
+ var settingsJson = JSON.stringify(settings, null, 2);
+
+ // Use heredoc delimiter pattern to avoid all escaping issues
+ var delimiter = "PLUGIN_SETTINGS_EOF_" + Math.random().toString(36).substr(2, 9);
+ var fileEsc = settingsFile.replace(/'/g, "'\\''");
+
+ // Get parent directory and ensure it exists
+ var settingsDir = settingsFile.substring(0, settingsFile.lastIndexOf('/'));
+ var dirEsc = settingsDir.replace(/'/g, "'\\''");
+
+ // Build the shell command with heredoc (create dir first)
+ var writeCmd = "mkdir -p '" + dirEsc + "' && cat > '" + fileEsc + "' << '" + delimiter + "'\n" + settingsJson + "\n" + delimiter + "\n";
+
+ Logger.d("PluginService", "Saving settings to:", settingsFile);
+
+ // Use Quickshell.execDetached to execute the command (use array syntax)
+ var pid = Quickshell.execDetached(["sh", "-c", writeCmd]);
+ Logger.d("PluginService", "Write process started, PID:", pid);
+ }
+
+ // Load manifest from file
+ function loadManifest(manifestPath, callback) {
+ var readProcess = Qt.createQmlObject(`
+ import QtQuick
+ import Quickshell.Io
+ Process {
+ command: ["cat", "${manifestPath}"]
+ stdout: StdioCollector {}
+ }
+ `, root, "ReadManifest_" + Date.now());
+
+ readProcess.exited.connect(function (exitCode) {
+ if (exitCode === 0) {
+ try {
+ var manifest = JSON.parse(readProcess.stdout.text);
+ callback(true, manifest);
+ } catch (e) {
+ Logger.e("PluginService", "Failed to parse manifest:", e);
+ callback(false, null);
+ }
+ } else {
+ Logger.e("PluginService", "Failed to read manifest at:", manifestPath);
+ callback(false, null);
+ }
+
+ readProcess.destroy();
+ });
+
+ readProcess.running = true;
+ }
+
+ // Update plugin metadata in available plugins list
+ function updatePluginInAvailable(pluginId, updates) {
+ for (var i = 0; i < root.availablePlugins.length; i++) {
+ if (root.availablePlugins[i].id === pluginId) {
+ for (var key in updates) {
+ root.availablePlugins[i][key] = updates[key];
+ }
+ root.availablePluginsUpdated();
+ break;
+ }
+ }
+ }
+
+ // Find available plugin by ID
+ function findAvailablePlugin(compositeKeyOrId) {
+ var parsed = PluginRegistry.parseCompositeKey(compositeKeyOrId);
+ var pluginId = parsed.pluginId;
+ var sourceUrl = PluginRegistry.getPluginSourceUrl(compositeKeyOrId);
+
+ for (var i = 0; i < root.availablePlugins.length; i++) {
+ if (root.availablePlugins[i].id === pluginId && root.availablePlugins[i].source.url === sourceUrl) {
+ return root.availablePlugins[i];
+ }
+ }
+ return null;
+ }
+
+ // Internal flag to track if we should check for updates after registry fetch
+ property bool shouldCheckUpdatesAfterFetch: false
+
+ // Flag to track if this is the initial startup update check (for auto-update)
+ property bool _isStartupCheck: false
+
+ // Check for plugin updates (call this after availablePlugins are loaded)
+ function checkForUpdates() {
+ Logger.i("PluginService", "Checking for plugin updates");
+
+ // If we have available plugins, check immediately regardless of active fetches
+ if (root.availablePlugins.length > 0) {
+ Logger.d("PluginService", "Available plugins already loaded, checking now");
+ performUpdateCheck();
+ return;
+ }
+
+ // No plugins yet - check if fetch is in progress
+ if (Object.keys(activeFetches).length > 0) {
+ Logger.d("PluginService", "Registry fetch in progress, will check after fetch completes");
+ shouldCheckUpdatesAfterFetch = true;
+ return;
+ }
+
+ // No plugins and no fetches - trigger refresh
+ Logger.d("PluginService", "No available plugins yet, triggering refresh");
+ shouldCheckUpdatesAfterFetch = true;
+ refreshAvailablePlugins();
+ }
+
+ // Perform the actual update check
+ function performUpdateCheck() {
+ var updates = {};
+ var pendingUpdates = {};
+ var installedIds = PluginRegistry.getAllInstalledPluginIds();
+
+ Logger.d("PluginService", "Checking", installedIds.length, "installed plugins against", root.availablePlugins.length, "available plugins");
+
+ for (var i = 0; i < installedIds.length; i++) {
+ var pluginId = installedIds[i];
+ var installedManifest = PluginRegistry.getPluginManifest(pluginId);
+ var availablePlugin = findAvailablePlugin(pluginId);
+
+ if (installedManifest && availablePlugin) {
+ var currentVersion = installedManifest.version;
+ var availableVersion = availablePlugin.version;
+
+ Logger.d("PluginService", "Comparing", pluginId + ":", currentVersion, "vs", availableVersion);
+
+ // Compare versions
+ if (compareVersions(availableVersion, currentVersion) > 0) {
+ // Check if the available version requires a higher Noctalia version
+ if (availablePlugin.minNoctaliaVersion) {
+ var noctaliaVersion = UpdateService.baseVersion;
+ if (compareVersions(availablePlugin.minNoctaliaVersion, noctaliaVersion) > 0) {
+ Logger.d("PluginService", "Pending update for", pluginId + ": requires Noctalia v" + availablePlugin.minNoctaliaVersion + " (current: v" + noctaliaVersion + ")");
+ pendingUpdates[pluginId] = {
+ currentVersion: currentVersion,
+ availableVersion: availableVersion,
+ minNoctaliaVersion: availablePlugin.minNoctaliaVersion
+ };
+ continue;
+ }
+ }
+
+ updates[pluginId] = {
+ currentVersion: currentVersion,
+ availableVersion: availableVersion
+ };
+ Logger.i("PluginService", "Update available for", pluginId + ":", currentVersion, "โ", availableVersion);
+ }
+ } else if (installedManifest && !availablePlugin) {
+ Logger.d("PluginService", "Plugin", pluginId, "not found in available plugins (might be from disabled source)");
+ }
+ }
+
+ root.pluginUpdates = updates;
+ root.pluginUpdatesPending = pendingUpdates;
+ var updateCount = Object.keys(updates).length;
+ var pendingCount = Object.keys(pendingUpdates).length;
+ var updatesDescription = Object.keys(updates).map(function (pluginId) {
+ return pluginId + ": " + updates[pluginId].currentVersion + " โ " + updates[pluginId].availableVersion;
+ }).join("\n");
+
+ if (updateCount > 0) {
+ Logger.i("PluginService", updateCount, "plugin update(s) available");
+
+ if (Settings.data.plugins.notifyUpdates) {
+ ToastService.showNotice(I18n.tr("panels.plugins.title"), I18n.trp("panels.plugins.update-available", updateCount) + "\n\n" + updatesDescription, "plugin", 5000, I18n.tr("panels.plugins.open-plugins-tab"), function () {
+ // Open settings panel to Plugins tab on the screen where the cursor is
+ if (root.screenDetector) {
+ root.screenDetector.withCurrentScreen(function (screen) {
+ var panel = PanelService.getPanel("settingsPanel", screen);
+ if (panel) {
+ panel.requestedTab = SettingsPanel.Tab.Plugins;
+ panel.open();
+ }
+ });
+ } else {
+ // Fallback to primary screen if screen detector is not available
+ var panel = PanelService.getPanel("settingsPanel", Quickshell.screens[0]);
+ if (panel) {
+ panel.requestedTab = SettingsPanel.Tab.Plugins;
+ panel.open();
+ }
+ }
+ });
+ }
+ } else if (pendingCount > 0) {
+ Logger.i("PluginService", pendingCount, "plugin update(s) pending (require newer Noctalia)");
+ } else {
+ Logger.i("PluginService", "All installed plugins are up to date");
+ }
+
+ // Auto-update on startup if enabled
+ if (root._isStartupCheck && Settings.data.plugins.autoUpdate && updateCount > 0) {
+ Logger.i("PluginService", "Auto-updating", updateCount, "plugin(s)");
+ updateAllPlugins();
+ }
+
+ root._isStartupCheck = false;
+ shouldCheckUpdatesAfterFetch = false;
+ }
+
+ // Update all plugins sequentially
+ function updateAllPlugins(callback) {
+ var pluginIds = Object.keys(root.pluginUpdates);
+ var currentIndex = 0;
+
+ function updateNext() {
+ if (currentIndex >= pluginIds.length) {
+ ToastService.showNotice(I18n.tr("panels.plugins.title"), I18n.tr("panels.plugins.update-all-success"));
+ if (callback)
+ callback();
+ return;
+ }
+
+ var pluginId = pluginIds[currentIndex];
+ currentIndex++;
+
+ root.updatePlugin(pluginId, function (success, error) {
+ if (!success) {
+ Logger.w("PluginService", "Failed to auto-update", pluginId + ":", error);
+ }
+ Qt.callLater(updateNext);
+ });
+ }
+
+ updateNext();
+ }
+
+ // Simple version comparison (semantic versioning x.y.z)
+ function compareVersions(a, b) {
+ var aParts = a.split('.').map(function (x) {
+ return parseInt(x) || 0;
+ });
+ var bParts = b.split('.').map(function (x) {
+ return parseInt(x) || 0;
+ });
+
+ for (var i = 0; i < 3; i++) {
+ var aNum = aParts[i] || 0;
+ var bNum = bParts[i] || 0;
+ if (aNum > bNum)
+ return 1;
+ if (aNum < bNum)
+ return -1;
+ }
+ return 0;
+ }
+
+ // Update a plugin to the latest version
+ function updatePlugin(pluginId, callback) {
+ Logger.i("PluginService", "Updating plugin:", pluginId);
+
+ // Find available plugin metadata
+ var availablePlugin = findAvailablePlugin(pluginId);
+ if (!availablePlugin) {
+ Logger.e("PluginService", "Plugin not found in available plugins:", pluginId);
+ if (callback)
+ callback(false, "Plugin not found");
+ return;
+ }
+
+ // Check Noctalia compatibility
+ if (availablePlugin.minNoctaliaVersion) {
+ // Simple check: just warn, don't block (UpdateService would have more sophisticated logic)
+ Logger.d("PluginService", "Plugin requires Noctalia v" + availablePlugin.minNoctaliaVersion);
+ }
+
+ // Backup entire bar layout (global + screen overrides)
+ var barBackup = {
+ left: JSON.parse(JSON.stringify(Settings.data.bar.widgets.left || [])),
+ center: JSON.parse(JSON.stringify(Settings.data.bar.widgets.center || [])),
+ right: JSON.parse(JSON.stringify(Settings.data.bar.widgets.right || []))
+ };
+ var screenOverridesBackup = JSON.parse(JSON.stringify(Settings.data.bar.screenOverrides || []));
+ Logger.d("PluginService", "Backed up bar layout (global + screen overrides)");
+
+ // Backup desktop widget settings (includes this plugin's widgets)
+ var desktopWidgetsBackup = JSON.parse(JSON.stringify(Settings.data.desktopWidgets.monitorWidgets || []));
+ Logger.d("PluginService", "Backed up desktop widget settings");
+
+ // Close any open panels for this plugin before update
+ for (var slotNum = 1; slotNum <= 2; slotNum++) {
+ var panelName = "pluginPanel" + slotNum;
+ for (var s = 0; s < Quickshell.screens.length; s++) {
+ var panel = PanelService.getPanel(panelName, Quickshell.screens[s]);
+ if (panel && panel.currentPluginId === pluginId) {
+ Logger.d("PluginService", "Closing plugin panel before update");
+ panel.close();
+ panel.unloadPluginPanel();
+ }
+ }
+ }
+
+ // Disable plugin (this removes widgets and unloads code)
+ if (PluginRegistry.isPluginEnabled(pluginId)) {
+ disablePlugin(pluginId);
+ }
+
+ // Now install the new version (reuse installPlugin logic, skip collision check since we're updating)
+ installPlugin(availablePlugin, true, function (success, error) {
+ if (success) {
+ Logger.i("PluginService", "Plugin updated successfully:", pluginId);
+
+ // Increment load version to invalidate Qt component cache
+ PluginRegistry.incrementPluginLoadVersion(pluginId);
+
+ // Re-enable the plugin first, so the new component is registered
+ // Skip adding to bar since we'll restore the layout from backup
+ enablePlugin(pluginId, true);
+
+ // Then restore bar layout (so BarWidgetLoaders can find the new component)
+ Settings.data.bar.widgets.left = barBackup.left;
+ Settings.data.bar.widgets.center = barBackup.center;
+ Settings.data.bar.widgets.right = barBackup.right;
+ Settings.data.bar.screenOverrides = screenOverridesBackup;
+ Logger.d("PluginService", "Restored bar layout (global + screen overrides)");
+
+ // Restore desktop widget settings
+ Settings.data.desktopWidgets.monitorWidgets = desktopWidgetsBackup;
+ Logger.d("PluginService", "Restored desktop widget settings");
+
+ // Persist restored layout immediately to prevent race with file watcher reload
+ // (the earlier disablePlugin write triggers a reload that can overwrite the restore)
+ Settings.saveImmediate();
+
+ // Remove from updates list
+ var updates = Object.assign({}, root.pluginUpdates);
+ delete updates[pluginId];
+ root.pluginUpdates = updates;
+
+ if (callback)
+ callback(true, null);
+ } else {
+ Logger.e("PluginService", "Failed to update plugin:", pluginId, error);
+
+ // Restore bar layout even on failure (global + screen overrides)
+ Settings.data.bar.widgets.left = barBackup.left;
+ Settings.data.bar.widgets.center = barBackup.center;
+ Settings.data.bar.widgets.right = barBackup.right;
+ Settings.data.bar.screenOverrides = screenOverridesBackup;
+
+ // Restore desktop widget settings even on failure
+ Settings.data.desktopWidgets.monitorWidgets = desktopWidgetsBackup;
+ Settings.saveImmediate();
+
+ if (callback)
+ callback(false, error);
+ }
+ });
+ }
+
+ // Get plugin API for a loaded plugin
+ function getPluginAPI(pluginId) {
+ return root.loadedPlugins[pluginId]?.api || null;
+ }
+
+ // Check if plugin is loaded
+ function isPluginLoaded(pluginId) {
+ return !!root.loadedPlugins[pluginId];
+ }
+
+ // Open a plugin's panel (finds a free slot and loads the panel)
+ function openPluginPanel(pluginId, screen, buttonItem) {
+ if (!isPluginLoaded(pluginId)) {
+ Logger.w("PluginService", "Cannot open panel: plugin not loaded:", pluginId);
+ return false;
+ }
+
+ var plugin = root.loadedPlugins[pluginId];
+ if (!plugin || !plugin.manifest || !plugin.manifest.entryPoints || !plugin.manifest.entryPoints.panel) {
+ Logger.w("PluginService", "Plugin does not provide a panel:", pluginId);
+ return false;
+ }
+
+ // Try to find the plugin panel slot (pluginPanel1 or pluginPanel2)
+ // Priority: 1) toggle same plugin, 2) empty slot, 3) closed slot, 4) replace open slot
+ var closedSlot = null;
+
+ for (var slotNum = 1; slotNum <= 2; slotNum++) {
+ var panelName = "pluginPanel" + slotNum;
+ var panel = PanelService.getPanel(panelName, screen);
+
+ if (panel) {
+ // If this slot is already showing this plugin's panel, toggle it
+ if (panel.currentPluginId === pluginId) {
+ panel.toggle(buttonItem);
+ return true;
+ }
+
+ // If this slot is empty, use it
+ if (panel.currentPluginId === "") {
+ panel.currentPluginId = pluginId;
+ panel.open(buttonItem);
+ return true;
+ }
+
+ // Track first closed slot (panel assigned but not showing)
+ if (!closedSlot && !panel.isPanelOpen) {
+ closedSlot = panel;
+ }
+ }
+ }
+
+ // Prefer reusing a closed slot over replacing an open one
+ if (closedSlot) {
+ closedSlot.currentPluginId = pluginId;
+ closedSlot.open(buttonItem);
+ return true;
+ }
+
+ // If both slots are occupied and open, use slot 1 (replace existing)
+ var panel1 = PanelService.getPanel("pluginPanel1", screen);
+ if (panel1) {
+ var wasAlreadyOpen = panel1.isPanelOpen;
+ panel1.unloadPluginPanel();
+ panel1.currentPluginId = pluginId;
+
+ // If panel was already open, Component.onCompleted won't fire again
+ // since panelContent is already loaded. We need to load the plugin manually.
+ if (wasAlreadyOpen && panel1.contentLoader) {
+ panel1.loadPluginPanel(pluginId);
+ }
+
+ panel1.open(buttonItem);
+ return true;
+ }
+
+ Logger.e("PluginService", "Failed to find plugin panel slot");
+ return false;
+ }
+
+ // Toggle a plugin's panel - close if open, open if closed
+ // buttonItem: optional, if provided the panel will position near this button
+ function togglePluginPanel(pluginId, screen, buttonItem) {
+ if (!isPluginLoaded(pluginId)) {
+ Logger.w("PluginService", "Cannot toggle panel: plugin not loaded:", pluginId);
+ return false;
+ }
+
+ var plugin = root.loadedPlugins[pluginId];
+ if (!plugin || !plugin.manifest || !plugin.manifest.entryPoints || !plugin.manifest.entryPoints.panel) {
+ Logger.w("PluginService", "Plugin does not provide a panel:", pluginId);
+ return false;
+ }
+
+ // Check if this plugin's panel is already open in any slot
+ for (var slotNum = 1; slotNum <= 2; slotNum++) {
+ var panelName = "pluginPanel" + slotNum;
+ var panel = PanelService.getPanel(panelName, screen);
+
+ if (panel && panel.currentPluginId === pluginId) {
+ // Panel is open for this plugin - toggle it (close)
+ panel.toggle(buttonItem);
+ return true;
+ }
+ }
+
+ // Panel is not open - open it using the existing logic
+ return openPluginPanel(pluginId, screen, buttonItem);
+ }
+
+ // ----- Error tracking functions -----
+
+ function recordPluginError(pluginId, entryPoint, errorMessage) {
+ var errors = Object.assign({}, root.pluginErrors);
+ errors[pluginId] = {
+ error: errorMessage,
+ entryPoint: entryPoint,
+ timestamp: new Date()
+ };
+ root.pluginErrors = errors;
+ root.pluginLoadError(pluginId, entryPoint, errorMessage);
+ Logger.e("PluginService", "Plugin load error [" + pluginId + "/" + entryPoint + "]:", errorMessage);
+ }
+
+ function clearPluginError(pluginId) {
+ if (pluginId in root.pluginErrors) {
+ var errors = Object.assign({}, root.pluginErrors);
+ delete errors[pluginId];
+ root.pluginErrors = errors;
+ }
+ }
+
+ function getPluginError(pluginId) {
+ return root.pluginErrors[pluginId] || null;
+ }
+
+ function hasPluginError(pluginId) {
+ return pluginId in root.pluginErrors;
+ }
+
+ // ----- Hot reload functions -----
+
+ // Set up file watcher for a plugin directory
+ function setupPluginFileWatcher(pluginId) {
+ if (!isPluginHotReloadEnabled(pluginId)) {
+ return;
+ }
+
+ // Don't create duplicate watchers
+ if (root.pluginFileWatchers[pluginId]) {
+ return;
+ }
+
+ var manifest = PluginRegistry.getPluginManifest(pluginId);
+ if (!manifest) {
+ return;
+ }
+
+ var pluginDir = PluginRegistry.getPluginDir(pluginId);
+
+ // Create a debounce timer for this plugin
+ var debounceTimer = Qt.createQmlObject(`
+ import QtQuick
+ Timer {
+ property string targetPluginId: ""
+ property var reloadCallback: null
+ interval: 500
+ repeat: false
+ onTriggered: {
+ if (reloadCallback) reloadCallback(targetPluginId);
+ }
+ }
+ `, root, "HotReloadDebounce_" + pluginId);
+
+ // Set properties after creation to pass the callback
+ debounceTimer.targetPluginId = pluginId;
+ debounceTimer.reloadCallback = root.reloadPlugin;
+
+ // Watch the manifest file - changes here indicate plugin updates
+ var manifestWatcher = Qt.createQmlObject(`
+ import Quickshell.Io
+ FileView {
+ path: "${pluginDir}/manifest.json"
+ watchChanges: true
+ }
+ `, root, "ManifestWatcher_" + pluginId);
+
+ var watchers = [manifestWatcher];
+
+ // Only watch .qml and .js files, also follow symlinks since some of the plugins might have been symlinked in.
+ var qmlWatcher = Qt.createQmlObject(`
+ import QtQuick
+ import Quickshell.Io
+
+ import qs.Commons
+
+ Item {
+ id: root
+ signal fileChanged();
+
+ Process {
+ command: [ "sh", "-c", "find -L ${pluginDir} -name '*.qml' -o -name '*.js'" ]
+ running: true
+ stdout: SplitParser {
+ splitMarker: "\n"
+ onRead: line => {
+ fileWatcher.createObject(root, { path: Qt.resolvedUrl(line) });
+ }
+ }
+ }
+
+ Component {
+ id: fileWatcher
+ FileView {
+ watchChanges: true
+
+ onFileChanged: {
+ root.fileChanged();
+ }
+ }
+ }
+
+ }
+ `, root, "QmlWatcher_" + pluginId);
+ watchers.push(qmlWatcher);
+
+ // Connect all watchers to the debounce timer
+ for (var j = 0; j < watchers.length; j++) {
+ watchers[j].fileChanged.connect(function () {
+ debounceTimer.restart();
+ });
+ }
+
+ // Create a separate debounce timer for translation reloads (lighter weight)
+ var translationDebounceTimer = Qt.createQmlObject(`
+ import QtQuick
+ Timer {
+ property string targetPluginId: ""
+ property var reloadCallback: null
+ interval: 300
+ repeat: false
+ onTriggered: {
+ if (reloadCallback) reloadCallback(targetPluginId);
+ }
+ }
+ `, root, "TranslationReloadDebounce_" + pluginId);
+
+ translationDebounceTimer.targetPluginId = pluginId;
+ translationDebounceTimer.reloadCallback = root.reloadPluginTranslations;
+
+ // Watch the current language's translation file
+ var translationWatcher = createTranslationWatcher(pluginId, pluginDir, I18n.langCode, translationDebounceTimer);
+
+ root.pluginFileWatchers[pluginId] = {
+ watchers: watchers,
+ debounceTimer: debounceTimer,
+ translationWatcher: translationWatcher,
+ translationDebounceTimer: translationDebounceTimer,
+ pluginDir: pluginDir
+ };
+
+ Logger.d("PluginService", "Set up hot reload watcher for plugin:", pluginId, "(including translations)");
+ }
+
+ // Create a translation file watcher for a specific language
+ function createTranslationWatcher(pluginId, pluginDir, language, debounceTimer) {
+ var translationFile = pluginDir + "/i18n/" + language + ".json";
+
+ var watcher = Qt.createQmlObject(`
+ import Quickshell.Io
+ FileView {
+ path: "${translationFile}"
+ watchChanges: true
+ }
+ `, root, "TranslationWatcher_" + pluginId + "_" + language);
+
+ watcher.fileChanged.connect(function () {
+ debounceTimer.restart();
+ });
+
+ Logger.d("PluginService", "Watching translation file:", translationFile);
+ return watcher;
+ }
+
+ // Update translation watchers when language changes
+ function updateTranslationWatchers() {
+ for (var pluginId in root.pluginFileWatchers) {
+ var watcherData = root.pluginFileWatchers[pluginId];
+ if (!watcherData || !watcherData.translationDebounceTimer)
+ continue;
+
+ // Destroy old translation watcher
+ if (watcherData.translationWatcher) {
+ watcherData.translationWatcher.destroy();
+ }
+
+ // Create new watcher for current language
+ watcherData.translationWatcher = createTranslationWatcher(pluginId, watcherData.pluginDir, I18n.langCode, watcherData.translationDebounceTimer);
+ }
+ Logger.d("PluginService", "Updated translation watchers for language:", I18n.langCode);
+ }
+
+ // Remove file watcher for a plugin
+ function removePluginFileWatcher(pluginId) {
+ var watcherData = root.pluginFileWatchers[pluginId];
+ if (!watcherData) {
+ return;
+ }
+
+ // Destroy all watchers
+ if (watcherData.watchers) {
+ for (var i = 0; i < watcherData.watchers.length; i++) {
+ if (watcherData.watchers[i]) {
+ watcherData.watchers[i].destroy();
+ }
+ }
+ }
+
+ // Destroy debounce timer
+ if (watcherData.debounceTimer) {
+ watcherData.debounceTimer.destroy();
+ }
+
+ // Destroy translation watcher
+ if (watcherData.translationWatcher) {
+ watcherData.translationWatcher.destroy();
+ }
+
+ // Destroy translation debounce timer
+ if (watcherData.translationDebounceTimer) {
+ watcherData.translationDebounceTimer.destroy();
+ }
+
+ delete root.pluginFileWatchers[pluginId];
+ Logger.d("PluginService", "Removed hot reload watcher for plugin:", pluginId);
+ }
+
+ // Reload a plugin (hot reload)
+ function reloadPlugin(pluginId) {
+ if (!root.loadedPlugins[pluginId]) {
+ Logger.w("PluginService", "Cannot reload: plugin not loaded:", pluginId);
+ return false;
+ }
+
+ Logger.i("PluginService", "Hot reloading plugin:", pluginId);
+
+ var manifest = PluginRegistry.getPluginManifest(pluginId);
+ if (!manifest) {
+ Logger.e("PluginService", "Cannot reload: manifest not found for:", pluginId);
+ return false;
+ }
+
+ // Unregister widget instances from the bar
+ BarService.destroyPluginWidgetInstances(pluginId);
+
+ // Unload the plugin (destroys components and instances)
+ // Pass true to preserve desktop widget settings during hot reload
+ unloadPlugin(pluginId, true);
+
+ // Increment load version to invalidate Qt's component cache
+ PluginRegistry.incrementPluginLoadVersion(pluginId);
+
+ // Use Qt.callLater to ensure destruction is complete before reloading
+ // This prevents IPC handler conflicts and other timing issues
+ Qt.callLater(function () {
+ // Reload the plugin
+ loadPlugin(pluginId);
+
+ // Re-setup file watcher (it was destroyed during unload)
+ setupPluginFileWatcher(pluginId);
+
+ // Emit signal
+ root.pluginReloaded(pluginId);
+
+ // Show toast notification
+ var pluginName = manifest.name || pluginId;
+ ToastService.showNotice(I18n.tr("panels.plugins.title"), I18n.tr("panels.plugins.hot-reloaded", {
+ "name": pluginName
+ }));
+
+ Logger.i("PluginService", "Hot reload complete for plugin:", pluginId);
+ });
+
+ return true;
+ }
+
+ // Hot reload only translations for a plugin (lightweight, no component reload)
+ function reloadPluginTranslations(pluginId) {
+ var plugin = root.loadedPlugins[pluginId];
+ if (!plugin || !plugin.api || !plugin.manifest) {
+ Logger.w("PluginService", "Cannot reload translations: plugin not loaded:", pluginId);
+ return false;
+ }
+
+ Logger.i("PluginService", "Hot reloading translations for plugin:", pluginId);
+
+ loadPluginTranslationsAsync(pluginId, plugin.manifest, I18n.langCode, function (translations) {
+ plugin.api.pluginTranslations = translations;
+
+ // Also reload English fallback for non-English languages
+ if (I18n.langCode !== "en") {
+ loadPluginTranslationsAsync(pluginId, plugin.manifest, "en", function (fallbackTranslations) {
+ plugin.api.pluginFallbackTranslations = fallbackTranslations;
+ plugin.api.translationVersion++;
+
+ var pluginName = plugin.manifest.name || pluginId;
+ ToastService.showNotice(I18n.tr("panels.plugins.title"), I18n.tr("panels.plugins.translations-reloaded", {
+ "name": pluginName
+ }));
+ Logger.i("PluginService", "Translation hot reload complete for plugin:", pluginId);
+ });
+ } else {
+ plugin.api.pluginFallbackTranslations = {};
+ plugin.api.translationVersion++;
+
+ var pluginName = plugin.manifest.name || pluginId;
+ ToastService.showNotice(I18n.tr("panels.plugins.title"), I18n.tr("panels.plugins.translations-reloaded", {
+ "name": pluginName
+ }));
+ Logger.i("PluginService", "Translation hot reload complete for plugin:", pluginId);
+ }
+ });
+
+ return true;
+ }
+
+ // Check if a certain plugin has hot reload enabled
+ function isPluginHotReloadEnabled(pluginId) {
+ return root.pluginHotReloadEnabled.indexOf(pluginId) !== -1;
+ }
+
+ // Toggle the hot reload state of a certain plugin
+ function togglePluginHotReload(pluginId) {
+ const index = root.pluginHotReloadEnabled.indexOf(pluginId);
+ if (index === -1) {
+ root.pluginHotReloadEnabled.push(pluginId);
+ setupPluginFileWatcher(pluginId);
+ Logger.i("PluginService", "Hot reload enabled for plugin:", pluginId);
+ } else {
+ root.pluginHotReloadEnabled.splice(index, 1);
+ removePluginFileWatcher(pluginId);
+ Logger.i("PluginService", "Hot reload disabled for plugin:", pluginId);
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Noctalia/SupporterService.qml b/arch/.config/quickshell/noctalia-shell/Services/Noctalia/SupporterService.qml
new file mode 100644
index 0000000..15605f0
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Noctalia/SupporterService.qml
@@ -0,0 +1,169 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+import qs.Services.UI
+
+Singleton {
+ id: root
+
+ property string supporterDataFile: Settings.cacheDir + "supporters.json"
+ property int updateFrequency: 60 * 60 // 1 hour in seconds
+ property bool isFetching: false
+ property bool isInitialized: false
+
+ readonly property alias data: adapter
+
+ property var supporters: []
+ property var cachedAvatars: ({}) // username -> file:// path
+ property bool avatarsCached: false
+
+ FileView {
+ id: supporterDataFileView
+ path: supporterDataFile
+ printErrors: false
+ watchChanges: false
+
+ onLoaded: {
+ if (!root.isInitialized) {
+ root.isInitialized = true;
+ loadFromCache();
+ }
+ }
+ onLoadFailed: function (error) {
+ if (error.toString().includes("No such file") || error === 2) {
+ root.isInitialized = true;
+ fetchFromApi();
+ }
+ }
+
+ JsonAdapter {
+ id: adapter
+ property var supporters: []
+ property real timestamp: 0
+ }
+ }
+
+ function init() {
+ Logger.i("Supporter", "Service started");
+ }
+
+ function loadFromCache() {
+ const now = Time.timestamp;
+ var needsRefetch = false;
+
+ if (!data.timestamp || (now >= data.timestamp + updateFrequency)) {
+ needsRefetch = true;
+ Logger.i("Supporter", "Cache expired or missing, scheduling fetch");
+ } else {
+ Logger.i("Supporter", "Cache is fresh, using cached data");
+ }
+
+ if (data.supporters && data.supporters.length > 0) {
+ root.supporters = data.supporters;
+ Logger.d("Supporter", "Loaded", data.supporters.length, "supporters from cache");
+ }
+
+ if (needsRefetch) {
+ fetchFromApi();
+ }
+ }
+
+ function fetchFromApi() {
+ if (isFetching) {
+ Logger.d("Supporter", "Already fetching");
+ return;
+ }
+
+ isFetching = true;
+ supporterProcess.running = true;
+ }
+
+ function saveData() {
+ data.timestamp = Time.timestamp;
+ Quickshell.execDetached(["mkdir", "-p", Settings.cacheDir]);
+
+ try {
+ supporterDataFileView.writeAdapter();
+ Logger.d("Supporter", "Cache file written successfully");
+ } catch (error) {
+ Logger.e("Supporter", "Failed to write cache file:", error);
+ }
+ }
+
+ function getAvatarPath(username) {
+ return cachedAvatars[username] || "";
+ }
+
+ function cacheAvatars() {
+ if (supporters.length === 0)
+ return;
+
+ avatarsCached = true;
+
+ for (var i = 0; i < supporters.length; i++) {
+ var supporter = supporters[i];
+ var username = supporter.github_username;
+
+ // Only cache avatars for supporters with GitHub accounts
+ if (!username)
+ continue;
+
+ var avatarUrl = "https://github.com/" + username + ".png?size=256";
+
+ (function (uname, url) {
+ ImageCacheService.getCircularAvatar(url, "supporter_" + uname, function (cachedPath, success) {
+ if (success) {
+ cachedAvatars[uname] = "file://" + cachedPath;
+ cachedAvatarsChanged();
+ }
+ });
+ })(username, avatarUrl);
+ }
+ }
+
+ onSupportersChanged: {
+ if (supporters.length > 0 && !avatarsCached && ImageCacheService.initialized) {
+ Qt.callLater(cacheAvatars);
+ }
+ }
+
+ Connections {
+ target: ImageCacheService
+ function onInitializedChanged() {
+ if (ImageCacheService.initialized && supporters.length > 0 && !avatarsCached) {
+ Qt.callLater(cacheAvatars);
+ }
+ }
+ }
+
+ Process {
+ id: supporterProcess
+
+ command: ["curl", "-s", "https://api.noctalia.dev/supporters"]
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try {
+ const response = text;
+ if (response && response.trim()) {
+ const parsed = JSON.parse(response);
+ if (Array.isArray(parsed)) {
+ root.data.supporters = parsed;
+ root.supporters = parsed;
+ root.saveData();
+ Logger.d("Supporter", "Fetched", parsed.length, "supporters");
+ } else if (parsed.message) {
+ Logger.w("Supporter", "API error:", parsed.message);
+ }
+ }
+ } catch (e) {
+ Logger.e("Supporter", "Failed to parse response:", e);
+ }
+ root.isFetching = false;
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Noctalia/TelemetryService.qml b/arch/.config/quickshell/noctalia-shell/Services/Noctalia/TelemetryService.qml
new file mode 100644
index 0000000..ed303bf
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Noctalia/TelemetryService.qml
@@ -0,0 +1,156 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+import qs.Services.Compositor
+import qs.Services.Noctalia
+import qs.Services.System
+
+Singleton {
+ id: root
+
+ property bool initialized: false
+ property bool isSending: false
+ property int totalRamGb: 0
+ property string instanceId: ""
+
+ readonly property string telemetryEndpoint: Quickshell.env("NOCTALIA_TELEMETRY_ENDPOINT") || "https://api.noctalia.dev/ping"
+
+ function init() {
+ if (initialized)
+ return;
+
+ initialized = true;
+
+ if (!Settings.data.general.telemetryEnabled) {
+ Logger.d("Telemetry", "Telemetry disabled by user");
+ return;
+ }
+
+ // Get or generate instance ID from ShellState
+ instanceId = ShellState.getTelemetryInstanceId();
+ if (!instanceId) {
+ instanceId = generateRandomId();
+ ShellState.setTelemetryInstanceId(instanceId);
+ Logger.d("Telemetry", "Generated new random instance ID");
+ } else {
+ Logger.d("Telemetry", "Using stored instance ID");
+ }
+
+ // Read RAM info, then send ping
+ memInfoProcess.running = true;
+ }
+
+ function generateRandomId() {
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
+ const r = Math.random() * 16 | 0;
+ const v = c === 'x' ? r : (r & 0x3 | 0x8);
+ return v.toString(16);
+ });
+ }
+
+ function getInstanceId() {
+ return instanceId;
+ }
+
+ Process {
+ id: memInfoProcess
+ command: ["sh", "-c", "grep MemTotal /proc/meminfo | awk '{print int($2/1048576)}'"]
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const ramGb = parseInt(text.trim()) || 0;
+ root.totalRamGb = ramGb;
+ root.sendPing();
+ }
+ }
+
+ onExited: function (exitCode) {
+ if (exitCode !== 0) {
+ // Still send ping even if RAM detection fails
+ root.sendPing();
+ }
+ }
+ }
+
+ function sendPing() {
+ if (isSending)
+ return;
+
+ isSending = true;
+
+ const payload = {
+ instanceId: instanceId,
+ version: UpdateService.currentVersion,
+ compositor: getCompositorType(),
+ os: HostService.osPretty || "Unknown",
+ ramGb: totalRamGb,
+ monitors: getMonitorInfo(),
+ ui: {
+ scaleRatio: Settings.data.general.scaleRatio,
+ fontDefaultScale: Settings.data.ui.fontDefaultScale,
+ fontFixedScale: Settings.data.ui.fontFixedScale
+ }
+ };
+
+ Logger.d("Telemetry", "Sending anonymous ping:", JSON.stringify(payload));
+
+ const request = new XMLHttpRequest();
+ request.onreadystatechange = function () {
+ if (request.readyState === XMLHttpRequest.DONE) {
+ if (request.status >= 200 && request.status < 300) {
+ Logger.d("Telemetry", "Ping sent successfully");
+ } else {
+ Logger.d("Telemetry", "Ping failed with status:", request.status);
+ }
+ isSending = false;
+ }
+ };
+
+ request.open("POST", telemetryEndpoint);
+ request.setRequestHeader("Content-Type", "application/json");
+ request.send(JSON.stringify(payload));
+ }
+
+ function getMonitorInfo() {
+ const monitors = [];
+ const screens = Quickshell.screens || [];
+ const scales = CompositorService.displayScales || {};
+
+ for (let i = 0; i < screens.length; i++) {
+ const screen = screens[i];
+ const name = screen.name || "Unknown";
+ const scaleData = scales[name];
+ // Extract just the numeric scale value
+ const scaleValue = (typeof scaleData === "object" && scaleData !== null) ? (scaleData.scale || 1.0) : (scaleData || 1.0);
+ monitors.push({
+ width: screen.width || 0,
+ height: screen.height || 0,
+ scale: scaleValue
+ });
+ }
+
+ return monitors;
+ }
+
+ function getCompositorType() {
+ if (CompositorService.isHyprland)
+ return "Hyprland";
+ if (CompositorService.isNiri)
+ return "Niri";
+ if (CompositorService.isScroll)
+ return "Scroll";
+ if (CompositorService.isSway)
+ return "Sway";
+ if (CompositorService.isMango)
+ return "MangoWC";
+ if (CompositorService.isLabwc)
+ return "LabWC";
+ if (CompositorService.isExtWorkspace)
+ return "ExtWorkspace";
+ return "Unknown";
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Noctalia/UpdateService.qml b/arch/.config/quickshell/noctalia-shell/Services/Noctalia/UpdateService.qml
new file mode 100644
index 0000000..c0c194e
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Noctalia/UpdateService.qml
@@ -0,0 +1,435 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+import qs.Services.Noctalia
+import qs.Services.UI
+
+Singleton {
+ id: root
+
+ // Version properties
+ readonly property string baseVersion: "4.7.5"
+ readonly property bool isDevelopment: false
+ readonly property string developmentSuffix: "-git"
+ readonly property string currentVersion: `v${!isDevelopment ? baseVersion : baseVersion + developmentSuffix}`
+
+ // Telemetry was introduced in this version - users upgrading from earlier need to see the wizard
+ readonly property string telemetryIntroVersion: "4.0.2"
+
+ // URLs
+ readonly property string discordUrl: "https://discord.noctalia.dev"
+ readonly property string feedbackUrl: Quickshell.env("NOCTALIA_CHANGELOG_FEEDBACK_URL") || ""
+ readonly property string upgradeLogBaseUrl: Quickshell.env("NOCTALIA_UPGRADELOG_URL") || "https://api.noctalia.dev/upgradelog"
+
+ // Changelog properties
+ property bool initialized: false
+ property bool changelogPending: false
+ property string changelogFromVersion: ""
+ property string changelogToVersion: ""
+ property string previousVersion: ""
+ property string changelogCurrentVersion: ""
+ property string releaseContent: ""
+ property string lastShownVersion: ""
+ property bool popupScheduled: false
+ property string fetchError: ""
+ property string changelogLastSeenVersion: ""
+ property bool changelogStateLoaded: false
+ property bool pendingShowRequest: false
+ property bool pendingTelemetryWizardCheck: false
+
+ // Fix for FileView race condition
+ property bool saveInProgress: false
+ property bool pendingSave: false
+ property int saveDebounceTimer: 0
+
+ Connections {
+ target: PanelService
+ function onPopupMenuWindowRegistered(screen) {
+ if (popupScheduled) {
+ if (!viewChangelogTargetScreen || viewChangelogTargetScreen.name === screen.name) {
+ openWhenReady();
+ }
+ }
+ }
+ }
+
+ signal popupQueued(string fromVersion, string toVersion)
+ signal telemetryWizardNeeded
+
+ function init() {
+ if (initialized)
+ return;
+
+ initialized = true;
+ Logger.i("UpdateService", "Version:", root.currentVersion);
+
+ // Load changelog state from ShellState
+ Qt.callLater(() => {
+ if (typeof ShellState !== 'undefined' && ShellState.isLoaded) {
+ loadChangelogState();
+ }
+ });
+ }
+
+ Connections {
+ target: typeof ShellState !== 'undefined' ? ShellState : null
+ function onIsLoadedChanged() {
+ if (ShellState.isLoaded) {
+ loadChangelogState();
+ }
+ }
+ }
+
+ // Debounce timer to prevent rapid successive saves
+ Timer {
+ id: saveDebouncer
+ interval: 300
+ repeat: false
+ onTriggered: executeSave()
+ }
+
+ function handleChangelogRequest() {
+ const fromVersion = changelogFromVersion || "";
+ const toVersion = changelogToVersion || "";
+
+ if (Settings.shouldOpenSetupWizard) {
+ // If you'll see the setup wizard then you don't need to see the changelog
+ markChangelogSeen(toVersion);
+ return;
+ }
+
+ if (!toVersion)
+ return;
+
+ if (popupScheduled && changelogCurrentVersion === toVersion)
+ return;
+
+ if (!popupScheduled && lastShownVersion === toVersion)
+ return;
+
+ previousVersion = fromVersion;
+ changelogCurrentVersion = toVersion;
+
+ // Fetch the upgrade log from the server
+ fetchUpgradeLog(fromVersion, toVersion);
+
+ popupScheduled = true;
+ root.popupQueued(previousVersion, changelogCurrentVersion);
+
+ clearChangelogRequest();
+ }
+
+ function fetchUpgradeLog(fromVersion, toVersion) {
+ // Normalize and ensure "v" prefix for consistent URL format
+ let from = ensureVersionPrefix(fromVersion || changelogLastSeenVersion || "3.0.0");
+ let to = ensureVersionPrefix(toVersion);
+
+ // Strip -git suffix
+ from = from.replace(root.developmentSuffix, "");
+ to = to.replace(root.developmentSuffix, "");
+
+ // 'from' always needs to be before 'to' (use semantic comparison)
+ if (compareVersions(from, to) >= 0) {
+ from = "v3.0.0";
+ }
+
+ const url = `${upgradeLogBaseUrl}/${from}/${to}`;
+ Logger.i("UpdateService", "Fetching upgrade log:", url);
+ const request = new XMLHttpRequest();
+ request.onreadystatechange = function () {
+ if (request.readyState === XMLHttpRequest.DONE) {
+ Logger.d("UpdateService", "Request completed with status:", request.status);
+ Logger.d("UpdateService", "Response text length:", request.responseText ? request.responseText.length : 0);
+
+ if (request.status >= 200 && request.status < 300) {
+ releaseContent = request.responseText || "";
+ Logger.d("UpdateService", "Successfully fetched upgrade log");
+ fetchError = "";
+ openWhenReady();
+ } else {
+ Logger.w("UpdateService", "Failed to fetch upgrade log, status:", request.status);
+ releaseContent = "";
+
+ if (request.status === 404) {
+ // Changelog not available for this version range - skip silently
+ Logger.w("UpdateService", "Changelog not found, skipping display");
+ fetchError = "";
+ popupScheduled = false;
+ markChangelogSeen(toVersion);
+ } else {
+ // Network error or server issue - show error to user
+ fetchError = I18n.tr("changelog.error.fetch-failed");
+ openWhenReady();
+ }
+ }
+ }
+ };
+ request.open("GET", url);
+ request.send();
+ }
+
+ function normalizeVersion(version) {
+ if (!version)
+ return "";
+ return version.startsWith("v") ? version.substring(1) : version;
+ }
+
+ function ensureVersionPrefix(version) {
+ if (!version)
+ return "";
+ return version.startsWith("v") ? version : "v" + version;
+ }
+
+ function parseVersionParts(version) {
+ const clean = normalizeVersion(version);
+ if (!clean)
+ return [];
+ return clean.split(/[^0-9]+/).filter(part => part.length > 0).map(part => parseInt(part));
+ }
+
+ function compareVersions(a, b) {
+ if (a === b)
+ return 0;
+ const partsA = parseVersionParts(a);
+ const partsB = parseVersionParts(b);
+ const length = Math.max(partsA.length, partsB.length);
+ for (var i = 0; i < length; i++) {
+ const valA = partsA[i] || 0;
+ const valB = partsB[i] || 0;
+ if (valA > valB)
+ return 1;
+ if (valA < valB)
+ return -1;
+ }
+ return 0;
+ }
+
+ // Check if user is upgrading from a version before telemetry was introduced
+ function shouldShowTelemetryWizard() {
+ if (!changelogStateLoaded)
+ return false;
+ if (Settings.isFreshInstall)
+ return false;
+ if (Settings.shouldOpenSetupWizard)
+ return false;
+
+ // No previous version recorded but settings exist - assume upgrading from old version
+ // (e.g., user deleted shell-state.json but has existing settings)
+ if (!changelogLastSeenVersion || changelogLastSeenVersion === "")
+ return true;
+
+ // Check if last seen version is before telemetry introduction
+ return compareVersions(changelogLastSeenVersion, telemetryIntroVersion) < 0;
+ }
+
+ // Called by shell.qml to check for telemetry wizard after init
+ // If state isn't loaded yet, sets a pending flag and emits telemetryWizardNeeded later
+ function checkTelemetryWizardOrChangelog() {
+ Logger.d("UpdateService", "checkTelemetryWizardOrChangelog called, stateLoaded:", changelogStateLoaded);
+ if (!changelogStateLoaded) {
+ // State not loaded yet, set pending flags
+ Logger.d("UpdateService", "State not loaded yet, setting pending flags");
+ pendingTelemetryWizardCheck = true;
+ pendingShowRequest = true;
+ return;
+ }
+
+ // State is already loaded, check immediately
+ const needsTelemetryWizard = shouldShowTelemetryWizard();
+ Logger.d("UpdateService", "shouldShowTelemetryWizard:", needsTelemetryWizard, "lastSeenVersion:", changelogLastSeenVersion);
+ if (needsTelemetryWizard) {
+ Logger.i("UpdateService", "Emitting telemetryWizardNeeded signal");
+ root.telemetryWizardNeeded();
+ } else {
+ showLatestChangelog();
+ }
+ }
+
+ function openWhenReady() {
+ if (!popupScheduled)
+ return;
+
+ if (!Quickshell.screens || Quickshell.screens.length === 0) {
+ return;
+ }
+
+ let targetScreen = viewChangelogTargetScreen;
+
+ if (targetScreen) {
+ // Explicit screen requested - validate it
+ if (!PanelService.canShowPanelsOnScreen(targetScreen)) {
+ Logger.w("UpdateService", "Changelog cannot be shown on screen without bar:", targetScreen.name);
+ popupScheduled = false;
+ viewChangelogTargetScreen = null;
+ return;
+ }
+ } else {
+ // No explicit screen - find one that can show panels
+ targetScreen = PanelService.findScreenForPanels();
+ if (!targetScreen) {
+ Logger.w("UpdateService", "No screen available to show changelog");
+ popupScheduled = false;
+ return;
+ }
+ }
+
+ const panel = PanelService.getPanel("changelogPanel", targetScreen);
+ if (!panel) {
+ // Panel not found yet. Wait for popupMenuWindowRegistered signal.
+ // This avoids the memory leak (#1306).
+ Logger.d("UpdateService", "Waiting for changelogPanel on screen:", targetScreen.name);
+ return;
+ }
+
+ panel.open();
+ popupScheduled = false;
+ lastShownVersion = changelogCurrentVersion;
+ viewChangelogTargetScreen = null;
+ }
+
+ function openDiscord() {
+ if (!discordUrl)
+ return;
+ Quickshell.execDetached(["xdg-open", discordUrl]);
+ }
+
+ function openFeedbackForm() {
+ if (!feedbackUrl)
+ return;
+ Quickshell.execDetached(["xdg-open", feedbackUrl]);
+ }
+
+ function showLatestChangelog() {
+ if (!currentVersion)
+ return;
+
+ if (!changelogStateLoaded) {
+ pendingShowRequest = true;
+ return;
+ }
+
+ // Normalize versions for comparison (strip -git, ensure v prefix)
+ const lastSeen = ensureVersionPrefix(changelogLastSeenVersion.replace(developmentSuffix, ""));
+ const target = ensureVersionPrefix(currentVersion.replace(developmentSuffix, ""));
+
+ if (lastSeen === target)
+ return;
+
+ if (!Settings.data.general.showChangelogOnStartup) {
+ // user has opted out of seeing changelogs, mark as seen
+ markChangelogSeen(target);
+ return;
+ }
+
+ changelogFromVersion = lastSeen;
+ changelogToVersion = target;
+ changelogPending = true;
+ handleChangelogRequest();
+ }
+
+ // Manual changelog viewing (e.g., from Settings > About > View Changelog)
+ // Shows all changes since v3.0.0, unlike showLatestChangelog() which uses lastSeenVersion
+ property var viewChangelogTargetScreen: null
+
+ function viewChangelog(screen) {
+ if (!currentVersion)
+ return;
+
+ const target = ensureVersionPrefix(currentVersion.replace(developmentSuffix, ""));
+ const fromVersion = "v3.8.2";
+
+ previousVersion = fromVersion;
+ changelogCurrentVersion = target;
+ viewChangelogTargetScreen = screen || null;
+ popupScheduled = true;
+ fetchUpgradeLog(fromVersion, target);
+ }
+
+ function clearChangelogRequest() {
+ changelogPending = false;
+ changelogFromVersion = "";
+ changelogToVersion = "";
+ }
+
+ function markChangelogSeen(version) {
+ if (!version)
+ return;
+ changelogLastSeenVersion = version;
+ debouncedSaveChangelogState();
+ }
+
+ function loadChangelogState() {
+ try {
+ const changelog = ShellState.getChangelogState();
+ changelogLastSeenVersion = changelog.lastSeenVersion || "";
+
+ // Migration is now handled in Settings.qml
+ Logger.d("UpdateService", "Loaded changelog state from ShellState");
+ } catch (error) {
+ Logger.e("UpdateService", "Failed to load changelog state:", error);
+ }
+ changelogStateLoaded = true;
+
+ // Handle pending telemetry wizard check first
+ if (pendingTelemetryWizardCheck) {
+ pendingTelemetryWizardCheck = false;
+ if (shouldShowTelemetryWizard()) {
+ root.telemetryWizardNeeded();
+ } else if (pendingShowRequest) {
+ pendingShowRequest = false;
+ Qt.callLater(root.showLatestChangelog);
+ }
+ return;
+ }
+
+ if (pendingShowRequest) {
+ pendingShowRequest = false;
+ Qt.callLater(root.showLatestChangelog);
+ }
+ }
+
+ function debouncedSaveChangelogState() {
+ // Queue a save and restart the debounce timer
+ pendingSave = true;
+ saveDebouncer.restart();
+ }
+
+ function executeSave() {
+ if (!pendingSave)
+ return;
+
+ // Prevent concurrent saves
+ if (saveInProgress) {
+ // Retry after a short delay
+ saveDebouncer.start();
+ return;
+ }
+
+ pendingSave = false;
+ saveInProgress = true;
+
+ try {
+ ShellState.setChangelogState({
+ lastSeenVersion: changelogLastSeenVersion || ""
+ });
+ Logger.d("UpdateService", "Saved changelog state to ShellState");
+ saveInProgress = false;
+
+ // Check if another save was queued while we were saving
+ if (pendingSave) {
+ Qt.callLater(executeSave);
+ }
+ } catch (error) {
+ Logger.e("UpdateService", "Failed to save changelog state:", error);
+ saveInProgress = false;
+ }
+ }
+
+ function saveChangelogState() {
+ // Immediate save (backward compatibility)
+ debouncedSaveChangelogState();
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Power/IdleInhibitorService.qml b/arch/.config/quickshell/noctalia-shell/Services/Power/IdleInhibitorService.qml
new file mode 100644
index 0000000..c7904b1
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Power/IdleInhibitorService.qml
@@ -0,0 +1,220 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+import qs.Services.UI
+
+Singleton {
+ id: root
+
+ property bool isInhibited: false
+ property string reason: I18n.tr("system.user-requested")
+ property var activeInhibitors: []
+ property var timeout: null // in seconds
+
+ // True when the native Wayland IdleInhibitor is handling inhibition
+ // (set by the IdleInhibitor element in MainScreen via the nativeInhibitor property)
+ property bool nativeInhibitorAvailable: false
+
+ function init() {
+ Logger.i("IdleInhibitor", "Service started");
+ }
+
+ // Add an inhibitor
+ function addInhibitor(id, reason = "Application request") {
+ if (activeInhibitors.includes(id)) {
+ Logger.w("IdleInhibitor", "Inhibitor already active:", id);
+ return false;
+ }
+
+ activeInhibitors.push(id);
+ updateInhibition(reason);
+ Logger.d("IdleInhibitor", "Added inhibitor:", id);
+ return true;
+ }
+
+ // Remove an inhibitor
+ function removeInhibitor(id) {
+ const index = activeInhibitors.indexOf(id);
+ if (index === -1) {
+ Logger.w("IdleInhibitor", "Inhibitor not found:", id);
+ return false;
+ }
+
+ activeInhibitors.splice(index, 1);
+ updateInhibition();
+ Logger.d("IdleInhibitor", "Removed inhibitor:", id);
+ return true;
+ }
+
+ // Update the actual system inhibition
+ function updateInhibition(newReason = reason) {
+ const shouldInhibit = activeInhibitors.length > 0;
+
+ if (shouldInhibit === isInhibited) {
+ return;
+ // No change needed
+ }
+
+ if (shouldInhibit) {
+ startInhibition(newReason);
+ } else {
+ stopInhibition();
+ }
+ }
+
+ // Start system inhibition
+ function startInhibition(newReason) {
+ reason = newReason;
+
+ if (nativeInhibitorAvailable) {
+ // Native IdleInhibitor in MainScreen handles it via isInhibited binding
+ Logger.d("IdleInhibitor", "Native inhibitor active");
+ } else {
+ startSubprocessInhibition();
+ }
+
+ isInhibited = true;
+ Logger.i("IdleInhibitor", "Started inhibition:", reason);
+ }
+
+ // Stop system inhibition
+ function stopInhibition() {
+ if (!isInhibited)
+ return;
+
+ if (!nativeInhibitorAvailable && inhibitorProcess.running) {
+ inhibitorProcess.signal(15); // SIGTERM
+ }
+
+ isInhibited = false;
+ Logger.i("IdleInhibitor", "Stopped inhibition");
+ }
+
+ // Subprocess fallback using systemd-inhibit
+ function startSubprocessInhibition() {
+ inhibitorProcess.command = ["systemd-inhibit", "--what=idle", "--why=" + reason, "--mode=block", "sleep", "infinity"];
+ inhibitorProcess.running = true;
+ }
+
+ // Process for maintaining the inhibition (subprocess fallback only)
+ Process {
+ id: inhibitorProcess
+ running: false
+
+ onExited: function (exitCode, exitStatus) {
+ if (isInhibited) {
+ Logger.w("IdleInhibitor", "Inhibitor process exited unexpectedly:", exitCode);
+ isInhibited = false;
+ }
+ }
+
+ onStarted: function () {
+ Logger.d("IdleInhibitor", "Inhibitor process started successfully");
+ }
+ }
+
+ Timer {
+ id: inhibitorTimeout
+ repeat: true
+ interval: 1000 // 1 second
+ onTriggered: function () {
+ if (timeout == null) {
+ inhibitorTimeout.stop();
+ return;
+ }
+
+ timeout -= 1;
+ if (timeout <= 0) {
+ removeManualInhibitor();
+ return;
+ }
+ }
+ }
+
+ // Manual toggle for user control
+ function manualToggle() {
+ // clear any existing timeout
+ timeout = null;
+ if (activeInhibitors.includes("manual")) {
+ removeManualInhibitor();
+ return false;
+ } else {
+ addManualInhibitor(null);
+ return true;
+ }
+ }
+
+ function changeTimeout(delta) {
+ if (timeout == null && delta < 0) {
+ // no inhibitor, ignored
+ return;
+ }
+
+ if (timeout == null && delta > 0) {
+ // enable manual inhibitor and set timeout
+ addManualInhibitor(timeout + delta);
+ return;
+ }
+
+ if (timeout + delta <= 0) {
+ // disable manual inhibitor
+ removeManualInhibitor();
+ return;
+ }
+
+ if (timeout + delta > 0) {
+ // change timeout
+ addManualInhibitor(timeout + delta);
+ return;
+ }
+ }
+
+ function removeManualInhibitor() {
+ if (timeout !== null) {
+ timeout = null;
+ if (inhibitorTimeout.running) {
+ inhibitorTimeout.stop();
+ }
+ }
+
+ if (activeInhibitors.includes("manual")) {
+ removeInhibitor("manual");
+ ToastService.showNotice(I18n.tr("tooltips.keep-awake"), I18n.tr("common.disabled"), "keep-awake-off");
+ Logger.i("IdleInhibitor", "Manual inhibition disabled");
+ }
+ }
+
+ function addManualInhibitor(timeoutSec) {
+ if (!activeInhibitors.includes("manual")) {
+ addInhibitor("manual", "Manually activated by user");
+ ToastService.showNotice(I18n.tr("tooltips.keep-awake"), I18n.tr("common.enabled"), "keep-awake-on");
+ }
+
+ if (timeoutSec === null && timeout === null) {
+ Logger.i("IdleInhibitor", "Manual inhibition enabled");
+ return;
+ } else if (timeoutSec !== null && timeout === null) {
+ timeout = timeoutSec;
+ inhibitorTimeout.start();
+ Logger.i("IdleInhibitor", "Manual inhibition enabled with timeout:", timeoutSec);
+ return;
+ } else if (timeoutSec !== null && timeout !== null) {
+ timeout = timeoutSec;
+ Logger.i("IdleInhibitor", "Manual inhibition timeout changed to:", timeoutSec);
+ return;
+ } else if (timeoutSec === null && timeout !== null) {
+ timeout = null;
+ inhibitorTimeout.stop();
+ Logger.i("IdleInhibitor", "Manual inhibition timeout cleared");
+ return;
+ }
+ }
+
+ // Clean up on shutdown
+ Component.onDestruction: {
+ stopInhibition();
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Power/IdleService.qml b/arch/.config/quickshell/noctalia-shell/Services/Power/IdleService.qml
new file mode 100644
index 0000000..2978157
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Power/IdleService.qml
@@ -0,0 +1,397 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import qs.Commons
+import qs.Services.Compositor
+import qs.Services.UI
+
+/**
+* IdleService โ native idle detection via ext-idle-notify-v1 Wayland protocol.
+*
+* Three configurable stages:
+* 1. Screen-off (DPMS) โ dims / turns off monitors
+* 2. Lock screen โ activates the session lock
+* 3. Suspend โ systemctl suspend
+*
+* Each stage shows a fade-to-black overlay for a configurable grace period
+* before executing the action. Any mouse movement cancels the fade.
+*
+* IdleMonitor instances are created with Qt.createQmlObject() so the shell
+* does not crash on compositors that lack the protocol.
+*
+* Timeouts come from Settings.data.idle (in seconds). 0 = disabled.
+*/
+Singleton {
+ id: root
+
+ // True if ext-idle-notify-v1 is supported by the compositor
+ readonly property bool nativeIdleMonitorAvailable: _monitorsCreated
+
+ // Live idle time in seconds (updated by the 1s heartbeat monitor)
+ property int idleSeconds: 0
+
+ // Fade overlay state โ "" means no fade in progress
+ property string fadePending: ""
+ readonly property int fadeDuration: Settings.data.idle.fadeDuration
+
+ property bool _monitorsCreated: false
+ property var _screenOffMonitor: null
+ property var _lockMonitor: null
+ property var _suspendMonitor: null
+ property var _heartbeatMonitor: null
+ property var _customMonitors: ({})
+ property var _queuedStages: []
+ property bool _screenOffActive: false
+
+ // Signals for external listeners (plugins, modules)
+ signal screenOffRequested
+ signal lockRequested
+ signal suspendRequested
+
+ // -------------------------------------------------------
+ function init() {
+ Logger.i("IdleService", "Service started");
+ _applyTimeouts();
+ }
+
+ // Grace period timer โ fires when fade completes without cancellation
+ Timer {
+ id: graceTimer
+ interval: root.fadeDuration * 1000
+ repeat: false
+ onTriggered: {
+ const action = root.fadePending;
+ root._executeAction(action);
+ overlayCleanupTimer.start();
+ }
+ }
+
+ Timer {
+ id: overlayCleanupTimer
+ interval: 500
+ repeat: false
+ onTriggered: {
+ root.fadePending = "";
+ root._runNextQueuedStage();
+ }
+ }
+
+ // Counts up idleSeconds while the heartbeat monitor reports idle
+ Timer {
+ id: idleCounter
+ interval: 1000
+ repeat: true
+ onTriggered: root.idleSeconds++
+ }
+
+ // -------------------------------------------------------
+ function cancelFade() {
+ if (fadePending === "") {
+ _queuedStages = [];
+ _restoreMonitors();
+ return;
+ }
+ Logger.i("IdleService", "Fade cancelled for:", fadePending);
+ fadePending = "";
+ _queuedStages = [];
+ graceTimer.stop();
+ overlayCleanupTimer.stop();
+ _restoreMonitors();
+ }
+
+ function _restoreMonitors() {
+ if (!_screenOffActive)
+ return;
+ _screenOffActive = false;
+ Logger.i("IdleService", "Restoring monitors (DPMS on)");
+ CompositorService.turnOnMonitors();
+
+ if (Settings.data.idle.resumeScreenOffCommand) {
+ Logger.i("IdleService", "Executing screen-off resume command");
+ Quickshell.execDetached(["sh", "-c", Settings.data.idle.resumeScreenOffCommand]);
+ }
+ }
+
+ function _queueStage(stage) {
+ if (!_isValidStage(stage)) {
+ Logger.w("IdleService", "Ignoring unknown queued stage:", stage);
+ return;
+ }
+ if (stage === fadePending)
+ return;
+ if (_queuedStages.indexOf(stage) !== -1)
+ return;
+ _queuedStages.push(stage);
+ Logger.d("IdleService", "Queued idle stage while fade is active:", stage);
+ }
+
+ function _isValidStage(stage) {
+ return stage === "screenOff" || stage === "lock" || stage === "suspend";
+ }
+
+ function _isStageEnabled(stage) {
+ const idle = Settings.data.idle;
+ if (stage === "screenOff")
+ return idle.screenOffTimeout > 0;
+ if (stage === "lock")
+ return idle.lockTimeout > 0;
+ if (stage === "suspend")
+ return idle.suspendTimeout > 0;
+ return false;
+ }
+
+ function _runNextQueuedStage() {
+ if (fadePending !== "")
+ return;
+ if (idleSeconds <= 0) {
+ _queuedStages = [];
+ return;
+ }
+
+ while (_queuedStages.length > 0) {
+ const nextStage = _queuedStages.shift();
+ if (!_isValidStage(nextStage)) {
+ Logger.w("IdleService", "Dropping queued unknown stage:", nextStage);
+ continue;
+ }
+ if (!_isStageEnabled(nextStage)) {
+ Logger.d("IdleService", "Dropping queued disabled stage:", nextStage);
+ continue;
+ }
+
+ Logger.i("IdleService", "Running queued idle stage:", nextStage);
+ _onIdle(nextStage);
+ return;
+ }
+ }
+
+ function _onIdle(stage) {
+ if (!_isValidStage(stage)) {
+ Logger.w("IdleService", "Idle fired with unknown stage:", stage);
+ return;
+ }
+ if (!_isStageEnabled(stage)) {
+ Logger.d("IdleService", "Ignoring idle stage because it is disabled:", stage);
+ return;
+ }
+
+ if (fadePending !== "") {
+ _queueStage(stage);
+ return;
+ }
+ Logger.i("IdleService", "Idle fired:", stage);
+ fadePending = stage;
+ graceTimer.restart();
+ }
+
+ function _executeAction(stage) {
+ Logger.i("IdleService", "Executing action:", stage);
+ if (stage === "screenOff") {
+ if (Settings.data.idle.screenOffCommand)
+ Quickshell.execDetached(["sh", "-c", Settings.data.idle.screenOffCommand]);
+ CompositorService.turnOffMonitors();
+ root._screenOffActive = true;
+ root.screenOffRequested();
+ } else if (stage === "lock") {
+ if (Settings.data.idle.lockCommand)
+ Quickshell.execDetached(["sh", "-c", Settings.data.idle.lockCommand]);
+ if (PanelService.lockScreen && !PanelService.lockScreen.active) {
+ PanelService.lockScreen.active = true;
+ }
+ root.lockRequested();
+ } else if (stage === "suspend") {
+ if (Settings.data.idle.suspendCommand)
+ Quickshell.execDetached(["sh", "-c", Settings.data.idle.suspendCommand]);
+ if (Settings.data.general.lockOnSuspend) {
+ CompositorService.lockAndSuspend();
+ } else {
+ CompositorService.suspend();
+ }
+ root.suspendRequested();
+ } else {
+ Logger.w("IdleService", "Unknown idle stage action:", stage);
+ }
+ }
+
+ // -------------------------------------------------------
+ // Re-apply when settings change
+ Connections {
+ target: Settings
+ function onSettingsLoaded() {
+ root._applyTimeouts();
+ }
+ }
+
+ Connections {
+ target: Settings.data.idle
+ function onScreenOffTimeoutChanged() {
+ root._applyTimeouts();
+ }
+ function onLockTimeoutChanged() {
+ root._applyTimeouts();
+ }
+ function onSuspendTimeoutChanged() {
+ root._applyTimeouts();
+ }
+ function onEnabledChanged() {
+ root._applyTimeouts();
+ }
+ function onCustomCommandsChanged() {
+ root._applyCustomMonitors();
+ }
+ }
+
+ function _applyTimeouts() {
+ const idle = Settings.data.idle;
+ const globalEnabled = idle.enabled;
+
+ _setMonitor("screenOff", globalEnabled ? idle.screenOffTimeout : 0);
+ _setMonitor("lock", globalEnabled ? idle.lockTimeout : 0);
+ _setMonitor("suspend", globalEnabled ? idle.suspendTimeout : 0);
+ _ensureHeartbeat();
+ _applyCustomMonitors();
+ }
+
+ function _applyCustomMonitors() {
+ // Destroy all existing custom monitors
+ for (var key in _customMonitors) {
+ if (_customMonitors[key]) {
+ _customMonitors[key].destroy();
+ }
+ }
+ root._customMonitors = {};
+
+ const idle = Settings.data.idle;
+ if (!idle.enabled)
+ return;
+
+ var entries = [];
+ try {
+ entries = JSON.parse(idle.customCommands);
+ } catch (e) {
+ Logger.w("IdleService", "Failed to parse customCommands:", e);
+ return;
+ }
+
+ var newMonitors = {};
+ for (var i = 0; i < entries.length; i++) {
+ const entry = entries[i];
+ const timeoutSec = parseInt(entry.timeout);
+ const cmd = entry.command;
+ const resumeCmd = entry.resumeCommand || "";
+ if (!cmd && !resumeCmd || timeoutSec <= 0)
+ continue;
+ try {
+ const qml = `
+ import Quickshell.Wayland
+ IdleMonitor { timeout: ${timeoutSec} }
+ `;
+
+ const monitor = Qt.createQmlObject(qml, root, "IdleMonitor_custom_" + i);
+ const capturedCmd = cmd;
+ const capturedResumeCmd = resumeCmd;
+ monitor.isIdleChanged.connect(function () {
+ if (monitor.isIdle) {
+ if (capturedCmd)
+ root._executeCustomCommand(capturedCmd);
+ } else {
+ if (capturedResumeCmd)
+ root._executeCustomCommand(capturedResumeCmd);
+ }
+ });
+ newMonitors[i] = monitor;
+ root._monitorsCreated = true;
+ Logger.i("IdleService", "Custom monitor " + i + " created, timeout", timeoutSec, "s");
+ } catch (e) {
+ Logger.w("IdleService", "Failed to create custom monitor " + i + ":", e);
+ }
+ }
+ root._customMonitors = newMonitors;
+ }
+
+ function _executeCustomCommand(cmd) {
+ Logger.i("IdleService", "Executing custom command:", cmd);
+ Quickshell.execDetached(["sh", "-c", cmd]);
+ }
+
+ function _setMonitor(stage, timeoutSec) {
+ const propName = "_" + stage + "Monitor";
+ const existing = root[propName];
+
+ if (timeoutSec <= 0) {
+ if (existing) {
+ existing.destroy();
+ root[propName] = null;
+ Logger.d("IdleService", stage + " monitor disabled");
+ }
+ return;
+ }
+
+ if (existing) {
+ if (existing.timeout === timeoutSec)
+ return;
+ // ext-idle-notify-v1 has no update-timeout request โ must recreate
+ existing.destroy();
+ root[propName] = null;
+ Logger.d("IdleService", stage + " monitor timeout changed to", timeoutSec, "s, recreating");
+ }
+
+ try {
+ const qml = `
+ import Quickshell.Wayland
+ IdleMonitor { timeout: ${timeoutSec} }
+ `;
+
+ const monitor = Qt.createQmlObject(qml, root, "IdleMonitor_" + stage);
+ monitor.isIdleChanged.connect(function () {
+ if (monitor.isIdle)
+ root._onIdle(stage);
+ else
+ root.cancelFade();
+ });
+ root[propName] = monitor;
+ root._monitorsCreated = true;
+ Logger.i("IdleService", stage + " monitor created, timeout", timeoutSec, "s");
+ } catch (e) {
+ Logger.w("IdleService", "IdleMonitor not available (compositor lacks ext-idle-notify-v1):", e);
+ root._monitorsCreated = false;
+ }
+ }
+
+ function _ensureHeartbeat() {
+ if (_heartbeatMonitor)
+ return;
+ try {
+ const qml = `
+ import Quickshell.Wayland
+ IdleMonitor { timeout: 1 }
+ `;
+
+ const monitor = Qt.createQmlObject(qml, root, "IdleMonitor_heartbeat");
+ monitor.isIdleChanged.connect(function () {
+ if (monitor.isIdle) {
+ root.idleSeconds = 1;
+ idleCounter.start();
+ } else {
+ idleCounter.stop();
+ root.idleSeconds = 0;
+ if (root.fadePending === "lock" && Settings.data.idle.resumeLockCommand) {
+ Logger.i("IdleService", "Executing lock resume command");
+ Quickshell.execDetached(["sh", "-c", Settings.data.idle.resumeLockCommand]);
+ } else if (root.fadePending === "suspend" && Settings.data.idle.resumeSuspendCommand) {
+ Logger.i("IdleService", "Executing suspend resume command");
+ Quickshell.execDetached(["sh", "-c", Settings.data.idle.resumeSuspendCommand]);
+ }
+ root.cancelFade();
+ overlayCleanupTimer.stop();
+ }
+ });
+ _heartbeatMonitor = monitor;
+ root._monitorsCreated = true;
+ Logger.d("IdleService", "Heartbeat monitor created");
+ } catch (e) {
+ Logger.w("IdleService", "Heartbeat monitor failed:", e);
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Power/PowerProfileService.qml b/arch/.config/quickshell/noctalia-shell/Services/Power/PowerProfileService.qml
new file mode 100644
index 0000000..5fed8f2
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Power/PowerProfileService.qml
@@ -0,0 +1,132 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Services.UPower
+import qs.Commons
+import qs.Services.UI
+
+Singleton {
+ id: root
+
+ readonly property var powerProfiles: PowerProfiles
+ readonly property bool available: powerProfiles && powerProfiles.hasPerformanceProfile
+ property int profile: powerProfiles ? powerProfiles.profile : PowerProfile.Balanced
+
+ // Not a power profile but a volatile property to quickly disable shadows, animations, etc..
+ property bool noctaliaPerformanceMode: false
+
+ function getName(p) {
+ if (!available)
+ return "Unknown";
+
+ const prof = (p !== undefined) ? p : profile;
+
+ switch (prof) {
+ case PowerProfile.Performance:
+ return "Performance";
+ case PowerProfile.Balanced:
+ return "Balanced";
+ case PowerProfile.PowerSaver:
+ return "Power saver";
+ default:
+ return "Unknown";
+ }
+ }
+
+ function getIcon(p) {
+ if (!available)
+ return "balanced";
+
+ const prof = (p !== undefined) ? p : profile;
+
+ switch (prof) {
+ case PowerProfile.Performance:
+ return "performance";
+ case PowerProfile.Balanced:
+ return "balanced";
+ case PowerProfile.PowerSaver:
+ return "powersaver";
+ default:
+ return "balanced";
+ }
+ }
+
+ function init() {
+ Logger.d("PowerProfileService", "Service started");
+ }
+
+ function setProfile(p) {
+ if (!available)
+ return;
+ try {
+ powerProfiles.profile = p;
+ } catch (e) {
+ Logger.e("PowerProfileService", "Failed to set profile:", e);
+ }
+ }
+
+ function cycleProfile() {
+ if (!available)
+ return;
+ const current = powerProfiles.profile;
+ if (current === PowerProfile.Performance)
+ setProfile(PowerProfile.PowerSaver);
+ else if (current === PowerProfile.Balanced)
+ setProfile(PowerProfile.Performance);
+ else if (current === PowerProfile.PowerSaver)
+ setProfile(PowerProfile.Balanced);
+ }
+
+ function cycleProfileReverse() {
+ if (!available)
+ return;
+ const current = powerProfiles.profile;
+ if (current === PowerProfile.Performance)
+ setProfile(PowerProfile.Balanced);
+ else if (current === PowerProfile.Balanced)
+ setProfile(PowerProfile.PowerSaver);
+ else if (current === PowerProfile.PowerSaver)
+ setProfile(PowerProfile.Performance);
+ }
+
+ function isDefault() {
+ if (!available)
+ return true;
+ return (profile === PowerProfile.Balanced);
+ }
+
+ Connections {
+ target: powerProfiles
+ function onProfileChanged() {
+ root.profile = powerProfiles.profile;
+ // Only show toast if we have a valid profile name (not "Unknown")
+ const profileName = root.getName();
+ if (profileName !== "Unknown") {
+ ToastService.showNotice(I18n.tr("toast.power-profile.profile-name", {
+ "profile": profileName
+ }), I18n.tr("toast.power-profile.changed"), profileName.toLowerCase().replace(" ", ""));
+ }
+ }
+ }
+
+ // Noctalia Performance Mode
+ // - Turning shadow off
+ // - Turning animation off
+ // - Do Not Disturb
+ function toggleNoctaliaPerformance() {
+ noctaliaPerformanceMode = !noctaliaPerformanceMode;
+ }
+
+ function setNoctaliaPerformance(value) {
+ noctaliaPerformanceMode = value;
+ }
+
+ onNoctaliaPerformanceModeChanged: {
+ if (noctaliaPerformanceMode) {
+ ToastService.showNotice(I18n.tr("toast.noctalia-performance.label"), I18n.tr("toast.noctalia-performance.enabled"), "rocket");
+ } else {
+ ToastService.showNotice(I18n.tr("toast.noctalia-performance.label"), I18n.tr("toast.noctalia-performance.disabled"), "rocket-off");
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/System/FontService.qml b/arch/.config/quickshell/noctalia-shell/Services/System/FontService.qml
new file mode 100644
index 0000000..8d3a25b
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/System/FontService.qml
@@ -0,0 +1,182 @@
+pragma Singleton
+
+import QtQuick
+import QtQuick.Controls
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+
+Singleton {
+ id: root
+
+ property ListModel availableFonts: ListModel {}
+ property ListModel monospaceFonts: ListModel {}
+ property bool fontsLoaded: false
+ property bool isLoading: false
+
+ function init() {
+ if (fontsLoaded || isLoading)
+ return;
+ Logger.i("Font", "Service started");
+ loadFontsViaFcList();
+ }
+
+ // Load all fonts using fc-list in background - no main thread blocking
+ function loadFontsViaFcList() {
+ if (isLoading)
+ return;
+ isLoading = true;
+ allFontsProcess.running = true;
+ }
+
+ function populateModels(allFontsText, monoFontsText) {
+ // Parse monospace fonts into a lookup set
+ // fc-list returns comma-separated family names for fonts with multiple families
+ var monoLookup = {};
+ var monoLines = monoFontsText.split('\n');
+ for (var i = 0; i < monoLines.length; i++) {
+ var line = monoLines[i].trim();
+ if (line) {
+ var monoFamilies = line.split(',');
+ for (var mi = 0; mi < monoFamilies.length; mi++) {
+ var monoName = monoFamilies[mi].trim();
+ if (monoName)
+ monoLookup[monoName] = true;
+ }
+ }
+ }
+
+ // Parse all fonts - split comma-separated family names
+ var allLines = allFontsText.split('\n');
+ var fontSet = {}; // Deduplicate font families
+
+ for (var j = 0; j < allLines.length; j++) {
+ var line = allLines[j].trim();
+ if (line) {
+ var families = line.split(',');
+ for (var fi = 0; fi < families.length; fi++) {
+ var fontName = families[fi].trim();
+ if (fontName && !fontSet[fontName]) {
+ fontSet[fontName] = true;
+ }
+ }
+ }
+ }
+
+ // Sort font names
+ var sortedFonts = Object.keys(fontSet).sort(function (a, b) {
+ return a.localeCompare(b);
+ });
+
+ // Build arrays for batch insert
+ var allBatch = [];
+ var monoBatch = [];
+
+ for (var k = 0; k < sortedFonts.length; k++) {
+ var name = sortedFonts[k];
+ var fontObj = {
+ "key": name,
+ "name": name
+ };
+ allBatch.push(fontObj);
+
+ // Check if monospace
+ if (monoLookup[name] || name.toLowerCase().includes("mono")) {
+ monoBatch.push(fontObj);
+ }
+ }
+
+ // Clear and populate models (single batch operation)
+ availableFonts.clear();
+ monospaceFonts.clear();
+
+ availableFonts.append({
+ "key": Qt.application.font.family,
+ "name": I18n.tr("panels.indicator.system-default")
+ });
+ monospaceFonts.append({
+ "key": "monospace",
+ "name": I18n.tr("panels.indicator.system-default")
+ });
+
+ for (var m = 0; m < allBatch.length; m++)
+ availableFonts.append(allBatch[m]);
+ for (var n = 0; n < monoBatch.length; n++)
+ monospaceFonts.append(monoBatch[n]);
+
+ fontsLoaded = true;
+ isLoading = false;
+ Logger.i("Font", "Loaded", availableFonts.count, "fonts,", monospaceFonts.count, "monospace");
+ }
+
+ // Temporary storage for process outputs
+ property string _allFontsOutput: ""
+ property string _monoFontsOutput: ""
+ property bool _allFontsDone: false
+ property bool _monoFontsDone: false
+
+ function checkBothProcessesDone() {
+ if (_allFontsDone && _monoFontsDone) {
+ populateModels(_allFontsOutput, _monoFontsOutput);
+ // Clear temp storage
+ _allFontsOutput = "";
+ _monoFontsOutput = "";
+ _allFontsDone = false;
+ _monoFontsDone = false;
+ }
+ }
+
+ // Process to get all font families (runs in background)
+ Process {
+ id: allFontsProcess
+ command: ["fc-list", "--format", "%{family}\\n"]
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ root._allFontsOutput = this.text;
+ root._allFontsDone = true;
+ root.checkBothProcessesDone();
+ }
+ }
+
+ onRunningChanged: {
+ if (running) {
+ // Start mono fonts process in parallel
+ monoFontsProcess.running = true;
+ }
+ }
+
+ onExited: function (exitCode, exitStatus) {
+ if (exitCode !== 0) {
+ Logger.w("Font", "fc-list failed with exit code", exitCode);
+ root._allFontsOutput = "";
+ root._allFontsDone = true;
+ root.checkBothProcessesDone();
+ }
+ }
+ }
+
+ // Process to get monospace font families (runs in background, parallel)
+ Process {
+ id: monoFontsProcess
+ command: ["fc-list", ":mono", "--format", "%{family}\\n"]
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ root._monoFontsOutput = this.text;
+ root._monoFontsDone = true;
+ root.checkBothProcessesDone();
+ }
+ }
+
+ onExited: function (exitCode, exitStatus) {
+ if (exitCode !== 0) {
+ root._monoFontsOutput = "";
+ root._monoFontsDone = true;
+ root.checkBothProcessesDone();
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/System/HostService.qml b/arch/.config/quickshell/noctalia-shell/Services/System/HostService.qml
new file mode 100644
index 0000000..7758bc6
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/System/HostService.qml
@@ -0,0 +1,214 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+
+Singleton {
+ id: root
+
+ // Public properties
+ property string osPretty: ""
+ property string osLogo: ""
+ property bool isNixOS: false
+ property bool isReady: false
+
+ // User info
+ readonly property string username: (Quickshell.env("USER") || "")
+ readonly property string envRealName: (Quickshell.env("NOCTALIA_REALNAME") || "")
+ property string realName: ""
+
+ // Machine info
+ property string hostName: ""
+
+ // Internal: pending logo name for fallback after probe fails
+ property string pendingLogoName: ""
+
+ readonly property string displayName: {
+ // Explicit override
+ if (envRealName && envRealName.length > 0) {
+ return envRealName;
+ }
+
+ // Name from getent
+ if (realName && realName.length > 0) {
+ return realName;
+ }
+
+ // Fallback: $USER as-is (login names are case-sensitive on some systems)
+ if (username && username.length > 0) {
+ return username;
+ }
+
+ // Last resort: placeholder
+ return "User";
+ }
+
+ function init() {
+ Logger.i("HostService", "Service started");
+ }
+
+ // Internal helpers
+ function buildCandidates(name) {
+ const n = (name || "").trim();
+ if (!n)
+ return [];
+
+ const sizes = ["512x512", "256x256", "128x128", "64x64", "48x48", "32x32", "24x24", "22x22", "16x16"];
+ const exts = ["svg", "png"];
+ const candidates = [];
+
+ // pixmaps
+ for (const ext of exts) {
+ candidates.push(`/usr/share/pixmaps/${n}.${ext}`);
+ }
+
+ // hicolor scalable and raster sizes
+ candidates.push(`/usr/share/icons/hicolor/scalable/apps/${n}.svg`);
+ for (const s of sizes) {
+ for (const ext of exts) {
+ candidates.push(`/usr/share/icons/hicolor/${s}/apps/${n}.${ext}`);
+ }
+ }
+
+ // NixOS hicolor paths
+ candidates.push(`/run/current-system/sw/share/icons/hicolor/scalable/apps/${n}.svg`);
+ for (const s of sizes) {
+ for (const ext of exts) {
+ candidates.push(`/run/current-system/sw/share/icons/hicolor/${s}/apps/${n}.${ext}`);
+ }
+ }
+
+ // Generic icon themes under /usr/share/icons (common cases)
+ for (const ext of exts) {
+ candidates.push(`/usr/share/icons/${n}.${ext}`);
+ candidates.push(`/usr/share/icons/${n}/${n}.${ext}`);
+ candidates.push(`/usr/share/icons/${n}/apps/${n}.${ext}`);
+ }
+
+ return candidates;
+ }
+
+ function resolveLogo(name) {
+ const n = (name || "").trim();
+ if (!n)
+ return;
+
+ // First try Quickshell's icon lookup for direct file paths
+ try {
+ const path = Quickshell.iconPath(n, "");
+ if (path && path !== "" && !path.startsWith("image://")) {
+ // Got a direct file path - use it
+ const finalPath = path.startsWith("file://") ? path : "file://" + path;
+ root.osLogo = finalPath;
+ Logger.d("HostService", "Found logo via icon theme:", root.osLogo);
+ return;
+ }
+ } catch (e) {
+ // Ignore and continue to manual probe
+ }
+
+ // Try manual probing for hicolor/pixmaps paths
+ // Store name for fallback to image:// URI if probe fails
+ root.pendingLogoName = n;
+ const all = buildCandidates(n);
+ if (all.length === 0) {
+ // No candidates, try image:// URI directly
+ root.osLogo = `image://icon/${n}`;
+ Logger.d("HostService", "Using theme icon URI:", root.osLogo);
+ return;
+ }
+ const script = all.map(p => `if [ -f "${p}" ]; then echo "${p}"; exit 0; fi`).join("; ") + "; exit 1";
+ probe.command = ["sh", "-c", script];
+ probe.running = true;
+ }
+
+ // Read /etc/os-release and trigger resolution
+ FileView {
+ id: osInfo
+ path: "/etc/os-release"
+ onLoaded: {
+ try {
+ const lines = text().split("\n");
+ const val = k => {
+ const l = lines.find(x => x.startsWith(k + "="));
+ return l ? l.split("=")[1].replace(/"/g, "") : "";
+ };
+ root.osPretty = val("PRETTY_NAME") || val("NAME");
+ Logger.i("HostService", "Detected", root.osPretty);
+
+ const osId = (val("ID") || "").toLowerCase();
+ root.isNixOS = osId === "nixos" || (root.osPretty || "").toLowerCase().includes("nixos");
+ const logoName = val("LOGO");
+ Logger.i("HostService", "Looking for logo icon:", logoName);
+ if (logoName) {
+ resolveLogo(logoName);
+ }
+ root.isReady = true;
+ } catch (e) {
+ Logger.w("HostService", "failed to read os-release", e);
+ }
+ }
+ }
+
+ Process {
+ id: probe
+ onExited: code => {
+ const p = String(stdout.text || "").trim();
+ if (code === 0 && p) {
+ root.osLogo = `file://${p}`;
+ root.pendingLogoName = "";
+ Logger.d("HostService", "Found", root.osLogo);
+ } else if (root.pendingLogoName) {
+ // Manual probe failed, fallback to image:// URI (theme icon)
+ root.osLogo = `image://icon/${root.pendingLogoName}`;
+ root.pendingLogoName = "";
+ Logger.d("HostService", "Using theme icon URI:", root.osLogo);
+ } else {
+ root.osLogo = "";
+ Logger.w("HostService", "No distro logo found");
+ }
+ }
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+ }
+
+ // Resolve GECOS real name once on startup
+ Process {
+ id: realNameProcess
+ command: ["sh", "-c", "getent passwd \"$USER\" | cut -d: -f5 | cut -d, -f1"]
+ running: true
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const name = String(text || "").trim();
+ if (name.length > 0) {
+ root.realName = name;
+ Logger.i("HostService", "resolved real name", name);
+ }
+ }
+ }
+ stderr: StdioCollector {}
+ }
+
+ // Resolve hostname from distro-specific locations.
+ // Prefer /etc/hostname, fallback to Gentoo's /etc/conf.d/hostname.
+ Process {
+ id: hostNameProcess
+ command: ["sh", "-c",
+ "if [ -r /etc/hostname ]; then sed -n '1p' /etc/hostname; exit 0; fi; if [ -r /etc/conf.d/hostname ]; then v=$(sed -n -E 's/^[[:space:]]*[Hh][Oo][Ss][Tt][Nn][Aa][Mm][Ee][[:space:]]*=[[:space:]]*//p' /etc/conf.d/hostname | sed -n '1p'); v=$(printf '%s' \"$v\" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//; s/^\"//; s/\"$//; s/^\x27//; s/\x27$//'); printf '%s\n' \"$v\"; exit 0; fi; exit 0"]
+ running: true
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const name = String(text || "").trim();
+ if (name.length > 0) {
+ root.hostName = name;
+ Logger.i("HostService", "resolved hostname", name);
+ }
+ }
+ }
+ stderr: StdioCollector {}
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/System/NotificationRulesService.qml b/arch/.config/quickshell/noctalia-shell/Services/System/NotificationRulesService.qml
new file mode 100644
index 0000000..792846e
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/System/NotificationRulesService.qml
@@ -0,0 +1,86 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+
+Singleton {
+ id: root
+
+ readonly property string rulesFilePath: Settings.configDir + "notification-rules.json"
+
+ property var rules: []
+
+ property FileView rulesFileView: FileView {
+ id: rulesFileView
+ path: root.rulesFilePath
+ watchChanges: true
+ printErrors: false
+
+ adapter: JsonAdapter {
+ id: rulesAdapter
+ property var rules: []
+ }
+
+ onLoaded: {
+ try {
+ const parsed = JSON.parse(rulesFileView.text());
+ const raw = Array.isArray(parsed.rules) ? parsed.rules : [];
+ root.rules = raw.filter(r => (r.pattern || "").trim() !== "");
+ } catch (e) {
+ root.rules = [];
+ }
+ }
+
+ onLoadFailed: function (error) {
+ root.rules = [];
+ }
+ }
+
+ function init() {
+ }
+
+ function save() {
+ Quickshell.execDetached(["mkdir", "-p", Settings.configDir]);
+ root.rules = root.rules.filter(r => (r.pattern || "").trim() !== "");
+ rulesAdapter.rules = root.rules;
+ rulesFileView.writeAdapter();
+ }
+
+ function evaluate(appName, summary, body) {
+ const haystack = [appName || "", summary || "", body || ""].join(" ");
+ for (let i = 0; i < root.rules.length; i++) {
+ const r = root.rules[i];
+ const pattern = (r.pattern || "").trim();
+ if (pattern === "")
+ continue;
+ let matched = false;
+ if (pattern.length >= 3 && pattern.startsWith("/") && pattern.endsWith("/")) {
+ try {
+ matched = new RegExp(pattern.slice(1, -1)).test(haystack);
+ } catch (e) {
+ Logger.w("NotificationRulesService", "Invalid regex:", pattern, e);
+ }
+ } else if (pattern.includes("*")) {
+ try {
+ const reStr = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
+ matched = new RegExp(reStr, "i").test(haystack);
+ } catch (e) {
+ matched = haystack.toLowerCase().includes(pattern.toLowerCase());
+ }
+ } else {
+ matched = haystack.toLowerCase().includes(pattern.toLowerCase());
+ }
+ if (matched) {
+ const a = (r.action || "block").toLowerCase();
+ if (a === "mute" || a === "hide")
+ return a;
+ if (a === "silence")
+ return "hide";
+ return "block";
+ }
+ }
+ return null;
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/System/NotificationService.qml b/arch/.config/quickshell/noctalia-shell/Services/System/NotificationService.qml
new file mode 100644
index 0000000..fff2419
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/System/NotificationService.qml
@@ -0,0 +1,1239 @@
+pragma Singleton
+
+import QtQuick
+import QtQuick.Window
+import Quickshell
+import Quickshell.Io
+import Quickshell.Services.Notifications
+import Quickshell.Wayland
+import "../../Helpers/sha256.js" as Checksum
+import qs.Commons
+import qs.Services.Compositor
+import qs.Services.Media
+import qs.Services.Power
+import qs.Services.System
+import qs.Services.UI
+
+Singleton {
+ id: root
+
+ // Configuration
+ property int maxPopups: 5
+ property int maxHistory: 100
+ property string historyFile: Quickshell.env("NOCTALIA_NOTIF_HISTORY_FILE") || (Settings.cacheDir + "notifications.json")
+
+ // State
+ property real lastSeenTs: 0
+ // Volatile property that doesn't persist to settings (similar to noctaliaPerformanceMode)
+ property bool doNotDisturb: false
+
+ // Models
+ property ListModel popupModel: ListModel {}
+ property ListModel historyModel: ListModel {}
+
+ // Internal state
+ property var popupState: ({}) // Maps internal ID to {notification, watcher, cachedActions, metadata}
+ property var quickshellIdToInternalId: ({})
+
+ // Rate limiting for notification sounds (minimum 100ms between sounds)
+ property var lastSoundTime: 0
+ readonly property int minSoundInterval: 100
+
+ // Notification server
+ property var notificationServerLoader: null
+
+ Component {
+ id: notificationServerComponent
+ NotificationServer {
+ keepOnReload: false
+ imageSupported: true
+ actionsSupported: true
+ onNotification: notification => handleNotification(notification)
+ }
+ }
+
+ Component {
+ id: notificationWatcherComponent
+ Connections {
+ property var targetNotification
+ property var targetDataId
+ target: targetNotification
+
+ function onSummaryChanged() {
+ updateNotificationFromObject(targetDataId);
+ }
+ function onBodyChanged() {
+ updateNotificationFromObject(targetDataId);
+ }
+ function onAppNameChanged() {
+ updateNotificationFromObject(targetDataId);
+ }
+ function onUrgencyChanged() {
+ updateNotificationFromObject(targetDataId);
+ }
+ function onAppIconChanged() {
+ updateNotificationFromObject(targetDataId);
+ }
+ function onImageChanged() {
+ updateNotificationFromObject(targetDataId);
+ }
+ function onActionsChanged() {
+ updateNotificationFromObject(targetDataId);
+ }
+ }
+ }
+
+ function updateNotificationServer() {
+ if (notificationServerLoader) {
+ notificationServerLoader.destroy();
+ notificationServerLoader = null;
+ }
+
+ if (Settings.isLoaded && Settings.data.notifications.enabled !== false) {
+ notificationServerLoader = notificationServerComponent.createObject(root);
+ }
+ }
+
+ Component.onCompleted: {
+ if (Settings.isLoaded) {
+ updateNotificationServer();
+ }
+
+ // Load state from ShellState
+ Qt.callLater(() => {
+ if (typeof ShellState !== 'undefined' && ShellState.isLoaded) {
+ loadState();
+ }
+ });
+ }
+
+ Connections {
+ target: typeof ShellState !== 'undefined' ? ShellState : null
+ function onIsLoadedChanged() {
+ if (ShellState.isLoaded) {
+ loadState();
+ }
+ }
+ }
+
+ Connections {
+ target: Settings
+ function onSettingsLoaded() {
+ updateNotificationServer();
+ }
+ function onSettingsSaved() {
+ updateNotificationServer();
+ }
+ }
+
+ // Helper function to generate content-based ID for deduplication
+ function getContentId(summary, body, appName) {
+ return Checksum.sha256(JSON.stringify({
+ "summary": summary || "",
+ "body": body || "",
+ "app": appName || ""
+ }));
+ }
+
+ // Main handler
+ function handleNotification(notification) {
+ const quickshellId = notification.id;
+ const data = createData(notification);
+
+ const ruleAction = NotificationRulesService.evaluate(data.appName, data.summary, data.body);
+ if (ruleAction === "block")
+ return;
+ if (ruleAction === "hide") {
+ trySaveToHistory(data, notification);
+ return;
+ }
+
+ trySaveToHistory(data, notification);
+
+ if (root.doNotDisturb || PowerProfileService.noctaliaPerformanceMode)
+ return;
+
+ // Check if this is a replacement notification
+ const existingInternalId = quickshellIdToInternalId[quickshellId];
+ if (existingInternalId && popupState[existingInternalId]) {
+ updatePopup(existingInternalId, notification, data);
+ return;
+ }
+
+ // Check for duplicate content
+ const duplicateId = findDuplicateNotification(data);
+ if (duplicateId) {
+ removePopup(duplicateId);
+ }
+
+ // Add new notification
+ addPopup(quickshellId, notification, data);
+ if (ruleAction !== "mute")
+ playNotificationSound(data.urgency, data.appName);
+ }
+
+ function playNotificationSound(urgency, appName) {
+ if (!SoundService.multimediaAvailable) {
+ return;
+ }
+ if (!Settings.data.notifications?.sounds?.enabled) {
+ return;
+ }
+ if (AudioService.muted) {
+ return;
+ }
+ if (appName) {
+ const excludedApps = Settings.data.notifications.sounds.excludedApps || "";
+ if (excludedApps.trim() !== "") {
+ const excludedList = excludedApps.toLowerCase().split(',').map(app => app.trim());
+ const normalizedName = appName.toLowerCase();
+
+ if (excludedList.includes(normalizedName)) {
+ Logger.i("NotificationService", `Skipping sound for excluded app: ${appName}`);
+ return;
+ }
+ }
+ }
+
+ // Get the sound file for this urgency level
+ const soundFile = getNotificationSoundFile(urgency);
+ if (!soundFile || soundFile.trim() === "") {
+ // No sound file configured for this urgency level
+ Logger.i("NotificationService", `No sound file configured for urgency ${urgency}`);
+ return;
+ }
+
+ // Rate limiting - prevent sound spam
+ const now = Date.now();
+ if (now - lastSoundTime < minSoundInterval) {
+ return;
+ }
+ lastSoundTime = now;
+
+ // Play sound using existing SoundService
+ const volume = Settings.data.notifications?.sounds?.volume ?? 0.5;
+ SoundService.playSound(soundFile, {
+ volume: volume,
+ fallback: false,
+ repeat: false
+ });
+ }
+
+ // Get the appropriate sound file path for a given urgency level
+ function getNotificationSoundFile(urgency) {
+ const settings = Settings.data.notifications?.sounds;
+ if (!settings) {
+ return "";
+ }
+
+ // Default sound file path
+ const defaultSoundFile = Quickshell.shellDir + "/Assets/Sounds/notification-generic.wav";
+
+ // If separate sounds is disabled, always use normal sound for all urgencies
+ if (!settings.separateSounds) {
+ const soundFile = settings.normalSoundFile;
+ if (soundFile && soundFile.trim() !== "") {
+ return soundFile;
+ }
+ // Return default if no sound file configured
+ return defaultSoundFile;
+ }
+
+ // Map urgency levels to sound file keys (when separate sounds is enabled)
+ let soundKey;
+ switch (urgency) {
+ case 0:
+ soundKey = "lowSoundFile";
+ break;
+ case 1:
+ soundKey = "normalSoundFile";
+ break;
+ case 2:
+ soundKey = "criticalSoundFile";
+ break;
+ default:
+ // Default to normal urgency for invalid values
+ soundKey = "normalSoundFile";
+ break;
+ }
+
+ const soundFile = settings[soundKey];
+ if (soundFile && soundFile.trim() !== "") {
+ return soundFile;
+ }
+
+ // Return default sound file if none configured for this urgency level
+ return defaultSoundFile;
+ }
+
+ function updatePopup(internalId, notification, data) {
+ const index = findPopupIndex(internalId);
+ if (index < 0)
+ return;
+ const existing = popupModel.get(index);
+ const oldTimestamp = existing.timestamp;
+ const oldProgress = existing.progress;
+
+ // Update properties (keeping original timestamp and progress)
+ popupModel.setProperty(index, "summary", data.summary);
+ popupModel.setProperty(index, "summaryMarkdown", data.summaryMarkdown);
+ popupModel.setProperty(index, "body", data.body);
+ popupModel.setProperty(index, "bodyMarkdown", data.bodyMarkdown);
+ popupModel.setProperty(index, "appName", data.appName);
+ popupModel.setProperty(index, "urgency", data.urgency);
+ popupModel.setProperty(index, "expireTimeout", data.expireTimeout);
+ popupModel.setProperty(index, "originalImage", data.originalImage);
+ popupModel.setProperty(index, "cachedImage", data.cachedImage);
+ popupModel.setProperty(index, "actionsJson", data.actionsJson);
+ popupModel.setProperty(index, "timestamp", oldTimestamp);
+ popupModel.setProperty(index, "progress", oldProgress);
+
+ // Update stored notification object
+ const notifData = popupState[internalId];
+ notifData.notification = notification;
+
+ // Deep copy actions to preserve them even if QML object clears list
+ var safeActions = [];
+ if (notification.actions) {
+ for (var i = 0; i < notification.actions.length; i++) {
+ safeActions.push({
+ "identifier": notification.actions[i].identifier,
+ "actionObject": notification.actions[i]
+ });
+ }
+ }
+ notifData.cachedActions = safeActions;
+ notifData.metadata.originalId = data.originalId;
+
+ notification.tracked = true;
+
+ function onClosed() {
+ dismissPopup(internalId);
+ }
+ notification.closed.connect(onClosed);
+ notifData.onClosed = onClosed;
+
+ // Update metadata
+ notifData.metadata.urgency = data.urgency;
+ notifData.metadata.duration = calculateDuration(data);
+ }
+
+ function addPopup(quickshellId, notification, data) {
+ // Map IDs
+ quickshellIdToInternalId[quickshellId] = data.id;
+
+ // Create watcher
+ const watcher = notificationWatcherComponent.createObject(root, {
+ "targetNotification": notification,
+ "targetDataId": data.id
+ });
+
+ // Deep copy actions
+ var safeActions = [];
+ if (notification.actions) {
+ for (var i = 0; i < notification.actions.length; i++) {
+ safeActions.push({
+ "identifier": notification.actions[i].identifier,
+ "actionObject": notification.actions[i]
+ });
+ }
+ }
+
+ // Store notification data
+ popupState[data.id] = {
+ "notification": notification,
+ "watcher": watcher,
+ "cachedActions": safeActions // Cache actions
+ ,
+ "metadata": {
+ "originalId": data.originalId // Store original ID
+ ,
+ "timestamp": data.timestamp.getTime(),
+ "duration": calculateDuration(data),
+ "urgency": data.urgency,
+ "paused": false,
+ "pauseTime": 0
+ }
+ };
+
+ notification.tracked = true;
+
+ function onClosed() {
+ dismissPopup(data.id);
+ }
+ notification.closed.connect(onClosed);
+ popupState[data.id].onClosed = onClosed;
+
+ // Defer list insertion to prevent re-entrant QML incubation crash.
+ // Direct insert triggers Repeater.modelUpdated synchronously, which
+ // incubates delegates whose signal handlers can re-enter the V4 engine
+ // and crash in QV4::Object::insertMember.
+ Qt.callLater(() => {
+ popupModel.insert(0, data);
+
+ // Remove overflow
+ while (popupModel.count > maxPopups) {
+ const last = popupModel.get(popupModel.count - 1);
+ // Overflow only removes from ACTIVE view, but keeps it for history
+ popupState[last.id]?.notification?.dismiss(); // Visually dismiss
+ popupModel.remove(popupModel.count - 1);
+ // DO NOT call cleanupNotification here, we want to keep it for history actions
+ }
+ });
+ }
+
+ function findDuplicateNotification(data) {
+ const contentId = getContentId(data.summary, data.body, data.appName);
+
+ for (var i = 0; i < popupModel.count; i++) {
+ const existing = popupModel.get(i);
+ const existingContentId = getContentId(existing.summary, existing.body, existing.appName);
+ if (existingContentId === contentId) {
+ return existing.id;
+ }
+ }
+ return null;
+ }
+
+ function calculateDuration(data) {
+ const durations = [Settings.data.notifications?.lowUrgencyDuration * 1000 || 3000, Settings.data.notifications?.normalUrgencyDuration * 1000 || 8000, Settings.data.notifications?.criticalUrgencyDuration * 1000 || 15000];
+
+ if (Settings.data.notifications?.respectExpireTimeout) {
+ if (data.expireTimeout === 0)
+ return -1; // Never expire
+ if (data.expireTimeout > 0)
+ return data.expireTimeout;
+ }
+
+ return durations[data.urgency];
+ }
+
+ function createData(n) {
+ const time = new Date();
+ const id = Checksum.sha256(JSON.stringify({
+ "summary": n.summary,
+ "body": n.body,
+ "app": n.appName,
+ "time": time.getTime()
+ }));
+
+ const image = n.image || getIcon(n.appIcon);
+ const imageId = generateImageId(n, image);
+ queueImage(image, n.appName || "", n.summary || "", id);
+
+ return {
+ "id": id,
+ "summary": processNotificationText(n.summary || ""),
+ "summaryMarkdown": processNotificationMarkdown(n.summary || ""),
+ "body": processNotificationText(n.body || ""),
+ "bodyMarkdown": processNotificationMarkdown(n.body || ""),
+ "appName": getAppName(n.appName || n.desktopEntry || ""),
+ "urgency": n.urgency < 0 || n.urgency > 2 ? 1 : n.urgency,
+ "expireTimeout": n.expireTimeout,
+ "timestamp": time,
+ "progress": 1.0,
+ "originalImage": image,
+ "cachedImage": image // Start with original, update when cached
+ ,
+ "originalId": n.originalId || n.id || 0 // Ensure originalId is passed through
+ ,
+ "actionsJson": JSON.stringify((n.actions || []).map(a => ({
+ "text": (a.text || "").trim() || "Action",
+ "identifier": a.identifier || ""
+ })))
+ };
+ }
+
+ function findPopupIndex(internalId) {
+ for (var i = 0; i < popupModel.count; i++) {
+ if (popupModel.get(i).id === internalId) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ function updateNotificationFromObject(internalId) {
+ const notifData = popupState[internalId];
+ if (!notifData)
+ return;
+ const index = findPopupIndex(internalId);
+ if (index < 0)
+ return;
+ const data = createData(notifData.notification);
+ const existing = popupModel.get(index);
+
+ // Update properties (keeping timestamp and progress)
+ popupModel.setProperty(index, "summary", data.summary);
+ popupModel.setProperty(index, "summaryMarkdown", data.summaryMarkdown);
+ popupModel.setProperty(index, "body", data.body);
+ popupModel.setProperty(index, "bodyMarkdown", data.bodyMarkdown);
+ popupModel.setProperty(index, "appName", data.appName);
+ popupModel.setProperty(index, "urgency", data.urgency);
+ popupModel.setProperty(index, "expireTimeout", data.expireTimeout);
+ popupModel.setProperty(index, "originalImage", data.originalImage);
+ popupModel.setProperty(index, "cachedImage", data.cachedImage);
+ popupModel.setProperty(index, "actionsJson", data.actionsJson);
+
+ // Update metadata
+ notifData.metadata.urgency = data.urgency;
+ notifData.metadata.duration = calculateDuration(data);
+ }
+
+ function removePopup(id) {
+ const index = findPopupIndex(id);
+ if (index >= 0) {
+ popupModel.remove(index);
+ }
+ cleanupNotification(id);
+ }
+
+ function cleanupNotification(id) {
+ const notifData = popupState[id];
+ if (notifData) {
+ notifData.watcher?.destroy();
+ delete popupState[id];
+ }
+
+ // Clean up quickshell ID mapping
+ for (const qsId in quickshellIdToInternalId) {
+ if (quickshellIdToInternalId[qsId] === id) {
+ delete quickshellIdToInternalId[qsId];
+ break;
+ }
+ }
+ }
+
+ // Progress updates
+ Timer {
+ interval: 50
+ repeat: true
+ running: popupModel.count > 0
+ onTriggered: updateAllProgress()
+ }
+
+ function updateAllProgress() {
+ const now = Date.now();
+ const toRemove = [];
+
+ for (var i = 0; i < popupModel.count; i++) {
+ const notif = popupModel.get(i);
+ const notifData = popupState[notif.id];
+ if (!notifData)
+ continue;
+ const meta = notifData.metadata;
+ if (meta.duration === -1 || meta.paused)
+ continue;
+ const elapsed = now - meta.timestamp;
+ const progress = Math.max(1.0 - (elapsed / meta.duration), 0.0);
+
+ if (progress <= 0) {
+ toRemove.push(notif.id);
+ } else if (Math.abs(notif.progress - progress) > 0.005) {
+ popupModel.setProperty(i, "progress", progress);
+ }
+ }
+
+ if (toRemove.length > 0) {
+ animateAndRemove(toRemove[0]);
+ }
+ }
+
+ // Image handling
+ function queueImage(path, appName, summary, notificationId) {
+ if (!path || !notificationId)
+ return;
+
+ // Cache image:// URIs and temporary file paths (e.g. /tmp/ from Chromium)
+ const filePath = path.startsWith("file://") ? path.substring(7) : path;
+ const isImageUri = path.startsWith("image://");
+ const isTempFile = (path.startsWith("/") || path.startsWith("file://")) && filePath.startsWith("/tmp/");
+
+ if (!isImageUri && !isTempFile)
+ return;
+
+ ImageCacheService.getNotificationIcon(path, appName, summary, function (cachedPath, success) {
+ if (success && cachedPath) {
+ updateImagePath(notificationId, "file://" + cachedPath);
+ }
+ });
+ }
+
+ function updateImagePath(notificationId, path) {
+ updateModel(popupModel, notificationId, "cachedImage", path);
+ updateModel(historyModel, notificationId, "cachedImage", path);
+ saveHistory();
+ }
+
+ function updateModel(model, notificationId, prop, value) {
+ for (var i = 0; i < model.count; i++) {
+ if (model.get(i).id === notificationId) {
+ model.setProperty(i, prop, value);
+ break;
+ }
+ }
+ }
+
+ // History management
+ function trySaveToHistory(data, notification) {
+ if (notification.transient)
+ return;
+ const s = Settings.data.notifications?.saveToHistory;
+ if (s) {
+ let ok = true;
+ if (data.urgency === 0)
+ ok = s.low !== false;
+ else if (data.urgency === 1)
+ ok = s.normal !== false;
+ else if (data.urgency === 2)
+ ok = s.critical !== false;
+ if (ok)
+ addToHistory(data);
+ } else {
+ addToHistory(data);
+ }
+ }
+
+ function addToHistory(data) {
+ // Defer list insertion to prevent re-entrant QML incubation crash.
+ // See addPopup for full explanation.
+ Qt.callLater(() => {
+ historyModel.insert(0, data);
+
+ while (historyModel.count > maxHistory) {
+ const old = historyModel.get(historyModel.count - 1);
+ // Only delete cached images that are in our cache directory
+ const cachedPath = old.cachedImage ? old.cachedImage.replace(/^file:\/\//, "") : "";
+ if (cachedPath && cachedPath.startsWith(ImageCacheService.notificationsDir)) {
+ Quickshell.execDetached(["rm", "-f", cachedPath]);
+ }
+ historyModel.remove(historyModel.count - 1);
+ }
+ saveHistory();
+ });
+ }
+
+ // Persistence - History
+ FileView {
+ id: historyFileView
+ path: historyFile
+ printErrors: false
+ onLoaded: loadHistory()
+ onLoadFailed: error => {
+ if (error === 2)
+ writeAdapter();
+ }
+
+ JsonAdapter {
+ id: adapter
+ property var notifications: []
+ }
+ }
+
+ Timer {
+ id: saveTimer
+ interval: 200
+ onTriggered: performSaveHistory()
+ }
+
+ function saveHistory() {
+ saveTimer.restart();
+ }
+
+ function performSaveHistory() {
+ try {
+ const items = [];
+ for (var i = 0; i < historyModel.count; i++) {
+ const n = historyModel.get(i);
+ const copy = Object.assign({}, n);
+ copy.timestamp = n.timestamp.getTime();
+ items.push(copy);
+ }
+ adapter.notifications = items;
+ historyFileView.writeAdapter();
+ } catch (e) {
+ Logger.e("Notifications", "Save history failed:", e);
+ }
+ }
+
+ function loadHistory() {
+ try {
+ historyModel.clear();
+ for (const item of adapter.notifications || []) {
+ const time = new Date(item.timestamp);
+
+ // Use the cached image if it exists and starts with file://, otherwise use originalImage
+ let cachedImage = item.cachedImage || "";
+ if (!cachedImage || (!cachedImage.startsWith("file://") && !cachedImage.startsWith("/"))) {
+ cachedImage = item.originalImage || "";
+ }
+
+ historyModel.append({
+ "id": item.id || "",
+ "summary": item.summary || "",
+ "summaryMarkdown": processNotificationMarkdown(item.summary || ""),
+ "body": item.body || "",
+ "bodyMarkdown": processNotificationMarkdown(item.body || ""),
+ "appName": item.appName || "",
+ "urgency": item.urgency < 0 || item.urgency > 2 ? 1 : item.urgency,
+ "timestamp": time,
+ "originalImage": item.originalImage || "",
+ "cachedImage": cachedImage,
+ "actionsJson": item.actionsJson || "[]",
+ "originalId": item.originalId || 0
+ });
+ }
+ } catch (e) {
+ Logger.e("Notifications", "Load failed:", e);
+ }
+ }
+
+ function loadState() {
+ try {
+ const notifState = ShellState.getNotificationsState();
+ root.lastSeenTs = notifState.lastSeenTs || 0;
+
+ // Migration is now handled in Settings.qml
+ Logger.d("Notifications", "Loaded state from ShellState");
+ } catch (e) {
+ Logger.e("Notifications", "Load state failed:", e);
+ }
+ }
+
+ function saveState() {
+ try {
+ ShellState.setNotificationsState({
+ lastSeenTs: root.lastSeenTs
+ });
+ Logger.d("Notifications", "Saved state to ShellState");
+ } catch (e) {
+ Logger.e("Notifications", "Save state failed:", e);
+ }
+ }
+
+ function updateLastSeenTs() {
+ root.lastSeenTs = Time.timestamp * 1000;
+ saveState();
+ }
+
+ // Utility functions
+ function getAppName(name) {
+ if (!name || name.trim() === "")
+ return "Unknown";
+ name = name.trim();
+
+ if (name.includes(".") && (name.startsWith("com.") || name.startsWith("org.") || name.startsWith("io.") || name.startsWith("net."))) {
+ const parts = name.split(".");
+ let appPart = parts[parts.length - 1];
+
+ if (!appPart || appPart === "app" || appPart === "desktop") {
+ appPart = parts[parts.length - 2] || parts[0];
+ }
+
+ if (appPart)
+ name = appPart;
+ }
+
+ if (name.includes(".")) {
+ const parts = name.split(".");
+ let displayName = parts[parts.length - 1];
+
+ if (!displayName || /^\d+$/.test(displayName)) {
+ displayName = parts[parts.length - 2] || parts[0];
+ }
+
+ if (displayName) {
+ displayName = displayName.charAt(0).toUpperCase() + displayName.slice(1);
+ displayName = displayName.replace(/([a-z])([A-Z])/g, '$1 $2');
+ displayName = displayName.replace(/app$/i, '').trim();
+ displayName = displayName.replace(/desktop$/i, '').trim();
+ displayName = displayName.replace(/flatpak$/i, '').trim();
+
+ if (!displayName) {
+ displayName = parts[parts.length - 1].charAt(0).toUpperCase() + parts[parts.length - 1].slice(1);
+ }
+ }
+
+ return displayName || name;
+ }
+
+ let displayName = name.charAt(0).toUpperCase() + name.slice(1);
+ displayName = displayName.replace(/([a-z])([A-Z])/g, '$1 $2');
+ displayName = displayName.replace(/app$/i, '').trim();
+ displayName = displayName.replace(/desktop$/i, '').trim();
+
+ return displayName || name;
+ }
+
+ function getIcon(icon) {
+ if (!icon)
+ return "";
+ if (icon.startsWith("/") || icon.startsWith("file://"))
+ return icon;
+ return ThemeIcons.iconFromName(icon);
+ }
+
+ function escapeHtml(text) {
+ if (!text)
+ return "";
+ return text.replace(/&/g, "&").replace(//g, ">");
+ }
+
+ function sanitizeMarkdownUrl(url) {
+ if (!url)
+ return "";
+ const trimmed = url.trim();
+ if (trimmed === "")
+ return "";
+ const lower = trimmed.toLowerCase();
+ if (lower.startsWith("http://") || lower.startsWith("https://") || lower.startsWith("mailto:")) {
+ return encodeURI(trimmed);
+ }
+ return "";
+ }
+
+ function sanitizeMarkdown(text) {
+ if (!text)
+ return "";
+
+ let input = String(text);
+
+ // Strip images entirely
+ input = input.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function (match, alt) {
+ return alt ? alt : "";
+ });
+
+ // Extract links into placeholders
+ const links = [];
+ input = input.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (match, label, urlAndTitle) {
+ const urlPart = (urlAndTitle || "").trim().split(/\s+/)[0] || "";
+ const safeUrl = sanitizeMarkdownUrl(urlPart);
+ const safeLabel = escapeHtml(label);
+ if (!safeUrl)
+ return safeLabel;
+ const token = "__MDLINK_" + links.length + "__";
+ links.push({
+ "label": safeLabel,
+ "url": safeUrl
+ });
+ return token;
+ });
+
+ // Escape any remaining HTML
+ input = escapeHtml(input);
+
+ // Restore sanitized links
+ for (let i = 0; i < links.length; i++) {
+ const token = "__MDLINK_" + i + "__";
+ const link = links[i];
+ input = input.split(token).join("[" + link.label + "](" + link.url + ")");
+ }
+
+ return input;
+ }
+
+ function processNotificationText(text) {
+ if (!text)
+ return "";
+
+ // Split by tags to process segments separately
+ const parts = text.split(/(<[^>]+>)/);
+ let result = "";
+ const allowedTags = ["b", "i", "u", "a", "br"];
+
+ for (let i = 0; i < parts.length; i++) {
+ const part = parts[i];
+ if (part.startsWith("<") && part.endsWith(">")) {
+ const content = part.substring(1, part.length - 1);
+ const firstWord = content.split(/[\s/]/).filter(s => s.length > 0)[0]?.toLowerCase();
+
+ if (allowedTags.includes(firstWord)) {
+ // Preserve valid HTML tag
+ result += part;
+ } else {
+ // Unknown tag: drop tag without leaking attributes
+ result += "";
+ }
+ } else {
+ // Normal text: escape everything
+ result += escapeHtml(part);
+ }
+ }
+ return result;
+ }
+
+ function processNotificationMarkdown(text) {
+ return sanitizeMarkdown(text);
+ }
+
+ function generateImageId(notification, image) {
+ if (image && image.startsWith("image://")) {
+ if (image.startsWith("image://qsimage/")) {
+ const key = (notification.appName || "") + "|" + (notification.summary || "");
+ return Checksum.sha256(key);
+ }
+ return Checksum.sha256(image);
+ }
+ return "";
+ }
+
+ function pauseTimeout(id) {
+ const notifData = popupState[id];
+ if (notifData && !notifData.metadata.paused) {
+ notifData.metadata.paused = true;
+ notifData.metadata.pauseTime = Date.now();
+ }
+ }
+
+ function resumeTimeout(id) {
+ const notifData = popupState[id];
+ if (notifData && notifData.metadata.paused) {
+ notifData.metadata.timestamp += Date.now() - notifData.metadata.pauseTime;
+ notifData.metadata.paused = false;
+ }
+ }
+
+ // Dismiss a popup notification (e.g. clicked close, swipe, or overflow).
+ // Removes from popup list but KEEPS data for history.
+ function dismissPopup(id) {
+ const index = findPopupIndex(id);
+ if (index >= 0) {
+ popupModel.remove(index);
+ }
+ }
+
+ function dismissOldestPopup() {
+ if (popupModel.count > 0) {
+ const lastNotif = popupModel.get(popupModel.count - 1);
+ dismissPopup(lastNotif.id);
+ }
+ }
+
+ function dismissAllPopups() {
+ for (const id in popupState) {
+ popupState[id].notification?.dismiss();
+ popupState[id].watcher?.destroy();
+ }
+ popupModel.clear();
+ popupState = {};
+ quickshellIdToInternalId = {};
+ }
+
+ function invokeActionAndSuppressClose(id, actionId) {
+ const notifData = popupState[id];
+ const notification = notifData?.notification;
+ const onClosed = notifData?.onClosed;
+ let restoreClosedHandler = false;
+
+ if (notification && onClosed) {
+ try {
+ // A successful action may synchronously close the notification. Disconnect
+ // our close handler first so the popup is only dismissed by the action path.
+ notification.closed.disconnect(onClosed);
+ restoreClosedHandler = true;
+ } catch (e) {}
+ }
+
+ const invoked = invokeAction(id, actionId);
+
+ if (!invoked && restoreClosedHandler && notification && onClosed) {
+ try {
+ // If invoking the action failed, restore normal close handling for this popup.
+ notification.closed.connect(onClosed);
+ } catch (e) {}
+ }
+
+ return invoked;
+ }
+
+ function invokeAction(id, actionId) {
+ let invoked = false;
+ const notifData = popupState[id];
+
+ if (notifData && notifData.notification) {
+ const actionsToUse = (notifData.notification.actions && notifData.notification.actions.length > 0) ? notifData.notification.actions : (notifData.cachedActions || []);
+
+ if (actionsToUse && actionsToUse.length > 0) {
+ for (const item of actionsToUse) {
+ const itemId = item.identifier;
+ const actionObj = item.actionObject ? item.actionObject : item;
+
+ if (itemId === actionId) {
+ if (actionObj.invoke) {
+ try {
+ actionObj.invoke();
+ invoked = true;
+ } catch (e) {
+ Logger.w("NotificationService", "invoke() failed, trying manual fallback: " + e);
+ if (manualInvoke(notifData.metadata.originalId, itemId)) {
+ invoked = true;
+ }
+ }
+ } else {
+ if (manualInvoke(notifData.metadata.originalId, itemId)) {
+ invoked = true;
+ }
+ }
+ break;
+ }
+ }
+ }
+
+ if (!invoked && notifData.metadata.originalId) {
+ Logger.w("NotificationService", "Action objects exhausted, trying manual invoke for id=" + id + " action=" + actionId);
+ invoked = manualInvoke(notifData.metadata.originalId, actionId);
+ }
+ } else if (!notifData) {
+ Logger.w("NotificationService", "No active notification data for id=" + id + ", searching history for manual invoke");
+ for (var i = 0; i < historyModel.count; i++) {
+ if (historyModel.get(i).id === id) {
+ const histEntry = historyModel.get(i);
+ if (histEntry.originalId) {
+ invoked = manualInvoke(histEntry.originalId, actionId);
+ }
+ break;
+ }
+ }
+ }
+
+ if (!invoked) {
+ Logger.w("NotificationService", "Failed to invoke action '" + actionId + "' for notification " + id);
+ return false;
+ }
+
+ // Clear actions after use
+ updateModel(popupModel, id, "actionsJson", "[]");
+ updateModel(historyModel, id, "actionsJson", "[]");
+ saveHistory();
+
+ return true;
+ }
+
+ function manualInvoke(originalId, actionId) {
+ if (!originalId) {
+ return false;
+ }
+
+ try {
+ // Construct the signal emission using dbus-send
+ // dbus-send --session --type=signal /org/freedesktop/Notifications org.freedesktop.Notifications.ActionInvoked uint32:ID string:"KEY"
+ const args = ["dbus-send", "--session", "--type=signal", "/org/freedesktop/Notifications", "org.freedesktop.Notifications.ActionInvoked", "uint32:" + originalId, "string:" + actionId];
+
+ Quickshell.execDetached(args);
+ return true;
+ } catch (e) {
+ Logger.e("NotificationService", "Manual invoke failed: " + e);
+ return false;
+ }
+ }
+
+ function focusSenderWindow(appName) {
+ if (!appName || appName === "" || appName === "Unknown")
+ return false;
+
+ const normalizedName = appName.toLowerCase().replace(/\s+/g, "");
+
+ for (var i = 0; i < CompositorService.windows.count; i++) {
+ const win = CompositorService.windows.get(i);
+ const winAppId = (win.appId || "").toLowerCase();
+
+ const segments = winAppId.split(".");
+ const lastSegment = segments[segments.length - 1] || "";
+
+ if (winAppId === normalizedName || lastSegment === normalizedName || winAppId.includes(normalizedName) || normalizedName.includes(lastSegment)) {
+ CompositorService.focusWindow(win);
+ return true;
+ }
+ }
+
+ Logger.d("NotificationService", "No window found for app: " + appName);
+ return false;
+ }
+
+ function removeFromHistory(notificationId) {
+ for (var i = 0; i < historyModel.count; i++) {
+ const notif = historyModel.get(i);
+ if (notif.id === notificationId) {
+ // Only delete cached images that are in our cache directory
+ const cachedPath = notif.cachedImage ? notif.cachedImage.replace(/^file:\/\//, "") : "";
+ if (cachedPath && cachedPath.startsWith(ImageCacheService.notificationsDir)) {
+ Quickshell.execDetached(["rm", "-f", cachedPath]);
+ }
+ historyModel.remove(i);
+ saveHistory();
+ return true;
+ }
+ }
+ return false;
+ }
+
+ function removeOldestHistory() {
+ if (historyModel.count > 0) {
+ const oldest = historyModel.get(historyModel.count - 1);
+ // Only delete cached images that are in our cache directory
+ const cachedPath = oldest.cachedImage ? oldest.cachedImage.replace(/^file:\/\//, "") : "";
+ if (cachedPath && cachedPath.startsWith(ImageCacheService.notificationsDir)) {
+ Quickshell.execDetached(["rm", "-f", cachedPath]);
+ }
+ historyModel.remove(historyModel.count - 1);
+ saveHistory();
+ return true;
+ }
+ return false;
+ }
+
+ function clearHistory() {
+ try {
+ Quickshell.execDetached(["sh", "-c", `rm -rf "${ImageCacheService.notificationsDir}"*`]);
+ } catch (e) {
+ Logger.e("Notifications", "Failed to clear cache directory:", e);
+ }
+
+ historyModel.clear();
+ saveHistory();
+ }
+
+ function getHistorySnapshot() {
+ const items = [];
+ for (var i = 0; i < historyModel.count; i++) {
+ const entry = historyModel.get(i);
+ items.push({
+ "id": entry.id,
+ "summary": entry.summary,
+ "body": entry.body,
+ "appName": entry.appName,
+ "urgency": entry.urgency,
+ "timestamp": entry.timestamp instanceof Date ? entry.timestamp.getTime() : entry.timestamp,
+ "originalImage": entry.originalImage,
+ "cachedImage": entry.cachedImage
+ });
+ }
+ return items;
+ }
+
+ // Signals
+ signal animateAndRemove(string notificationId)
+
+ onDoNotDisturbChanged: {
+ ToastService.showNotice(doNotDisturb ? I18n.tr("toast.do-not-disturb.enabled") : I18n.tr("toast.do-not-disturb.disabled"), doNotDisturb ? I18n.tr("toast.do-not-disturb.enabled-desc") : I18n.tr("toast.do-not-disturb.disabled-desc"), doNotDisturb ? "bell-off" : "bell");
+ }
+
+ // Media toast functionality
+ property string previousMediaTitle: ""
+ property string previousMediaArtist: ""
+ property bool previousMediaIsPlaying: false
+ property bool mediaToastInitialized: false
+
+ Timer {
+ id: mediaToastInitTimer
+ interval: 3000 // Wait 3 seconds after startup to avoid initial toast
+ running: true
+ onTriggered: {
+ root.mediaToastInitialized = true;
+ root.previousMediaTitle = MediaService.trackTitle;
+ root.previousMediaArtist = MediaService.trackArtist;
+ root.previousMediaIsPlaying = MediaService.isPlaying;
+ }
+ }
+
+ Timer {
+ id: mediaToastDebounce
+ interval: 250 // Dynamic interval based on player
+ onTriggered: {
+ checkMediaToast();
+ }
+ }
+
+ function checkMediaToast() {
+ if (!Settings.data.notifications.enableMediaToast || !mediaToastInitialized)
+ return;
+
+ if (doNotDisturb || PowerProfileService.noctaliaPerformanceMode)
+ return;
+
+ // Re-evaluate player identity here to handle race conditions where
+ // the identity wasn't updated yet when the timer started.
+ const player = (MediaService.playerIdentity || "").toLowerCase();
+ const browsers = ["firefox", "chromium", "chrome", "brave", "edge", "opera", "vivaldi", "zen"];
+ const isBrowser = browsers.some(b => player.includes(b));
+
+ // Safety check: If it's a browser, ensure we waited long enough.
+ // If we started with a short interval (e.g. 250ms because we thought it was Spotify),
+ // correct it now and wait the full duration.
+ if (isBrowser && mediaToastDebounce.interval < 1500) {
+ mediaToastDebounce.interval = 1500;
+ mediaToastDebounce.restart();
+ return;
+ }
+
+ const title = MediaService.trackTitle || "";
+ const artist = MediaService.trackArtist || "";
+ const isPlaying = MediaService.isPlaying;
+
+ // Only show toast if something meaningful changed
+ const titleChanged = title !== previousMediaTitle && title !== "";
+ const playStateChanged = isPlaying !== previousMediaIsPlaying;
+ const hasMedia = title !== "" || artist !== "";
+
+ // Browser Specific Logic:
+ // If a browser reports a new title but is PAUSED, ignore it.
+ if (isBrowser && !isPlaying && titleChanged) {
+ previousMediaTitle = title;
+ previousMediaArtist = artist;
+ previousMediaIsPlaying = isPlaying;
+ return;
+ }
+
+ if (hasMedia && (titleChanged || playStateChanged)) {
+ const icon = isPlaying ? "media-play" : "media-pause";
+ let message = "";
+
+ if (artist && title) {
+ message = artist + " โ " + title;
+ } else if (title) {
+ message = title;
+ } else if (artist) {
+ message = artist;
+ }
+
+ if (message !== "") {
+ const toastTitle = isPlaying ? I18n.tr("common.play") : I18n.tr("common.pause");
+ ToastService.showNotice(toastTitle, message, icon, 3000);
+ }
+ }
+
+ previousMediaTitle = title;
+ previousMediaArtist = artist;
+ previousMediaIsPlaying = isPlaying;
+ }
+
+ Connections {
+ target: MediaService
+
+ function onTrackTitleChanged() {
+ restartDebounce();
+ }
+
+ function onTrackArtistChanged() {
+ restartDebounce();
+ }
+
+ function onIsPlayingChanged() {
+ restartDebounce();
+ }
+
+ function onPlayerIdentityChanged() {
+ restartDebounce();
+ }
+ }
+
+ function restartDebounce() {
+ const player = (MediaService.playerIdentity || "").toLowerCase();
+ const browsers = ["firefox", "chromium", "chrome", "brave", "edge", "opera", "vivaldi"];
+ const isBrowser = browsers.some(b => player.includes(b));
+
+ // Use long delay for browsers to filter hover previews, short for music apps
+ mediaToastDebounce.interval = isBrowser ? 1500 : 250;
+ mediaToastDebounce.restart();
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/System/ProgramCheckerService.qml b/arch/.config/quickshell/noctalia-shell/Services/System/ProgramCheckerService.qml
new file mode 100644
index 0000000..7f50e88
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/System/ProgramCheckerService.qml
@@ -0,0 +1,337 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+import qs.Services.Theming
+
+// Service to check if various programs are available on the system
+Singleton {
+ id: root
+
+ // Program availability properties
+ property bool nmcliAvailable: false
+ property bool bluetoothctlAvailable: false
+ property bool wlsunsetAvailable: false
+ property bool gnomeCalendarAvailable: false
+ property bool pythonAvailable: false
+ property bool wtypeAvailable: false
+
+ // Programs to check - maps property names to commands
+ readonly property var programsToCheck: ({
+ "bluetoothctlAvailable": ["sh", "-c", "command -v bluetoothctl"],
+ "nmcliAvailable": ["sh", "-c", "command -v nmcli"],
+ "wlsunsetAvailable": ["sh", "-c", "command -v wlsunset"],
+ "gnomeCalendarAvailable": ["sh", "-c", "command -v gnome-calendar"],
+ "wtypeAvailable": ["sh", "-c", "command -v wtype"],
+ "pythonAvailable": ["sh", "-c", "command -v python3"]
+ })
+
+ // Discord client auto-detection
+ property var availableDiscordClients: []
+
+ // Code client auto-detection
+ property var availableCodeClients: []
+
+ // Emacs client auto-detection
+ property var availableEmacsClients: []
+
+ // Signal emitted when all checks are complete
+ signal checksCompleted
+
+ // disable Night Light in settings if wlsunset is not available
+ onChecksCompleted: {
+ if (!wlsunsetAvailable && Settings.data.nightLight.enabled) {
+ Settings.data.nightLight.enabled = false;
+ }
+ }
+
+ onWlsunsetAvailableChanged: {
+ if (!wlsunsetAvailable && Settings.data.nightLight.enabled) {
+ Settings.data.nightLight.enabled = false;
+ }
+ }
+
+ // Function to detect Discord client by checking config directories
+ function detectDiscordClient() {
+ // Build shell script to check each client
+ var scriptParts = ["available_clients=\"\";"];
+
+ for (var i = 0; i < TemplateRegistry.discordClients.length; i++) {
+ var client = TemplateRegistry.discordClients[i];
+ var clientName = client.name;
+ var configPath = client.configPath;
+
+ // Use the actual config path from the client, removing ~ prefix
+ var checkPath = configPath.startsWith("~") ? configPath.substring(2) : configPath.substring(1);
+
+ scriptParts.push("if [ -d \"$HOME/" + checkPath + "\" ]; then available_clients=\"$available_clients " + clientName + "\"; fi;");
+ }
+
+ scriptParts.push("echo \"$available_clients\"");
+
+ // Use a Process to check directory existence for all clients
+ discordDetector.command = ["sh", "-c", scriptParts.join(" ")];
+ discordDetector.running = true;
+ }
+
+ // Process to detect Discord client directories
+ Process {
+ id: discordDetector
+ running: false
+
+ onExited: function (exitCode) {
+ availableDiscordClients = [];
+
+ if (exitCode === 0) {
+ var detectedClients = stdout.text.trim().split(/\s+/).filter(function (client) {
+ return client.length > 0;
+ });
+
+ if (detectedClients.length > 0) {
+ // Build list of available clients
+ for (var i = 0; i < detectedClients.length; i++) {
+ var clientName = detectedClients[i];
+ for (var j = 0; j < TemplateRegistry.discordClients.length; j++) {
+ var client = TemplateRegistry.discordClients[j];
+ if (client.name === clientName) {
+ availableDiscordClients.push(client);
+ break;
+ }
+ }
+ }
+
+ Logger.d("ProgramChecker", "Detected Discord clients:", detectedClients.join(", "));
+ }
+ }
+
+ if (availableDiscordClients.length === 0) {
+ Logger.d("ProgramChecker", "No Discord clients detected");
+ }
+ }
+
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+ }
+
+ // Function to detect Code client by checking config directories
+ function detectCodeClient() {
+ // Build shell script to check each client
+ var scriptParts = ["available_clients=\"\";"];
+
+ for (var i = 0; i < TemplateRegistry.codeClients.length; i++) {
+ var client = TemplateRegistry.codeClients[i];
+ var clientName = client.name;
+ var configPath = client.configPath;
+
+ // Check if the config directory exists
+ scriptParts.push("if [ -d \"$HOME" + configPath.substring(1) + "\" ]; then available_clients=\"$available_clients " + clientName + "\"; fi;");
+ }
+
+ scriptParts.push("echo \"$available_clients\"");
+
+ // Use a Process to check directory existence for all clients
+ codeDetector.command = ["sh", "-c", scriptParts.join(" ")];
+ codeDetector.running = true;
+ }
+
+ // Process to detect Code client directories
+ Process {
+ id: codeDetector
+ running: false
+
+ onExited: function (exitCode) {
+ availableCodeClients = [];
+
+ if (exitCode === 0) {
+ var detectedClients = stdout.text.trim().split(/\s+/).filter(function (client) {
+ return client.length > 0;
+ });
+
+ if (detectedClients.length > 0) {
+ // Build list of available clients
+ for (var i = 0; i < detectedClients.length; i++) {
+ var clientName = detectedClients[i];
+ for (var j = 0; j < TemplateRegistry.codeClients.length; j++) {
+ var client = TemplateRegistry.codeClients[j];
+ if (client.name === clientName) {
+ availableCodeClients.push(client);
+ break;
+ }
+ }
+ }
+
+ Logger.d("ProgramChecker", "Detected Code clients:", detectedClients.join(", "));
+ }
+ }
+
+ if (availableCodeClients.length === 0) {
+ Logger.d("ProgramChecker", "No Code clients detected");
+ }
+ }
+
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+ }
+
+ // Function to detect Emacs client by checking config directories
+ function detectEmacsClient() {
+ // Build shell script to check each client
+ var scriptParts = ["available_clients=\"\";"];
+
+ for (var i = 0; i < TemplateRegistry.emacsClients.length; i++) {
+ var client = TemplateRegistry.emacsClients[i];
+ var clientName = client.name;
+ var configPath = client.path;
+
+ // Check if the config directory exists
+ scriptParts.push("if [ -d \"$HOME" + configPath.substring(1) + "\" ]; then available_clients=\"$available_clients " + clientName + "\"; fi;");
+ }
+
+ scriptParts.push("echo \"$available_clients\"");
+
+ // Use a Process to check directory existence for all clients
+ emacsDetector.command = ["sh", "-c", scriptParts.join(" ")];
+ emacsDetector.running = true;
+ }
+
+ // Process to detect Emacs client directories
+ Process {
+ id: emacsDetector
+ running: false
+
+ onExited: function (exitCode) {
+ availableEmacsClients = [];
+
+ if (exitCode === 0) {
+ var detectedClients = stdout.text.trim().split(/\s+/).filter(function (client) {
+ return client.length > 0;
+ });
+
+ if (detectedClients.length > 0) {
+ // Build list of available clients
+ for (var i = 0; i < detectedClients.length; i++) {
+ var clientName = detectedClients[i];
+ for (var j = 0; j < TemplateRegistry.emacsClients.length; j++) {
+ var client = TemplateRegistry.emacsClients[j];
+ if (client.name === clientName) {
+ availableEmacsClients.push(client);
+ break;
+ }
+ }
+ }
+
+ Logger.d("ProgramChecker", "Detected Emacs clients:", detectedClients.join(", "));
+ }
+ }
+
+ if (availableEmacsClients.length === 0) {
+ Logger.d("ProgramChecker", "No Emacs clients detected");
+ }
+ }
+
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+ }
+
+ // Internal tracking
+ property int completedChecks: 0
+ property int totalChecks: Object.keys(programsToCheck).length
+
+ // Single reusable Process object
+ Process {
+ id: checker
+ running: false
+
+ property string currentProperty: ""
+
+ onExited: function (exitCode) {
+ // Set the availability property
+ root[currentProperty] = (exitCode === 0);
+
+ // Stop the process to free resources
+ running = false;
+
+ // Track completion
+ root.completedChecks++;
+
+ // Check next program or emit completion signal
+ if (root.completedChecks >= root.totalChecks) {
+ // Run Discord, Code and Emacs client detection after all checks are complete
+ root.detectDiscordClient();
+ root.detectCodeClient();
+ root.detectEmacsClient();
+ root.checksCompleted();
+ } else {
+ root.checkNextProgram();
+ }
+ }
+
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+ }
+
+ // Queue of programs to check
+ property var checkQueue: []
+ property int currentCheckIndex: 0
+
+ // Function to check the next program in the queue
+ function checkNextProgram() {
+ if (currentCheckIndex >= checkQueue.length)
+ return;
+ var propertyName = checkQueue[currentCheckIndex];
+ var command = programsToCheck[propertyName];
+
+ checker.currentProperty = propertyName;
+ checker.command = command;
+ checker.running = true;
+
+ currentCheckIndex++;
+ }
+
+ // Function to run all program checks
+ function checkAllPrograms() {
+ // Reset state
+ completedChecks = 0;
+ currentCheckIndex = 0;
+ checkQueue = Object.keys(programsToCheck);
+
+ // Start first check
+ if (checkQueue.length > 0) {
+ checkNextProgram();
+ }
+ }
+
+ // Function to check a specific program
+ function checkProgram(programProperty) {
+ if (!programsToCheck.hasOwnProperty(programProperty)) {
+ Logger.w("ProgramChecker", "Unknown program property:", programProperty);
+ return;
+ }
+
+ checker.currentProperty = programProperty;
+ checker.command = programsToCheck[programProperty];
+ checker.running = true;
+ }
+
+ // Manual function to test Discord detection (for debugging)
+ function testDiscordDetection() {
+ Logger.d("ProgramChecker", "Testing Discord detection...");
+ Logger.d("ProgramChecker", "HOME:", Quickshell.env("HOME"));
+
+ // Test each client directory
+ for (var i = 0; i < TemplateRegistry.discordClients.length; i++) {
+ var client = TemplateRegistry.discordClients[i];
+ var configDir = client.configPath.replace("~", Quickshell.env("HOME"));
+ Logger.d("ProgramChecker", "Checking:", configDir);
+ }
+
+ detectDiscordClient();
+ }
+
+ // Initialize checks when service is created
+ Component.onCompleted: {
+ checkAllPrograms();
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/System/SoundService.qml b/arch/.config/quickshell/noctalia-shell/Services/System/SoundService.qml
new file mode 100644
index 0000000..53f9df0
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/System/SoundService.qml
@@ -0,0 +1,205 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import qs.Commons
+
+Singleton {
+ id: root
+
+ // Map to track active sound players: resolvedPath -> MediaPlayer instance
+ property var activePlayers: ({})
+
+ // Check if QtMultimedia is available
+ property bool multimediaAvailable: false
+
+ // Container for dynamically created players
+ Item {
+ id: playersContainer
+ }
+
+ Component.onCompleted: {
+ // Test if QtMultimedia is available by trying to create a simple component
+ try {
+ var testComponent = Qt.createQmlObject(`
+ import QtQuick
+ import QtMultimedia
+ Item {}
+ `, root, "MultimediaTest");
+ if (testComponent) {
+ multimediaAvailable = true;
+ testComponent.destroy();
+ Logger.i("SoundService", "QtMultimedia found - sound playback enabled");
+ }
+ } catch (e) {
+ multimediaAvailable = false;
+ Logger.w("SoundService", "QtMultimedia not available - no audio will be played from noctalia-shell");
+ }
+ }
+
+ function resolvePath(soundPath) {
+ if (!soundPath || soundPath === "") {
+ return "";
+ }
+
+ let resolvedPath = soundPath;
+
+ // If it's just a filename (no path separators), assume it's in Assets/Sounds/
+ if (!soundPath.includes("/") && !soundPath.startsWith("file://")) {
+ resolvedPath = Quickshell.shellDir + "/Assets/Sounds/" + soundPath;
+ } else if (!soundPath.startsWith("/") && !soundPath.startsWith("file://")) {
+ // Relative path - assume it's relative to shellDir
+ resolvedPath = Quickshell.shellDir + "/" + soundPath;
+ } else if (soundPath.startsWith("file://")) {
+ resolvedPath = soundPath.substring(7); // Remove "file://" prefix
+ }
+ // Absolute paths are used as-is
+
+ return resolvedPath;
+ }
+
+ function playSound(soundPath, options) {
+ if (!soundPath || soundPath === "") {
+ Logger.w("SoundService", "No sound path provided");
+ return;
+ }
+
+ if (!multimediaAvailable) {
+ Logger.d("SoundService", "QtMultimedia not available, cannot play sound:", soundPath);
+ return;
+ }
+
+ const opts = options || {};
+ const volume = opts.volume !== undefined ? opts.volume : 1.0;
+ const fallback = opts.fallback !== undefined ? opts.fallback : false;
+ const repeat = opts.repeat !== undefined ? opts.repeat : false;
+
+ // Resolve path
+ const resolvedPath = resolvePath(soundPath);
+
+ // Stop any existing player for this path if it's looping
+ if (repeat && activePlayers[resolvedPath]) {
+ stopSound(soundPath);
+ }
+
+ // Create MediaPlayer instance dynamically with QtMultimedia import
+ const loopsValue = repeat ? "MediaPlayer.Infinite" : "1";
+ const escapedPath = resolvedPath.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
+ const playerQml = `
+ import QtQuick
+ import QtMultimedia
+ import Quickshell
+ import qs.Commons
+ import qs.Services.System
+ MediaPlayer {
+ id: mediaPlayer
+ property string resolvedPath: "${escapedPath}"
+ property bool shouldFallback: ${fallback && !repeat}
+ property real soundVolume: ${Math.max(0, Math.min(1, volume))}
+ source: "file://${escapedPath}"
+ loops: ${loopsValue}
+ audioOutput: AudioOutput {
+ volume: soundVolume
+ }
+ onErrorOccurred: {
+ Logger.w("SoundService", "Error playing sound:", source, error, errorString);
+ if (shouldFallback) {
+ const fallbackPath = Quickshell.shellDir + "/Assets/Sounds/notification.mp3";
+ if (fallbackPath !== resolvedPath) {
+ SoundService.playSound(fallbackPath, {
+ volume: soundVolume,
+ fallback: false,
+ repeat: false
+ });
+ }
+ }
+ if (SoundService.activePlayers[resolvedPath]) {
+ delete SoundService.activePlayers[resolvedPath];
+ }
+ destroy();
+ }
+ onPlaybackStateChanged: function (state) {
+ if (state === MediaPlayer.StoppedState && loops === 1) {
+ if (SoundService.activePlayers[resolvedPath]) {
+ delete SoundService.activePlayers[resolvedPath];
+ }
+ destroy();
+ }
+ }
+ Component.onCompleted: {
+ play();
+ }
+ }
+ `;
+
+ try {
+ const player = Qt.createQmlObject(playerQml, playersContainer, "MediaPlayer_" + resolvedPath.replace(/[^a-zA-Z0-9]/g, "_"));
+
+ if (!player) {
+ Logger.w("SoundService", "Failed to create MediaPlayer for:", resolvedPath);
+ // Try fallback if requested
+ if (fallback && !repeat) {
+ const defaultSound = Quickshell.shellDir + "/Assets/Sounds/notification.mp3";
+ if (defaultSound !== resolvedPath) {
+ playSound(defaultSound, {
+ volume: volume,
+ fallback: false,
+ repeat: false
+ });
+ }
+ }
+ return;
+ }
+
+ // Store player in activePlayers map
+ activePlayers[resolvedPath] = player;
+
+ Logger.d("SoundService", "Playing sound:", resolvedPath, `(volume: ${Math.round(volume * 100)}%)`, repeat ? "(repeat)" : "");
+ } catch (e) {
+ Logger.w("SoundService", "Failed to create MediaPlayer:", e);
+ // Try fallback if requested
+ if (fallback && !repeat) {
+ const defaultSound = Quickshell.shellDir + "/Assets/Sounds/notification.mp3";
+ if (defaultSound !== resolvedPath) {
+ playSound(defaultSound, {
+ volume: volume,
+ fallback: false,
+ repeat: false
+ });
+ }
+ }
+ }
+ }
+
+ function stopSound(soundPath) {
+ if (!multimediaAvailable) {
+ // If multimedia isn't available, there are no active players to stop
+ return;
+ }
+
+ if (soundPath) {
+ // Resolve path the same way as playSound
+ const resolvedPath = resolvePath(soundPath);
+
+ // Stop and remove the player for this specific sound
+ if (activePlayers[resolvedPath]) {
+ const player = activePlayers[resolvedPath];
+ player.stop();
+ delete activePlayers[resolvedPath];
+ player.destroy();
+ Logger.d("SoundService", "Stopped sound:", resolvedPath);
+ }
+ } else {
+ // Stop all active players (typically used for repeating sounds)
+ const paths = Object.keys(activePlayers);
+ for (let i = 0; i < paths.length; i++) {
+ const path = paths[i];
+ const player = activePlayers[path];
+ player.stop();
+ player.destroy();
+ }
+ activePlayers = {};
+ Logger.d("SoundService", "Stopped all sounds");
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/System/SystemStatService.qml b/arch/.config/quickshell/noctalia-shell/Services/System/SystemStatService.qml
new file mode 100644
index 0000000..ae7ebf4
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/System/SystemStatService.qml
@@ -0,0 +1,1506 @@
+pragma Singleton
+import Qt.labs.folderlistmodel
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+import qs.Services.UI
+
+Singleton {
+ id: root
+
+ // Component registration - only poll when something needs system stat data
+ function registerComponent(componentId) {
+ root._registered[componentId] = true;
+ root._registered = Object.assign({}, root._registered);
+ Logger.d("SystemStat", "Component registered:", componentId, "- total:", root._registeredCount);
+ }
+
+ function unregisterComponent(componentId) {
+ delete root._registered[componentId];
+ root._registered = Object.assign({}, root._registered);
+ Logger.d("SystemStat", "Component unregistered:", componentId, "- total:", root._registeredCount);
+ }
+
+ property var _registered: ({})
+ readonly property int _registeredCount: Object.keys(_registered).length
+ readonly property bool _lockScreenActive: PanelService.lockScreen?.active ?? false
+ readonly property bool shouldRun: _registeredCount > 0 && !_lockScreenActive
+
+ // Polling intervals (hardcoded to sensible values per stat type)
+ readonly property int cpuUsageIntervalMs: 1000
+ readonly property int cpuFreqIntervalMs: 3000
+ readonly property int memIntervalMs: 5000
+ readonly property int networkIntervalMs: 3000
+ readonly property int loadAvgIntervalMs: 10000
+ readonly property int diskIntervalMs: 30000
+ readonly property int gpuIntervalMs: 5000
+
+ // Public values
+ property real cpuUsage: 0
+ property list coresUsage: []
+ property real cpuTemp: 0
+ property string cpuFreq: "0.0GHz"
+ property real cpuFreqRatio: 0
+ property real cpuGlobalMaxFreq: 3.5
+ property real gpuTemp: 0
+ property bool gpuAvailable: false
+ property string gpuType: "" // "amd", "intel", "nvidia"
+ property real memGb: 0
+ property real memPercent: 0
+ property real memTotalGb: 0
+ property real swapGb: 0
+ property real swapPercent: 0
+ property real swapTotalGb: 0
+ property var diskPercents: ({})
+ property var diskAvailPercents: ({}) // available disk space in percent
+ property var diskUsedGb: ({}) // Used space in GB per mount point
+ property var diskAvailableGb: ({}) // available space in GB per mount point
+ property var diskSizeGb: ({}) // Total size in GB per mount point
+ property real rxSpeed: 0
+ property real txSpeed: 0
+ property real zfsArcSizeKb: 0 // ZFS ARC cache size in KB
+ property real zfsArcCminKb: 0 // ZFS ARC minimum (non-reclaimable) size in KB
+ property real loadAvg1: 0
+ property real loadAvg5: 0
+ property real loadAvg15: 0
+ property int nproc: 0 // Number of cpu cores
+
+ // History arrays (2 minutes of data, length computed from polling interval)
+ // Pre-filled with zeros so the graph scrolls smoothly from the start
+ readonly property int historyDurationMs: (1 * 60 * 1000) // 1 minute
+
+ // Computed history lengths based on polling intervals
+ readonly property int cpuHistoryLength: Math.ceil(historyDurationMs / cpuUsageIntervalMs)
+ readonly property int gpuHistoryLength: Math.ceil(historyDurationMs / gpuIntervalMs)
+ readonly property int memHistoryLength: Math.ceil(historyDurationMs / memIntervalMs)
+ readonly property int diskHistoryLength: Math.max(10, Math.ceil(historyDurationMs / diskIntervalMs))
+ readonly property int networkHistoryLength: Math.ceil(historyDurationMs / networkIntervalMs)
+
+ property var cpuHistory: new Array(cpuHistoryLength).fill(0)
+ property var cpuTempHistory: new Array(cpuHistoryLength).fill(40) // Reasonable default temp
+ property var gpuTempHistory: new Array(gpuHistoryLength).fill(40) // Reasonable default temp
+ property var memHistory: new Array(memHistoryLength).fill(0)
+ property var diskHistories: ({}) // Keyed by mount path, initialized on first update
+ property var rxSpeedHistory: new Array(networkHistoryLength).fill(0)
+ property var txSpeedHistory: new Array(networkHistoryLength).fill(0)
+
+ // Historical min/max tracking (since shell started) for consistent graph scaling
+ // Temperature defaults create a valid 30-80ยฐC range that expands as real data comes in
+ property real cpuTempHistoryMin: 30
+ property real cpuTempHistoryMax: 80
+ property real gpuTempHistoryMin: 30
+ property real gpuTempHistoryMax: 80
+ // Network uses autoscaling from current history window
+ // Disk is always 0-100%
+
+ // History management - called from update functions, not change handlers
+ // (change handlers don't fire when value stays the same)
+ function pushCpuHistory() {
+ let h = cpuHistory.slice();
+ h.push(cpuUsage);
+ if (h.length > cpuHistoryLength)
+ h.shift();
+ cpuHistory = h;
+ }
+
+ function pushCpuTempHistory() {
+ if (cpuTemp > 0) {
+ if (cpuTemp < cpuTempHistoryMin)
+ cpuTempHistoryMin = cpuTemp;
+ if (cpuTemp > cpuTempHistoryMax)
+ cpuTempHistoryMax = cpuTemp;
+ }
+ let h = cpuTempHistory.slice();
+ h.push(cpuTemp);
+ if (h.length > cpuHistoryLength)
+ h.shift();
+ cpuTempHistory = h;
+ }
+
+ function pushGpuHistory() {
+ if (gpuTemp > 0) {
+ if (gpuTemp < gpuTempHistoryMin)
+ gpuTempHistoryMin = gpuTemp;
+ if (gpuTemp > gpuTempHistoryMax)
+ gpuTempHistoryMax = gpuTemp;
+ }
+ let h = gpuTempHistory.slice();
+ h.push(gpuTemp);
+ if (h.length > gpuHistoryLength)
+ h.shift();
+ gpuTempHistory = h;
+ }
+
+ function pushMemHistory() {
+ let h = memHistory.slice();
+ h.push(memPercent);
+ if (h.length > memHistoryLength)
+ h.shift();
+ memHistory = h;
+ }
+
+ function pushDiskHistory() {
+ let newHistories = {};
+ for (let path in diskPercents) {
+ // Pre-fill with zeros if this is a new path
+ let h = diskHistories[path] ? diskHistories[path].slice() : new Array(diskHistoryLength).fill(0);
+ h.push(diskPercents[path]);
+ if (h.length > diskHistoryLength)
+ h.shift();
+ newHistories[path] = h;
+ }
+ diskHistories = newHistories;
+ }
+
+ function pushNetworkHistory() {
+ let rxH = rxSpeedHistory.slice();
+ rxH.push(rxSpeed);
+ if (rxH.length > networkHistoryLength)
+ rxH.shift();
+ rxSpeedHistory = rxH;
+
+ let txH = txSpeedHistory.slice();
+ txH.push(txSpeed);
+ if (txH.length > networkHistoryLength)
+ txH.shift();
+ txSpeedHistory = txH;
+ }
+
+ // Network max speed tracking (autoscales from current history window)
+ // Minimum floor of 1 MB/s so graph doesn't fluctuate at low speeds
+ readonly property real rxMaxSpeed: {
+ const max = Math.max(...rxSpeedHistory);
+ return Math.max(max, 1000000); // 1 MB/s floor
+ }
+ readonly property real txMaxSpeed: {
+ const max = Math.max(...txSpeedHistory);
+ return Math.max(max, 512000); // 512 KB/s floor
+ }
+
+ // Ready-to-use ratios based on current maximums (0..1 range)
+ readonly property real rxRatio: rxMaxSpeed > 0 ? Math.min(1, rxSpeed / rxMaxSpeed) : 0
+ readonly property real txRatio: txMaxSpeed > 0 ? Math.min(1, txSpeed / txMaxSpeed) : 0
+
+ // Color resolution (respects useCustomColors setting)
+ readonly property color warningColor: Settings.data.systemMonitor.useCustomColors ? (Settings.data.systemMonitor.warningColor || Color.mTertiary) : Color.mTertiary
+ readonly property color criticalColor: Settings.data.systemMonitor.useCustomColors ? (Settings.data.systemMonitor.criticalColor || Color.mError) : Color.mError
+
+ // Threshold values from settings
+ readonly property int cpuWarningThreshold: Settings.data.systemMonitor.cpuWarningThreshold
+ readonly property int cpuCriticalThreshold: Settings.data.systemMonitor.cpuCriticalThreshold
+ readonly property int tempWarningThreshold: Settings.data.systemMonitor.tempWarningThreshold
+ readonly property int tempCriticalThreshold: Settings.data.systemMonitor.tempCriticalThreshold
+ readonly property int gpuWarningThreshold: Settings.data.systemMonitor.gpuWarningThreshold
+ readonly property int gpuCriticalThreshold: Settings.data.systemMonitor.gpuCriticalThreshold
+ readonly property int memWarningThreshold: Settings.data.systemMonitor.memWarningThreshold
+ readonly property int memCriticalThreshold: Settings.data.systemMonitor.memCriticalThreshold
+ readonly property int swapWarningThreshold: Settings.data.systemMonitor.swapWarningThreshold
+ readonly property int swapCriticalThreshold: Settings.data.systemMonitor.swapCriticalThreshold
+ readonly property int diskWarningThreshold: Settings.data.systemMonitor.diskWarningThreshold
+ readonly property int diskCriticalThreshold: Settings.data.systemMonitor.diskCriticalThreshold
+ readonly property int diskAvailWarningThreshold: Settings.data.systemMonitor.diskAvailWarningThreshold
+ readonly property int diskAvailCriticalThreshold: Settings.data.systemMonitor.diskAvailCriticalThreshold
+
+ // Computed warning/critical states (uses >= inclusive comparison)
+ readonly property bool cpuWarning: cpuUsage >= cpuWarningThreshold
+ readonly property bool cpuCritical: cpuUsage >= cpuCriticalThreshold
+ readonly property bool tempWarning: cpuTemp >= tempWarningThreshold
+ readonly property bool tempCritical: cpuTemp >= tempCriticalThreshold
+ readonly property bool gpuWarning: gpuAvailable && gpuTemp >= gpuWarningThreshold
+ readonly property bool gpuCritical: gpuAvailable && gpuTemp >= gpuCriticalThreshold
+ readonly property bool memWarning: memPercent >= memWarningThreshold
+ readonly property bool memCritical: memPercent >= memCriticalThreshold
+ readonly property bool swapWarning: swapPercent >= swapWarningThreshold
+ readonly property bool swapCritical: swapPercent >= swapCriticalThreshold
+
+ // Helper functions for disk (disk path is dynamic)
+ function isDiskWarning(diskPath, available = false) {
+ return available ? (diskAvailPercents[diskPath] || 0) <= diskAvailWarningThreshold : (diskPercents[diskPath] || 0) >= diskWarningThreshold;
+ }
+
+ function isDiskCritical(diskPath, available = false) {
+ return available ? (diskAvailPercents[diskPath] || 0) <= diskAvailCriticalThreshold : (diskPercents[diskPath] || 0) >= diskCriticalThreshold;
+ }
+
+ // Ready-to-use stat colors (for gauges, panels, icons)
+ readonly property color cpuColor: cpuCritical ? criticalColor : (cpuWarning ? warningColor : Color.mPrimary)
+ readonly property color tempColor: tempCritical ? criticalColor : (tempWarning ? warningColor : Color.mPrimary)
+ readonly property color gpuColor: gpuCritical ? criticalColor : (gpuWarning ? warningColor : Color.mPrimary)
+ readonly property color memColor: memCritical ? criticalColor : (memWarning ? warningColor : Color.mPrimary)
+ readonly property color swapColor: swapCritical ? criticalColor : (swapWarning ? warningColor : Color.mPrimary)
+
+ function getCoreUsageColor(usage) {
+ if (usage >= cpuCriticalThreshold)
+ return criticalColor;
+ if (usage >= cpuWarningThreshold)
+ return warningColor;
+ return Color.mPrimary;
+ }
+
+ function getDiskColor(diskPath, available = false) {
+ return isDiskCritical(diskPath, available) ? criticalColor : (isDiskWarning(diskPath, available) ? warningColor : Color.mPrimary);
+ }
+
+ // Internal state for CPU calculation
+ property var prevCpuStats: null
+ property var prevCpuCoresStats: null
+
+ // Internal state for network speed calculation
+ // Previous Bytes need to be stored as 'real' as they represent the total of bytes transfered
+ // since the computer started, so their value will easily overlfow a 32bit int.
+ property real prevRxBytes: 0
+ property real prevTxBytes: 0
+ property real prevTime: 0
+
+ // Cpu temperature is the most complex
+ readonly property var supportedTempCpuSensorNames: ["coretemp", "k10temp", "zenpower"]
+ property string cpuTempSensorName: ""
+ property string cpuTempHwmonPath: ""
+ // For Intel coretemp averaging of all cores/sensors
+ property var intelTempValues: []
+ property int intelTempFilesChecked: 0
+ property int intelTempMaxFiles: 20 // Will test up to temp20_input
+
+ // Thermal zone fallback (for ARM SoCs with SCMI sensors, etc.)
+ property var cpuThermalZonePaths: [] // All matching CPU zones for averaging
+ property string gpuThermalZonePath: ""
+
+ // GPU temperature detection
+ // On dual-GPU systems, we prioritize discrete GPUs over integrated GPUs
+ // Priority: NVIDIA (opt-in) > AMD dGPU > Intel Arc > AMD iGPU
+ // Note: NVIDIA requires opt-in because nvidia-smi wakes the dGPU on laptops, draining battery
+ readonly property var supportedTempGpuSensorNames: ["amdgpu", "xe"]
+ property string gpuTempHwmonPath: ""
+ property var foundGpuSensors: [] // [{hwmonPath, type, hasDedicatedVram}]
+ property int gpuVramCheckIndex: 0
+
+ // --------------------------------------------
+ Component.onCompleted: {
+ Logger.i("SystemStat", "Service started (polling deferred until a consumer registers).");
+
+ // Kickoff the cpu name detection for temperature (one-time probes, not polling)
+ cpuTempNameReader.checkNext();
+
+ // Kickoff the gpu sensor detection for temperature (one-time probes, not polling)
+ gpuTempNameReader.checkNext();
+
+ // Get nproc on startup (one-time)
+ nprocProcess.running = true;
+ }
+
+ onShouldRunChanged: {
+ if (shouldRun) {
+ // Reset differential state so first readings after resume are clean
+ root.prevCpuStats = null;
+ root.prevCpuCoresStats = null;
+ root.prevTime = 0;
+
+ // Trigger initial reads
+ zfsArcStatsFile.reload();
+ loadAvgFile.reload();
+
+ // Start persistent disk shell
+ if (!dfShell.running) {
+ dfShell.running = true;
+ }
+ } else {
+ // Stop persistent disk shell
+ if (dfShell.running) {
+ dfShell.running = false;
+ }
+ }
+ }
+
+ // Re-run GPU detection when dGPU opt-in setting changes
+ Connections {
+ target: Settings.data.systemMonitor
+ function onEnableDgpuMonitoringChanged() {
+ Logger.i("SystemStat", "dGPU monitoring opt-in setting changed, re-detecting GPUs");
+ restartGpuDetection();
+ }
+ }
+
+ // Reset differential state after suspend so the first reading is treated as fresh
+ Connections {
+ target: Time
+ function onResumed() {
+ Logger.i("SystemStat", "System resumed - resetting differential state");
+ root.prevCpuStats = null;
+ root.prevCpuCoresStats = null;
+ root.prevTime = 0;
+ }
+ }
+
+ function restartGpuDetection() {
+ // Reset GPU state
+ root.gpuAvailable = false;
+ root.gpuType = "";
+ root.gpuTempHwmonPath = "";
+ root.gpuTemp = 0;
+ root.foundGpuSensors = [];
+ root.gpuVramCheckIndex = 0;
+
+ // Restart GPU detection
+ gpuTempNameReader.currentIndex = 0;
+ gpuTempNameReader.checkNext();
+ }
+
+ // --------------------------------------------
+ // Timer for CPU usage and temperature
+ Timer {
+ id: cpuTimer
+ interval: root.cpuUsageIntervalMs
+ repeat: true
+ running: root.shouldRun
+ triggeredOnStart: true
+ onTriggered: {
+ cpuStatFile.reload();
+ updateCpuTemperature();
+ }
+ }
+
+ // Timer for CPU frequency (slower โ /proc/cpuinfo is large and freq changes infrequently)
+ Timer {
+ id: cpuFreqTimer
+ interval: root.cpuFreqIntervalMs
+ repeat: true
+ running: root.shouldRun
+ triggeredOnStart: true
+ onTriggered: cpuInfoFile.reload()
+ }
+
+ // Timer for load average
+ Timer {
+ id: loadAvgTimer
+ interval: root.loadAvgIntervalMs
+ repeat: true
+ running: root.shouldRun
+ triggeredOnStart: true
+ onTriggered: loadAvgFile.reload()
+ }
+
+ // Timer for memory stats
+ Timer {
+ id: memoryTimer
+ interval: root.memIntervalMs
+ repeat: true
+ running: root.shouldRun
+ triggeredOnStart: true
+ onTriggered: {
+ memInfoFile.reload();
+ zfsArcStatsFile.reload();
+ }
+ }
+
+ // Timer for disk usage
+ Timer {
+ id: diskTimer
+ interval: root.diskIntervalMs
+ repeat: true
+ running: root.shouldRun
+ triggeredOnStart: true
+ onTriggered: {
+ if (dfShell.running) {
+ dfShell.write("df --output=target,pcent,used,size,avail --block-size=1 -x efivarfs 2>/dev/null; echo '@@DF_END@@'\n");
+ }
+ }
+ }
+
+ // Timer for network speeds
+ Timer {
+ id: networkTimer
+ interval: root.networkIntervalMs
+ repeat: true
+ running: root.shouldRun
+ triggeredOnStart: true
+ onTriggered: netDevFile.reload()
+ }
+
+ // Timer for GPU temperature
+ Timer {
+ id: gpuTempTimer
+ interval: root.gpuIntervalMs
+ repeat: true
+ running: root.shouldRun && root.gpuAvailable
+ triggeredOnStart: true
+ onTriggered: updateGpuTemperature()
+ }
+
+ // --------------------------------------------
+ // FileView components for reading system files
+ FileView {
+ id: memInfoFile
+ path: "/proc/meminfo"
+ onLoaded: parseMemoryInfo(text())
+ }
+
+ FileView {
+ id: cpuStatFile
+ path: "/proc/stat"
+ onLoaded: calculateCpuUsage(text())
+ }
+
+ FileView {
+ id: netDevFile
+ path: "/proc/net/dev"
+ onLoaded: calculateNetworkSpeed(text())
+ }
+
+ FileView {
+ id: loadAvgFile
+ path: "/proc/loadavg"
+ onLoaded: parseLoadAverage(text())
+ }
+
+ // ZFS ARC stats file (only exists on ZFS systems)
+ FileView {
+ id: zfsArcStatsFile
+ path: "/proc/spl/kstat/zfs/arcstats"
+ printErrors: false
+ onLoaded: parseZfsArcStats(text())
+ onLoadFailed: {
+ // File doesn't exist (non-ZFS system), set ARC values to 0
+ root.zfsArcSizeKb = 0;
+ root.zfsArcCminKb = 0;
+ }
+ }
+
+ // --------------------------------------------
+ // Persistent shell for disk usage queries (avoids fork+exec of large Quickshell process every poll)
+ // Uses 'df' aka 'disk free'
+ // "-x efivarfs" skips efivarfs mountpoints, for which the `statfs` syscall may cause system-wide stuttering
+ // --block-size=1 gives us bytes for precise GB calculation
+ // Timer writes commands to stdin; SplitParser reads output delimited by @@DF_END@@
+ Process {
+ id: dfShell
+ command: ["sh"]
+ stdinEnabled: true
+ running: false
+
+ onRunningChanged: {
+ if (!running && root.shouldRun) {
+ // Restart if it died unexpectedly while we still need it
+ Logger.w("SystemStat", "Disk shell exited unexpectedly, restarting");
+ Qt.callLater(() => {
+ dfShell.running = true;
+ });
+ }
+ }
+
+ stdout: SplitParser {
+ splitMarker: "@@DF_END@@"
+ onRead: data => {
+ const lines = data.trim().split('\n');
+ const newPercents = {};
+ const newAvailPercents = {};
+ const newUsedGb = {};
+ const newSizeGb = {};
+ const newAvailableGb = {};
+ const bytesPerGb = 1000 * 1000 * 1000;
+ // Start from line 1 (skip header)
+ for (var i = 1; i < lines.length; i++) {
+ const parts = lines[i].trim().split(/\s+/);
+ if (parts.length >= 5) {
+ const target = parts[0];
+ const percent = parseInt(parts[1].replace(/[^0-9]/g, '')) || 0;
+ const usedBytes = parseFloat(parts[2]) || 0;
+ const sizeBytes = parseFloat(parts[3]) || 0;
+ const availBytes = parseFloat(parts[4]) || 0;
+ const availPercent = sizeBytes > 0 ? (availBytes / sizeBytes) * 100 : 0;
+ newPercents[target] = percent;
+ newAvailPercents[target] = Math.round(availPercent);
+ newUsedGb[target] = usedBytes / bytesPerGb;
+ newSizeGb[target] = sizeBytes / bytesPerGb;
+ newAvailableGb[target] = availBytes / bytesPerGb;
+ }
+ }
+ root.diskPercents = newPercents;
+ root.diskAvailPercents = newAvailPercents;
+ root.diskUsedGb = newUsedGb;
+ root.diskSizeGb = newSizeGb;
+ root.diskAvailableGb = newAvailableGb;
+ root.pushDiskHistory();
+ }
+ }
+ }
+
+ // Process to get number of processors
+ Process {
+ id: nprocProcess
+ command: ["nproc"]
+ running: false
+ stdout: StdioCollector {
+ onStreamFinished: {
+ root.nproc = parseInt(text.trim());
+ }
+ }
+ }
+
+ // FileView to get avg cpu frequency (replaces subprocess spawn of `cat /proc/cpuinfo`)
+ FileView {
+ id: cpuInfoFile
+ path: "/proc/cpuinfo"
+ onLoaded: {
+ let txt = text();
+ let matches = txt.match(/cpu MHz\s+:\s+([0-9.]+)/g);
+ if (matches && matches.length > 0) {
+ let totalFreq = 0.0;
+ for (let i = 0; i < matches.length; i++) {
+ totalFreq += parseFloat(matches[i].split(":")[1]);
+ }
+ let avgFreq = (totalFreq / matches.length) / 1000.0;
+ root.cpuFreq = avgFreq.toFixed(1) + "GHz";
+ cpuMaxFreqFile.reload();
+ if (avgFreq > root.cpuGlobalMaxFreq)
+ root.cpuGlobalMaxFreq = avgFreq;
+ if (root.cpuGlobalMaxFreq > 0) {
+ root.cpuFreqRatio = Math.min(1.0, avgFreq / root.cpuGlobalMaxFreq);
+ }
+ }
+ }
+ }
+
+ // FileView to get maximum CPU frequency limit (replaces subprocess spawn)
+ // Reads cpu0's scaling_max_freq as representative value
+ FileView {
+ id: cpuMaxFreqFile
+ path: "/sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq"
+ printErrors: false
+ onLoaded: {
+ let maxKHz = parseInt(text().trim());
+ if (!isNaN(maxKHz) && maxKHz > 0) {
+ let newMaxFreq = maxKHz / 1000000.0;
+ if (Math.abs(root.cpuGlobalMaxFreq - newMaxFreq) > 0.01) {
+ root.cpuGlobalMaxFreq = newMaxFreq;
+ }
+ }
+ }
+ }
+
+ // --------------------------------------------
+ // --------------------------------------------
+ // CPU Temperature
+ // It's more complex.
+ // ----
+ // #1 - Find a common cpu sensor name ie: "coretemp", "k10temp", "zenpower"
+ FileView {
+ id: cpuTempNameReader
+ property int currentIndex: 0
+ printErrors: false
+
+ function checkNext() {
+ if (currentIndex >= 16) {
+ // No hwmon sensor found, try thermal_zone fallback (ARM SoCs, SCMI, etc.)
+ thermalZoneScanner.startScan();
+ return;
+ }
+
+ //Logger.i("SystemStat", "---- Probing: hwmon", currentIndex)
+ cpuTempNameReader.path = `/sys/class/hwmon/hwmon${currentIndex}/name`;
+ cpuTempNameReader.reload();
+ }
+
+ onLoaded: {
+ const name = text().trim();
+ if (root.supportedTempCpuSensorNames.includes(name)) {
+ root.cpuTempSensorName = name;
+ root.cpuTempHwmonPath = `/sys/class/hwmon/hwmon${currentIndex}`;
+ Logger.i("SystemStat", `Found ${root.cpuTempSensorName} CPU thermal sensor at ${root.cpuTempHwmonPath}`);
+ } else {
+ currentIndex++;
+ Qt.callLater(() => {
+ // Qt.callLater is mandatory
+ checkNext();
+ });
+ }
+ }
+
+ onLoadFailed: function (error) {
+ currentIndex++;
+ Qt.callLater(() => {
+ // Qt.callLater is mandatory
+ checkNext();
+ });
+ }
+ }
+
+ // ----
+ // #2 - Read sensor value
+ FileView {
+ id: cpuTempReader
+ printErrors: false
+
+ onLoaded: {
+ const data = text().trim();
+ if (root.cpuTempSensorName === "coretemp") {
+ // For Intel, collect all temperature values
+ const temp = parseInt(data) / 1000.0;
+ //console.log(temp, cpuTempReader.path)
+ root.intelTempValues.push(temp);
+ Qt.callLater(() => {
+ // Qt.callLater is mandatory
+ checkNextIntelTemp();
+ });
+ } else {
+ // For AMD sensors (k10temp and zenpower), directly set the temperature
+ root.cpuTemp = Math.round(parseInt(data) / 1000.0);
+ root.pushCpuTempHistory();
+ }
+ }
+ onLoadFailed: function (error) {
+ Qt.callLater(() => {
+ // Qt.callLater is mandatory
+ checkNextIntelTemp();
+ });
+ }
+ }
+
+ // --------------------------------------------
+ // Thermal zone fallback for CPU and GPU temperature
+ // Used on ARM SoCs (e.g., SCMI sensors) where hwmon doesn't expose
+ // coretemp/k10temp/zenpower. Scans /sys/class/thermal/thermal_zoneN/type
+ // for CPU and GPU zone names, then reads temp from all matching zones.
+ //
+ // CPU: reads all cpu-*-thermal zones and reports the hottest core.
+ // GPU: prefers gpu-avg-thermal (firmware average), falls back to max of gpu[0-9]-thermal.
+
+ FileView {
+ id: thermalZoneScanner
+ property int currentIndex: 0
+ property var cpuZones: []
+ property var gpuZones: []
+ property string gpuAvgZonePath: ""
+ printErrors: false
+
+ function startScan() {
+ currentIndex = 0;
+ cpuZones = [];
+ gpuZones = [];
+ gpuAvgZonePath = "";
+ checkNext();
+ }
+
+ function checkNext() {
+ if (currentIndex >= 20) {
+ finishScan();
+ return;
+ }
+ thermalZoneScanner.path = `/sys/class/thermal/thermal_zone${currentIndex}/type`;
+ thermalZoneScanner.reload();
+ }
+
+ onLoaded: {
+ const name = text().trim();
+ const zonePath = `/sys/class/thermal/thermal_zone${currentIndex}`;
+ if (name.startsWith("cpu") && name.endsWith("thermal")) {
+ cpuZones.push({
+ "type": name,
+ "path": zonePath + "/temp"
+ });
+ } else if (name === "gpu-avg-thermal") {
+ gpuAvgZonePath = zonePath + "/temp";
+ } else if (/^gpu[0-9]+-?thermal$/.test(name)) {
+ gpuZones.push({
+ "type": name,
+ "path": zonePath + "/temp"
+ });
+ }
+ currentIndex++;
+ Qt.callLater(() => {
+ checkNext();
+ });
+ }
+
+ onLoadFailed: function (error) {
+ currentIndex++;
+ Qt.callLater(() => {
+ checkNext();
+ });
+ }
+
+ function finishScan() {
+ // CPU thermal zones
+ if (cpuZones.length > 0) {
+ root.cpuTempSensorName = "thermal_zone";
+ root.cpuThermalZonePaths = cpuZones.map(z => z.path);
+ const types = cpuZones.map(z => z.type).join(", ");
+ Logger.i("SystemStat", `Found ${cpuZones.length} CPU thermal zone(s): ${types}`);
+ } else if (root.cpuTempHwmonPath === "") {
+ Logger.w("SystemStat", "No supported temperature sensor found");
+ }
+
+ // GPU thermal zones
+ if (gpuAvgZonePath !== "") {
+ root.gpuThermalZonePath = gpuAvgZonePath;
+ root.gpuAvailable = true;
+ root.gpuType = "thermal_zone";
+ Logger.i("SystemStat", `Found GPU thermal zone: gpu-avg-thermal`);
+ } else if (gpuZones.length > 0) {
+ root.gpuThermalZonePaths = gpuZones.map(z => z.path);
+ root.gpuThermalZonePath = gpuZones[0].path; // fallback single path
+ root.gpuAvailable = true;
+ root.gpuType = "thermal_zone";
+ const types = gpuZones.map(z => z.type).join(", ");
+ Logger.i("SystemStat", `Found ${gpuZones.length} GPU thermal zone(s): ${types} (using max)`);
+ }
+ }
+ }
+
+ // Thermal zone reader for CPU: reads all zones, reports max (hottest core)
+ FileView {
+ id: cpuThermalZoneReader
+ property int currentZoneIndex: 0
+ property var collectedTemps: []
+ printErrors: false
+
+ onLoaded: {
+ const temp = parseInt(text().trim()) / 1000.0;
+ if (!isNaN(temp) && temp > 0)
+ collectedTemps.push(temp);
+ currentZoneIndex++;
+ Qt.callLater(() => {
+ readNextCpuThermalZone();
+ });
+ }
+
+ onLoadFailed: function (error) {
+ currentZoneIndex++;
+ Qt.callLater(() => {
+ readNextCpuThermalZone();
+ });
+ }
+ }
+
+ function readNextCpuThermalZone() {
+ if (cpuThermalZoneReader.currentZoneIndex >= root.cpuThermalZonePaths.length) {
+ if (cpuThermalZoneReader.collectedTemps.length > 0) {
+ root.cpuTemp = Math.round(Math.max(...cpuThermalZoneReader.collectedTemps));
+ } else {
+ root.cpuTemp = 0;
+ }
+ root.pushCpuTempHistory();
+ return;
+ }
+ cpuThermalZoneReader.path = root.cpuThermalZonePaths[cpuThermalZoneReader.currentZoneIndex];
+ cpuThermalZoneReader.reload();
+ }
+
+ // Thermal zone reader for GPU: reads single zone (gpu-avg-thermal) or max of gpu[N] zones
+ FileView {
+ id: gpuThermalZoneReader
+ property int currentZoneIndex: 0
+ property var collectedTemps: []
+ printErrors: false
+
+ onLoaded: {
+ const temp = parseInt(text().trim()) / 1000.0;
+ if (!isNaN(temp) && temp > 0)
+ collectedTemps.push(temp);
+
+ // If we have multiple GPU zones (no gpu-avg), iterate and take max
+ if (root.gpuThermalZonePaths && root.gpuThermalZonePaths.length > 0) {
+ currentZoneIndex++;
+ if (currentZoneIndex < root.gpuThermalZonePaths.length) {
+ Qt.callLater(() => {
+ readNextGpuThermalZone();
+ });
+ return;
+ }
+ // All zones read, take max
+ root.gpuTemp = Math.round(Math.max(...collectedTemps));
+ } else {
+ // Single gpu-avg-thermal zone
+ root.gpuTemp = Math.round(temp);
+ }
+ root.pushGpuHistory();
+ }
+ }
+
+ function readNextGpuThermalZone() {
+ gpuThermalZoneReader.path = root.gpuThermalZonePaths[gpuThermalZoneReader.currentZoneIndex];
+ gpuThermalZoneReader.reload();
+ }
+
+ // Property to store multiple GPU thermal zone paths (when no gpu-avg is available)
+ property var gpuThermalZonePaths: []
+
+ // --------------------------------------------
+ // --------------------------------------------
+ // GPU Temperature
+ // On dual-GPU systems (e.g., Intel iGPU + NVIDIA dGPU, or AMD APU + AMD dGPU),
+ // we scan all hwmon entries, then select the best GPU based on priority.
+ // ----
+ // #1 - Scan all hwmon entries to find GPU sensors
+ FileView {
+ id: gpuTempNameReader
+ property int currentIndex: 0
+ printErrors: false
+
+ function checkNext() {
+ if (currentIndex >= 16) {
+ // Finished scanning all hwmon entries
+ // Only check nvidia-smi if user has explicitly enabled dGPU monitoring (opt-in)
+ // because nvidia-smi wakes up the dGPU on laptops, draining battery
+ if (Settings.data.systemMonitor.enableDgpuMonitoring) {
+ Logger.d("SystemStat", `Found ${root.foundGpuSensors.length} sysfs GPU sensor(s), checking nvidia-smi (dGPU opt-in enabled)`);
+ nvidiaSmiCheck.running = true;
+ } else {
+ Logger.d("SystemStat", `Found ${root.foundGpuSensors.length} sysfs GPU sensor(s), skipping nvidia-smi (dGPU opt-in disabled)`);
+ root.gpuVramCheckIndex = 0;
+ checkNextGpuVram();
+ }
+ return;
+ }
+
+ gpuTempNameReader.path = `/sys/class/hwmon/hwmon${currentIndex}/name`;
+ gpuTempNameReader.reload();
+ }
+
+ onLoaded: {
+ const name = text().trim();
+ if (root.supportedTempGpuSensorNames.includes(name)) {
+ // Collect this GPU sensor, don't stop - continue scanning for more
+ const hwmonPath = `/sys/class/hwmon/hwmon${currentIndex}`;
+ const gpuType = name === "amdgpu" ? "amd" : "intel";
+ root.foundGpuSensors.push({
+ "hwmonPath": hwmonPath,
+ "type": gpuType,
+ "hasDedicatedVram": false // Will be checked later for AMD
+ });
+ Logger.d("SystemStat", `Found ${name} GPU sensor at ${hwmonPath}`);
+ }
+ // Continue scanning regardless of whether we found a match
+ currentIndex++;
+ Qt.callLater(() => {
+ checkNext();
+ });
+ }
+
+ onLoadFailed: function (error) {
+ currentIndex++;
+ Qt.callLater(() => {
+ checkNext();
+ });
+ }
+ }
+
+ // ----
+ // #2 - Read GPU sensor value (AMD/Intel via sysfs)
+ FileView {
+ id: gpuTempReader
+ printErrors: false
+
+ onLoaded: {
+ const data = text().trim();
+ root.gpuTemp = Math.round(parseInt(data) / 1000.0);
+ root.pushGpuHistory();
+ }
+ }
+
+ // ----
+ // #3 - Check if nvidia-smi is available (for NVIDIA GPUs)
+ Process {
+ id: nvidiaSmiCheck
+ command: ["sh", "-c", "command -v nvidia-smi"]
+ running: false
+ stdout: StdioCollector {
+ onStreamFinished: {
+ if (text.trim().length > 0) {
+ // Add NVIDIA as a GPU option (always discrete, highest priority)
+ root.foundGpuSensors.push({
+ "hwmonPath": "",
+ "type": "nvidia",
+ "hasDedicatedVram": true // NVIDIA is always discrete
+ });
+ Logger.d("SystemStat", "Found NVIDIA GPU (nvidia-smi available)");
+ }
+ // After NVIDIA check, check VRAM for AMD GPUs to distinguish dGPU from iGPU
+ root.gpuVramCheckIndex = 0;
+ checkNextGpuVram();
+ }
+ }
+ }
+
+ // ----
+ // #4 - Check VRAM for AMD GPUs to distinguish dGPU from iGPU
+ // dGPUs have dedicated VRAM, iGPUs don't (use system RAM)
+ FileView {
+ id: gpuVramChecker
+ printErrors: false
+
+ onLoaded: {
+ // File exists and has content = dGPU with dedicated VRAM
+ const vramSize = parseInt(text().trim());
+ if (vramSize > 0) {
+ root.foundGpuSensors[root.gpuVramCheckIndex].hasDedicatedVram = true;
+ Logger.d("SystemStat", `GPU at ${root.foundGpuSensors[root.gpuVramCheckIndex].hwmonPath} has dedicated VRAM (dGPU)`);
+ }
+ root.gpuVramCheckIndex++;
+ Qt.callLater(() => {
+ checkNextGpuVram();
+ });
+ }
+
+ onLoadFailed: function (error) {
+ // File doesn't exist = iGPU (no dedicated VRAM)
+ // hasDedicatedVram is already false by default
+ root.gpuVramCheckIndex++;
+ Qt.callLater(() => {
+ checkNextGpuVram();
+ });
+ }
+ }
+
+ // ----
+ // #4 - Read GPU temperature via nvidia-smi (NVIDIA only)
+ Process {
+ id: nvidiaTempProcess
+ command: ["nvidia-smi", "--query-gpu=temperature.gpu", "--format=csv,noheader,nounits"]
+ running: false
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const temp = parseInt(text.trim());
+ if (!isNaN(temp)) {
+ root.gpuTemp = temp;
+ root.pushGpuHistory();
+ }
+ }
+ }
+ }
+
+ // -------------------------------------------------------
+ // -------------------------------------------------------
+ // Parse ZFS ARC stats from /proc/spl/kstat/zfs/arcstats
+ function parseZfsArcStats(text) {
+ if (!text)
+ return;
+ const lines = text.split('\n');
+
+ // The file format is: name type data
+ // We need to find the lines with "size" and "c_min" and extract the values (third column)
+ let foundSize = false;
+ let foundCmin = false;
+
+ for (const line of lines) {
+ const parts = line.trim().split(/\s+/);
+ if (parts.length >= 3) {
+ if (parts[0] === 'size') {
+ // The value is in bytes, convert to KB
+ const arcSizeBytes = parseInt(parts[2]) || 0;
+ root.zfsArcSizeKb = Math.floor(arcSizeBytes / 1024);
+ foundSize = true;
+ } else if (parts[0] === 'c_min') {
+ // The value is in bytes, convert to KB
+ const arcCminBytes = parseInt(parts[2]) || 0;
+ root.zfsArcCminKb = Math.floor(arcCminBytes / 1024);
+ foundCmin = true;
+ }
+
+ // If we found both, we can return early
+ if (foundSize && foundCmin) {
+ return;
+ }
+ }
+ }
+
+ // If fields not found, set to 0
+ if (!foundSize) {
+ root.zfsArcSizeKb = 0;
+ }
+ if (!foundCmin) {
+ root.zfsArcCminKb = 0;
+ }
+ }
+
+ // -------------------------------------------------------
+ // Parse load average from /proc/loadavg
+ function parseLoadAverage(text) {
+ if (!text)
+ return;
+ const parts = text.trim().split(/\s+/);
+ if (parts.length >= 3) {
+ root.loadAvg1 = parseFloat(parts[0]);
+ root.loadAvg5 = parseFloat(parts[1]);
+ root.loadAvg15 = parseFloat(parts[2]);
+ }
+ }
+
+ // -------------------------------------------------------
+ // Parse memory info from /proc/meminfo
+ function parseMemoryInfo(text) {
+ if (!text)
+ return;
+ const lines = text.split('\n');
+ let memTotal = 0;
+ let memAvailable = 0;
+ let swapTotal = 0;
+ let swapFree = 0;
+
+ for (const line of lines) {
+ if (line.startsWith('MemTotal:')) {
+ memTotal = parseInt(line.split(/\s+/)[1]) || 0;
+ } else if (line.startsWith('MemAvailable:')) {
+ memAvailable = parseInt(line.split(/\s+/)[1]) || 0;
+ } else if (line.startsWith('SwapTotal:')) {
+ swapTotal = parseInt(line.split(/\s+/)[1]) || 0;
+ } else if (line.startsWith('SwapFree:')) {
+ swapFree = parseInt(line.split(/\s+/)[1]) || 0;
+ }
+ }
+
+ if (memTotal > 0) {
+ // Calculate usage, adjusting for ZFS ARC cache if present
+ let usageKb = memTotal - memAvailable;
+ if (root.zfsArcSizeKb > 0) {
+ usageKb = Math.max(0, usageKb - root.zfsArcSizeKb + root.zfsArcCminKb);
+ }
+ root.memGb = (usageKb / 1048576).toFixed(1); // 1024*1024 = 1048576
+ root.memPercent = Math.round((usageKb / memTotal) * 100);
+ root.memTotalGb = (memTotal / 1048576).toFixed(1);
+ root.pushMemHistory();
+ }
+
+ // Swap usage
+ root.swapTotalGb = (swapTotal / 1048576).toFixed(1);
+ if (swapTotal > 0) {
+ const swapUsedKb = swapTotal - swapFree;
+ root.swapGb = (swapUsedKb / 1048576).toFixed(1);
+ root.swapPercent = Math.round((swapUsedKb / swapTotal) * 100);
+ } else {
+ root.swapGb = 0;
+ root.swapPercent = 0;
+ }
+ }
+
+ // -------------------------------------------------------
+ // Calculate CPU usage from /proc/stat
+
+ function calculateLineUsage(line) {
+ const parts = line.split(/\s+/);
+ const stats = {
+ "user": parseInt(parts[1]) || 0,
+ "nice": parseInt(parts[2]) || 0,
+ "system": parseInt(parts[3]) || 0,
+ "idle": parseInt(parts[4]) || 0,
+ "iowait": parseInt(parts[5]) || 0,
+ "irq": parseInt(parts[6]) || 0,
+ "softirq": parseInt(parts[7]) || 0,
+ "steal": parseInt(parts[8]) || 0,
+ "guest": parseInt(parts[9]) || 0,
+ "guestNice": parseInt(parts[10]) || 0
+ };
+ return stats;
+ }
+
+ function computeUsage(prev, curr) {
+ if (!prev || !curr)
+ return -1;
+ const currTotalIdle = curr.idle + curr.iowait;
+ const currTotal = Object.values(curr).reduce((sum, val) => sum + val, 0);
+ const prevTotalIdle = prev.idle + prev.iowait;
+ const prevTotal = Object.values(prev).reduce((sum, val) => sum + val, 0);
+
+ const diffTotal = currTotal - prevTotal;
+ const diffIdle = currTotalIdle - prevTotalIdle;
+ if (diffTotal > 0) {
+ return (((diffTotal - diffIdle) / diffTotal) * 100).toFixed(1);
+ }
+ return -1;
+ }
+
+ function calculateCpuUsage(text) {
+ if (!text)
+ return;
+ const lines = text.split('\n');
+ const cpuLine = lines[0];
+
+ // First line is total CPU
+ if (!cpuLine.startsWith('cpu '))
+ return;
+
+ const currCpuStats = calculateLineUsage(cpuLine);
+ const usage = computeUsage(root.prevCpuStats, currCpuStats);
+
+ if (usage >= 0) {
+ root.cpuUsage = usage;
+ root.pushCpuHistory();
+ }
+ root.prevCpuStats = currCpuStats;
+
+ // Find the number of CPU cores
+ let nbCores = 0;
+ for (let i = 1; i < lines.length; i++) {
+ if (!lines[i].startsWith('cpu'))
+ break;
+ nbCores++;
+ }
+
+ // Fallback if we did not find any cores
+ if (nbCores === 0)
+ return;
+
+ // If we found more cores than before, we reset our stats
+ if (root.coresUsage.length < nbCores)
+ root.coresUsage = new Array(nbCores).fill(0);
+
+ let coresStats = [];
+ let newCoresUsage = root.coresUsage.slice();
+ for (let i = 0; i < nbCores; i++) {
+ const coreCpuLine = lines[i + 1];
+ const currCoreStats = calculateLineUsage(coreCpuLine);
+ const coreUsage = computeUsage(root.prevCpuCoresStats?.[i], currCoreStats);
+ if (coreUsage >= 0) {
+ newCoresUsage[i] = coreUsage;
+ }
+ coresStats.push(currCoreStats);
+ }
+ root.coresUsage = newCoresUsage;
+ root.prevCpuCoresStats = coresStats;
+ }
+
+ // -------------------------------------------------------
+ // Check whether a network interface is virtual/tunnel/bridge.
+ // Only physical interfaces (eth*, en*, wl*, ww*) are kept so
+ // that traffic routed through VPNs, Docker bridges, etc. is
+ // not double-counted.
+ readonly property var _virtualPrefixes: ["lo", "docker", "veth", "br-", "virbr", "vnet", "tun", "tap", "wg", "tailscale", "nordlynx", "proton", "mullvad", "flannel", "cni", "cali", "vxlan", "genev", "gre", "sit", "ip6tnl", "dummy", "ifb", "nlmon", "bond"]
+
+ function isVirtualInterface(name) {
+ for (let i = 0; i < _virtualPrefixes.length; ++i) {
+ if (name.startsWith(_virtualPrefixes[i]))
+ return true;
+ }
+ return false;
+ }
+
+ // -------------------------------------------------------
+ // Calculate RX and TX speed from /proc/net/dev
+ // Sums speeds of all physical interfaces
+ function calculateNetworkSpeed(text) {
+ if (!text) {
+ return;
+ }
+
+ const currentTime = Date.now() / 1000;
+ const lines = text.split('\n');
+
+ let totalRx = 0;
+ let totalTx = 0;
+
+ for (var i = 2; i < lines.length; i++) {
+ const line = lines[i].trim();
+ if (!line) {
+ continue;
+ }
+
+ const colonIndex = line.indexOf(':');
+ if (colonIndex === -1) {
+ continue;
+ }
+
+ const iface = line.substring(0, colonIndex).trim();
+ if (isVirtualInterface(iface)) {
+ continue;
+ }
+
+ const statsLine = line.substring(colonIndex + 1).trim();
+ const stats = statsLine.split(/\s+/);
+
+ const rxBytes = parseInt(stats[0], 10) || 0;
+ const txBytes = parseInt(stats[8], 10) || 0;
+
+ totalRx += rxBytes;
+ totalTx += txBytes;
+ }
+
+ // Compute only if we have a previous run to compare to.
+ if (root.prevTime > 0) {
+ const timeDiff = currentTime - root.prevTime;
+
+ // Avoid division by zero if time hasn't passed.
+ if (timeDiff > 0) {
+ let rxDiff = totalRx - root.prevRxBytes;
+ let txDiff = totalTx - root.prevTxBytes;
+
+ // Handle counter resets (e.g., WiFi reconnect), which would cause a negative value.
+ if (rxDiff < 0) {
+ rxDiff = 0;
+ }
+ if (txDiff < 0) {
+ txDiff = 0;
+ }
+
+ root.rxSpeed = Math.round(rxDiff / timeDiff); // Speed in Bytes/s
+ root.txSpeed = Math.round(txDiff / timeDiff);
+ }
+ }
+
+ root.prevRxBytes = totalRx;
+ root.prevTxBytes = totalTx;
+ root.prevTime = currentTime;
+
+ // Update network history after speeds are computed
+ root.pushNetworkHistory();
+ }
+
+ // -------------------------------------------------------
+ // Helper function to format network speeds
+ function formatSpeed(bytesPerSecond) {
+ const units = ["KB", "MB", "GB"];
+ let value = bytesPerSecond / 1000;
+ let unitIndex = 0;
+
+ while (value >= 1000 && unitIndex < units.length - 1) {
+ value /= 1000;
+ unitIndex++;
+ }
+
+ const unit = units[unitIndex];
+ const shortUnit = unit[0];
+ const numStr = value < 10 ? value.toFixed(1) : Math.round(value).toString();
+
+ return (numStr + unit).length > 5 ? numStr + shortUnit : numStr + unit;
+ }
+
+ // -------------------------------------------------------
+ // Compact speed formatter for vertical bar display
+ function formatCompactSpeed(bytesPerSecond) {
+ if (!bytesPerSecond || bytesPerSecond <= 0)
+ return "0";
+ const units = ["", "K", "M", "G"];
+ let value = bytesPerSecond;
+ let unitIndex = 0;
+ while (value >= 1000 && unitIndex < units.length - 1) {
+ value = value / 1000.0;
+ unitIndex++;
+ }
+ // Promote at ~100 of current unit (e.g., 100k -> ~0.1M shown as 0.1M or 0M if rounded)
+ if (unitIndex < units.length - 1 && value >= 100) {
+ value = value / 1000.0;
+ unitIndex++;
+ }
+ const display = Math.round(value).toString();
+ return display + units[unitIndex];
+ }
+
+ // -------------------------------------------------------
+ // Smart formatter for memory values (GB) - max 4 chars
+ // Uses decimal for < 10GB, integer otherwise
+ function formatGigabytes(memGb) {
+ const value = parseFloat(memGb);
+ if (isNaN(value))
+ return "0G";
+
+ if (value < 10)
+ return value.toFixed(1) + "G"; // "0.0G" to "9.9G"
+ return Math.round(value) + "G"; // "10G" to "999G"
+ }
+
+ // -------------------------------------------------------
+ // Formatting gigabytes with optional padding
+ function formatGigabytesDisplay(memGb, maxGb = null) {
+ const value = formatGigabytes(memGb === null ? 0 : memGb);
+ if (maxGb !== null) {
+ const padding = Math.max(4, formatGigabytes(maxGb).length);
+ return value.padStart(padding, " ");
+ }
+ return value;
+ }
+
+ // -------------------------------------------------------
+ // Formatting percentage with optional padding
+ function formatPercentageDisplay(value, padding = false) {
+ return `${Math.round(value === null ? 0 : value)}%`.padStart(padding ? 4 : 0, " ");
+ }
+
+ // -------------------------------------------------------
+ // Formatting disk usage
+ function formatDiskDisplay(diskPath, {
+ percent = false,
+ available = false,
+ padding = false
+} = {}) {
+ if (percent) {
+ const raw = available ? root.diskAvailPercents[diskPath] : root.diskPercents[diskPath];
+ return formatPercentageDisplay(raw, padding);
+ } else {
+ const rawGb = available ? root.diskAvailableGb[diskPath] : root.diskUsedGb[diskPath];
+ const maxGb = padding ? root.diskSizeGb[diskPath] : null;
+ return formatGigabytesDisplay(rawGb, maxGb);
+ }
+ }
+
+ // -------------------------------------------------------
+ // Formatting ram usage
+ function formatRamDisplay({
+ swap = false,
+ percent = false,
+ padding = false
+} = {}) {
+ if (percent) {
+ const raw = swap ? swapPercent : memPercent;
+ return formatPercentageDisplay(raw, padding);
+ } else {
+ const rawGb = swap ? swapGb : memGb;
+ const maxGb = padding ? (swap ? swapTotalGb : memTotalGb) : null;
+ return formatGigabytesDisplay(rawGb, maxGb);
+ }
+ }
+
+ // -------------------------------------------------------
+ // Function to start fetching and computing the cpu temperature
+ function updateCpuTemperature() {
+ // For AMD sensors (k10temp and zenpower), only use Tctl sensor
+ // temp1_input corresponds to Tctl (Temperature Control) on these sensors
+ if (root.cpuTempSensorName === "k10temp" || root.cpuTempSensorName === "zenpower") {
+ cpuTempReader.path = `${root.cpuTempHwmonPath}/temp1_input`;
+ cpuTempReader.reload();
+ } // For Intel coretemp, start averaging all available sensors/cores
+ else if (root.cpuTempSensorName === "coretemp") {
+ root.intelTempValues = [];
+ root.intelTempFilesChecked = 0;
+ checkNextIntelTemp();
+ } // For thermal_zone fallback (ARM SoCs, SCMI, etc.), read all CPU zones and take max
+ else if (root.cpuTempSensorName === "thermal_zone") {
+ cpuThermalZoneReader.currentZoneIndex = 0;
+ cpuThermalZoneReader.collectedTemps = [];
+ readNextCpuThermalZone();
+ }
+ }
+
+ // -------------------------------------------------------
+ // Function to check next Intel temperature sensor
+ function checkNextIntelTemp() {
+ if (root.intelTempFilesChecked >= root.intelTempMaxFiles) {
+ // Calculate average of all found temperatures
+ if (root.intelTempValues.length > 0) {
+ let sum = 0;
+ for (var i = 0; i < root.intelTempValues.length; i++) {
+ sum += root.intelTempValues[i];
+ }
+ root.cpuTemp = Math.round(sum / root.intelTempValues.length);
+ root.pushCpuTempHistory();
+ //Logger.i("SystemStat", `Averaged ${root.intelTempValues.length} CPU thermal sensors: ${root.cpuTemp}ยฐC`)
+ } else {
+ Logger.w("SystemStat", "No temperature sensors found for coretemp");
+ root.cpuTemp = 0;
+ root.pushCpuTempHistory();
+ }
+ return;
+ }
+
+ // Check next temperature file
+ root.intelTempFilesChecked++;
+ cpuTempReader.path = `${root.cpuTempHwmonPath}/temp${root.intelTempFilesChecked}_input`;
+ cpuTempReader.reload();
+ }
+
+ // -------------------------------------------------------
+ // Function to check VRAM for each AMD GPU to determine if it's a dGPU
+ function checkNextGpuVram() {
+ // Skip non-AMD GPUs (NVIDIA and Intel Arc are always discrete)
+ while (root.gpuVramCheckIndex < root.foundGpuSensors.length) {
+ const gpu = root.foundGpuSensors[root.gpuVramCheckIndex];
+ if (gpu.type === "amd") {
+ // Check for dedicated VRAM at hwmonPath/device/mem_info_vram_total
+ gpuVramChecker.path = `${gpu.hwmonPath}/device/mem_info_vram_total`;
+ gpuVramChecker.reload();
+ return;
+ }
+ // Skip non-AMD GPUs
+ root.gpuVramCheckIndex++;
+ }
+
+ // All VRAM checks complete, now select the best GPU
+ selectBestGpu();
+ }
+
+ // -------------------------------------------------------
+ // Function to select the best GPU based on priority
+ // Priority (when dGPU monitoring enabled): NVIDIA > AMD dGPU > Intel Arc > AMD iGPU
+ // Priority (when dGPU monitoring disabled): AMD iGPU only (discrete GPUs skipped to preserve D3cold)
+ function selectBestGpu() {
+ const dgpuEnabled = Settings.data.systemMonitor.enableDgpuMonitoring;
+
+ if (root.foundGpuSensors.length === 0) {
+ // No hwmon GPU sensors found, try thermal_zone fallback
+ if (dgpuEnabled && root.gpuThermalZonePath === "" && root.gpuThermalZonePaths.length === 0) {
+ // Thermal zone scanner hasn't found GPU zones yet; start a scan
+ thermalZoneScanner.startScan();
+ }
+ return;
+ }
+
+ let best = null;
+
+ for (var i = 0; i < root.foundGpuSensors.length; i++) {
+ const gpu = root.foundGpuSensors[i];
+
+ // NVIDIA is always highest priority (always discrete) - skip if dGPU monitoring disabled
+ if (gpu.type === "nvidia") {
+ if (dgpuEnabled) {
+ best = gpu;
+ break;
+ }
+ continue;
+ }
+
+ // AMD dGPU is second priority - skip if dGPU monitoring disabled (preserves D3cold power state)
+ if (gpu.type === "amd" && gpu.hasDedicatedVram) {
+ if (dgpuEnabled) {
+ best = gpu;
+ break;
+ }
+ continue;
+ }
+
+ // Intel Arc is third priority (always discrete) - skip if dGPU monitoring disabled
+ if (gpu.type === "intel" && !best) {
+ if (dgpuEnabled) {
+ best = gpu;
+ }
+ continue;
+ }
+
+ // AMD iGPU is lowest priority (fallback) - always allowed (no D3cold issue)
+ if (gpu.type === "amd" && !gpu.hasDedicatedVram && !best) {
+ best = gpu;
+ }
+ }
+
+ if (best) {
+ root.gpuTempHwmonPath = best.hwmonPath;
+ root.gpuType = best.type;
+ root.gpuAvailable = true;
+
+ const gpuDesc = best.type === "nvidia" ? "NVIDIA" : (best.type === "intel" ? "Intel Arc" : (best.hasDedicatedVram ? "AMD dGPU" : "AMD iGPU"));
+ Logger.i("SystemStat", `Selected ${gpuDesc} for temperature monitoring at ${best.hwmonPath || "nvidia-smi"}`);
+ } else if (!dgpuEnabled) {
+ Logger.d("SystemStat", "No iGPU found and dGPU monitoring is disabled");
+ }
+ }
+
+ // -------------------------------------------------------
+ // Function to update GPU temperature
+ function updateGpuTemperature() {
+ if (root.gpuType === "nvidia") {
+ nvidiaTempProcess.running = true;
+ } else if (root.gpuType === "amd" || root.gpuType === "intel") {
+ gpuTempReader.path = `${root.gpuTempHwmonPath}/temp1_input`;
+ gpuTempReader.reload();
+ } else if (root.gpuType === "thermal_zone") {
+ if (root.gpuThermalZonePaths && root.gpuThermalZonePaths.length > 0) {
+ // Multiple GPU zones (no gpu-avg), read all and take max
+ gpuThermalZoneReader.currentZoneIndex = 0;
+ gpuThermalZoneReader.collectedTemps = [];
+ readNextGpuThermalZone();
+ } else {
+ // Single gpu-avg-thermal zone
+ gpuThermalZoneReader.path = root.gpuThermalZonePath;
+ gpuThermalZoneReader.reload();
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Theming/AppThemeService.qml b/arch/.config/quickshell/noctalia-shell/Services/Theming/AppThemeService.qml
new file mode 100644
index 0000000..137e573
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Theming/AppThemeService.qml
@@ -0,0 +1,90 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import qs.Commons
+import qs.Services.UI
+
+Singleton {
+ id: root
+
+ Connections {
+ target: WallpaperService
+
+ // When the wallpaper changes, regenerate theme if necessary
+ function onWallpaperChanged(screenName, path) {
+ var effectiveMonitor = Settings.data.colorSchemes.monitorForColors;
+ if (effectiveMonitor === "" || effectiveMonitor === undefined) {
+ effectiveMonitor = Screen.name;
+ }
+
+ if (screenName !== effectiveMonitor)
+ return;
+
+ if (Settings.data.colorSchemes.useWallpaperColors) {
+ generateFromWallpaper();
+ } else {
+ // Re-run predefined scheme templates so {{image}} reflects the new wallpaper path
+ ColorSchemeService.applyScheme(Settings.data.colorSchemes.predefinedScheme);
+ }
+ }
+ }
+
+ Connections {
+ target: Settings.data.colorSchemes
+ function onDarkModeChanged() {
+ Logger.d("AppThemeService", "Detected dark mode change");
+ generate();
+ }
+ function onMonitorForColorsChanged() {
+ if (Settings.data.colorSchemes.useWallpaperColors) {
+ Logger.d("AppThemeService", "Monitor for colors changed to:", Settings.data.colorSchemes.monitorForColors);
+ generateFromWallpaper();
+ }
+ }
+ function onGenerationMethodChanged() {
+ Logger.d("AppThemeService", "Generation method changed to:", Settings.data.colorSchemes.generationMethod);
+ generate();
+ }
+ }
+
+ // PUBLIC FUNCTIONS
+ function init() {
+ Logger.i("AppThemeService", "Service started");
+ }
+
+ function generate() {
+ if (Settings.data.colorSchemes.useWallpaperColors) {
+ generateFromWallpaper();
+ } else {
+ // applyScheme will trigger template generation via schemeReader.onLoaded
+ ColorSchemeService.applyScheme(Settings.data.colorSchemes.predefinedScheme);
+ }
+ }
+
+ function generateFromWallpaper() {
+ var effectiveMonitor = Settings.data.colorSchemes.monitorForColors;
+ if (effectiveMonitor === "" || effectiveMonitor === undefined) {
+ effectiveMonitor = Screen.name;
+ }
+
+ const wp = WallpaperService.getWallpaper(effectiveMonitor);
+ if (!wp) {
+ Logger.e("AppThemeService", "No wallpaper found for monitor:", effectiveMonitor);
+ return;
+ }
+ const mode = Settings.data.colorSchemes.darkMode ? "dark" : "light";
+ TemplateProcessor.processWallpaperColors(wp, mode);
+ }
+
+ function generateFromPredefinedScheme(schemeData) {
+ Logger.i("AppThemeService", "Generating templates from predefined color scheme");
+ const mode = Settings.data.colorSchemes.darkMode ? "dark" : "light";
+ var effectiveMonitor = Settings.data.colorSchemes.monitorForColors;
+ if (effectiveMonitor === "" || effectiveMonitor === undefined) {
+ effectiveMonitor = Screen.name;
+ }
+ const wallpaperPath = WallpaperService.getWallpaper(effectiveMonitor) || "";
+ TemplateProcessor.processPredefinedScheme(schemeData, mode, wallpaperPath);
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Theming/ColorSchemeService.qml b/arch/.config/quickshell/noctalia-shell/Services/Theming/ColorSchemeService.qml
new file mode 100644
index 0000000..9348fe4
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Theming/ColorSchemeService.qml
@@ -0,0 +1,286 @@
+pragma Singleton
+import Qt.labs.folderlistmodel
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+import qs.Services.Theming
+import qs.Services.UI
+
+Singleton {
+ id: root
+
+ property var schemes: []
+ property bool scanning: false
+ property string schemesDirectory: Quickshell.shellDir + "/Assets/ColorScheme"
+ property string downloadedSchemesDirectory: Settings.configDir + "colorschemes"
+ property string colorsJsonFilePath: Settings.configDir + "colors.json"
+ readonly property string gtkRefreshScript: Quickshell.shellDir + "/Scripts/python/src/theming/gtk-refresh.py"
+
+ // prefer-light/prefer-dark only; GTK template post_hook still runs full gtk-refresh.
+ function pushSystemColorScheme() {
+ if (!Settings.data.colorSchemes.syncGsettings)
+ return;
+ const mode = Settings.data.colorSchemes.darkMode ? "dark" : "light";
+ Quickshell.execDetached(["python3", gtkRefreshScript, "--appearance-only", mode]);
+ }
+
+ Connections {
+ target: Settings.data.colorSchemes
+ function onDarkModeChanged() {
+ Logger.d("ColorScheme", "Detected dark mode change");
+ if (!Settings.data.colorSchemes.useWallpaperColors && Settings.data.colorSchemes.predefinedScheme) {
+ // Re-apply current scheme to pick the right variant
+ applyScheme(Settings.data.colorSchemes.predefinedScheme);
+ }
+ root.pushSystemColorScheme();
+ // Toast: dark/light mode switched
+ const enabled = !!Settings.data.colorSchemes.darkMode;
+ const label = enabled ? I18n.tr("tooltips.switch-to-dark-mode") : I18n.tr("tooltips.switch-to-light-mode");
+ const description = I18n.tr("common.enabled");
+ ToastService.showNotice(label, description, "dark-mode");
+ }
+ }
+
+ // --------------------------------
+ function init() {
+ // does nothing but ensure the singleton is created
+ // do not remove
+ Logger.i("ColorScheme", "Service started");
+ loadColorSchemes();
+ }
+
+ function loadColorSchemes() {
+ Logger.d("ColorScheme", "Load colorScheme");
+ scanning = true;
+ schemes = [];
+ // Use find command to locate all scheme.json files in both directories
+ // First ensure the downloaded schemes directory exists
+ Quickshell.execDetached(["mkdir", "-p", downloadedSchemesDirectory]);
+ // Find in both preinstalled and downloaded directories
+ findProcess.command = ["find", "-L", schemesDirectory, downloadedSchemesDirectory, "-mindepth", "2", "-name", "*.json", "-type", "f"];
+ findProcess.running = true;
+ }
+
+ function getBasename(path) {
+ if (!path)
+ return "";
+ var chunks = path.split("/");
+ // Get the filename without extension
+ var filename = chunks[chunks.length - 1];
+ var schemeName = filename.replace(".json", "");
+ // Convert back to display names for special cases
+ if (schemeName === "Noctalia-default") {
+ return "Noctalia (default)";
+ } else if (schemeName === "Noctalia-legacy") {
+ return "Noctalia (legacy)";
+ } else if (schemeName === "Tokyo-Night") {
+ return "Tokyo Night";
+ } else if (schemeName === "Rosepine") {
+ return "Rose Pine";
+ }
+ return schemeName;
+ }
+
+ function resolveSchemePath(nameOrPath) {
+ if (!nameOrPath)
+ return "";
+ if (nameOrPath.indexOf("/") !== -1) {
+ return nameOrPath;
+ }
+ // Handle special cases for Noctalia schemes
+ var schemeName = nameOrPath.replace(".json", "");
+ if (schemeName === "Noctalia (default)") {
+ schemeName = "Noctalia-default";
+ } else if (schemeName === "Noctalia (legacy)") {
+ schemeName = "Noctalia-legacy";
+ } else if (schemeName === "Tokyo Night") {
+ schemeName = "Tokyo-Night";
+ } else if (schemeName === "Rose Pine") {
+ schemeName = "Rosepine";
+ }
+ // Check preinstalled directory first, then downloaded directory
+ var preinstalledPath = schemesDirectory + "/" + schemeName + "/" + schemeName + ".json";
+ var downloadedPath = downloadedSchemesDirectory + "/" + schemeName + "/" + schemeName + ".json";
+ // Try to find the scheme in the loaded schemes list to determine which directory it's in
+ for (var i = 0; i < schemes.length; i++) {
+ if (schemes[i].indexOf("/" + schemeName + "/") !== -1 || schemes[i].indexOf("/" + schemeName + ".json") !== -1) {
+ return schemes[i];
+ }
+ }
+ // Fallback: prefer preinstalled, then downloaded
+ return preinstalledPath;
+ }
+
+ function applyScheme(nameOrPath) {
+ // Force reload by bouncing the path
+ var filePath = resolveSchemePath(nameOrPath);
+ schemeReader.path = "";
+ schemeReader.path = filePath;
+ }
+
+ function setPredefinedScheme(schemeName) {
+ Logger.i("ColorScheme", "Attempting to set predefined scheme to:", schemeName);
+
+ var resolvedPath = resolveSchemePath(schemeName);
+ var basename = getBasename(schemeName);
+
+ // Check if the scheme actually exists in the loaded schemes list
+ var schemeExists = false;
+ for (var i = 0; i < schemes.length; i++) {
+ if (getBasename(schemes[i]) === basename) {
+ schemeExists = true;
+ break;
+ }
+ }
+
+ if (schemeExists) {
+ Settings.data.colorSchemes.predefinedScheme = basename;
+ applyScheme(schemeName);
+ ToastService.showNotice(I18n.tr("panels.color-scheme.title"), basename, "settings-color-scheme");
+ } else {
+ Logger.e("ColorScheme", "Scheme not found:", schemeName);
+ ToastService.showError(I18n.tr("panels.color-scheme.title"), `'${basename}' ` + I18n.tr("common.not-found"));
+ }
+ }
+
+ Process {
+ id: findProcess
+ running: false
+
+ onExited: function (exitCode) {
+ if (exitCode === 0) {
+ var output = stdout.text.trim();
+ var files = output.split('\n').filter(function (line) {
+ return line.length > 0;
+ });
+ files.sort(function (a, b) {
+ var nameA = getBasename(a).toLowerCase();
+ var nameB = getBasename(b).toLowerCase();
+ return nameA.localeCompare(nameB);
+ });
+ schemes = files;
+ scanning = false;
+ Logger.d("ColorScheme", "Listed", schemes.length, "schemes");
+ // Normalize stored scheme to basename and re-apply if necessary
+ var stored = Settings.data.colorSchemes.predefinedScheme;
+ if (stored) {
+ var basename = getBasename(stored);
+ if (basename !== stored) {
+ Settings.data.colorSchemes.predefinedScheme = basename;
+ }
+ if (!Settings.data.colorSchemes.useWallpaperColors) {
+ applyScheme(basename);
+ }
+ }
+ } else {
+ Logger.e("ColorScheme", "Failed to find color scheme files");
+ schemes = [];
+ scanning = false;
+ }
+ }
+
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+ }
+
+ // Internal loader to read a scheme file
+ FileView {
+ id: schemeReader
+ onLoaded: {
+ try {
+ var data = JSON.parse(text());
+ var variant = data;
+ // If scheme provides dark/light variants, pick based on settings
+ if (data && (data.dark || data.light)) {
+ if (Settings.data.colorSchemes.darkMode) {
+ variant = data.dark || data.light;
+ } else {
+ variant = data.light || data.dark;
+ }
+ }
+ writeColorsToDisk(variant);
+ Logger.i("ColorScheme", "Applying color scheme:", getBasename(path));
+
+ // Generate templates for predefined color schemes
+ if (hasEnabledTemplates() || Settings.data.templates.enableUserTheming) {
+ AppThemeService.generateFromPredefinedScheme(data);
+ }
+ } catch (e) {
+ Logger.e("ColorScheme", "Failed to parse scheme JSON:", path, e);
+ }
+ }
+ }
+
+ // Check if any templates are enabled
+ function hasEnabledTemplates() {
+ const activeTemplates = Settings.data.templates.activeTemplates;
+ if (!activeTemplates || activeTemplates.length === 0) {
+ return false;
+ }
+ for (let i = 0; i < activeTemplates.length; i++) {
+ if (activeTemplates[i].enabled) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ // Writer to colors.json using a JsonAdapter for safety
+ FileView {
+ id: colorsWriter
+ path: colorsJsonFilePath
+ printErrors: false
+ onSaved:
+
+ // Logger.i("ColorScheme", "Colors saved")
+ {}
+ JsonAdapter {
+ id: out
+ property color mPrimary: "#000000"
+ property color mOnPrimary: "#000000"
+ property color mSecondary: "#000000"
+ property color mOnSecondary: "#000000"
+ property color mTertiary: "#000000"
+ property color mOnTertiary: "#000000"
+ property color mError: "#000000"
+ property color mOnError: "#000000"
+ property color mSurface: "#000000"
+ property color mOnSurface: "#000000"
+ property color mSurfaceVariant: "#000000"
+ property color mOnSurfaceVariant: "#000000"
+ property color mOutline: "#000000"
+ property color mShadow: "#000000"
+ property color mHover: "#000000"
+ property color mOnHover: "#000000"
+ }
+ }
+
+ function writeColorsToDisk(obj) {
+ function pick(o, a, b, fallback) {
+ return (o && (o[a] || o[b])) || fallback;
+ }
+ out.mPrimary = pick(obj, "mPrimary", "primary", out.mPrimary);
+ out.mOnPrimary = pick(obj, "mOnPrimary", "onPrimary", out.mOnPrimary);
+ out.mSecondary = pick(obj, "mSecondary", "secondary", out.mSecondary);
+ out.mOnSecondary = pick(obj, "mOnSecondary", "onSecondary", out.mOnSecondary);
+ out.mTertiary = pick(obj, "mTertiary", "tertiary", out.mTertiary);
+ out.mOnTertiary = pick(obj, "mOnTertiary", "onTertiary", out.mOnTertiary);
+ out.mError = pick(obj, "mError", "error", out.mError);
+ out.mOnError = pick(obj, "mOnError", "onError", out.mOnError);
+ out.mSurface = pick(obj, "mSurface", "surface", out.mSurface);
+ out.mOnSurface = pick(obj, "mOnSurface", "onSurface", out.mOnSurface);
+ out.mSurfaceVariant = pick(obj, "mSurfaceVariant", "surfaceVariant", out.mSurfaceVariant);
+ out.mOnSurfaceVariant = pick(obj, "mOnSurfaceVariant", "onSurfaceVariant", out.mOnSurfaceVariant);
+ out.mOutline = pick(obj, "mOutline", "outline", out.mOutline);
+ out.mShadow = pick(obj, "mShadow", "shadow", out.mShadow);
+ out.mHover = pick(obj, "mHover", "hover", out.mHover);
+ out.mOnHover = pick(obj, "mOnHover", "onHover", out.mOnHover);
+
+ // Force a rewrite by updating the path
+ colorsWriter.path = "";
+ colorsWriter.path = colorsJsonFilePath;
+ colorsWriter.writeAdapter();
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Theming/TemplateProcessor.qml b/arch/.config/quickshell/noctalia-shell/Services/Theming/TemplateProcessor.qml
new file mode 100644
index 0000000..0339f38
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Theming/TemplateProcessor.qml
@@ -0,0 +1,475 @@
+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);
+ }
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/Theming/TemplateRegistry.qml b/arch/.config/quickshell/noctalia-shell/Services/Theming/TemplateRegistry.qml
new file mode 100644
index 0000000..d9d022b
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/Theming/TemplateRegistry.qml
@@ -0,0 +1,619 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+
+Singleton {
+ id: root
+
+ Component.onCompleted: {
+ if (Settings.data.templates.enableUserTheming)
+ writeUserTemplatesToml();
+ }
+
+ readonly property string templateApplyScript: Quickshell.shellDir + '/Scripts/bash/template-apply.sh'
+ readonly property string gtkRefreshScript: Quickshell.shellDir + '/Scripts/python/src/theming/gtk-refresh.py'
+ readonly property string vscodeHelperScript: Quickshell.shellDir + '/Scripts/python/src/theming/vscode-helper.py'
+
+ // Dynamically resolved VSCode extension theme paths (all matching noctalia extensions)
+ property var resolvedCodePaths: []
+ property var resolvedCodiumPaths: []
+
+ // Terminal configurations (for wallpaper-based templates)
+ // Each terminal must define a postHook that sets up config includes and triggers reload
+ readonly property var terminals: [
+ {
+ "id": "foot",
+ "name": "Foot",
+ "templatePath": "terminal/foot",
+ "predefinedTemplatePath": "terminal/foot-predefined",
+ "outputPath": "~/.config/foot/themes/noctalia",
+ "postHook": `${templateApplyScript} foot`
+ },
+ {
+ "id": "ghostty",
+ "name": "Ghostty",
+ "templatePath": "terminal/ghostty",
+ "predefinedTemplatePath": "terminal/ghostty-predefined",
+ "outputPath": "~/.config/ghostty/themes/noctalia",
+ "postHook": `${templateApplyScript} ghostty`
+ },
+ {
+ "id": "kitty",
+ "name": "Kitty",
+ "templatePath": "terminal/kitty.conf",
+ "predefinedTemplatePath": "terminal/kitty-predefined.conf",
+ "outputPath": "~/.config/kitty/themes/noctalia.conf",
+ "postHook": `${templateApplyScript} kitty`
+ },
+ {
+ "id": "alacritty",
+ "name": "Alacritty",
+ "templatePath": "terminal/alacritty.toml",
+ "predefinedTemplatePath": "terminal/alacritty-predefined.toml",
+ "outputPath": "~/.config/alacritty/themes/noctalia.toml",
+ "postHook": `${templateApplyScript} alacritty`
+ },
+ {
+ "id": "wezterm",
+ "name": "Wezterm",
+ "templatePath": "terminal/wezterm.toml",
+ "predefinedTemplatePath": "terminal/wezterm-predefined.toml",
+ "outputPath": "~/.config/wezterm/colors/Noctalia.toml",
+ "postHook": `${templateApplyScript} wezterm`
+ }
+ ]
+
+ // Application configurations - consolidated from Theming + AppThemeService
+ readonly property var applications: [
+ {
+ "id": "gtk",
+ "name": "GTK",
+ "category": "system",
+ "input": "gtk4.css",
+ "outputs": [
+ {
+ "path": "~/.config/gtk-3.0/noctalia.css",
+ "input": "gtk3.css"
+ },
+ {
+ "path": "~/.config/gtk-4.0/noctalia.css",
+ "input": "gtk4.css"
+ }
+ ],
+ "postProcess": mode => `python3 ${gtkRefreshScript} ${mode}`
+ },
+ {
+ "id": "qt",
+ "name": "Qt",
+ "category": "system",
+ "input": "qtct.conf",
+ "outputs": [
+ {
+ "path": "~/.config/qt5ct/colors/noctalia.conf"
+ },
+ {
+ "path": "~/.config/qt6ct/colors/noctalia.conf"
+ }
+ ]
+ },
+ {
+ "id": "kcolorscheme",
+ "name": "KColorScheme",
+ "category": "system",
+ "input": "kcolorscheme.colors",
+ "outputs": [
+ {
+ "path": "~/.local/share/color-schemes/noctalia.colors"
+ }
+ ],
+ "postProcess": () => "if command -v plasma-apply-colorscheme >/dev/null 2>&1; then plasma-apply-colorscheme BreezeDark; sleep 0.5; plasma-apply-colorscheme noctalia; fi"
+ },
+ {
+ "id": "fuzzel",
+ "name": "Fuzzel",
+ "category": "launcher",
+ "input": "fuzzel.conf",
+ "outputs": [
+ {
+ "path": "~/.config/fuzzel/themes/noctalia"
+ }
+ ],
+ "postProcess": () => `${templateApplyScript} fuzzel`
+ },
+ {
+ "id": "vicinae",
+ "name": "Vicinae",
+ "category": "launcher",
+ "input": "vicinae.toml",
+ "outputs": [
+ {
+ "path": "~/.local/share/vicinae/themes/noctalia.toml"
+ }
+ ],
+ "postProcess": () => `cp --update=none ${Quickshell.shellDir}/Assets/noctalia.svg ~/.local/share/vicinae/themes/noctalia.svg && ${templateApplyScript} vicinae`
+ },
+ {
+ "id": "walker",
+ "name": "Walker",
+ "category": "launcher",
+ "input": "walker.css",
+ "outputs": [
+ {
+ "path": "~/.config/walker/themes/noctalia/style.css"
+ }
+ ],
+ "postProcess": () => `${templateApplyScript} walker`,
+ "strict": true // Use strict mode for palette generation (preserves custom surface/outline values)
+ },
+ {
+ "id": "pywalfox",
+ "name": "Pywalfox",
+ "category": "browser",
+ "input": "pywalfox.json",
+ "outputs": [
+ {
+ "path": "~/.cache/wal/colors.json"
+ }
+ ],
+ "postProcess": mode => `${templateApplyScript} pywalfox ${mode}`
+ } // CONSOLIDATED DISCORD CLIENTS
+ ,
+ {
+ "id": "discord",
+ "name": "Discord",
+ "category": "misc",
+ "input": ["discord-midnight.css", "discord-material.css"],
+ "clients": [
+ {
+ "name": "vesktop",
+ "path": "~/.config/vesktop"
+ },
+ {
+ "name": "webcord",
+ "path": "~/.config/webcord"
+ },
+ {
+ "name": "armcord",
+ "path": "~/.config/armcord"
+ },
+ {
+ "name": "equibop",
+ "path": "~/.config/equibop"
+ },
+ {
+ "name": "equicord",
+ "path": "~/.config/Equicord"
+ },
+ {
+ "name": "lightcord",
+ "path": "~/.config/lightcord"
+ },
+ {
+ "name": "dorion",
+ "path": "~/.config/dorion"
+ },
+ {
+ "name": "vencord",
+ "path": "~/.config/Vencord"
+ },
+ {
+ "name": "vencord-flatpak",
+ "path": "~/.var/app/com.discordapp.Discord/config/Vencord"
+ },
+ {
+ "name": "betterdiscord",
+ "path": "~/.config/BetterDiscord"
+ }
+ ]
+ },
+ {
+ "id": "code",
+ "name": "VSCode",
+ "category": "editor",
+ "input": "code.json",
+ "clients": [
+ {
+ "name": "code",
+ "path": "~/.vscode/extensions/noctalia.noctaliatheme-0.0.5/themes/NoctaliaTheme-color-theme.json"
+ },
+ {
+ "name": "codium",
+ "path": "~/.vscode-oss/extensions/noctalia.noctaliatheme-0.0.5-universal/themes/NoctaliaTheme-color-theme.json"
+ }
+ ]
+ },
+ {
+ "id": "zed",
+ "name": "Zed",
+ "category": "editor",
+ "input": "zed.json",
+ "outputs": [
+ {
+ "path": "~/.config/zed/themes/noctalia.json"
+ }
+ ],
+ "dualMode": true // Template contains both dark and light theme patterns
+ },
+ {
+ "id": "helix",
+ "name": "Helix",
+ "category": "editor",
+ "input": "helix.toml",
+ "outputs": [
+ {
+ "path": "~/.config/helix/themes/noctalia.toml"
+ }
+ ]
+ },
+ {
+ "id": "spicetify",
+ "name": "Spicetify",
+ "category": "audio",
+ "input": "spicetify.ini",
+ "outputs": [
+ {
+ "path": "~/.config/spicetify/Themes/Comfy/color.ini"
+ }
+ ],
+ "postProcess": () => `spicetify -q apply --no-restart`
+ },
+ {
+ "id": "telegram",
+ "name": "Telegram",
+ "category": "misc",
+ "input": "telegram.tdesktop-theme",
+ "outputs": [
+ {
+ "path": "~/.config/telegram-desktop/themes/noctalia.tdesktop-theme"
+ }
+ ]
+ },
+ {
+ "id": "zenBrowser",
+ "name": "Zen Browser",
+ "category": "browser",
+ "input": "zen-browser/zen-userChrome.css",
+ "outputs": [
+ {
+ "path": "~/.cache/noctalia/zen-browser/zen-userChrome.css"
+ },
+ {
+ "path": "~/.cache/noctalia/zen-browser/zen-userContent.css",
+ "input": "zen-browser/zen-userContent.css"
+ }
+ ],
+ "postProcess": ()
+ => "sh -c 'CSS_CHROME=\"$HOME/.cache/noctalia/zen-browser/zen-userChrome.css\"; CSS_CONTENT=\"$HOME/.cache/noctalia/zen-browser/zen-userContent.css\"; LINE_CHROME=\"@import \\\"$CSS_CHROME\\\";\"; LINE_CONTENT=\"@import \\\"$CSS_CONTENT\\\";\"; find \"$HOME/.config/zen\" \"$HOME/.zen\" -mindepth 2 -maxdepth 2 -type d -name chrome -print0 2>/dev/null | while IFS= read -r -d \"\" dir; do USER_CHROME=\"$dir/userChrome.css\"; USER_CONTENT=\"$dir/userContent.css\"; mkdir -p \"$dir\"; touch \"$USER_CHROME\" \"$USER_CONTENT\"; sed -i \"/zen-browser\\/zen-userChrome\\.css/d\" \"$USER_CHROME\"; sed -i \"/zen-browser\\/zen-userContent\\.css/d\" \"$USER_CONTENT\"; if ! grep -Fq \"$LINE_CHROME\" \"$USER_CHROME\"; then printf \"%s\\n\" \"$LINE_CHROME\" >> \"$USER_CHROME\"; fi; if ! grep -Fq \"$LINE_CONTENT\" \"$USER_CONTENT\"; then printf \"%s\\n\" \"$LINE_CONTENT\" >> \"$USER_CONTENT\"; fi; done'"
+ },
+ {
+ "id": "cava",
+ "name": "Cava",
+ "category": "audio",
+ "input": "cava.ini",
+ "outputs": [
+ {
+ "path": "~/.config/cava/themes/noctalia"
+ }
+ ],
+ "postProcess": () => `${templateApplyScript} cava`
+ },
+ {
+ "id": "yazi",
+ "name": "Yazi",
+ "category": "misc",
+ "input": "yazi.toml",
+ "outputs": [
+ {
+ "path": "~/.config/yazi/flavors/noctalia.yazi/flavor.toml"
+ }
+ ],
+ "postProcess": () => `${templateApplyScript} yazi`
+ },
+ {
+ "id": "emacs",
+ "name": "Emacs",
+ "category": "editor",
+ "input": "emacs.el",
+ "postProcess": () => `emacsclient -e "(load-theme 'noctalia t)"`
+ },
+ {
+ "id": "labwc",
+ "name": "Labwc",
+ "category": "compositor",
+ "input": "labwc.conf",
+ "outputs": [
+ {
+ "path": "~/.config/labwc/themerc-override"
+ }
+ ],
+ "postProcess": () => `${templateApplyScript} labwc`
+ },
+ {
+ "id": "niri",
+ "name": "Niri",
+ "category": "compositor",
+ "input": "niri.kdl",
+ "outputs": [
+ {
+ "path": "~/.config/niri/noctalia.kdl"
+ }
+ ],
+ "postProcess": () => `${templateApplyScript} niri`
+ },
+ {
+ "id": "sway",
+ "name": "Sway",
+ "category": "compositor",
+ "input": "sway",
+ "outputs": [
+ {
+ "path": "~/.config/sway/noctalia"
+ }
+ ],
+ "postProcess": () => `${templateApplyScript} sway`
+ },
+ {
+ "id": "scroll",
+ "name": "Scroll",
+ "category": "compositor",
+ "input": "scroll",
+ "outputs": [
+ {
+ "path": "~/.config/scroll/noctalia"
+ }
+ ],
+ "postProcess": () => `${templateApplyScript} scroll`
+ },
+ {
+ "id": "hyprland",
+ "name": "Hyprland",
+ "category": "compositor",
+ "input": "hyprland.conf",
+ "outputs": [
+ {
+ "path": "~/.config/hypr/noctalia/noctalia-colors.conf"
+ }
+ ],
+ "postProcess": () => `${templateApplyScript} hyprland`
+ },
+ {
+ "id": "hyprtoolkit",
+ "name": "Hyprtoolkit",
+ "category": "system",
+ "input": "hyprtoolkit.conf",
+ "outputs": [
+ {
+ "path": "~/.config/hypr/hyprtoolkit.conf"
+ }
+ ]
+ },
+ {
+ "id": "mango",
+ "name": "Mango",
+ "category": "compositor",
+ "input": "mango.conf",
+ "outputs": [
+ {
+ "path": "~/.config/mango/noctalia.conf"
+ }
+ ],
+ "postProcess": () => `${templateApplyScript} mango`
+ },
+ {
+ "id": "btop",
+ "name": "btop",
+ "category": "misc",
+ "input": "btop.theme",
+ "outputs": [
+ {
+ "path": "~/.config/btop/themes/noctalia.theme"
+ }
+ ],
+ "postProcess": () => `${templateApplyScript} btop`
+ },
+ {
+ "id": "zathura",
+ "name": "Zathura",
+ "category": "misc",
+ "input": "zathurarc",
+ "outputs": [
+ {
+ "path": "~/.config/zathura/noctaliarc"
+ }
+ ],
+ "postProcess": () => `${templateApplyScript} zathura`
+ },
+ {
+ "id": "steam",
+ "name": "Steam",
+ "category": "misc",
+ "input": "steam.css",
+ "outputs": [
+ {
+ "path": "~/.steam/steam/steamui/skins/Material-Theme/css/main/colors/matugen.css"
+ }
+ ]
+ }
+ ]
+
+ // Extract Discord clients for ProgramCheckerService compatibility
+ readonly property var discordClients: {
+ var clients = [];
+ var discordApp = applications.find(app => app.id === "discord");
+ if (discordApp && discordApp.clients) {
+ discordApp.clients.forEach(client => {
+ clients.push({
+ "name": client.name,
+ "configPath": client.path,
+ "themePath": `${client.path}/themes/noctalia.theme.css`
+ });
+ });
+ }
+ return clients;
+ }
+
+ // Get resolved theme paths for a code client (returns array of all matching paths)
+ function resolvedCodeClientPaths(clientName) {
+ if (clientName === "code")
+ return resolvedCodePaths;
+ if (clientName === "codium")
+ return resolvedCodiumPaths;
+ return [];
+ }
+
+ // Extract Code clients for ProgramCheckerService compatibility
+ readonly property var codeClients: {
+ var clients = [];
+ var codeApp = applications.find(app => app.id === "code");
+ if (codeApp && codeApp.clients) {
+ codeApp.clients.forEach(client => {
+ // Extract base config directory from theme path
+ var themePath = client.path;
+ var baseConfigDir = "";
+ if (client.name === "code") {
+ // For VSCode: ~/.vscode/extensions/... -> ~/.vscode
+ baseConfigDir = "~/.vscode";
+ } else if (client.name === "codium") {
+ // For VSCodium: ~/.vscode-oss/extensions/... -> ~/.vscode-oss
+ baseConfigDir = "~/.vscode-oss";
+ }
+ clients.push({
+ "name": client.name,
+ "configPath": baseConfigDir,
+ "themePath": "" // resolved dynamically via resolvedCodeClientPaths()
+ });
+ });
+ }
+ return clients;
+ }
+
+ // Resolve VSCode extension paths dynamically
+ Process {
+ id: codeResolverProcess
+ command: ["python3", vscodeHelperScript, "~/.vscode/extensions"]
+ running: true
+ property var paths: []
+ stdout: SplitParser {
+ onRead: data => {
+ var line = data.trim();
+ if (line)
+ codeResolverProcess.paths.push(line);
+ }
+ }
+ onExited: {
+ root.resolvedCodePaths = paths;
+ }
+ }
+
+ Process {
+ id: codiumResolverProcess
+ command: ["python3", vscodeHelperScript, "~/.vscode-oss/extensions"]
+ running: true
+ property var paths: []
+ stdout: SplitParser {
+ onRead: data => {
+ var line = data.trim();
+ if (line)
+ codiumResolverProcess.paths.push(line);
+ }
+ }
+ onExited: {
+ root.resolvedCodiumPaths = paths;
+ }
+ }
+ // Build user templates TOML content
+ function buildUserTemplatesToml() {
+ var lines = [];
+ lines.push("[config]");
+ lines.push("");
+ lines.push("[templates]");
+ lines.push("");
+ lines.push("# User-defined templates");
+ lines.push("# Add your custom templates below");
+ lines.push("# Example:");
+ lines.push("# [templates.myapp]");
+ lines.push("# input_path = \"~/.config/noctalia/templates/myapp.css\"");
+ lines.push("# output_path = \"~/.config/myapp/theme.css\"");
+ lines.push("# post_hook = \"myapp --reload-theme\"");
+ lines.push("");
+ lines.push("# Remove this section and add your own templates");
+ lines.push("#[templates.placeholder]");
+ lines.push("#input_path = \"" + Quickshell.shellDir + "/Assets/Templates/noctalia.json\"");
+ lines.push("#output_path = \"" + Settings.cacheDir + "placeholder.json\"");
+ lines.push("");
+
+ return lines.join("\n") + "\n";
+ }
+
+ // Write user templates TOML file (moved from Theming)
+ function writeUserTemplatesToml() {
+ var userConfigPath = Settings.configDir + "user-templates.toml";
+
+ // Check if file already exists
+ fileCheckProcess.command = ["test", "-s", userConfigPath];
+ fileCheckProcess.running = true;
+ }
+
+ function doWriteUserTemplatesToml() {
+ var userConfigPath = Settings.configDir + "user-templates.toml";
+ var configContent = buildUserTemplatesToml();
+ var userConfigPathEsc = userConfigPath.replace(/'/g, "'\\''");
+ var configDirEsc = Settings.configDir.replace(/'/g, "'\\''");
+
+ // Combine mkdir and write in a single script to avoid race condition
+ var script = `mkdir -p '${configDirEsc}' && cat > '${userConfigPathEsc}' << 'EOF'\n`;
+ script += configContent;
+ script += "EOF\n";
+ fileWriteProcess.command = ["sh", "-c", script];
+ fileWriteProcess.running = true;
+ }
+
+ // Extract Emacs clients for ProgramCheckerService compatibility
+ readonly property var emacsClients: [
+ {
+ "name": "doom",
+ "path": "~/.config/doom"
+ },
+ {
+ "name": "modern",
+ "path": "~/.config/emacs"
+ },
+ {
+ "name": "traditional",
+ "path": "~/.emacs.d"
+ }
+ ]
+
+ // Process for checking if user templates file exists and is non-empty
+ Process {
+ id: fileCheckProcess
+ running: false
+
+ onExited: function (exitCode) {
+ if (exitCode === 0) {
+ // File exists and is non-empty, skip creation
+ Logger.d("TemplateRegistry", "User templates config already exists, skipping creation");
+ } else {
+ // File doesn't exist or is empty, create it
+ doWriteUserTemplatesToml();
+ }
+ }
+ }
+
+ // Process for writing user templates file with error reporting
+ Process {
+ id: fileWriteProcess
+ running: false
+
+ onExited: function (exitCode) {
+ if (exitCode === 0) {
+ Logger.d("TemplateRegistry", "User templates config written to:", Settings.configDir + "user-templates.toml");
+ } else {
+ Logger.e("TemplateRegistry", "Failed to write user templates config (exit code:", exitCode + ")");
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/UI/BarService.qml b/arch/.config/quickshell/noctalia-shell/Services/UI/BarService.qml
new file mode 100644
index 0000000..c16cd81
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/UI/BarService.qml
@@ -0,0 +1,668 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import qs.Commons
+import qs.Services.Compositor
+import qs.Services.UI
+
+Singleton {
+ id: root
+
+ property bool isVisible: true
+
+ // Computed visibility that factors in compositor overview state
+ readonly property bool effectivelyVisible: {
+ if (!isVisible) {
+ return false;
+ }
+ if (Settings.data.bar.hideOnOverview && CompositorService.overviewActive) {
+ return false;
+ }
+ return true;
+ }
+
+ property var readyBars: ({})
+
+ // Revision counter - increment when widget list structure changes (add/remove/reorder)
+ // This triggers Bar.qml to re-sync its ListModels
+ property int widgetsRevision: 0
+
+ // Registry to store actual widget instances
+ // Key format: "screenName|section|widgetId|index"
+ property var widgetInstances: ({})
+
+ signal activeWidgetsChanged
+ signal barReadyChanged(string screenName)
+ signal barAutoHideStateChanged(string screenName, bool hidden)
+ signal barHoverStateChanged(string screenName, bool hovered)
+
+ // Track if a popup menu is open from the bar (prevents auto-hide)
+ property bool popupOpen: false
+
+ // Auto-hide state per screen: { screenName: { hovered: bool, hidden: bool } }
+ property var screenAutoHideState: ({})
+
+ // Get or create auto-hide state for a screen
+ function getOrCreateAutoHideState(screenName) {
+ if (!screenAutoHideState[screenName]) {
+ screenAutoHideState[screenName] = {
+ "hovered": false,
+ "hidden": Settings.getBarDisplayModeForScreen(screenName) === "auto_hide"
+ };
+ }
+ return screenAutoHideState[screenName];
+ }
+
+ // Set hover state for a screen
+ function setScreenHovered(screenName, hovered) {
+ var state = getOrCreateAutoHideState(screenName);
+ if (state.hovered !== hovered) {
+ state.hovered = hovered;
+ screenAutoHideState = Object.assign({}, screenAutoHideState);
+ barHoverStateChanged(screenName, hovered);
+ }
+ }
+
+ // Set hidden state for a screen
+ function setScreenHidden(screenName, hidden) {
+ var state = getOrCreateAutoHideState(screenName);
+ if (state.hidden !== hidden) {
+ state.hidden = hidden;
+ screenAutoHideState = Object.assign({}, screenAutoHideState);
+ barAutoHideStateChanged(screenName, hidden);
+ }
+ }
+
+ // Check if bar is hidden on a screen
+ function isBarHidden(screenName) {
+ var state = screenAutoHideState[screenName];
+ return state ? state.hidden : false;
+ }
+
+ // Check if bar is hovered on a screen
+ function isBarHovered(screenName) {
+ var state = screenAutoHideState[screenName];
+ return state ? state.hovered : false;
+ }
+
+ // Toggle bar visibility. In auto-hide mode, toggles the per-screen hidden
+ // state without touching isVisible (so hover-to-show still works).
+ // For non-auto-hide screens, toggles the global isVisible flag.
+ function toggleVisibility() {
+ // Check if any auto-hide screen is currently visible
+ var anyAutoHideVisible = false;
+ var hasAutoHideScreens = false;
+ for (var screenName in screenAutoHideState) {
+ if (Settings.getBarDisplayModeForScreen(screenName) === "auto_hide") {
+ hasAutoHideScreens = true;
+ if (!screenAutoHideState[screenName].hidden) {
+ anyAutoHideVisible = true;
+ break;
+ }
+ }
+ }
+
+ // Toggle auto-hide screens (per-screen hidden state only)
+ if (hasAutoHideScreens) {
+ for (var screenName in screenAutoHideState) {
+ if (Settings.getBarDisplayModeForScreen(screenName) === "auto_hide") {
+ setScreenHidden(screenName, anyAutoHideVisible);
+ }
+ }
+ }
+
+ // Only toggle global visibility when no auto-hide screens exist,
+ // otherwise it would permanently disable hover-to-show
+ if (!hasAutoHideScreens) {
+ isVisible = !isVisible;
+ }
+ }
+
+ // Show bar. In auto-hide mode, un-hides on screens with auto-hide enabled.
+ // The bar stays visible until the user hovers and moves away.
+ function show() {
+ // Show auto-hide screens
+ for (var screenName in screenAutoHideState) {
+ if (Settings.getBarDisplayModeForScreen(screenName) === "auto_hide") {
+ setScreenHidden(screenName, false);
+ }
+ }
+ // Set global visibility (affects non-auto-hide screens)
+ isVisible = true;
+ }
+
+ // Hide bar. In auto-hide mode, sets per-screen hidden state without touching
+ // isVisible so hover-to-show still works. For non-auto-hide screens, sets
+ // global visibility to false.
+ function hide() {
+ var hasAutoHideScreens = false;
+ for (var screenName in screenAutoHideState) {
+ if (Settings.getBarDisplayModeForScreen(screenName) === "auto_hide") {
+ setScreenHidden(screenName, true);
+ hasAutoHideScreens = true;
+ }
+ }
+ // Only set global visibility off when no auto-hide screens exist,
+ // otherwise it would permanently disable hover-to-show
+ if (!hasAutoHideScreens) {
+ isVisible = false;
+ }
+ }
+
+ // Temporarily show the bar, then auto-hide after the configured delay.
+ // Uses the same pattern as workspace switch: show, then emit unhover
+ // to start the hide timer.
+ function peek() {
+ for (var screenName in screenAutoHideState) {
+ if (Settings.getBarDisplayModeForScreen(screenName) === "auto_hide") {
+ setScreenHidden(screenName, false);
+ if (!isBarHovered(screenName)) {
+ barHoverStateChanged(screenName, false);
+ }
+ }
+ }
+ }
+
+ Component.onCompleted: {
+ Logger.i("BarService", "Service started");
+ }
+
+ // Bump widgetsRevision when settings are reloaded from an external file change
+ // so Bar.qml re-syncs its widget ListModels with the updated widget configuration
+ Connections {
+ target: Settings
+ function onSettingsReloaded() {
+ Logger.d("BarService", "Settings reloaded externally, bumping widgetsRevision");
+ root.widgetsRevision++;
+ }
+ }
+
+ // update bar's hidden state when mode changes
+ Connections {
+ target: Settings.data.bar
+ function onDisplayModeChanged() {
+ Logger.d("BarService", "Display mode changed to:", Settings.data.bar.displayMode);
+
+ // Only affect screens without displayMode overrides
+ for (let screenName in screenAutoHideState) {
+ if (!Settings.hasScreenOverride(screenName, "displayMode")) {
+ var displayMode = Settings.getBarDisplayModeForScreen(screenName);
+ if (displayMode === "auto_hide") {
+ setScreenHidden(screenName, true);
+ } else {
+ if (screenAutoHideState[screenName].hidden) {
+ setScreenHidden(screenName, false);
+ }
+ }
+ }
+ }
+ }
+
+ function onScreenOverridesChanged() {
+ Logger.d("BarService", "Screen overrides changed, re-evaluating auto-hide states");
+
+ // Re-evaluate auto-hide state for all screens
+ for (let screenName in screenAutoHideState) {
+ var displayMode = Settings.getBarDisplayModeForScreen(screenName);
+ if (displayMode === "auto_hide") {
+ if (!screenAutoHideState[screenName].hidden) {
+ setScreenHidden(screenName, true);
+ }
+ } else {
+ if (screenAutoHideState[screenName].hidden) {
+ setScreenHidden(screenName, false);
+ }
+ }
+ }
+ }
+ }
+
+ // Track last workspace ID to detect actual workspace changes
+ property var lastWorkspaceId: null
+
+ // Debounce rapid workspace switches to reduce load/unload races (SIGSEGV in QV4)
+ property string _pendingWorkspaceScreen: ""
+
+ Timer {
+ id: workspaceDebounceTimer
+ interval: 80
+ repeat: false
+ onTriggered: {
+ var screen = root._pendingWorkspaceScreen;
+ root._pendingWorkspaceScreen = "";
+ if (screen) {
+ setScreenHidden(screen, false);
+ if (!root.isBarHovered(screen)) {
+ barHoverStateChanged(screen, false);
+ }
+ }
+ }
+ }
+
+ // Workspace switch handler - directly show bar on the focused workspace screen
+ Connections {
+ target: CompositorService
+ function onWorkspaceChanged() {
+ if (!Settings.data.bar.showOnWorkspaceSwitch)
+ return;
+ if (Settings.data.bar.displayMode !== "auto_hide")
+ return;
+
+ var ws = CompositorService.getCurrentWorkspace();
+ if (!ws || !ws.output) {
+ return;
+ }
+
+ // Only trigger if workspace actually changed
+ var currentWsId = ws.id;
+ if (currentWsId === root.lastWorkspaceId) {
+ return;
+ }
+ root.lastWorkspaceId = currentWsId;
+
+ var screenName = ws.output || "";
+ Logger.d("BarService", "Workspace switched to:", currentWsId, "on screen:", screenName);
+
+ // Debounce: rapid switches (e.g. external monitor โ laptop) cause overlapping
+ // bar load/unload; 80ms delay coalesces them and reduces QV4 incubation races
+ root._pendingWorkspaceScreen = screenName;
+ workspaceDebounceTimer.restart();
+ }
+ }
+
+ // Function for the Bar to call when it's ready
+ function registerBar(screenName) {
+ if (!readyBars[screenName]) {
+ readyBars[screenName] = true;
+ Logger.d("BarService", "Bar is ready on screen:", screenName);
+ barReadyChanged(screenName);
+ }
+ }
+
+ // Function for the Dock to check if the bar is ready
+ function isBarReady(screenName) {
+ return readyBars[screenName] || false;
+ }
+
+ // Register a widget instance
+ function registerWidget(screenName, section, widgetId, index, instance) {
+ const key = [screenName, section, widgetId, index].join("|");
+ widgetInstances[key] = {
+ "key": key,
+ "screenName": screenName,
+ "section": section,
+ "widgetId": widgetId,
+ "index": index,
+ "instance": instance
+ };
+
+ Logger.d("BarService", "Registered widget:", key);
+ root.activeWidgetsChanged();
+ }
+
+ // Unregister a widget instance
+ function unregisterWidget(screenName, section, widgetId, index) {
+ const key = [screenName, section, widgetId, index].join("|");
+ delete widgetInstances[key];
+ Logger.d("BarService", "Unregistered widget:", key);
+ root.activeWidgetsChanged();
+ }
+
+ // Lookup a specific widget instance (returns the actual QML instance)
+ function lookupWidget(widgetId, screenName = null, section = null, index = null) {
+ // If looking for a specific instance
+ if (screenName && section !== null) {
+ for (var key in widgetInstances) {
+ var widget = widgetInstances[key];
+ if (!widget)
+ continue;
+ if (widget.widgetId === widgetId && widget.screenName === screenName && widget.section === section) {
+ if (index === null) {
+ return widget.instance;
+ } else if (widget.index == index) {
+ return widget.instance;
+ }
+ }
+ }
+ }
+
+ // Return first match if no specific screen/section specified
+ for (var key in widgetInstances) {
+ var widget = widgetInstances[key];
+ if (!widget)
+ continue;
+ if (widget.widgetId === widgetId) {
+ if (!screenName || widget.screenName === screenName) {
+ if (section === null || widget.section === section) {
+ return widget.instance;
+ }
+ }
+ }
+ }
+
+ return undefined;
+ }
+
+ // Get all instances of a widget type
+ function getAllWidgetInstances(widgetId = null, screenName = null, section = null) {
+ var instances = [];
+
+ for (var key in widgetInstances) {
+ var widget = widgetInstances[key];
+ if (!widget)
+ continue;
+
+ var matches = true;
+ if (widgetId && widget.widgetId !== widgetId)
+ matches = false;
+ if (screenName && widget.screenName !== screenName)
+ matches = false;
+ if (section !== null && widget.section !== section)
+ matches = false;
+
+ if (matches) {
+ instances.push(widget.instance);
+ }
+ }
+
+ return instances;
+ }
+
+ // Get widget with full metadata
+ function getWidgetWithMetadata(widgetId, screenName = null, section = null) {
+ for (var key in widgetInstances) {
+ var widget = widgetInstances[key];
+ if (!widget)
+ continue;
+ if (widget.widgetId === widgetId) {
+ if (!screenName || widget.screenName === screenName) {
+ if (section === null || widget.section === section) {
+ return widget;
+ }
+ }
+ }
+ }
+ return undefined;
+ }
+
+ // Get all widgets in a specific section
+ function getWidgetsBySection(section, screenName = null) {
+ var widgetEntries = [];
+
+ for (var key in widgetInstances) {
+ var widget = widgetInstances[key];
+ if (!widget)
+ continue;
+ if (widget.section === section) {
+ if (!screenName || widget.screenName === screenName) {
+ widgetEntries.push(widget);
+ }
+ }
+ }
+
+ // Sort by index to maintain order
+ widgetEntries.sort(function (a, b) {
+ return (a.index || 0) - (b.index || 0);
+ });
+
+ // Return just the instances
+ return widgetEntries.map(function (w) {
+ return w.instance;
+ });
+ }
+
+ // Get all registered widgets (for debugging)
+ function getAllRegisteredWidgets() {
+ var result = [];
+ for (var key in widgetInstances) {
+ var widget = widgetInstances[key];
+ if (!widget)
+ continue;
+ result.push({
+ "key": key,
+ "widgetId": widget.widgetId,
+ "section": widget.section,
+ "screenName": widget.screenName,
+ "index": widget.index
+ });
+ }
+ return result;
+ }
+
+ // Check if a widget type exists in a section
+ function hasWidget(widgetId, section = null, screenName = null) {
+ for (var key in widgetInstances) {
+ var widget = widgetInstances[key];
+ if (!widget)
+ continue;
+ if (widget.widgetId === widgetId) {
+ if (section === null || widget.section === section) {
+ if (!screenName || widget.screenName === screenName) {
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+ }
+
+ // Unregister all widget instances for a plugin (used during hot reload)
+ // Note: We don't destroy instances here - the Loader manages that when the component is unregistered
+ function destroyPluginWidgetInstances(pluginId) {
+ var widgetId = "plugin:" + pluginId;
+ var keysToRemove = [];
+
+ // Find all instances of this plugin's widget
+ for (var key in widgetInstances) {
+ var widget = widgetInstances[key];
+ if (widget && widget.widgetId === widgetId) {
+ keysToRemove.push(key);
+ Logger.d("BarService", "Unregistering plugin widget instance:", key);
+ }
+ }
+
+ // Remove from registry
+ for (var i = 0; i < keysToRemove.length; i++) {
+ delete widgetInstances[keysToRemove[i]];
+ }
+
+ if (keysToRemove.length > 0) {
+ Logger.i("BarService", "Unregistered", keysToRemove.length, "instance(s) of plugin widget:", widgetId);
+ root.activeWidgetsChanged();
+ }
+ }
+
+ // Get pill direction for a widget instance
+ function getPillDirection(widgetInstance) {
+ try {
+ if (widgetInstance.section === "left") {
+ return false;
+ } else if (widgetInstance.section === "right") {
+ return true;
+ } else {
+ // middle section
+ if (widgetInstance.sectionWidgetIndex < widgetInstance.sectionWidgetsCount / 2) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+ } catch (e) {
+ Logger.e(e);
+ }
+ return true;
+ }
+
+ function getTooltipDirection(screenName) {
+ const position = Settings.getBarPositionForScreen(screenName);
+ switch (position) {
+ case "right":
+ return "left";
+ case "left":
+ return "right";
+ case "bottom":
+ return "top";
+ default:
+ return "bottom";
+ }
+ }
+
+ // Helper to close any existing dialogs in a popup menu window
+ function closeExistingDialogs(popupMenuWindow) {
+ if (!popupMenuWindow || !popupMenuWindow.dialogParent)
+ return;
+
+ var dialogParent = popupMenuWindow.dialogParent;
+ for (var i = dialogParent.children.length - 1; i >= 0; i--) {
+ var child = dialogParent.children[i];
+ if (child && typeof child.close === "function") {
+ child.close();
+ }
+ }
+ popupMenuWindow.hasDialog = false;
+ }
+
+ // Open widget settings dialog for a bar widget
+ // Parameters:
+ // screen: The screen to show the dialog on
+ // section: Section id ("left", "center", "right")
+ // index: Widget index in section
+ // widgetId: Widget type id (e.g., "Volume")
+ // widgetData: Current widget settings object
+ function openWidgetSettings(screen, section, index, widgetId, widgetData) {
+ // Get the popup menu window to use as parent (avoids clipping issues with bar height)
+ var popupMenuWindow = PanelService.getPopupMenuWindow(screen);
+ if (!popupMenuWindow) {
+ Logger.e("BarService", "No popup menu window found for screen");
+ return;
+ }
+
+ // Close any existing dialogs first to prevent stacking
+ closeExistingDialogs(popupMenuWindow);
+
+ if (PanelService.openedPanel) {
+ PanelService.openedPanel.close();
+ }
+
+ var component = Qt.createComponent(Quickshell.shellDir + "/Modules/Panels/Settings/Bar/BarWidgetSettingsDialog.qml");
+
+ function instantiateAndOpen() {
+ // Use dialogParent (Item) instead of window directly for proper Popup anchoring
+ var dialog = component.createObject(popupMenuWindow.dialogParent, {
+ "widgetIndex": index,
+ "widgetData": widgetData,
+ "widgetId": widgetId,
+ "sectionId": section,
+ "screen": screen
+ });
+
+ if (dialog) {
+ dialog.updateWidgetSettings.connect((sec, idx, settings) => {
+ var screenName = screen?.name || "";
+ if (Settings.hasScreenOverride(screenName, "widgets")) {
+ var overrideWidgets = Settings.getBarWidgetsForScreen(screenName);
+ if (overrideWidgets && overrideWidgets[sec] && idx < overrideWidgets[sec].length) {
+ overrideWidgets[sec][idx] = Object.assign({}, overrideWidgets[sec][idx], settings);
+ Settings.setScreenOverride(screenName, "widgets", overrideWidgets);
+ }
+ } else {
+ var widgets = Settings.data.bar.widgets[sec];
+ if (widgets && idx < widgets.length) {
+ widgets[idx] = Object.assign({}, widgets[idx], settings);
+ Settings.data.bar.widgets[sec] = widgets;
+ Settings.saveImmediate();
+ }
+ }
+ });
+ // Enable keyboard focus for the popup menu window when dialog is open
+ popupMenuWindow.hasDialog = true;
+ // Close the popup menu window when dialog closes
+ dialog.closed.connect(() => {
+ popupMenuWindow.hasDialog = false;
+ popupMenuWindow.close();
+ dialog.destroy();
+ });
+ // Show the popup menu window and open the dialog
+ popupMenuWindow.open();
+ dialog.open();
+ } else {
+ Logger.e("BarService", "Failed to create widget settings dialog");
+ }
+ }
+
+ if (component.status === Component.Ready) {
+ instantiateAndOpen();
+ } else if (component.status === Component.Error) {
+ Logger.e("BarService", "Error loading widget settings dialog:", component.errorString());
+ } else {
+ component.statusChanged.connect(function () {
+ if (component.status === Component.Ready) {
+ instantiateAndOpen();
+ } else if (component.status === Component.Error) {
+ Logger.e("BarService", "Error loading widget settings dialog:", component.errorString());
+ }
+ });
+ }
+ }
+
+ // Open plugin settings dialog
+ // Parameters:
+ // screen: The screen to show the dialog on
+ // pluginManifest: The plugin's manifest object (must have entryPoints.settings)
+ function openPluginSettings(screen, pluginManifest) {
+ if (!pluginManifest || !pluginManifest.entryPoints || !pluginManifest.entryPoints.settings) {
+ Logger.e("BarService", "Cannot open plugin settings: no settings entry point");
+ return;
+ }
+
+ // Get the popup menu window to use as parent
+ var popupMenuWindow = PanelService.getPopupMenuWindow(screen);
+ if (!popupMenuWindow) {
+ Logger.e("BarService", "No popup menu window found for screen");
+ return;
+ }
+
+ // Close any existing dialogs first to prevent stacking
+ closeExistingDialogs(popupMenuWindow);
+
+ var component = Qt.createComponent(Quickshell.shellDir + "/Widgets/NPluginSettingsPopup.qml");
+
+ function instantiateAndOpen() {
+ var dialog = component.createObject(popupMenuWindow.dialogParent, {
+ "showToastOnSave": true,
+ "screen": screen
+ });
+
+ if (dialog) {
+ // Enable keyboard focus for the popup menu window when dialog is open
+ popupMenuWindow.hasDialog = true;
+ // Close the popup menu window when dialog closes
+ dialog.closed.connect(() => {
+ popupMenuWindow.hasDialog = false;
+ popupMenuWindow.close();
+ dialog.destroy();
+ });
+ // Show the popup menu window and open the dialog
+ popupMenuWindow.open();
+ dialog.openPluginSettings(pluginManifest);
+ } else {
+ Logger.e("BarService", "Failed to create plugin settings dialog");
+ }
+ }
+
+ if (component.status === Component.Ready) {
+ instantiateAndOpen();
+ } else if (component.status === Component.Error) {
+ Logger.e("BarService", "Error loading plugin settings dialog:", component.errorString());
+ } else {
+ component.statusChanged.connect(function () {
+ if (component.status === Component.Ready) {
+ instantiateAndOpen();
+ } else if (component.status === Component.Error) {
+ Logger.e("BarService", "Error loading plugin settings dialog:", component.errorString());
+ }
+ });
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/UI/BarWidgetRegistry.qml b/arch/.config/quickshell/noctalia-shell/Services/UI/BarWidgetRegistry.qml
new file mode 100644
index 0000000..5c951d4
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/UI/BarWidgetRegistry.qml
@@ -0,0 +1,503 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import qs.Commons
+import qs.Modules.Bar.Widgets
+
+Singleton {
+ id: root
+
+ signal pluginWidgetRegistryUpdated
+
+ // Widget registry object mapping widget names to components
+ property var widgets: ({
+ "ActiveWindow": activeWindowComponent,
+ "AudioVisualizer": audioVisualizerComponent,
+ "Battery": batteryComponent,
+ "Bluetooth": bluetoothComponent,
+ "Brightness": brightnessComponent,
+ "Clock": clockComponent,
+ "ControlCenter": controlCenterComponent,
+ "CustomButton": customButtonComponent,
+ "DarkMode": darkModeComponent,
+ "KeepAwake": keepAwakeComponent,
+ "KeyboardLayout": keyboardLayoutComponent,
+ "LockKeys": lockKeysComponent,
+ "Launcher": launcherComponent,
+ "MediaMini": mediaMiniComponent,
+ "Microphone": microphoneComponent,
+ "Network": networkComponent,
+ "NightLight": nightLightComponent,
+ "NoctaliaPerformance": noctaliaPerformanceComponent,
+ "NotificationHistory": notificationHistoryComponent,
+ "PowerProfile": powerProfileComponent,
+ "SessionMenu": sessionMenuComponent,
+ "Settings": settingsComponent,
+ "Spacer": spacerComponent,
+ "SystemMonitor": systemMonitorComponent,
+ "Taskbar": taskbarComponent,
+ "Tray": trayComponent,
+ "Volume": volumeComponent,
+ "VPN": vpnComponent,
+ "WallpaperSelector": wallpaperSelectorComponent,
+ "Workspace": workspaceComponent
+ })
+
+ property var widgetSettingsMap: ({
+ "ActiveWindow": "WidgetSettings/ActiveWindowSettings.qml",
+ "AudioVisualizer": "WidgetSettings/AudioVisualizerSettings.qml",
+ "Battery": "WidgetSettings/BatterySettings.qml",
+ "Bluetooth": "WidgetSettings/BluetoothSettings.qml",
+ "Brightness": "WidgetSettings/BrightnessSettings.qml",
+ "Clock": "WidgetSettings/ClockSettings.qml",
+ "ControlCenter": "WidgetSettings/ControlCenterSettings.qml",
+ "CustomButton": "WidgetSettings/CustomButtonSettings.qml",
+ "DarkMode": "WidgetSettings/DarkModeSettings.qml",
+ "KeepAwake": "WidgetSettings/KeepAwakeSettings.qml",
+ "KeyboardLayout": "WidgetSettings/KeyboardLayoutSettings.qml",
+ "Launcher": "WidgetSettings/LauncherSettings.qml",
+ "LockKeys": "WidgetSettings/LockKeysSettings.qml",
+ "MediaMini": "WidgetSettings/MediaMiniSettings.qml",
+ "Microphone": "WidgetSettings/MicrophoneSettings.qml",
+ "Network": "WidgetSettings/NetworkSettings.qml",
+ "NightLight": "WidgetSettings/NightLightSettings.qml",
+ "NoctaliaPerformance": "WidgetSettings/NoctaliaPerformanceSettings.qml",
+ "NotificationHistory": "WidgetSettings/NotificationHistorySettings.qml",
+ "PowerProfile": "WidgetSettings/PowerProfileSettings.qml",
+ "SessionMenu": "WidgetSettings/SessionMenuSettings.qml",
+ "Settings": "WidgetSettings/SettingsSettings.qml",
+ "Spacer": "WidgetSettings/SpacerSettings.qml",
+ "SystemMonitor": "WidgetSettings/SystemMonitorSettings.qml",
+ "Taskbar": "WidgetSettings/TaskbarSettings.qml",
+ "Tray": "WidgetSettings/TraySettings.qml",
+ "Volume": "WidgetSettings/VolumeSettings.qml",
+ "VPN": "WidgetSettings/VPNSettings.qml",
+ "WallpaperSelector": "WidgetSettings/WallpaperSelectorSettings.qml",
+ "Workspace": "WidgetSettings/WorkspaceSettings.qml"
+ })
+
+ property var widgetMetadata: ({
+ "ActiveWindow": {
+ "showText": true,
+ "showIcon": true,
+ "hideMode": "hidden",
+ "scrollingMode": "hover",
+ "maxWidth": 145,
+ "useFixedWidth": false,
+ "colorizeIcons": false,
+ "textColor": "none"
+ },
+ "AudioVisualizer": {
+ "width": 200,
+ "colorName": "primary",
+ "hideWhenIdle": false
+ },
+ "Battery": {
+ "displayMode": "graphic-clean",
+ "deviceNativePath": "__default__",
+ "showPowerProfiles": false,
+ "showNoctaliaPerformance": false,
+ "hideIfNotDetected": true,
+ "hideIfIdle": false
+ },
+ "Bluetooth": {
+ "displayMode": "onhover",
+ "iconColor": "none",
+ "textColor": "none"
+ },
+ "Brightness": {
+ "displayMode": "onhover",
+ "iconColor": "none",
+ "textColor": "none",
+ "applyToAllMonitors": false
+ },
+ "Clock": {
+ "clockColor": "none",
+ "useCustomFont": false,
+ "customFont": "",
+ "formatHorizontal": "HH:mm ddd, MMM dd",
+ "formatVertical": "HH mm - dd MM",
+ "tooltipFormat": "HH:mm ddd, MMM dd"
+ },
+ "ControlCenter": {
+ "useDistroLogo": false,
+ "icon": "noctalia",
+ "customIconPath": "",
+ "colorizeDistroLogo": false,
+ "colorizeSystemIcon": "none",
+ "enableColorization": false
+ },
+ "CustomButton": {
+ "icon": "heart",
+ "showIcon": true,
+ "showExecTooltip": true,
+ "showTextTooltip": true,
+ "generalTooltipText": "",
+ "hideMode": "alwaysExpanded",
+ "leftClickExec": "",
+ "leftClickUpdateText": false,
+ "rightClickExec": "",
+ "rightClickUpdateText": false,
+ "middleClickExec": "",
+ "middleClickUpdateText": false,
+ "textCommand": "",
+ "textStream": false,
+ "textIntervalMs": 3000,
+ "textCollapse": "",
+ "parseJson": false,
+ "wheelExec": "",
+ "wheelUpExec": "",
+ "wheelDownExec": "",
+ "wheelMode": "unified",
+ "wheelUpdateText": false,
+ "wheelUpUpdateText": false,
+ "wheelDownUpdateText": false,
+ "maxTextLength": {
+ "horizontal": 10,
+ "vertical": 10
+ },
+ "enableColorization": false,
+ "colorizeSystemIcon": "none",
+ "ipcIdentifier": ""
+ },
+ "DarkMode": {
+ "iconColor": "none"
+ },
+ "KeepAwake": {
+ "iconColor": "none",
+ "textColor": "none"
+ },
+ "KeyboardLayout": {
+ "displayMode": "onhover",
+ "showIcon": true,
+ "iconColor": "none",
+ "textColor": "none"
+ },
+ "LockKeys": {
+ "showCapsLock": true,
+ "showNumLock": true,
+ "showScrollLock": true,
+ "capsLockIcon": "letter-c",
+ "numLockIcon": "letter-n",
+ "scrollLockIcon": "letter-s",
+ "hideWhenOff": false
+ },
+ "Launcher": {
+ "useDistroLogo": false,
+ "icon": "rocket",
+ "customIconPath": "",
+ "colorizeSystemIcon": "none",
+ "enableColorization": false,
+ "iconColor": "none"
+ },
+ "MediaMini": {
+ "hideMode": "hidden",
+ "scrollingMode": "hover",
+ "maxWidth": 145,
+ "useFixedWidth": false,
+ "hideWhenIdle": false,
+ "showAlbumArt": true,
+ "showArtistFirst": true,
+ "showVisualizer": false,
+ "showProgressRing": true,
+ "visualizerType": "linear",
+ "textColor": "none",
+ "compactMode": false,
+ "panelShowAlbumArt": true
+ },
+ "Microphone": {
+ "displayMode": "onhover",
+ "middleClickCommand": "pwvucontrol || pavucontrol",
+ "iconColor": "none",
+ "textColor": "none"
+ },
+ "NotificationHistory": {
+ "showUnreadBadge": true,
+ "hideWhenZero": false,
+ "hideWhenZeroUnread": false,
+ "unreadBadgeColor": "primary",
+ "iconColor": "none"
+ },
+ "SessionMenu": {
+ "iconColor": "error"
+ },
+ "Settings": {
+ "iconColor": "none"
+ },
+ "Spacer": {
+ "width": 20
+ },
+ "SystemMonitor": {
+ "compactMode": true,
+ "iconColor": "none",
+ "textColor": "none",
+ "useMonospaceFont": true,
+ "usePadding": false,
+ "showCpuUsage": true,
+ "showCpuCores": false,
+ "showCpuFreq": false,
+ "showCpuTemp": true,
+ "showGpuTemp": false,
+ "showLoadAverage": false,
+ "showMemoryUsage": true,
+ "showMemoryAsPercent": false,
+ "showSwapUsage": false,
+ "showNetworkStats": false,
+ "showDiskUsage": false,
+ "showDiskUsageAsPercent": false,
+ "showDiskAvailable": false,
+ "diskPath": "/"
+ },
+ "Taskbar": {
+ "onlySameOutput": true,
+ "onlyActiveWorkspaces": true,
+ "hideMode": "hidden",
+ "colorizeIcons": false,
+ "showTitle": false,
+ "titleWidth": 120,
+ "showPinnedApps": true,
+ "smartWidth": true,
+ "maxTaskbarWidth": 40,
+ "iconScale": 0.8
+ },
+ "Tray": {
+ "blacklist": [],
+ "colorizeIcons": false,
+ "chevronColor": "none",
+ "pinned": [],
+ "drawerEnabled": true,
+ "hidePassive": false
+ },
+ "VPN": {
+ "displayMode": "onhover",
+ "iconColor": "none",
+ "textColor": "none"
+ },
+ "Network": {
+ "displayMode": "onhover",
+ "iconColor": "none",
+ "textColor": "none"
+ },
+ "NightLight": {
+ "iconColor": "none"
+ },
+ "NoctaliaPerformance": {
+ "iconColor": "none"
+ },
+ "PowerProfile": {
+ "iconColor": "none"
+ },
+ "Workspace": {
+ "labelMode": "index",
+ "followFocusedScreen": false,
+ "hideUnoccupied": false,
+ "characterCount": 2,
+ "showApplications": false,
+ "showApplicationsHover": false,
+ "showLabelsOnlyWhenOccupied": true,
+ "colorizeIcons": false,
+ "unfocusedIconsOpacity": 1.0,
+ "groupedBorderOpacity": 1.0,
+ "enableScrollWheel": true,
+ "iconScale": 0.8,
+ "focusedColor": "primary",
+ "occupiedColor": "secondary",
+ "emptyColor": "secondary",
+ "showBadge": true,
+ "pillSize": 0.6,
+ "fontWeight": "bold"
+ },
+ "Volume": {
+ "displayMode": "onhover",
+ "middleClickCommand": "pwvucontrol || pavucontrol",
+ "iconColor": "none",
+ "textColor": "none"
+ },
+ "WallpaperSelector": {
+ "iconColor": "none"
+ }
+ })
+
+ // Component definitions - these are loaded once at startup
+ property Component activeWindowComponent: Component {
+ ActiveWindow {}
+ }
+ property Component audioVisualizerComponent: Component {
+ AudioVisualizer {}
+ }
+ property Component batteryComponent: Component {
+ Battery {}
+ }
+ property Component bluetoothComponent: Component {
+ Bluetooth {}
+ }
+ property Component brightnessComponent: Component {
+ Brightness {}
+ }
+ property Component clockComponent: Component {
+ Clock {}
+ }
+ property Component customButtonComponent: Component {
+ CustomButton {}
+ }
+ property Component darkModeComponent: Component {
+ DarkMode {}
+ }
+ property Component keyboardLayoutComponent: Component {
+ KeyboardLayout {}
+ }
+ property Component keepAwakeComponent: Component {
+ KeepAwake {}
+ }
+ property Component lockKeysComponent: Component {
+ LockKeys {}
+ }
+ property Component launcherComponent: Component {
+ Launcher {}
+ }
+ property Component mediaMiniComponent: Component {
+ MediaMini {}
+ }
+ property Component microphoneComponent: Component {
+ Microphone {}
+ }
+ property Component nightLightComponent: Component {
+ NightLight {}
+ }
+ property Component noctaliaPerformanceComponent: Component {
+ NoctaliaPerformance {}
+ }
+ property Component notificationHistoryComponent: Component {
+ NotificationHistory {}
+ }
+ property Component powerProfileComponent: Component {
+ PowerProfile {}
+ }
+ property Component sessionMenuComponent: Component {
+ SessionMenu {}
+ }
+ property Component settingsComponent: Component {
+ Settings {}
+ }
+ property Component controlCenterComponent: Component {
+ ControlCenter {}
+ }
+ property Component spacerComponent: Component {
+ Spacer {}
+ }
+ property Component systemMonitorComponent: Component {
+ SystemMonitor {}
+ }
+ property Component trayComponent: Component {
+ Tray {}
+ }
+ property Component volumeComponent: Component {
+ Volume {}
+ }
+ property Component vpnComponent: Component {
+ VPN {}
+ }
+ property Component networkComponent: Component {
+ Network {}
+ }
+ property Component wallpaperSelectorComponent: Component {
+ WallpaperSelector {}
+ }
+ property Component workspaceComponent: Component {
+ Workspace {}
+ }
+ property Component taskbarComponent: Component {
+ Taskbar {}
+ }
+ function init() {
+ Logger.i("BarWidgetRegistry", "Service started");
+ }
+
+ // ------------------------------
+ // Helper function to get widget component by name
+ function getWidget(id) {
+ return widgets[id] || null;
+ }
+
+ // Helper function to check if widget exists
+ function hasWidget(id) {
+ return id in widgets;
+ }
+
+ // Get list of available widget id
+ function getAvailableWidgets() {
+ return Object.keys(widgets);
+ }
+
+ // Helper function to check if widget has user settings
+ function widgetHasUserSettings(id) {
+ return widgetMetadata[id] !== undefined;
+ }
+
+ // ------------------------------
+ // Plugin widget registration
+
+ // Track plugin widgets separately
+ property var pluginWidgets: ({})
+ property var pluginWidgetMetadata: ({})
+
+ // Register a plugin widget
+ function registerPluginWidget(pluginId, component, metadata) {
+ if (!pluginId || !component) {
+ Logger.e("BarWidgetRegistry", "Cannot register plugin widget: invalid parameters");
+ return false;
+ }
+
+ // Add plugin: prefix to avoid conflicts with core widgets
+ var widgetId = "plugin:" + pluginId;
+
+ pluginWidgets[widgetId] = component;
+ pluginWidgetMetadata[widgetId] = metadata || {};
+
+ // Also add to main widgets object for unified access
+ widgets[widgetId] = component;
+ widgetMetadata[widgetId] = metadata || {};
+
+ Logger.i("BarWidgetRegistry", "Registered plugin widget:", widgetId);
+ root.pluginWidgetRegistryUpdated();
+ return true;
+ }
+
+ // Unregister a plugin widget
+ function unregisterPluginWidget(pluginId) {
+ var widgetId = "plugin:" + pluginId;
+
+ if (!pluginWidgets[widgetId]) {
+ Logger.w("BarWidgetRegistry", "Plugin widget not registered:", widgetId);
+ return false;
+ }
+
+ delete pluginWidgets[widgetId];
+ delete pluginWidgetMetadata[widgetId];
+ delete widgets[widgetId];
+ delete widgetMetadata[widgetId];
+
+ Logger.i("BarWidgetRegistry", "Unregistered plugin widget:", widgetId);
+ root.pluginWidgetRegistryUpdated();
+ return true;
+ }
+
+ // Check if a widget is a plugin widget
+ function isPluginWidget(id) {
+ return id.startsWith("plugin:");
+ }
+
+ property var cpuIntensiveWidgets: ["AudioVisualizer"]
+
+ function isCpuIntensive(id) {
+ if (pluginWidgetMetadata[id]?.cpuIntensive)
+ return true;
+ return cpuIntensiveWidgets.indexOf(id) >= 0;
+ }
+
+ // Get list of plugin widget IDs
+ function getPluginWidgets() {
+ return Object.keys(pluginWidgets);
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/UI/ControlCenterWidgetRegistry.qml b/arch/.config/quickshell/noctalia-shell/Services/UI/ControlCenterWidgetRegistry.qml
new file mode 100644
index 0000000..52bde99
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/UI/ControlCenterWidgetRegistry.qml
@@ -0,0 +1,163 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import qs.Commons
+import qs.Modules.Panels.ControlCenter.Widgets
+
+Singleton {
+ id: root
+
+ // Widget registry object mapping widget names to components
+ property var widgets: ({
+ "AirplaneMode": airplaneModeComponent,
+ "Bluetooth": bluetoothComponent,
+ "CustomButton": customButtonComponent,
+ "DarkMode": darkModeComponent,
+ "KeepAwake": keepAwakeComponent,
+ "NightLight": nightLightComponent,
+ "Notifications": notificationsComponent,
+ "PowerProfile": powerProfileComponent,
+ "WiFi": networkComponent,
+ "Network": networkComponent,
+ "NoctaliaPerformance": noctaliaPerformanceComponent,
+ "WallpaperSelector": wallpaperSelectorComponent
+ })
+
+ property var widgetMetadata: ({
+ "CustomButton": {
+ "icon": "heart",
+ "onClicked": "",
+ "onRightClicked": "",
+ "onMiddleClicked": "",
+ "stateChecksJson": "[]",
+ "generalTooltipText": "",
+ "enableOnStateLogic": false,
+ "showExecTooltip": true
+ }
+ })
+
+ property var cpuIntensiveWidgets: ["SystemStat"]
+
+ // Component definitions - these are loaded once at startup
+ property Component airplaneModeComponent: Component {
+ AirplaneMode {}
+ }
+ property Component bluetoothComponent: Component {
+ Bluetooth {}
+ }
+ property Component customButtonComponent: Component {
+ CustomButton {}
+ }
+ property Component darkModeComponent: Component {
+ DarkMode {}
+ }
+ property Component keepAwakeComponent: Component {
+ KeepAwake {}
+ }
+ property Component nightLightComponent: Component {
+ NightLight {}
+ }
+ property Component notificationsComponent: Component {
+ Notifications {}
+ }
+ property Component powerProfileComponent: Component {
+ PowerProfile {}
+ }
+ property Component networkComponent: Component {
+ Network {}
+ }
+ property Component noctaliaPerformanceComponent: Component {
+ NoctaliaPerformance {}
+ }
+ property Component wallpaperSelectorComponent: Component {
+ WallpaperSelector {}
+ }
+
+ function init() {
+ Logger.i("ControlCenterWidgetRegistry", "Service started");
+ }
+
+ // ------------------------------
+ // Helper function to get widget component by name
+ function getWidget(id) {
+ return widgets[id] || null;
+ }
+
+ // Helper function to check if widget exists
+ function hasWidget(id) {
+ return id in widgets;
+ }
+
+ // Get list of available widget id
+ function getAvailableWidgets() {
+ return Object.keys(widgets);
+ }
+
+ // Helper function to check if widget has user settings
+ function widgetHasUserSettings(id) {
+ return widgetMetadata[id] !== undefined;
+ }
+
+ // ------------------------------
+ // Plugin widget registration
+
+ // Track plugin widgets separately
+ property var pluginWidgets: ({})
+ property var pluginWidgetMetadata: ({})
+
+ // Register a plugin widget
+ function registerPluginWidget(pluginId, component, metadata) {
+ if (!pluginId || !component) {
+ Logger.e("ControlCenterWidgetRegistry", "Cannot register plugin widget: invalid parameters");
+ return false;
+ }
+
+ // Add plugin: prefix to avoid conflicts with core widgets
+ var widgetId = "plugin:" + pluginId;
+
+ pluginWidgets[widgetId] = component;
+ pluginWidgetMetadata[widgetId] = metadata || {};
+
+ // Also add to main widgets object for unified access
+ widgets[widgetId] = component;
+ widgetMetadata[widgetId] = metadata || {};
+
+ Logger.i("ControlCenterWidgetRegistry", "Registered plugin widget:", widgetId);
+ return true;
+ }
+
+ // Unregister a plugin widget
+ function unregisterPluginWidget(pluginId) {
+ var widgetId = "plugin:" + pluginId;
+
+ if (!pluginWidgets[widgetId]) {
+ Logger.w("ControlCenterWidgetRegistry", "Plugin widget not registered:", widgetId);
+ return false;
+ }
+
+ delete pluginWidgets[widgetId];
+ delete pluginWidgetMetadata[widgetId];
+ delete widgets[widgetId];
+ delete widgetMetadata[widgetId];
+
+ Logger.i("ControlCenterWidgetRegistry", "Unregistered plugin widget:", widgetId);
+ return true;
+ }
+
+ // Check if a widget is a plugin widget
+ function isPluginWidget(id) {
+ return id.startsWith("plugin:");
+ }
+
+ // Get list of plugin widget IDs
+ function getPluginWidgets() {
+ return Object.keys(pluginWidgets);
+ }
+
+ function isCpuIntensive(id) {
+ if (pluginWidgetMetadata[id]?.cpuIntensive)
+ return true;
+ return cpuIntensiveWidgets.indexOf(id) >= 0;
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/UI/DesktopWidgetRegistry.qml b/arch/.config/quickshell/noctalia-shell/Services/UI/DesktopWidgetRegistry.qml
new file mode 100644
index 0000000..f40dcb5
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/UI/DesktopWidgetRegistry.qml
@@ -0,0 +1,346 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import qs.Commons
+import qs.Modules.DesktopWidgets.Widgets
+import qs.Services.Noctalia
+
+Singleton {
+ id: root
+
+ // Transient state - not persisted, resets on shell restart
+ property bool editMode: false
+
+ // Signal emitted when plugin widgets are registered/unregistered
+ signal pluginWidgetRegistryUpdated
+
+ // Component definitions
+ property Component clockComponent: Component {
+ DesktopClock {}
+ }
+ property Component mediaPlayerComponent: Component {
+ DesktopMediaPlayer {}
+ }
+ property Component weatherComponent: Component {
+ DesktopWeather {}
+ }
+ property Component systemStatComponent: Component {
+ DesktopSystemStat {}
+ }
+ property Component audioVisualizerComponent: Component {
+ DesktopAudioVisualizer {}
+ }
+
+ // Widget registry object mapping widget names to components
+ // Created in Component.onCompleted to ensure Components are ready
+ property var widgets: ({})
+
+ // Explicit URLs for core widgets. Inline Component.url returns the parent
+ // file's URL, so we need these for Loader.setSource() with initial properties.
+ // Plugin widgets use their Component.url directly (loaded from actual files).
+ readonly property string _widgetsDir: Quickshell.shellDir + "/Modules/DesktopWidgets/Widgets/"
+ property var widgetUrls: ({})
+
+ Component.onCompleted: {
+ // Initialize widgets object after Components are ready
+ var widgetsObj = {};
+ widgetsObj["Clock"] = clockComponent;
+ widgetsObj["MediaPlayer"] = mediaPlayerComponent;
+ widgetsObj["Weather"] = weatherComponent;
+ widgetsObj["SystemStat"] = systemStatComponent;
+ widgetsObj["AudioVisualizer"] = audioVisualizerComponent;
+ widgets = widgetsObj;
+
+ var urlsObj = {};
+ urlsObj["Clock"] = _widgetsDir + "DesktopClock.qml";
+ urlsObj["MediaPlayer"] = _widgetsDir + "DesktopMediaPlayer.qml";
+ urlsObj["Weather"] = _widgetsDir + "DesktopWeather.qml";
+ urlsObj["SystemStat"] = _widgetsDir + "DesktopSystemStat.qml";
+ urlsObj["AudioVisualizer"] = _widgetsDir + "DesktopAudioVisualizer.qml";
+ widgetUrls = urlsObj;
+
+ Logger.i("DesktopWidgetRegistry", "Service started");
+ }
+
+ property var widgetSettingsMap: ({
+ "Clock": "WidgetSettings/ClockSettings.qml",
+ "MediaPlayer": "WidgetSettings/MediaPlayerSettings.qml",
+ "Weather": "WidgetSettings/WeatherSettings.qml",
+ "SystemStat": "WidgetSettings/SystemStatSettings.qml",
+ "AudioVisualizer": "WidgetSettings/AudioVisualizerSettings.qml"
+ })
+
+ property var widgetMetadata: ({
+ "Clock": {
+ "showBackground": true,
+ "roundedCorners": true,
+ "clockStyle": "digital",
+ "clockColor": "none",
+ "useCustomFont": false,
+ "customFont": "",
+ "format": "HH:mm\\nd MMMM yyyy"
+ },
+ "MediaPlayer": {
+ "showBackground": true,
+ "roundedCorners": true,
+ "visualizerType": "linear",
+ "hideMode": "visible",
+ "showButtons": true,
+ "showAlbumArt": true,
+ "showVisualizer": true
+ },
+ "Weather": {
+ "showBackground": true,
+ "roundedCorners": true
+ },
+ "SystemStat": {
+ "showBackground": true,
+ "roundedCorners": true,
+ "statType": "CPU",
+ "diskPath": "/",
+ "layout": "bottom"
+ },
+ "AudioVisualizer": {
+ "showBackground": true,
+ "roundedCorners": true,
+ "width": 320,
+ "height": 72,
+ "visualizerType": "linear",
+ "hideWhenIdle": false,
+ "colorName": "primary"
+ }
+ })
+
+ property var cpuIntensiveWidgets: ["SystemStat", "AudioVisualizer"]
+
+ // Plugin widget storage (mirroring BarWidgetRegistry pattern)
+ property var pluginWidgets: ({})
+ property var pluginWidgetMetadata: ({})
+
+ function init() {
+ Logger.i("DesktopWidgetRegistry", "Service started");
+ }
+
+ // Helper function to get widget component by name
+ function getWidget(id) {
+ return widgets[id] || null;
+ }
+
+ // Helper function to check if widget exists
+ function hasWidget(id) {
+ return id in widgets;
+ }
+
+ // Get list of available widget ids
+ function getAvailableWidgets() {
+ var keys = Object.keys(widgets);
+ Logger.d("DesktopWidgetRegistry", "getAvailableWidgets() called, returning:", keys);
+ return keys;
+ }
+
+ // Helper function to check if widget has user settings
+ function widgetHasUserSettings(id) {
+ return widgetMetadata[id] !== undefined;
+ }
+
+ function isCpuIntensive(id) {
+ if (pluginWidgetMetadata[id]?.cpuIntensive)
+ return true;
+ return cpuIntensiveWidgets.indexOf(id) >= 0;
+ }
+
+ // Check if a widget is a plugin widget
+ function isPluginWidget(id) {
+ return id.startsWith("plugin:");
+ }
+
+ // Get list of plugin widget IDs
+ function getPluginWidgets() {
+ return Object.keys(pluginWidgets);
+ }
+
+ // Get display name for a widget ID
+ function getWidgetDisplayName(widgetId) {
+ if (widgetId.startsWith("plugin:")) {
+ var pluginId = widgetId.replace("plugin:", "");
+ var manifest = PluginRegistry.getPluginManifest(pluginId);
+ return manifest ? manifest.name : pluginId;
+ }
+ // Core widgets - return as-is (Clock, MediaPlayer, Weather, SystemStat)
+ return widgetId;
+ }
+
+ // Register a plugin desktop widget
+ function registerPluginWidget(pluginId, component, metadata) {
+ if (!pluginId || !component) {
+ Logger.e("DesktopWidgetRegistry", "Cannot register plugin widget: invalid parameters");
+ return false;
+ }
+
+ var widgetId = "plugin:" + pluginId;
+
+ // Create new objects to trigger QML property change detection
+ var newPluginWidgets = Object.assign({}, pluginWidgets);
+ newPluginWidgets[widgetId] = component;
+ pluginWidgets = newPluginWidgets;
+
+ var newPluginMetadata = Object.assign({}, pluginWidgetMetadata);
+ newPluginMetadata[widgetId] = metadata || {};
+ pluginWidgetMetadata = newPluginMetadata;
+
+ // Also add to main widgets object for unified access - reassign to trigger change
+ var newWidgets = Object.assign({}, widgets);
+ newWidgets[widgetId] = component;
+ widgets = newWidgets;
+
+ var newMetadata = Object.assign({}, widgetMetadata);
+ newMetadata[widgetId] = Object.assign({}, {
+ "showBackground": true
+ }, metadata || {});
+ widgetMetadata = newMetadata;
+
+ Logger.i("DesktopWidgetRegistry", "Registered plugin widget:", widgetId);
+ root.pluginWidgetRegistryUpdated();
+ return true;
+ }
+
+ // Unregister a plugin desktop widget
+ function unregisterPluginWidget(pluginId) {
+ var widgetId = "plugin:" + pluginId;
+
+ if (!pluginWidgets[widgetId]) {
+ Logger.w("DesktopWidgetRegistry", "Plugin widget not registered:", widgetId);
+ return false;
+ }
+
+ // Create new objects without the widget to trigger QML property change detection
+ var newPluginWidgets = Object.assign({}, pluginWidgets);
+ delete newPluginWidgets[widgetId];
+ pluginWidgets = newPluginWidgets;
+
+ var newPluginMetadata = Object.assign({}, pluginWidgetMetadata);
+ delete newPluginMetadata[widgetId];
+ pluginWidgetMetadata = newPluginMetadata;
+
+ var newWidgets = Object.assign({}, widgets);
+ delete newWidgets[widgetId];
+ widgets = newWidgets;
+
+ var newMetadata = Object.assign({}, widgetMetadata);
+ delete newMetadata[widgetId];
+ widgetMetadata = newMetadata;
+
+ Logger.i("DesktopWidgetRegistry", "Unregistered plugin widget:", widgetId);
+ root.pluginWidgetRegistryUpdated();
+ return true;
+ }
+
+ function updateWidgetData(monitorName, widgetIndex, properties) {
+ if (widgetIndex < 0 || !monitorName) {
+ return;
+ }
+
+ var monitorWidgets = Settings.data.desktopWidgets.monitorWidgets || [];
+ var newMonitorWidgets = monitorWidgets.slice();
+
+ for (var i = 0; i < newMonitorWidgets.length; i++) {
+ if (newMonitorWidgets[i].name === monitorName) {
+ var widgets = (newMonitorWidgets[i].widgets || []).slice();
+ if (widgetIndex < widgets.length) {
+ widgets[widgetIndex] = Object.assign({}, widgets[widgetIndex], properties);
+ newMonitorWidgets[i] = Object.assign({}, newMonitorWidgets[i], {
+ "widgets": widgets
+ });
+ Settings.data.desktopWidgets.monitorWidgets = newMonitorWidgets;
+ }
+ break;
+ }
+ }
+ }
+
+ property var currentSettingsDialog: null
+
+ function openWidgetSettings(screen, widgetIndex, widgetId, widgetData) {
+ if (!widgetId || !screen) {
+ return;
+ }
+
+ if (root.currentSettingsDialog) {
+ root.currentSettingsDialog.close();
+ root.currentSettingsDialog.destroy();
+ root.currentSettingsDialog = null;
+ }
+
+ var hasSettings = false;
+ if (root.isPluginWidget(widgetId)) {
+ var pluginId = widgetId.replace("plugin:", "");
+ var manifest = PluginRegistry.getPluginManifest(pluginId);
+ if (manifest && manifest.entryPoints && (manifest.entryPoints.desktopWidgetSettings || manifest.entryPoints.settings)) {
+ hasSettings = true;
+ }
+ } else {
+ hasSettings = root.widgetSettingsMap[widgetId] !== undefined;
+ }
+
+ if (!hasSettings) {
+ Logger.w("DesktopWidgetRegistry", "Widget does not have settings:", widgetId);
+ return;
+ }
+
+ var popupMenuWindow = PanelService.getPopupMenuWindow(screen);
+ if (!popupMenuWindow) {
+ Logger.e("DesktopWidgetRegistry", "No popup menu window found for screen");
+ return;
+ }
+
+ if (popupMenuWindow.hideDynamicMenu) {
+ popupMenuWindow.hideDynamicMenu();
+ }
+
+ var component = Qt.createComponent(Quickshell.shellDir + "/Modules/Panels/Settings/DesktopWidgets/DesktopWidgetSettingsDialog.qml");
+
+ function instantiateAndOpen() {
+ var dialog = component.createObject(popupMenuWindow.dialogParent, {
+ "widgetIndex": widgetIndex,
+ "widgetData": widgetData,
+ "widgetId": widgetId,
+ "sectionId": screen.name,
+ "screen": screen
+ });
+
+ if (dialog) {
+ root.currentSettingsDialog = dialog;
+ dialog.updateWidgetSettings.connect((sec, idx, settings) => {
+ root.updateWidgetData(sec, idx, settings);
+ });
+ popupMenuWindow.hasDialog = true;
+ dialog.closed.connect(() => {
+ popupMenuWindow.hasDialog = false;
+ popupMenuWindow.close();
+ if (root.currentSettingsDialog === dialog) {
+ root.currentSettingsDialog = null;
+ }
+ dialog.destroy();
+ });
+ dialog.open();
+ } else {
+ Logger.e("DesktopWidgetRegistry", "Failed to create widget settings dialog");
+ }
+ }
+
+ if (component.status === Component.Ready) {
+ instantiateAndOpen();
+ } else if (component.status === Component.Error) {
+ Logger.e("DesktopWidgetRegistry", "Error loading settings dialog component:", component.errorString());
+ } else {
+ component.statusChanged.connect(() => {
+ if (component.status === Component.Ready) {
+ instantiateAndOpen();
+ } else if (component.status === Component.Error) {
+ Logger.e("DesktopWidgetRegistry", "Error loading settings dialog component:", component.errorString());
+ }
+ });
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/UI/ImageCacheService.qml b/arch/.config/quickshell/noctalia-shell/Services/UI/ImageCacheService.qml
new file mode 100644
index 0000000..47d6976
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/UI/ImageCacheService.qml
@@ -0,0 +1,784 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import Quickshell.Wayland
+import "../../Helpers/sha256.js" as Checksum
+import qs.Commons
+
+Singleton {
+ id: root
+
+ // -------------------------------------------------
+ // Public Properties
+ // -------------------------------------------------
+ property bool imageMagickAvailable: false
+ property bool initialized: false
+
+ // Cache directories
+ readonly property string baseDir: Settings.cacheDir + "images/"
+ readonly property string wpThumbDir: baseDir + "wallpapers/thumbnails/"
+ readonly property string wpLargeDir: baseDir + "wallpapers/large/"
+ readonly property string notificationsDir: baseDir + "notifications/"
+ readonly property string contributorsDir: baseDir + "contributors/"
+
+ // Supported image formats - extended list when ImageMagick is available
+ readonly property var basicImageFilters: ["*.jpg", "*.jpeg", "*.png", "*.gif", "*.bmp"]
+ readonly property var extendedImageFilters: ["*.jpg", "*.jpeg", "*.png", "*.gif", "*.bmp", "*.webp", "*.avif", "*.heic", "*.heif", "*.tiff", "*.tif", "*.pnm", "*.pgm", "*.ppm", "*.pbm", "*.svg", "*.svgz", "*.ico", "*.icns", "*.jxl", "*.jp2", "*.j2k", "*.exr", "*.hdr", "*.dds", "*.tga"]
+ readonly property var imageFilters: imageMagickAvailable ? extendedImageFilters : basicImageFilters
+
+ // Check if a file format needs conversion (not natively supported by Qt)
+ function needsConversion(filePath) {
+ const ext = "*." + filePath.toLowerCase().split('.').pop();
+ return !basicImageFilters.includes(ext);
+ }
+
+ // -------------------------------------------------
+ // Internal State
+ // -------------------------------------------------
+ property var pendingRequests: ({})
+ property var fallbackQueue: []
+ property bool fallbackProcessing: false
+
+ // Process queues to prevent "too many open files" errors
+ property var utilityProcessQueue: []
+ property int runningUtilityProcesses: 0
+ readonly property int maxConcurrentUtilityProcesses: 16
+
+ // Separate queue for heavy ImageMagick processing (lower concurrency)
+ property var imageMagickQueue: []
+ property int runningImageMagickProcesses: 0
+ readonly property int maxConcurrentImageMagickProcesses: 4
+
+ // -------------------------------------------------
+ // Signals
+ // -------------------------------------------------
+ signal cacheHit(string cacheKey, string cachedPath)
+ signal cacheMiss(string cacheKey)
+ signal processingComplete(string cacheKey, string cachedPath)
+ signal processingFailed(string cacheKey, string error)
+
+ // -------------------------------------------------
+ // Initialization
+ // -------------------------------------------------
+ function init() {
+ Logger.i("ImageCache", "Service started");
+ createDirectories();
+ cleanupOldCache();
+ checkMagickProcess.running = true;
+ }
+
+ function createDirectories() {
+ Quickshell.execDetached(["mkdir", "-p", wpThumbDir]);
+ Quickshell.execDetached(["mkdir", "-p", wpLargeDir]);
+ Quickshell.execDetached(["mkdir", "-p", notificationsDir]);
+ Quickshell.execDetached(["mkdir", "-p", contributorsDir]);
+ }
+
+ function cleanupOldCache() {
+ const dirs = [wpThumbDir, wpLargeDir, notificationsDir, contributorsDir];
+ dirs.forEach(function (dir) {
+ Quickshell.execDetached(["find", dir, "-type", "f", "-mtime", "+15", "-delete"]);
+ });
+ Logger.d("ImageCache", "Cleanup triggered for files older than 15 days");
+ }
+
+ // -------------------------------------------------
+ // Public API: Get Thumbnail (384x384)
+ // -------------------------------------------------
+ function getThumbnail(sourcePath, callback) {
+ if (!sourcePath || sourcePath === "") {
+ callback("", false);
+ return;
+ }
+
+ getMtime(sourcePath, function (mtime) {
+ const cacheKey = generateThumbnailKey(sourcePath, mtime);
+ const cachedPath = wpThumbDir + cacheKey + ".png";
+
+ processRequest(cacheKey, cachedPath, sourcePath, callback, function () {
+ if (imageMagickAvailable) {
+ startThumbnailProcessing(sourcePath, cachedPath, cacheKey);
+ } else {
+ queueFallbackProcessing(sourcePath, cachedPath, cacheKey, 384);
+ }
+ });
+ });
+ }
+
+ // -------------------------------------------------
+ // Public API: Get Large Image (scaled to specified dimensions)
+ // -------------------------------------------------
+ function getLarge(sourcePath, width, height, callback) {
+ if (!sourcePath || sourcePath === "") {
+ callback("", false);
+ return;
+ }
+
+ if (Settings.data.wallpaper.useOriginalImages && !needsConversion(sourcePath)) {
+ callback(sourcePath, false);
+ return;
+ }
+
+ if (!imageMagickAvailable) {
+ Logger.d("ImageCache", "ImageMagick not available, using original:", sourcePath);
+ callback(sourcePath, false);
+ return;
+ }
+
+ // Fast dimension check - skip processing if image fits screen AND format is Qt-native
+ getImageDimensions(sourcePath, function (imgWidth, imgHeight) {
+ const fitsScreen = imgWidth > 0 && imgHeight > 0 && imgWidth <= width && imgHeight <= height;
+
+ if (fitsScreen) {
+ // Only skip if format is natively supported by Qt
+ if (!needsConversion(sourcePath)) {
+ Logger.d("ImageCache", `Image ${imgWidth}x${imgHeight} fits screen ${width}x${height}, using original`);
+ callback(sourcePath, false);
+ return;
+ }
+ Logger.d("ImageCache", `Image needs conversion despite fitting screen`);
+ }
+
+ // Use actual image dimensions if it fits (convert without upscaling), otherwise use screen dimensions
+ const targetWidth = fitsScreen ? imgWidth : width;
+ const targetHeight = fitsScreen ? imgHeight : height;
+
+ getMtime(sourcePath, function (mtime) {
+ const cacheKey = generateLargeKey(sourcePath, width, height, mtime);
+ const cachedPath = wpLargeDir + cacheKey + ".png";
+
+ processRequest(cacheKey, cachedPath, sourcePath, callback, function () {
+ startLargeProcessing(sourcePath, cachedPath, targetWidth, targetHeight, cacheKey);
+ });
+ });
+ });
+ }
+
+ // -------------------------------------------------
+ // Public API: Get Notification Icon (64x64)
+ // -------------------------------------------------
+ function getNotificationIcon(imageUri, appName, summary, callback) {
+ if (!imageUri || imageUri === "") {
+ callback("", false);
+ return;
+ }
+
+ // Resolve bare file path for temp check
+ const filePath = imageUri.startsWith("file://") ? imageUri.substring(7) : imageUri;
+
+ // File paths in persistent locations are used directly, not cached
+ if ((imageUri.startsWith("/") || imageUri.startsWith("file://")) && !isTemporaryPath(filePath)) {
+ callback(imageUri, false);
+ return;
+ }
+
+ const cacheKey = generateNotificationKey(imageUri, appName, summary);
+ const cachedPath = notificationsDir + cacheKey + ".png";
+
+ // Temporary file paths are copied to cache before the source is cleaned up
+ if (imageUri.startsWith("/") || imageUri.startsWith("file://")) {
+ processRequest(cacheKey, cachedPath, imageUri, callback, function () {
+ copyTempFileToCache(filePath, cachedPath, cacheKey);
+ });
+ return;
+ }
+
+ processRequest(cacheKey, cachedPath, imageUri, callback, function () {
+ // Notifications always use Qt fallback (image:// URIs can't be read by ImageMagick)
+ queueFallbackProcessing(imageUri, cachedPath, cacheKey, 64);
+ });
+ }
+
+ // Check if a path is in a temporary directory that may be cleaned up
+ function isTemporaryPath(path) {
+ return path.startsWith("/tmp/");
+ }
+
+ // Copy a temporary file to the cache directory
+ function copyTempFileToCache(sourcePath, destPath, cacheKey) {
+ const srcEsc = sourcePath.replace(/'/g, "'\\''");
+ const dstEsc = destPath.replace(/'/g, "'\\''");
+
+ const processString = `
+ import QtQuick
+ import Quickshell.Io
+ Process {
+ command: ["cp", "--", "${srcEsc}", "${dstEsc}"]
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+ }
+ `;
+
+ queueUtilityProcess({
+ name: "CopyTempFile_" + cacheKey,
+ processString: processString,
+ onComplete: function (exitCode) {
+ if (exitCode === 0) {
+ Logger.d("ImageCache", "Temp file cached:", destPath);
+ notifyCallbacks(cacheKey, destPath, true);
+ } else {
+ Logger.w("ImageCache", "Failed to cache temp file:", sourcePath);
+ notifyCallbacks(cacheKey, "", false);
+ }
+ },
+ onError: function () {
+ Logger.e("ImageCache", "Error caching temp file:", sourcePath);
+ notifyCallbacks(cacheKey, "", false);
+ }
+ });
+ }
+
+ // -------------------------------------------------
+ // Public API: Get Circular Avatar (256x256)
+ // -------------------------------------------------
+ function getCircularAvatar(url, username, callback) {
+ if (!url || !username) {
+ callback("", false);
+ return;
+ }
+
+ const cacheKey = username;
+ const cachedPath = contributorsDir + username + "_circular.png";
+
+ processRequest(cacheKey, cachedPath, url, callback, function () {
+ if (imageMagickAvailable) {
+ downloadAndProcessAvatar(url, username, cachedPath, cacheKey);
+ } else {
+ // No fallback for circular avatars without ImageMagick
+ Logger.w("ImageCache", "Circular avatars require ImageMagick");
+ notifyCallbacks(cacheKey, "", false);
+ }
+ });
+ }
+
+ // -------------------------------------------------
+ // Cache Key Generation
+ // -------------------------------------------------
+ function generateThumbnailKey(sourcePath, mtime) {
+ const keyString = sourcePath + "@384x384@" + (mtime || "unknown");
+ return Checksum.sha256(keyString);
+ }
+
+ function generateLargeKey(sourcePath, width, height, mtime) {
+ const keyString = sourcePath + "@" + width + "x" + height + "@" + (mtime || "unknown");
+ return Checksum.sha256(keyString);
+ }
+
+ function generateNotificationKey(imageUri, appName, summary) {
+ if (imageUri.startsWith("image://qsimage/")) {
+ return Checksum.sha256(appName + "|" + summary);
+ }
+ return Checksum.sha256(imageUri);
+ }
+
+ // -------------------------------------------------
+ // Request Processing (with coalescing)
+ // -------------------------------------------------
+ function processRequest(cacheKey, cachedPath, sourcePath, callback, processFn) {
+ // Check if already processing this request
+ if (pendingRequests[cacheKey]) {
+ pendingRequests[cacheKey].callbacks.push(callback);
+ Logger.d("ImageCache", "Coalescing request for:", cacheKey);
+ return;
+ }
+
+ // Check cache first
+ checkFileExists(cachedPath, function (exists) {
+ if (exists) {
+ Logger.d("ImageCache", "Cache hit:", cachedPath);
+ callback(cachedPath, true);
+ cacheHit(cacheKey, cachedPath);
+ return;
+ }
+
+ // Re-check pendingRequests (race condition fix)
+ if (pendingRequests[cacheKey]) {
+ pendingRequests[cacheKey].callbacks.push(callback);
+ return;
+ }
+
+ // Start new processing
+ Logger.d("ImageCache", "Cache miss, processing:", sourcePath);
+ cacheMiss(cacheKey);
+ pendingRequests[cacheKey] = {
+ callbacks: [callback],
+ sourcePath: sourcePath
+ };
+
+ processFn();
+ });
+ }
+
+ function notifyCallbacks(cacheKey, path, success) {
+ const request = pendingRequests[cacheKey];
+ if (request) {
+ request.callbacks.forEach(function (cb) {
+ cb(path, success);
+ });
+ delete pendingRequests[cacheKey];
+ }
+
+ if (success) {
+ processingComplete(cacheKey, path);
+ } else {
+ processingFailed(cacheKey, "Processing failed");
+ }
+ }
+
+ // -------------------------------------------------
+ // ImageMagick Processing: Thumbnail
+ // -------------------------------------------------
+ function startThumbnailProcessing(sourcePath, outputPath, cacheKey) {
+ const srcEsc = sourcePath.replace(/'/g, "'\\''");
+ const dstEsc = outputPath.replace(/'/g, "'\\''");
+
+ // Use Lanczos filter for high-quality downscaling, subtle unsharp mask, and PNG for lossless output
+ const command = `magick '${srcEsc}' -auto-orient -filter Lanczos -resize '384x384^' -gravity center -extent 384x384 -unsharp 0x0.5 '${dstEsc}'`;
+
+ runProcess(command, cacheKey, outputPath, sourcePath);
+ }
+
+ // -------------------------------------------------
+ // ImageMagick Processing: Large
+ // -------------------------------------------------
+ function startLargeProcessing(sourcePath, outputPath, width, height, cacheKey) {
+ const srcEsc = sourcePath.replace(/'/g, "'\\''");
+ const dstEsc = outputPath.replace(/'/g, "'\\''");
+
+ // Use Lanczos filter for high-quality downscaling and PNG for lossless output
+ const command = `magick '${srcEsc}' -auto-orient -filter Lanczos -resize '${width}x${height}^' '${dstEsc}'`;
+
+ runProcess(command, cacheKey, outputPath, sourcePath);
+ }
+
+ // -------------------------------------------------
+ // ImageMagick Processing: Circular Avatar
+ // -------------------------------------------------
+ function downloadAndProcessAvatar(url, username, outputPath, cacheKey) {
+ const tempPath = contributorsDir + username + "_temp.png";
+ const tempEsc = tempPath.replace(/'/g, "'\\''");
+ const urlEsc = url.replace(/'/g, "'\\''");
+
+ // Download first (uses utility queue since curl/wget are lightweight)
+ const downloadCmd = `curl -L -s -o '${tempEsc}' '${urlEsc}' || wget -q -O '${tempEsc}' '${urlEsc}'`;
+
+ const processString = `
+ import QtQuick
+ import Quickshell.Io
+ Process {
+ command: ["sh", "-c", "${downloadCmd.replace(/"/g, '\\"')}"]
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+ }
+ `;
+
+ queueUtilityProcess({
+ name: "DownloadProcess_" + cacheKey,
+ processString: processString,
+ onComplete: function (exitCode) {
+ if (exitCode !== 0) {
+ Logger.e("ImageCache", "Failed to download avatar for", username);
+ notifyCallbacks(cacheKey, "", false);
+ return;
+ }
+ // Now process with ImageMagick
+ processCircularAvatar(tempPath, outputPath, cacheKey);
+ },
+ onError: function () {
+ notifyCallbacks(cacheKey, "", false);
+ }
+ });
+ }
+
+ function processCircularAvatar(inputPath, outputPath, cacheKey) {
+ const srcEsc = inputPath.replace(/'/g, "'\\''");
+ const dstEsc = outputPath.replace(/'/g, "'\\''");
+
+ // ImageMagick command for circular crop with alpha
+ const command = `magick '${srcEsc}' -resize 256x256^ -gravity center -extent 256x256 -alpha set \\( +clone -channel A -evaluate set 0 +channel -fill white -draw 'circle 128,128 128,0' \\) -compose DstIn -composite '${dstEsc}'`;
+
+ queueImageMagickProcess({
+ command: command,
+ cacheKey: cacheKey,
+ onComplete: function (exitCode) {
+ // Clean up temp file
+ Quickshell.execDetached(["rm", "-f", inputPath]);
+
+ if (exitCode !== 0) {
+ Logger.e("ImageCache", "Failed to create circular avatar");
+ notifyCallbacks(cacheKey, "", false);
+ } else {
+ Logger.d("ImageCache", "Circular avatar created:", outputPath);
+ notifyCallbacks(cacheKey, outputPath, true);
+ }
+ },
+ onError: function () {
+ Quickshell.execDetached(["rm", "-f", inputPath]);
+ notifyCallbacks(cacheKey, "", false);
+ }
+ });
+ }
+
+ // -------------------------------------------------
+ // Generic Process Runner (with queue for ImageMagick)
+ // -------------------------------------------------
+
+ // Queue an ImageMagick process and run it when a slot is available
+ function queueImageMagickProcess(request) {
+ imageMagickQueue.push(request);
+ processImageMagickQueue();
+ }
+
+ // Process queued ImageMagick requests up to the concurrency limit
+ function processImageMagickQueue() {
+ while (runningImageMagickProcesses < maxConcurrentImageMagickProcesses && imageMagickQueue.length > 0) {
+ const request = imageMagickQueue.shift();
+ runImageMagickProcess(request);
+ }
+ }
+
+ // Actually run an ImageMagick process
+ function runImageMagickProcess(request) {
+ runningImageMagickProcesses++;
+
+ const processString = `
+ import QtQuick
+ import Quickshell.Io
+ Process {
+ command: ["sh", "-c", ""]
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+ }
+ `;
+
+ try {
+ const processObj = Qt.createQmlObject(processString, root, "ImageProcess_" + request.cacheKey);
+ processObj.command = ["sh", "-c", request.command];
+
+ processObj.exited.connect(function (exitCode) {
+ processObj.destroy();
+ runningImageMagickProcesses--;
+ request.onComplete(exitCode, processObj);
+ processImageMagickQueue();
+ });
+
+ processObj.running = true;
+ } catch (e) {
+ Logger.e("ImageCache", "Failed to create process:", e);
+ runningImageMagickProcesses--;
+ request.onError(e);
+ processImageMagickQueue();
+ }
+ }
+
+ function runProcess(command, cacheKey, outputPath, sourcePath) {
+ queueImageMagickProcess({
+ command: command,
+ cacheKey: cacheKey,
+ onComplete: function (exitCode, proc) {
+ if (exitCode !== 0) {
+ const stderrText = proc.stderr.text || "";
+ Logger.e("ImageCache", "Processing failed:", stderrText);
+ notifyCallbacks(cacheKey, sourcePath, false);
+ } else {
+ Logger.d("ImageCache", "Processing complete:", outputPath);
+ notifyCallbacks(cacheKey, outputPath, true);
+ }
+ },
+ onError: function () {
+ notifyCallbacks(cacheKey, sourcePath, false);
+ }
+ });
+ }
+
+ // -------------------------------------------------
+ // Qt Fallback Renderer
+ // -------------------------------------------------
+ PanelWindow {
+ id: fallbackRenderer
+ implicitWidth: 0
+ implicitHeight: 0
+ WlrLayershell.exclusionMode: ExclusionMode.Ignore
+ WlrLayershell.namespace: "noctalia-image-cache-renderer"
+ color: "transparent"
+ mask: Region {}
+
+ Image {
+ id: fallbackImage
+ property string cacheKey: ""
+ property string destPath: ""
+ property int targetSize: 256
+
+ width: targetSize
+ height: targetSize
+ visible: true
+ cache: false
+ asynchronous: true
+ fillMode: Image.PreserveAspectCrop
+ mipmap: true
+ antialiasing: true
+
+ onStatusChanged: {
+ if (!cacheKey)
+ return;
+
+ if (status === Image.Ready) {
+ grabToImage(function (result) {
+ if (result.saveToFile(destPath)) {
+ Logger.d("ImageCache", "Fallback cache created:", destPath);
+ root.notifyCallbacks(cacheKey, destPath, true);
+ } else {
+ Logger.e("ImageCache", "Failed to save fallback cache");
+ root.notifyCallbacks(cacheKey, "", false);
+ }
+ processNextFallback();
+ });
+ } else if (status === Image.Error) {
+ Logger.e("ImageCache", "Fallback image load failed");
+ root.notifyCallbacks(cacheKey, "", false);
+ processNextFallback();
+ }
+ }
+
+ function processNextFallback() {
+ cacheKey = "";
+ destPath = "";
+ source = "";
+
+ if (fallbackQueue.length > 0) {
+ const next = fallbackQueue.shift();
+ cacheKey = next.cacheKey;
+ destPath = next.destPath;
+ targetSize = next.size;
+ source = next.sourcePath;
+ } else {
+ fallbackProcessing = false;
+ }
+ }
+ }
+ }
+
+ function queueFallbackProcessing(sourcePath, destPath, cacheKey, size) {
+ fallbackQueue.push({
+ sourcePath: sourcePath,
+ destPath: destPath,
+ cacheKey: cacheKey,
+ size: size
+ });
+
+ if (!fallbackProcessing) {
+ fallbackProcessing = true;
+ const item = fallbackQueue.shift();
+ fallbackImage.cacheKey = item.cacheKey;
+ fallbackImage.destPath = item.destPath;
+ fallbackImage.targetSize = item.size;
+ fallbackImage.source = item.sourcePath;
+ }
+ }
+
+ // -------------------------------------------------
+ // Utility Functions (with process queue to prevent fd exhaustion)
+ // -------------------------------------------------
+
+ // Queue a utility process and run it when a slot is available
+ function queueUtilityProcess(request) {
+ utilityProcessQueue.push(request);
+ processUtilityQueue();
+ }
+
+ // Process queued utility requests up to the concurrency limit
+ function processUtilityQueue() {
+ while (runningUtilityProcesses < maxConcurrentUtilityProcesses && utilityProcessQueue.length > 0) {
+ const request = utilityProcessQueue.shift();
+ runUtilityProcess(request);
+ }
+ }
+
+ // Actually run a utility process
+ function runUtilityProcess(request) {
+ runningUtilityProcesses++;
+
+ try {
+ const processObj = Qt.createQmlObject(request.processString, root, request.name);
+
+ processObj.exited.connect(function (exitCode) {
+ processObj.destroy();
+ runningUtilityProcesses--;
+ request.onComplete(exitCode, processObj);
+ processUtilityQueue();
+ });
+
+ processObj.running = true;
+ } catch (e) {
+ Logger.e("ImageCache", "Failed to create " + request.name + ":", e);
+ runningUtilityProcesses--;
+ request.onError(e);
+ processUtilityQueue();
+ }
+ }
+
+ function getMtime(filePath, callback) {
+ const pathEsc = filePath.replace(/'/g, "'\\''");
+ const processString = `
+ import QtQuick
+ import Quickshell.Io
+ Process {
+ command: ["stat", "-c", "%Y", "${pathEsc}"]
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+ }
+ `;
+
+ queueUtilityProcess({
+ name: "MtimeProcess",
+ processString: processString,
+ onComplete: function (exitCode, proc) {
+ const mtime = exitCode === 0 ? proc.stdout.text.trim() : "";
+ callback(mtime);
+ },
+ onError: function () {
+ callback("");
+ }
+ });
+ }
+
+ function checkFileExists(filePath, callback) {
+ const pathEsc = filePath.replace(/'/g, "'\\''");
+ const processString = `
+ import QtQuick
+ import Quickshell.Io
+ Process {
+ command: ["test", "-f", "${pathEsc}"]
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+ }
+ `;
+
+ queueUtilityProcess({
+ name: "FileExistsProcess",
+ processString: processString,
+ onComplete: function (exitCode) {
+ callback(exitCode === 0);
+ },
+ onError: function () {
+ callback(false);
+ }
+ });
+ }
+
+ function getImageDimensions(filePath, callback) {
+ const pathEsc = filePath.replace(/'/g, "'\\''");
+ const processString = `
+ import QtQuick
+ import Quickshell.Io
+ Process {
+ command: ["identify", "-ping", "-format", "%w %h", "${pathEsc}[0]"]
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+ }
+ `;
+
+ queueUtilityProcess({
+ name: "IdentifyProcess",
+ processString: processString,
+ onComplete: function (exitCode, proc) {
+ let width = 0, height = 0;
+ if (exitCode === 0) {
+ const parts = proc.stdout.text.trim().split(" ");
+ if (parts.length >= 2) {
+ width = parseInt(parts[0], 10) || 0;
+ height = parseInt(parts[1], 10) || 0;
+ }
+ }
+ callback(width, height);
+ },
+ onError: function () {
+ callback(0, 0);
+ }
+ });
+ }
+
+ // -------------------------------------------------
+ // Cache Invalidation
+ // -------------------------------------------------
+ function invalidateThumbnail(sourcePath) {
+ Logger.i("ImageCache", "Invalidating thumbnail for:", sourcePath);
+ // Since cache keys include hash, we'd need to track mappings
+ // For simplicity, clear all thumbnails
+ clearThumbnails();
+ }
+
+ function invalidateLarge(sourcePath) {
+ Logger.i("ImageCache", "Invalidating large for:", sourcePath);
+ clearLarge();
+ }
+
+ function invalidateNotification(imageId) {
+ const path = notificationsDir + imageId + ".png";
+ Quickshell.execDetached(["rm", "-f", path]);
+ }
+
+ function invalidateAvatar(username) {
+ const path = contributorsDir + username + "_circular.png";
+ Quickshell.execDetached(["rm", "-f", path]);
+ }
+
+ // -------------------------------------------------
+ // Clear Cache Functions
+ // -------------------------------------------------
+ function clearAll() {
+ Logger.i("ImageCache", "Clearing all cache");
+ clearThumbnails();
+ clearLarge();
+ clearNotifications();
+ clearContributors();
+ }
+
+ function clearThumbnails() {
+ Logger.i("ImageCache", "Clearing thumbnails cache");
+ Quickshell.execDetached(["rm", "-rf", wpThumbDir]);
+ Quickshell.execDetached(["mkdir", "-p", wpThumbDir]);
+ }
+
+ function clearLarge() {
+ Logger.i("ImageCache", "Clearing large cache");
+ Quickshell.execDetached(["rm", "-rf", wpLargeDir]);
+ Quickshell.execDetached(["mkdir", "-p", wpLargeDir]);
+ }
+
+ function clearNotifications() {
+ Logger.i("ImageCache", "Clearing notifications cache");
+ Quickshell.execDetached(["rm", "-rf", notificationsDir]);
+ Quickshell.execDetached(["mkdir", "-p", notificationsDir]);
+ }
+
+ function clearContributors() {
+ Logger.i("ImageCache", "Clearing contributors cache");
+ Quickshell.execDetached(["rm", "-rf", contributorsDir]);
+ Quickshell.execDetached(["mkdir", "-p", contributorsDir]);
+ }
+
+ // -------------------------------------------------
+ // ImageMagick Detection
+ // -------------------------------------------------
+ Process {
+ id: checkMagickProcess
+ command: ["sh", "-c", "command -v magick"]
+ running: false
+
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+
+ onExited: function (exitCode) {
+ root.imageMagickAvailable = (exitCode === 0);
+ root.initialized = true;
+ if (root.imageMagickAvailable) {
+ Logger.i("ImageCache", "ImageMagick available");
+ } else {
+ Logger.w("ImageCache", "ImageMagick not found, using Qt fallback");
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/UI/LauncherProviderRegistry.qml b/arch/.config/quickshell/noctalia-shell/Services/UI/LauncherProviderRegistry.qml
new file mode 100644
index 0000000..f5810ab
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/UI/LauncherProviderRegistry.qml
@@ -0,0 +1,108 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import qs.Commons
+import qs.Services.Noctalia
+
+Singleton {
+ id: root
+
+ signal pluginProviderRegistryUpdated
+
+ // Plugin provider storage
+ property var pluginProviders: ({}) // { "plugin:pluginId": component }
+ property var pluginProviderMetadata: ({}) // { "plugin:pluginId": metadata }
+
+ // Persistent provider instances โ survive LauncherCore destruction/recreation
+ // so plugins don't re-parse large datasets on every launcher open.
+ property var providerInstances: ({}) // { "plugin:pluginId": instance }
+
+ function init() {
+ Logger.i("LauncherProviderRegistry", "Service started");
+ }
+
+ // Register a plugin launcher provider and instantiate it immediately
+ function registerPluginProvider(pluginId, component, metadata) {
+ if (!pluginId || !component) {
+ Logger.e("LauncherProviderRegistry", "Cannot register plugin provider: invalid parameters");
+ return false;
+ }
+
+ var providerId = "plugin:" + pluginId;
+
+ pluginProviders[providerId] = component;
+ pluginProviderMetadata[providerId] = metadata || {};
+
+ // Instantiate immediately so data loading starts in the background
+ var pluginApi = PluginService.getPluginAPI(pluginId);
+ if (pluginApi) {
+ var instance = component.createObject(null, {
+ pluginApi: pluginApi
+ });
+ if (instance) {
+ providerInstances[providerId] = instance;
+ if (instance.init)
+ instance.init();
+ Logger.i("LauncherProviderRegistry", "Registered and instantiated plugin provider:", providerId);
+ } else {
+ Logger.e("LauncherProviderRegistry", "Failed to instantiate plugin provider:", providerId);
+ }
+ }
+
+ root.pluginProviderRegistryUpdated();
+ return true;
+ }
+
+ // Unregister a plugin launcher provider
+ function unregisterPluginProvider(pluginId) {
+ var providerId = "plugin:" + pluginId;
+
+ if (!pluginProviders[providerId]) {
+ Logger.w("LauncherProviderRegistry", "Plugin provider not registered:", providerId);
+ return false;
+ }
+
+ if (providerInstances[providerId]) {
+ providerInstances[providerId].destroy();
+ delete providerInstances[providerId];
+ }
+
+ delete pluginProviders[providerId];
+ delete pluginProviderMetadata[providerId];
+
+ Logger.i("LauncherProviderRegistry", "Unregistered plugin provider:", providerId);
+ root.pluginProviderRegistryUpdated();
+ return true;
+ }
+
+ // Get list of registered plugin provider IDs
+ function getPluginProviders() {
+ return Object.keys(pluginProviders);
+ }
+
+ // Get the live provider instance by ID
+ function getProviderInstance(providerId) {
+ return providerInstances[providerId] || null;
+ }
+
+ // Get provider component by ID
+ function getProviderComponent(providerId) {
+ return pluginProviders[providerId] || null;
+ }
+
+ // Get provider metadata by ID
+ function getProviderMetadata(providerId) {
+ return pluginProviderMetadata[providerId] || null;
+ }
+
+ // Check if ID is a plugin provider
+ function isPluginProvider(id) {
+ return id.startsWith("plugin:");
+ }
+
+ // Check if provider exists
+ function hasProvider(providerId) {
+ return providerId in pluginProviders;
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/UI/PanelService.qml b/arch/.config/quickshell/noctalia-shell/Services/UI/PanelService.qml
new file mode 100644
index 0000000..89993f9
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/UI/PanelService.qml
@@ -0,0 +1,421 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import qs.Commons
+import qs.Services.Compositor
+
+Singleton {
+ id: root
+
+ // A ref. to the lockScreen, so it's accessible from anywhere.
+ property var lockScreen: null
+
+ // Panels
+ property var registeredPanels: ({})
+ property var openedPanel: null
+ property var closingPanel: null
+ property bool closedImmediately: false
+
+ // Overlay launcher state (separate from normal panels)
+ property bool overlayLauncherOpen: false
+ property var overlayLauncherScreen: null
+ property var overlayLauncherCore: null // Reference to LauncherCore when overlay is active
+ // Brief window after panel opens where Exclusive keyboard is allowed on Hyprland
+ // This allows text inputs to receive focus, then switches to OnDemand for click-to-close
+ property bool isInitializingKeyboard: false
+
+ // Global state for keybind recording components to block global shortcuts
+ property bool isKeybindRecording: false
+
+ signal willOpen
+ signal didClose
+
+ // Background slot assignments for dynamic panel background rendering
+ // Slot 0: currently opening/open panel, Slot 1: closing panel
+ property var backgroundSlotAssignments: [null, null]
+ signal slotAssignmentChanged(int slotIndex, var panel)
+
+ function assignToSlot(slotIndex, panel) {
+ if (backgroundSlotAssignments[slotIndex] !== panel) {
+ var newAssignments = backgroundSlotAssignments.slice();
+ newAssignments[slotIndex] = panel;
+ backgroundSlotAssignments = newAssignments;
+ slotAssignmentChanged(slotIndex, panel);
+ }
+ }
+
+ // Popup menu windows (one per screen) - used for both tray menus and context menus
+ property var popupMenuWindows: ({})
+ signal popupMenuWindowRegistered(var screen)
+
+ // Register this panel (called after panel is loaded)
+ function registerPanel(panel) {
+ registeredPanels[panel.objectName] = panel;
+ Logger.d("PanelService", "Registered panel:", panel.objectName);
+ }
+
+ // Register popup menu window for a screen
+ function registerPopupMenuWindow(screen, window) {
+ if (!screen || !window)
+ return;
+ var key = screen.name;
+ popupMenuWindows[key] = window;
+ Logger.d("PanelService", "Registered popup menu window for screen:", key);
+ popupMenuWindowRegistered(screen);
+ }
+
+ // Unregister popup menu window for a screen (called on destruction)
+ function unregisterPopupMenuWindow(screen) {
+ if (!screen)
+ return;
+ var key = screen.name;
+ delete popupMenuWindows[key];
+ Logger.d("PanelService", "Unregistered popup menu window for screen:", key);
+ }
+
+ // Get popup menu window for a screen
+ function getPopupMenuWindow(screen) {
+ if (!screen)
+ return null;
+ return popupMenuWindows[screen.name] || null;
+ }
+
+ // Show a context menu with proper handling for all compositors
+ // Optional targetItem: if provided, menu will be horizontally centered on this item instead of anchorItem
+ function showContextMenu(contextMenu, anchorItem, screen, targetItem) {
+ if (!contextMenu || !anchorItem)
+ return;
+
+ // Close any previously opened context menu first
+ closeContextMenu(screen);
+
+ var popupMenuWindow = getPopupMenuWindow(screen);
+ if (popupMenuWindow) {
+ popupMenuWindow.showContextMenu(contextMenu);
+ contextMenu.openAtItem(anchorItem, screen, targetItem);
+ }
+ }
+
+ // Close any open context menu or popup menu window
+ function closeContextMenu(screen) {
+ var popupMenuWindow = getPopupMenuWindow(screen);
+ if (popupMenuWindow && popupMenuWindow.visible) {
+ popupMenuWindow.close();
+ }
+ }
+
+ // Show a tray menu with proper handling for all compositors
+ // Returns true if menu was shown successfully
+ function showTrayMenu(screen, trayItem, trayMenu, anchorItem, menuX, menuY, widgetSection, widgetIndex) {
+ if (!trayItem || !trayMenu || !anchorItem)
+ return false;
+
+ // Close any previously opened menu first
+ closeContextMenu(screen);
+
+ trayMenu.trayItem = trayItem;
+ trayMenu.widgetSection = widgetSection;
+ trayMenu.widgetIndex = widgetIndex;
+
+ var popupMenuWindow = getPopupMenuWindow(screen);
+ if (popupMenuWindow) {
+ popupMenuWindow.open();
+ trayMenu.showAt(anchorItem, menuX, menuY);
+ } else {
+ return false;
+ }
+ return true;
+ }
+
+ // Close tray menu
+ function closeTrayMenu(screen) {
+ var popupMenuWindow = getPopupMenuWindow(screen);
+ if (popupMenuWindow) {
+ // This closes both the window and calls hideMenu on the tray menu
+ popupMenuWindow.close();
+ }
+ }
+
+ // Find a fallback screen, prioritizing 0x0 position (primary)
+ function findFallbackScreen() {
+ let primaryCandidate = null;
+ let firstScreen = null;
+
+ for (let i = 0; i < Quickshell.screens.length; i++) {
+ const s = Quickshell.screens[i];
+ if (s.x === 0 && s.y === 0) {
+ primaryCandidate = s;
+ }
+ if (!firstScreen) {
+ firstScreen = s;
+ }
+ }
+
+ return primaryCandidate || firstScreen || null;
+ }
+
+ // Returns a panel (loads it on-demand if not yet loaded)
+ // By default, if panel not found on screen, tries other screens (favoring 0x0)
+ // Pass fallback=false to disable this behavior
+ function getPanel(name, screen, fallback = true) {
+ if (!screen) {
+ Logger.d("PanelService", "missing screen for getPanel:", name);
+ // If no screen specified, return the first matching panel
+ for (var key in registeredPanels) {
+ if (key.startsWith(name + "-")) {
+ return registeredPanels[key];
+ }
+ }
+ return null;
+ }
+
+ var panelKey = `${name}-${screen.name}`;
+
+ // Check if panel is already loaded
+ if (registeredPanels[panelKey]) {
+ return registeredPanels[panelKey];
+ }
+
+ // If fallback enabled, try to find panel on another screen
+ if (fallback) {
+ // First try the primary screen (0x0)
+ var fallbackScreen = findFallbackScreen();
+ if (fallbackScreen && fallbackScreen.name !== screen.name) {
+ var fallbackKey = `${name}-${fallbackScreen.name}`;
+ if (registeredPanels[fallbackKey]) {
+ Logger.d("PanelService", "Panel fallback from", screen.name, "to", fallbackScreen.name);
+ return registeredPanels[fallbackKey];
+ }
+ }
+
+ // Try any other screen
+ for (var key in registeredPanels) {
+ if (key.startsWith(name + "-")) {
+ Logger.d("PanelService", "Panel fallback to first available:", key);
+ return registeredPanels[key];
+ }
+ }
+ }
+
+ Logger.w("PanelService", "Panel not found:", panelKey);
+ return null;
+ }
+
+ // Check if a panel exists
+ function hasPanel(name) {
+ return name in registeredPanels;
+ }
+
+ // Check if panels can be shown on a given screen (has bar enabled or allowPanelsOnScreenWithoutBar)
+ function canShowPanelsOnScreen(screen) {
+ const name = screen?.name || "";
+ const monitors = Settings.data.bar.monitors || [];
+ const allowPanelsOnScreenWithoutBar = Settings.data.general.allowPanelsOnScreenWithoutBar;
+ return allowPanelsOnScreenWithoutBar || monitors.length === 0 || monitors.includes(name);
+ }
+
+ // Find a screen that can show panels
+ function findScreenForPanels() {
+ for (let i = 0; i < Quickshell.screens.length; i++) {
+ if (canShowPanelsOnScreen(Quickshell.screens[i])) {
+ return Quickshell.screens[i];
+ }
+ }
+ return null;
+ }
+
+ // Timer to switch from Exclusive to OnDemand keyboard focus on Hyprland
+ Timer {
+ id: keyboardInitTimer
+ interval: 100
+ repeat: false
+ onTriggered: {
+ root.isInitializingKeyboard = false;
+ }
+ }
+
+ // Helper to keep only one panel open at any time
+ function willOpenPanel(panel) {
+ // Close overlay launcher if open
+ if (overlayLauncherOpen) {
+ overlayLauncherOpen = false;
+ overlayLauncherScreen = null;
+ }
+
+ if (openedPanel && openedPanel !== panel) {
+ // Move current panel to closing slot before closing it
+ closingPanel = openedPanel;
+ assignToSlot(1, closingPanel);
+ openedPanel.close();
+ }
+
+ // Assign new panel to open slot
+ openedPanel = panel;
+ assignToSlot(0, panel);
+
+ // Start keyboard initialization period (for Hyprland workaround)
+ if (panel && panel.exclusiveKeyboard) {
+ isInitializingKeyboard = true;
+ keyboardInitTimer.restart();
+ }
+
+ // emit signal
+ willOpen();
+ }
+
+ // Open launcher panel (handles both normal and overlay mode)
+ function openLauncher(screen) {
+ if (Settings.data.appLauncher.overviewLayer) {
+ // Close any regular panel first
+ if (openedPanel) {
+ closingPanel = openedPanel;
+ assignToSlot(1, closingPanel);
+ openedPanel.close();
+ openedPanel = null;
+ }
+ // Open overlay launcher
+ overlayLauncherOpen = true;
+ overlayLauncherScreen = screen;
+ willOpen();
+ } else {
+ // Normal mode - use the SmartPanel
+ var panel = getPanel("launcherPanel", screen);
+ if (panel)
+ panel.open();
+ }
+ }
+
+ // Toggle launcher panel
+ function toggleLauncher(screen) {
+ if (Settings.data.appLauncher.overviewLayer) {
+ if (overlayLauncherOpen && overlayLauncherScreen === screen) {
+ closeOverlayLauncher();
+ } else {
+ openLauncher(screen);
+ }
+ } else {
+ var panel = getPanel("launcherPanel", screen);
+ if (panel)
+ panel.toggle();
+ }
+ }
+
+ // Close overlay launcher
+ function closeOverlayLauncher() {
+ if (overlayLauncherOpen) {
+ overlayLauncherOpen = false;
+ overlayLauncherScreen = null;
+ didClose();
+ }
+ }
+
+ // Close overlay launcher immediately (for app launches)
+ function closeOverlayLauncherImmediately() {
+ if (overlayLauncherOpen) {
+ closedImmediately = true;
+ overlayLauncherOpen = false;
+ overlayLauncherScreen = null;
+ didClose();
+ }
+ }
+
+ // ==================== Unified Launcher API ====================
+ // These methods work for both normal (SmartPanel) and overlay modes
+
+ function isLauncherOpen(screen) {
+ if (Settings.data.appLauncher.overviewLayer) {
+ return overlayLauncherOpen && overlayLauncherScreen === screen;
+ } else {
+ var panel = getPanel("launcherPanel", screen);
+ return panel ? panel.isPanelOpen : false;
+ }
+ }
+
+ function getLauncherSearchText(screen) {
+ if (Settings.data.appLauncher.overviewLayer) {
+ return overlayLauncherCore ? overlayLauncherCore.searchText : "";
+ } else {
+ var panel = getPanel("launcherPanel", screen);
+ return panel ? panel.searchText : "";
+ }
+ }
+
+ function setLauncherSearchText(screen, text) {
+ if (Settings.data.appLauncher.overviewLayer) {
+ if (overlayLauncherCore)
+ overlayLauncherCore.setSearchText(text);
+ } else {
+ var panel = getPanel("launcherPanel", screen);
+ if (panel)
+ panel.setSearchText(text);
+ }
+ }
+
+ function openLauncherWithSearch(screen, searchText) {
+ if (Settings.data.appLauncher.overviewLayer) {
+ openLauncher(screen);
+ // Set search text after core is ready
+ Qt.callLater(() => {
+ if (overlayLauncherCore)
+ overlayLauncherCore.setSearchText(searchText);
+ });
+ } else {
+ var panel = getPanel("launcherPanel", screen);
+ if (panel) {
+ panel.open();
+ panel.setSearchText(searchText);
+ }
+ }
+ }
+
+ function closeLauncher(screen) {
+ if (Settings.data.appLauncher.overviewLayer) {
+ closeOverlayLauncher();
+ } else {
+ var panel = getPanel("launcherPanel", screen);
+ if (panel)
+ panel.close();
+ }
+ }
+
+ // Close any open panel (for general use)
+ function closePanel() {
+ if (overlayLauncherOpen) {
+ closeOverlayLauncher();
+ } else if (openedPanel && openedPanel.close) {
+ openedPanel.close();
+ }
+ }
+
+ function closedPanel(panel) {
+ if (openedPanel && openedPanel === panel) {
+ openedPanel = null;
+ assignToSlot(0, null);
+ }
+
+ if (closingPanel && closingPanel === panel) {
+ closingPanel = null;
+ assignToSlot(1, null);
+ }
+
+ // Reset keyboard init state
+ isInitializingKeyboard = false;
+ keyboardInitTimer.stop();
+
+ // emit signal
+ didClose();
+ }
+
+ // Close panels when compositor overview opens (if setting is enabled)
+ Connections {
+ target: CompositorService
+ enabled: Settings.data.bar.hideOnOverview
+
+ function onOverviewActiveChanged() {
+ if (CompositorService.overviewActive && root.openedPanel) {
+ root.openedPanel.close();
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/UI/SettingsPanelService.qml b/arch/.config/quickshell/noctalia-shell/Services/UI/SettingsPanelService.qml
new file mode 100644
index 0000000..fa561e2
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/UI/SettingsPanelService.qml
@@ -0,0 +1,143 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import qs.Commons
+import qs.Services.UI
+
+Singleton {
+ id: root
+
+ // Track if the settings window is open
+ property bool isWindowOpen: false
+
+ // Reference to the window (set by SettingsPanelWindow)
+ property var settingsWindow: null
+
+ // Requested tab when opening
+ property int requestedTab: 0
+
+ // Requested subtab when opening (-1 means no specific subtab)
+ property int requestedSubTab: -1
+
+ // Requested entry for search navigation
+ property var requestedEntry: null
+
+ signal windowOpened
+ signal windowClosed
+
+ function openToEntry(entry, screen) {
+ if (Settings.data.ui.settingsPanelMode === "window") {
+ requestedEntry = entry;
+ if (settingsWindow) {
+ settingsWindow.visible = true;
+ isWindowOpen = true;
+ windowOpened();
+ settingsWindow.navigateToEntry(entry);
+ }
+ } else {
+ if (!screen) {
+ Logger.w("SettingsPanelService", "Screen parameter required for panel mode");
+ return;
+ }
+ var settingsPanel = PanelService.getPanel("settingsPanel", screen);
+ if (settingsPanel) {
+ settingsPanel.requestedEntry = entry;
+ settingsPanel.open();
+ }
+ }
+ }
+
+ // Unified function to open settings to a specific tab and subtab
+ // Respects user's settingsPanelMode setting (window vs panel)
+ // For panel mode, screen parameter is required
+ function openToTab(tab, subTab, screen) {
+ const tabId = tab !== undefined ? tab : 0;
+ const subTabId = subTab !== undefined ? subTab : -1;
+
+ if (Settings.data.ui.settingsPanelMode === "window") {
+ requestedTab = tabId;
+ requestedSubTab = subTabId;
+ if (settingsWindow) {
+ settingsWindow.visible = true;
+ isWindowOpen = true;
+ windowOpened();
+ settingsWindow.navigateTo(tabId, subTabId);
+ }
+ } else {
+ if (!screen) {
+ Logger.w("SettingsPanelService", "Screen parameter required for panel mode");
+ return;
+ }
+ var settingsPanel = PanelService.getPanel("settingsPanel", screen);
+ if (settingsPanel) {
+ settingsPanel.openToTab(tabId, subTabId);
+ }
+ }
+ }
+
+ function openWindow(tab) {
+ requestedTab = tab !== undefined ? tab : 0;
+ requestedSubTab = -1;
+ if (settingsWindow) {
+ settingsWindow.visible = true;
+ isWindowOpen = true;
+ windowOpened();
+ settingsWindow.navigateTo(requestedTab, -1);
+ }
+ }
+
+ function closeWindow() {
+ if (settingsWindow) {
+ settingsWindow.visible = false;
+ isWindowOpen = false;
+ windowClosed();
+ }
+ }
+
+ function toggleWindow(tab) {
+ if (isWindowOpen) {
+ closeWindow();
+ } else {
+ openWindow(tab);
+ }
+ }
+
+ // Unified toggle: opens to tab/subtab if closed, closes if open
+ // Respects settingsPanelMode setting
+ function toggle(tab, subTab, screen) {
+ const tabId = tab !== undefined ? tab : 0;
+ const subTabId = subTab !== undefined ? subTab : -1;
+
+ if (Settings.data.ui.settingsPanelMode === "window") {
+ if (isWindowOpen) {
+ closeWindow();
+ } else {
+ openToTab(tabId, subTabId);
+ }
+ } else {
+ if (!screen) {
+ Logger.w("SettingsPanelService", "Screen parameter required for panel mode");
+ return;
+ }
+ var settingsPanel = PanelService.getPanel("settingsPanel", screen);
+ if (settingsPanel?.isPanelOpen) {
+ settingsPanel.close();
+ } else {
+ settingsPanel?.openToTab(tabId, subTabId);
+ }
+ }
+ }
+
+ // Unified close for both modes
+ function close(screen) {
+ if (Settings.data.ui.settingsPanelMode === "window") {
+ closeWindow();
+ } else {
+ if (!screen)
+ return;
+ var settingsPanel = PanelService.getPanel("settingsPanel", screen);
+ settingsPanel?.close();
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/UI/SettingsSearchService.qml b/arch/.config/quickshell/noctalia-shell/Services/UI/SettingsSearchService.qml
new file mode 100644
index 0000000..50dc7ac
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/UI/SettingsSearchService.qml
@@ -0,0 +1,158 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+import qs.Services.Compositor
+import qs.Services.Power
+import qs.Services.System
+
+Singleton {
+ id: root
+
+ property var searchIndex: []
+
+ FileView {
+ path: Quickshell.shellDir + "/Assets/settings-search-index.json"
+ watchChanges: false
+ printErrors: false
+
+ onLoaded: {
+ try {
+ root.searchIndex = JSON.parse(text());
+ } catch (e) {
+ root.searchIndex = [];
+ }
+ }
+ }
+
+ readonly property var _roots: ({
+ "CompositorService": CompositorService,
+ "Settings": Settings,
+ "Quickshell": Quickshell,
+ "IdleService": IdleService,
+ "SystemStatService": SystemStatService,
+ "SoundService": SoundService
+ })
+
+ function isEntryVisible(entry) {
+ if (!entry.visibleWhen || entry.visibleWhen.length === 0)
+ return true;
+ for (let i = 0; i < entry.visibleWhen.length; i++) {
+ if (!_evalCondition(entry.visibleWhen[i]))
+ return false;
+ }
+ return true;
+ }
+
+ function _resolveValue(path) {
+ const parts = path.split(".");
+ const rootObj = _roots[parts[0]];
+ if (rootObj === undefined)
+ return undefined;
+
+ let obj = rootObj;
+ for (let i = 1; i < parts.length; i++) {
+ if (obj === undefined || obj === null)
+ return undefined;
+ let key = parts[i];
+ if (key.endsWith("?"))
+ key = key.slice(0, -1);
+ obj = obj[key];
+ }
+ return obj;
+ }
+
+ function _splitAnd(expr) {
+ const parts = [];
+ let depth = 0;
+ let current = "";
+ for (let i = 0; i < expr.length; i++) {
+ const ch = expr[i];
+ if (ch === "(")
+ depth++;
+ else if (ch === ")")
+ depth--;
+ else if (depth === 0 && ch === "&" && i + 1 < expr.length && expr[i + 1] === "&") {
+ parts.push(current);
+ current = "";
+ i++;
+ continue;
+ }
+ current += ch;
+ }
+ parts.push(current);
+ return parts;
+ }
+
+ function _evalCondition(expr) {
+ expr = expr.trim();
+
+ // Strip outer parentheses
+ if (expr.startsWith("(") && expr.endsWith(")")) {
+ let depth = 0;
+ let allWrapped = true;
+ for (let i = 0; i < expr.length - 1; i++) {
+ if (expr[i] === "(")
+ depth++;
+ else if (expr[i] === ")")
+ depth--;
+ if (depth === 0) {
+ allWrapped = false;
+ break;
+ }
+ }
+ if (allWrapped)
+ return _evalCondition(expr.slice(1, -1));
+ }
+
+ // AND: all parts must be true
+ if (expr.includes("&&")) {
+ const parts = _splitAnd(expr);
+ if (parts.length > 1) {
+ for (let i = 0; i < parts.length; i++) {
+ if (!_evalCondition(parts[i]))
+ return false;
+ }
+ return true;
+ }
+ }
+
+ // Negation
+ if (expr.startsWith("!"))
+ return !_evalCondition(expr.slice(1).trim());
+
+ // Literal false
+ if (expr === "false")
+ return false;
+
+ // Strip nullish coalescing fallback
+ const nullishMatch = expr.match(/^(.+?)\s*\?\?\s*(?:false|true)\s*$/);
+ if (nullishMatch)
+ return _evalCondition(nullishMatch[1]);
+
+ // === comparison
+ let m = expr.match(/^(.+?)\s*===\s*"([^"]*)"\s*$/);
+ if (m)
+ return _resolveValue(m[1].trim()) === m[2];
+
+ // !== comparison
+ m = expr.match(/^(.+?)\s*!==\s*"([^"]*)"\s*$/);
+ if (m)
+ return _resolveValue(m[1].trim()) !== m[2];
+
+ // > comparison
+ m = expr.match(/^(.+?)\s*>\s*(\d+)\s*$/);
+ if (m)
+ return _resolveValue(m[1].trim()) > parseInt(m[2]);
+
+ // Simple property path โ resolve and return truthiness
+ const val = _resolveValue(expr);
+ if (val !== undefined)
+ return !!val;
+
+ // Unrecognized expression โ assume visible
+ return true;
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/UI/ToastService.qml b/arch/.config/quickshell/noctalia-shell/Services/UI/ToastService.qml
new file mode 100644
index 0000000..93126c3
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/UI/ToastService.qml
@@ -0,0 +1,31 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+
+Singleton {
+ id: root
+
+ // Simple signal-based notification system
+ // actionLabel: optional label for clickable action link
+ // actionCallback: optional function to call when action is clicked
+ signal notify(string title, string description, string icon, string type, int duration, string actionLabel, var actionCallback)
+ signal dismiss
+
+ // Convenience methods
+ function showNotice(title, description = "", icon = "", duration = 3000, actionLabel = "", actionCallback = null) {
+ notify(title, description, icon, "notice", duration, actionLabel, actionCallback);
+ }
+
+ function showWarning(title, description = "", duration = 4000, actionLabel = "", actionCallback = null) {
+ notify(title, description, "", "warning", duration, actionLabel, actionCallback);
+ }
+
+ function showError(title, description = "", duration = 6000, actionLabel = "", actionCallback = null) {
+ notify(title, description, "", "error", duration, actionLabel, actionCallback);
+ }
+
+ function dismissToast() {
+ dismiss();
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/UI/TooltipService.qml b/arch/.config/quickshell/noctalia-shell/Services/UI/TooltipService.qml
new file mode 100644
index 0000000..8325855
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/UI/TooltipService.qml
@@ -0,0 +1,133 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import qs.Commons
+import qs.Modules.Tooltip
+
+Singleton {
+ id: root
+
+ property var activeTooltip: null
+ property var pendingTooltip: null // Track tooltip being created
+
+ property Component tooltipComponent: Component {
+ Tooltip {}
+ }
+
+ function show(target, content, direction, delay, fontFamily) {
+ if (!Settings.data.ui.tooltipsEnabled) {
+ return;
+ }
+
+ // Don't create if no content
+ if (!target || !content || (Array.isArray(content) && content.length === 0)) {
+ Logger.i("Tooltip", "No target or content");
+ return;
+ }
+
+ // Cancel any pending tooltip (same or different target)
+ if (pendingTooltip) {
+ pendingTooltip.hideImmediately();
+ pendingTooltip.destroy();
+ pendingTooltip = null;
+ }
+
+ // If we have an active tooltip for a different target, hide it
+ if (activeTooltip && activeTooltip.targetItem !== target) {
+ activeTooltip.hideImmediately();
+ // Don't destroy immediately - let it clean itself up
+ activeTooltip = null;
+ }
+
+ // If we already have a tooltip for this target, just update it
+ if (activeTooltip && activeTooltip.targetItem === target) {
+ activeTooltip.updateContent(content);
+ return activeTooltip;
+ }
+
+ // Create new tooltip instance
+ const newTooltip = tooltipComponent.createObject(null);
+
+ if (newTooltip) {
+ // Track as pending until it's visible
+ pendingTooltip = newTooltip;
+
+ // Connect cleanup when tooltip hides
+ newTooltip.visibleChanged.connect(() => {
+ if (!newTooltip.visible) {
+ // Clean up after a delay to avoid interfering with new tooltips
+ Qt.callLater(() => {
+ if (newTooltip && !newTooltip.visible) {
+ if (activeTooltip === newTooltip) {
+ activeTooltip = null;
+ }
+ if (pendingTooltip === newTooltip) {
+ pendingTooltip = null;
+ }
+ newTooltip.destroy();
+ }
+ });
+ } else {
+ // Tooltip is now visible, move from pending to active
+ if (pendingTooltip === newTooltip) {
+ activeTooltip = newTooltip;
+ pendingTooltip = null;
+ }
+ }
+ });
+
+ // Show the tooltip
+ newTooltip.show(target, content, direction || "auto", delay || Style.tooltipDelay, fontFamily);
+
+ return newTooltip;
+ } else {
+ Logger.e("Tooltip", "Failed to create tooltip instance");
+ }
+
+ return null;
+ }
+
+ function hide(target) {
+ // If target is provided, only hide if tooltip belongs to that target
+ if (target) {
+ if (pendingTooltip && pendingTooltip.targetItem === target) {
+ pendingTooltip.hide();
+ }
+ if (activeTooltip && activeTooltip.targetItem === target) {
+ activeTooltip.hide();
+ }
+ } else {
+ if (pendingTooltip) {
+ pendingTooltip.hide();
+ }
+ if (activeTooltip) {
+ activeTooltip.hide();
+ }
+ }
+ }
+
+ function hideImmediately() {
+ if (pendingTooltip) {
+ pendingTooltip.hideImmediately();
+ pendingTooltip.destroy();
+ pendingTooltip = null;
+ }
+ if (activeTooltip) {
+ activeTooltip.hideImmediately();
+ activeTooltip.destroy();
+ activeTooltip = null;
+ }
+ }
+
+ function updateContent(newContent) {
+ if (activeTooltip) {
+ activeTooltip.updateContent(newContent);
+ }
+ }
+
+ // Backward compatibility alias
+ function updateText(newText) {
+ updateContent(newText);
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/UI/WallhavenService.qml b/arch/.config/quickshell/noctalia-shell/Services/UI/WallhavenService.qml
new file mode 100644
index 0000000..f10d84e
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/UI/WallhavenService.qml
@@ -0,0 +1,274 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import qs.Commons
+
+Singleton {
+ id: root
+
+ // State
+ property bool fetching: false
+ property bool initialSearchScheduled: false
+ property var currentResults: []
+ property var currentMeta: ({})
+ property string lastError: ""
+ property string currentQuery: ""
+ property int currentPage: 1
+ property int lastPage: 1
+
+ // Search parameters
+ property string categories: "111" // general,anime,people (all enabled by default)
+ property string purity: "100" // sfw
+ property string sorting: "relevance" // date_added, relevance, random, views, favorites, toplist
+ property string order: "desc" // desc, asc
+ property string topRange: "1M" // 1d, 3d, 1w, 1M, 3M, 6M, 1y
+ property string seed: "" // For random sorting
+ property string minResolution: "" // e.g., "1920x1080"
+ property string resolutions: "" // e.g., "1920x1080,1920x1200"
+ property string ratios: "" // e.g., "16x9,16x10"
+ property string colors: "" // Color hex codes
+ // API Key Priority: Environment Variable > Local Settings
+ readonly property string envApiKey: Quickshell.env("NOCTALIA_WALLHAVEN_API_KEY") || ""
+ readonly property string apiKey: envApiKey !== "" ? envApiKey : (Settings.data.wallpaper.wallhavenApiKey || "")
+ readonly property bool apiKeyManagedByEnv: envApiKey !== ""
+
+ // Signals
+ signal searchCompleted(var results, var meta)
+ signal searchFailed(string error)
+ signal wallpaperDownloaded(string wallpaperId, string localPath)
+
+ // Base API URL
+ readonly property string apiBaseUrl: "https://wallhaven.cc/api/v1"
+
+ // -------------------------------------------------
+ function search(query, page) {
+ if (fetching) {
+ return;
+ }
+
+ // Reset initial search flag once we start a search
+ if (initialSearchScheduled) {
+ initialSearchScheduled = false;
+ }
+
+ fetching = true;
+ lastError = "";
+ currentQuery = query || "";
+ currentPage = page || 1;
+
+ var url = apiBaseUrl + "/search";
+ var params = [];
+
+ if (currentQuery) {
+ params.push("q=" + encodeURIComponent(currentQuery));
+ }
+
+ params.push("categories=" + categories);
+ // Safety: Force SFW if no purity selected to prevent API error
+ var safePurity = (purity === "000") ? "100" : purity;
+ params.push("purity=" + safePurity);
+ params.push("sorting=" + sorting);
+ params.push("order=" + order);
+
+ if (sorting === "toplist") {
+ params.push("topRange=" + topRange);
+ }
+
+ if (sorting === "random" && seed) {
+ params.push("seed=" + seed);
+ }
+
+ if (minResolution) {
+ params.push("atleast=" + minResolution);
+ }
+
+ if (resolutions) {
+ params.push("resolutions=" + resolutions);
+ }
+
+ if (ratios) {
+ params.push("ratios=" + ratios);
+ }
+
+ if (colors) {
+ params.push("colors=" + colors);
+ }
+
+ if (apiKey) {
+ params.push("apikey=" + apiKey);
+ }
+
+ params.push("page=" + currentPage);
+
+ url += "?" + params.join("&");
+
+ Logger.d("Wallhaven", "Searching:", url);
+
+ var xhr = new XMLHttpRequest();
+ xhr.onreadystatechange = function () {
+ if (xhr.readyState === XMLHttpRequest.DONE) {
+ fetching = false;
+ if (xhr.status === 200) {
+ try {
+ var response = JSON.parse(xhr.responseText);
+ if (response.data && Array.isArray(response.data)) {
+ currentResults = response.data;
+ currentMeta = response.meta || {};
+ lastPage = currentMeta.last_page || 1;
+
+ // Store seed for random sorting
+ if (currentMeta.seed) {
+ seed = currentMeta.seed;
+ }
+
+ Logger.d("Wallhaven", "Search completed:", currentResults.length, "results, page", currentPage, "of", lastPage);
+ searchCompleted(currentResults, currentMeta);
+ } else {
+ var errorMsg = "Invalid API response";
+ lastError = errorMsg;
+ Logger.e("Wallhaven", errorMsg);
+ searchFailed(errorMsg);
+ }
+ } catch (e) {
+ var errorMsg = "Failed to parse API response: " + e.toString();
+ lastError = errorMsg;
+ Logger.e("Wallhaven", errorMsg);
+ searchFailed(errorMsg);
+ }
+ } else if (xhr.status === 429) {
+ var errorMsg = "Rate limit exceeded (45 requests/minute)";
+ lastError = errorMsg;
+ Logger.w("Wallhaven", errorMsg);
+ searchFailed(errorMsg);
+ } else if (xhr.status === 401) {
+ var errorMsg = "Invalid API Key. Please check your settings.";
+ lastError = errorMsg;
+ Logger.e("Wallhaven", errorMsg);
+ searchFailed(errorMsg);
+ } else {
+ var errorMsg = "API error: " + xhr.status;
+ lastError = errorMsg;
+ Logger.e("Wallhaven", "Search failed:", errorMsg);
+ searchFailed(errorMsg);
+ }
+ }
+ };
+
+ xhr.open("GET", url);
+ xhr.send();
+ }
+
+ // -------------------------------------------------
+ function getWallpaperUrl(wallpaper) {
+ // Use the 'path' field which contains the full resolution image URL
+ if (wallpaper.path) {
+ return wallpaper.path;
+ }
+ // Fallback to constructing URL from ID
+ if (wallpaper.id) {
+ var idPrefix = wallpaper.id.substring(0, 2);
+ return "https://w.wallhaven.cc/full/" + idPrefix + "/wallhaven-" + wallpaper.id + ".jpg";
+ }
+ return "";
+ }
+
+ // -------------------------------------------------
+ function getThumbnailUrl(wallpaper, size) {
+ // size: "small", "large", "original"
+ if (wallpaper.thumbs && wallpaper.thumbs[size]) {
+ return wallpaper.thumbs[size];
+ }
+ // Fallback
+ if (wallpaper.id) {
+ var idPrefix = wallpaper.id.substring(0, 2);
+ var sizeMap = {
+ "small": "small",
+ "large": "lg",
+ "original": "orig"
+ };
+ var sizePath = sizeMap[size] || "lg";
+ return "https://th.wallhaven.cc/" + sizePath + "/" + idPrefix + "/" + wallpaper.id + ".jpg";
+ }
+ return "";
+ }
+
+ // -------------------------------------------------
+ function downloadWallpaper(wallpaper, callback) {
+ var url = getWallpaperUrl(wallpaper);
+ if (!url) {
+ Logger.e("Wallhaven", "No URL available for wallpaper", wallpaper.id);
+ if (callback)
+ callback(false, "");
+ return;
+ }
+
+ var wallpaperId = wallpaper.id;
+
+ // Get the user's wallpaper directory
+ var wallpaperDir = Settings.preprocessPath(Settings.data.wallpaper.directory);
+ if (!wallpaperDir || wallpaperDir === "") {
+ wallpaperDir = Settings.defaultWallpapersDirectory;
+ }
+
+ // Ensure directory ends with /
+ if (!wallpaperDir.endsWith("/")) {
+ wallpaperDir += "/";
+ }
+
+ var localPath = wallpaperDir + "wallhaven_" + wallpaperId + ".jpg";
+
+ Logger.d("Wallhaven", "Downloading wallpaper", wallpaperId, "to", localPath);
+
+ // Use curl or wget to download the file, ensuring directory exists first
+ var downloadProcess = Qt.createQmlObject(`
+ import QtQuick
+ import Quickshell.Io
+ Process {
+ id: downloadProcess
+ command: ["sh", "-c", "mkdir -p '` + wallpaperDir + `' && (curl -L -s -o '` + localPath + `' '` + url + `' || wget -q -O '` + localPath + `' '` + url + `')"]
+ }
+ `, root, "DownloadProcess_" + wallpaperId);
+
+ downloadProcess.exited.connect(function (exitCode) {
+ if (exitCode === 0) {
+ Logger.i("Wallhaven", "Wallpaper downloaded:", localPath);
+ wallpaperDownloaded(wallpaperId, localPath);
+ if (callback)
+ callback(true, localPath);
+ } else {
+ Logger.e("Wallhaven", "Failed to download wallpaper, exit code:", exitCode);
+ if (callback)
+ callback(false, "");
+ }
+ downloadProcess.destroy();
+ });
+
+ downloadProcess.running = true;
+ }
+
+ // -------------------------------------------------
+ function reset() {
+ currentResults = [];
+ currentMeta = {};
+ currentQuery = "";
+ currentPage = 1;
+ lastPage = 1;
+ seed = "";
+ lastError = "";
+ }
+
+ // -------------------------------------------------
+ function nextPage() {
+ if (currentPage < lastPage && !fetching) {
+ search(currentQuery, currentPage + 1);
+ }
+ }
+
+ // -------------------------------------------------
+ function previousPage() {
+ if (currentPage > 1 && !fetching) {
+ search(currentQuery, currentPage - 1);
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Services/UI/WallpaperService.qml b/arch/.config/quickshell/noctalia-shell/Services/UI/WallpaperService.qml
new file mode 100644
index 0000000..fc8e849
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Services/UI/WallpaperService.qml
@@ -0,0 +1,1683 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+import qs.Services.Power
+import qs.Services.Theming
+import qs.Services.UI
+
+Singleton {
+ id: root
+
+ readonly property ListModel fillModeModel: ListModel {}
+ readonly property string defaultDirectory: Settings.preprocessPath(Settings.data.wallpaper.directory)
+ readonly property string solidColorPrefix: "solid://"
+
+ // All available wallpaper transitions
+ readonly property ListModel transitionsModel: ListModel {}
+
+ // All transition keys but filter out "none" and "random" so we are left with the real transitions
+ readonly property var allTransitions: Array.from({
+ "length": transitionsModel.count
+ }, (_, i) => transitionsModel.get(i).key).filter(key => key !== "random" && key != "none")
+
+ property var wallpaperLists: ({})
+ property int scanningCount: 0
+
+ // Cache for current wallpapers - can be updated directly since we use signals for notifications
+ property var currentWallpapers: ({})
+
+ // Track current alphabetical index for each screen
+ property var alphabeticalIndices: ({})
+
+ // Track used wallpapers for random mode (persisted across reboots)
+ property var usedRandomWallpapers: ({})
+
+ property bool isInitialized: false
+ property string wallpaperCacheFile: ""
+
+ readonly property bool scanning: (scanningCount > 0)
+ readonly property string noctaliaDefaultWallpaper: Quickshell.shellDir + "/Assets/Wallpaper/noctalia.png"
+ property string defaultWallpaper: noctaliaDefaultWallpaper
+
+ // Signals for reactive UI updates
+ signal wallpaperChanged(string screenName, string path)
+ // Emitted when a wallpaper changes
+ signal wallpaperProcessingComplete(string screenName, string path, string cachedPath)
+ // Emitted when wallpaper processing (resize/cache) is complete. cachedPath is the resized version.
+ signal wallpaperDirectoryChanged(string screenName, string directory)
+ // Emitted when a monitor's directory changes
+ signal wallpaperListChanged(string screenName, int count)
+
+ // Emitted when available wallpapers list changes
+
+ // Browse mode: track current browse path per screen (separate from root directory)
+ property var currentBrowsePaths: ({})
+
+ // Wallpaper panel: which appearance slot (light/dark) new selections apply to โ like picking a monitor tab
+ property string wallpaperSelectionAppearance: "light"
+
+ // Bumped when favorites are added/removed so grid delegates can refresh star state
+ property int favoritesRevision: 0
+
+ // After favoriting, refresh snapshot once theme colors finish transitioning
+ property var pendingFavoriteSchemeRefresh: null
+
+ // Signal emitted when browse path changes for a screen
+ signal browsePathChanged(string screenName, string path)
+
+ Timer {
+ id: favoriteSchemeDebounceTimer
+ interval: 450
+ repeat: false
+ property string pendingPath: ""
+ property string pendingSlot: ""
+ onTriggered: {
+ var p = pendingPath;
+ var s = pendingSlot;
+ pendingPath = "";
+ pendingSlot = "";
+ if (p && root.isFavorite(p)) {
+ root.updateFavoriteColorScheme(p, s);
+ }
+ }
+ }
+
+ function scheduleFavoriteSchemeSnapshot(path, slot) {
+ Qt.callLater(function () {
+ if (root.isFavorite(path)) {
+ root.updateFavoriteColorScheme(path, slot);
+ }
+ });
+ favoriteSchemeDebounceTimer.pendingPath = path;
+ favoriteSchemeDebounceTimer.pendingSlot = slot;
+ favoriteSchemeDebounceTimer.restart();
+ if (Color.isTransitioning) {
+ root.pendingFavoriteSchemeRefresh = {
+ "path": path,
+ "slot": slot
+ };
+ }
+ }
+
+ Connections {
+ target: Settings.data.wallpaper
+ function onDirectoryChanged() {
+ root.usedRandomWallpapers = {};
+ root.refreshWallpapersList();
+ // Emit directory change signals for monitors using the default directory
+ if (!Settings.data.wallpaper.enableMultiMonitorDirectories) {
+ // All monitors use the main directory
+ for (var i = 0; i < Quickshell.screens.length; i++) {
+ root.wallpaperDirectoryChanged(Quickshell.screens[i].name, root.defaultDirectory);
+ }
+ } else {
+ // Only monitors without custom directories are affected
+ for (var i = 0; i < Quickshell.screens.length; i++) {
+ var screenName = Quickshell.screens[i].name;
+ var monitor = root.getMonitorConfig(screenName);
+ if (!monitor || !monitor.directory) {
+ root.wallpaperDirectoryChanged(screenName, root.defaultDirectory);
+ }
+ }
+ }
+ }
+ function onEnableMultiMonitorDirectoriesChanged() {
+ root.usedRandomWallpapers = {};
+ root.refreshWallpapersList();
+ // Notify all monitors about potential directory changes
+ for (var i = 0; i < Quickshell.screens.length; i++) {
+ var screenName = Quickshell.screens[i].name;
+ root.wallpaperDirectoryChanged(screenName, root.getMonitorDirectory(screenName));
+ }
+ }
+ function onAutomationEnabledChanged() {
+ root.toggleRandomWallpaper();
+ }
+ function onRandomIntervalSecChanged() {
+ root.restartRandomWallpaperTimer();
+ }
+ function onWallpaperChangeModeChanged() {
+ // Reset alphabetical indices when mode changes
+ root.alphabeticalIndices = {};
+ if (Settings.data.wallpaper.automationEnabled) {
+ root.restartRandomWallpaperTimer();
+ root.setNextWallpaper();
+ }
+ }
+ function onViewModeChanged() {
+ // Reset browse paths to root when mode changes
+ root.currentBrowsePaths = {};
+ root.refreshWallpapersList();
+ }
+ function onShowHiddenFilesChanged() {
+ root.refreshWallpapersList();
+ }
+ function onUseSolidColorChanged() {
+ if (Settings.data.wallpaper.useSolidColor) {
+ var solidPath = root.createSolidColorPath(Settings.data.wallpaper.solidColor.toString());
+ for (var i = 0; i < Quickshell.screens.length; i++) {
+ root.wallpaperChanged(Quickshell.screens[i].name, solidPath);
+ }
+ } else {
+ for (var i = 0; i < Quickshell.screens.length; i++) {
+ var screenName = Quickshell.screens[i].name;
+ root.wallpaperChanged(screenName, root.getWallpaper(screenName) || root.defaultWallpaper);
+ }
+ }
+ }
+ function onSolidColorChanged() {
+ if (Settings.data.wallpaper.useSolidColor) {
+ var solidPath = root.createSolidColorPath(Settings.data.wallpaper.solidColor.toString());
+ for (var i = 0; i < Quickshell.screens.length; i++) {
+ root.wallpaperChanged(Quickshell.screens[i].name, solidPath);
+ }
+ }
+ }
+ function onSortOrderChanged() {
+ root.refreshWallpapersList();
+ }
+ function onLinkLightAndDarkWallpapersChanged() {
+ if (Settings.data.wallpaper.linkLightAndDarkWallpapers) {
+ root.wallpaperSelectionAppearance = Settings.data.colorSchemes.darkMode ? "dark" : "light";
+ root._syncWallpaperSlotsWhenLinking();
+ }
+ root._notifyAllWallpapersChanged();
+ }
+ }
+
+ Connections {
+ target: Settings.data.colorSchemes
+ function onDarkModeChanged() {
+ if (Settings.data.wallpaper.linkLightAndDarkWallpapers) {
+ root.wallpaperSelectionAppearance = Settings.data.colorSchemes.darkMode ? "dark" : "light";
+ }
+ // Restore scheme from favorite for this light/dark slot before wallpaper refresh
+ root.reapplyFavoriteThemeForActiveWallpaper();
+ root._notifyAllWallpapersChanged();
+ }
+ }
+
+ Connections {
+ target: WallhavenService
+ function onWallpaperDownloaded() {
+ root.refreshWallpapersList();
+ }
+ }
+
+ // -------------------------------------------------
+ function init() {
+ Logger.i("Wallpaper", "Service started");
+
+ translateModels();
+ Qt.callLater(root._dedupeWallpaperFavoritesByPath);
+
+ // Initialize cache file path
+ Qt.callLater(() => {
+ if (typeof Settings !== 'undefined' && Settings.cacheDir) {
+ wallpaperCacheFile = Settings.cacheDir + "wallpapers.json";
+ wallpaperCacheView.path = wallpaperCacheFile;
+ }
+ });
+
+ // Note: isInitialized will be set to true in wallpaperCacheView.onLoaded
+ Logger.d("Wallpaper", "Triggering initial wallpaper scan");
+ Qt.callLater(refreshWallpapersList);
+ }
+
+ // Cache restore updates currentWallpapers without _setWallpaper, so wallpaperChanged does not fire.
+ function _scheduleThemeSyncFromCachedWallpaper() {
+ Qt.callLater(function () {
+ root.reapplyFavoriteThemeForActiveWallpaper();
+ if (Settings.data.colorSchemes.useWallpaperColors) {
+ AppThemeService.generate();
+ }
+ });
+ }
+
+ // -------------------------------------------------
+ function translateModels() {
+ // Wait for i18n to be ready by retrying every time
+ if (!I18n.isLoaded) {
+ Qt.callLater(translateModels);
+ return;
+ }
+
+ // Populate fillModeModel with translated names
+ fillModeModel.append({
+ "key": "center",
+ "name": I18n.tr("positions.center"),
+ "uniform": 0.0
+ });
+ fillModeModel.append({
+ "key": "crop",
+ "name": I18n.tr("wallpaper.fill-modes.crop"),
+ "uniform": 1.0
+ });
+ fillModeModel.append({
+ "key": "fit",
+ "name": I18n.tr("wallpaper.fill-modes.fit"),
+ "uniform": 2.0
+ });
+ fillModeModel.append({
+ "key": "stretch",
+ "name": I18n.tr("wallpaper.fill-modes.stretch"),
+ "uniform": 3.0
+ });
+ fillModeModel.append({
+ "key": "repeat",
+ "name": I18n.tr("wallpaper.fill-modes.repeat"),
+ "uniform": 4.0
+ });
+
+ // Populate transitionsModel with translated names
+ transitionsModel.append({
+ "key": "none",
+ "name": I18n.tr("common.none")
+ });
+ transitionsModel.append({
+ "key": "random",
+ "name": I18n.tr("common.random")
+ });
+ transitionsModel.append({
+ "key": "fade",
+ "name": I18n.tr("wallpaper.transitions.fade")
+ });
+ transitionsModel.append({
+ "key": "disc",
+ "name": I18n.tr("wallpaper.transitions.disc")
+ });
+ transitionsModel.append({
+ "key": "stripes",
+ "name": I18n.tr("wallpaper.transitions.stripes")
+ });
+ transitionsModel.append({
+ "key": "wipe",
+ "name": I18n.tr("wallpaper.transitions.wipe")
+ });
+ transitionsModel.append({
+ "key": "pixelate",
+ "name": I18n.tr("wallpaper.transitions.pixelate")
+ });
+ transitionsModel.append({
+ "key": "honeycomb",
+ "name": I18n.tr("wallpaper.transitions.honeycomb")
+ });
+ }
+
+ // -------------------------------------------------------------------
+ function getFillModeUniform() {
+ for (var i = 0; i < fillModeModel.count; i++) {
+ const mode = fillModeModel.get(i);
+ if (mode.key === Settings.data.wallpaper.fillMode) {
+ return mode.uniform;
+ }
+ }
+ // Fallback to crop
+ return 1.0;
+ }
+
+ // -------------------------------------------------------------------
+ // Solid color helpers
+ // -------------------------------------------------------------------
+ function isSolidColorPath(path) {
+ return path && typeof path === "string" && path.startsWith(solidColorPrefix);
+ }
+
+ function getSolidColor(path) {
+ if (!isSolidColorPath(path)) {
+ return null;
+ }
+ return path.substring(solidColorPrefix.length);
+ }
+
+ function createSolidColorPath(colorString) {
+ return solidColorPrefix + colorString;
+ }
+
+ function setSolidColor(colorString) {
+ Settings.data.wallpaper.solidColor = colorString;
+ Settings.data.wallpaper.useSolidColor = true;
+ }
+
+ // -------------------------------------------------------------------
+ // Per-screen wallpaper: persisted as { light, dark } (legacy string loads are normalized)
+ // -------------------------------------------------------------------
+ function _isSplitWallpaperEntry(entry) {
+ if (!entry || typeof entry !== "object") {
+ return false;
+ }
+ return entry.light !== undefined || entry.dark !== undefined;
+ }
+
+ function _pathsFromEntry(entry) {
+ if (!entry) {
+ return {
+ light: "",
+ dark: ""
+ };
+ }
+ if (typeof entry === "string") {
+ return {
+ light: entry,
+ dark: entry
+ };
+ }
+ return {
+ light: entry.light || "",
+ dark: entry.dark || ""
+ };
+ }
+
+ function _cloneWallpaperEntry(entry) {
+ if (typeof entry === "string") {
+ return {
+ light: entry,
+ dark: entry
+ };
+ }
+ var p = _pathsFromEntry(entry);
+ return {
+ light: p.light,
+ dark: p.dark
+ };
+ }
+
+ function _entriesEqual(a, b) {
+ if (a === b) {
+ return true;
+ }
+ if (typeof a === "string" && typeof b === "string") {
+ return a === b;
+ }
+ if (typeof a === "string" || typeof b === "string") {
+ return false;
+ }
+ if (!_isSplitWallpaperEntry(a) || !_isSplitWallpaperEntry(b)) {
+ return false;
+ }
+ var pa = _pathsFromEntry(a);
+ var pb = _pathsFromEntry(b);
+ return pa.light === pb.light && pa.dark === pb.dark;
+ }
+
+ function _entryToEffectivePath(entry) {
+ if (!entry) {
+ return "";
+ }
+ if (typeof entry === "string") {
+ return entry;
+ }
+ var p = _pathsFromEntry(entry);
+ if (Settings.data.colorSchemes.darkMode) {
+ return p.dark || p.light || "";
+ }
+ return p.light || p.dark || "";
+ }
+
+ function _normalizeAppearanceSlot(slot) {
+ return slot === "dark" ? "dark" : "light";
+ }
+
+ function _defaultAppearanceSlotForChange(slot) {
+ if (slot === "light" || slot === "dark") {
+ return slot;
+ }
+ return Settings.data.colorSchemes.darkMode ? "dark" : "light";
+ }
+
+ function getWallpaperPathForSlot(screenName, appearanceSlot) {
+ if (Settings.data.wallpaper.useSolidColor) {
+ return createSolidColorPath(Settings.data.wallpaper.solidColor.toString());
+ }
+ var slot = _normalizeAppearanceSlot(appearanceSlot);
+ var entry = currentWallpapers[screenName];
+ if (!entry) {
+ return "";
+ }
+ var p = _pathsFromEntry(entry);
+ if (slot === "dark") {
+ return p.dark || p.light || "";
+ }
+ return p.light || p.dark || "";
+ }
+
+ function getWallpapersEffectiveMap() {
+ var out = {};
+ for (var i = 0; i < Quickshell.screens.length; i++) {
+ var n = Quickshell.screens[i].name;
+ out[n] = getWallpaper(n);
+ }
+ return out;
+ }
+
+ function _ensureObjectWallpaperEntries() {
+ var names = {};
+ Object.keys(currentWallpapers).forEach(function (k) {
+ names[k] = true;
+ });
+ for (var i = 0; i < Quickshell.screens.length; i++) {
+ names[Quickshell.screens[i].name] = true;
+ }
+ Object.keys(names).forEach(function (name) {
+ var e = currentWallpapers[name];
+ if (!e) {
+ return;
+ }
+ if (typeof e === "string") {
+ if (Settings.data.wallpaper.linkLightAndDarkWallpapers) {
+ currentWallpapers[name] = {
+ light: e,
+ dark: e
+ };
+ } else {
+ currentWallpapers[name] = {
+ light: e,
+ dark: e
+ };
+ }
+ } else if (_isSplitWallpaperEntry(e)) {
+ var p = _pathsFromEntry(e);
+ if (Settings.data.wallpaper.linkLightAndDarkWallpapers) {
+ currentWallpapers[name] = {
+ light: p.light || p.dark || "",
+ dark: p.dark || p.light || ""
+ };
+ } else {
+ currentWallpapers[name] = {
+ light: p.light || "",
+ dark: p.dark || ""
+ };
+ }
+ }
+ });
+ saveTimer.restart();
+ }
+
+ function _notifyAllWallpapersChanged() {
+ for (var i = 0; i < Quickshell.screens.length; i++) {
+ var n = Quickshell.screens[i].name;
+ root.wallpaperChanged(n, getWallpaper(n));
+ }
+ }
+
+ // -------------------------------------------------------------------
+ // Get specific monitor wallpaper data
+ function getMonitorConfig(screenName) {
+ var monitors = Settings.data.wallpaper.monitorDirectories;
+ if (monitors !== undefined) {
+ for (var i = 0; i < monitors.length; i++) {
+ if (monitors[i].name !== undefined && monitors[i].name === screenName) {
+ return monitors[i];
+ }
+ }
+ }
+ }
+
+ // -------------------------------------------------------------------
+ // Get specific monitor directory
+ function getMonitorDirectory(screenName) {
+ if (!Settings.data.wallpaper.enableMultiMonitorDirectories) {
+ return root.defaultDirectory;
+ }
+
+ var monitor = getMonitorConfig(screenName);
+ if (monitor !== undefined && monitor.directory !== undefined) {
+ return Settings.preprocessPath(monitor.directory);
+ }
+
+ // Fall back to the main/single directory
+ return root.defaultDirectory;
+ }
+
+ // -------------------------------------------------------------------
+ // Set specific monitor directory
+ function setMonitorDirectory(screenName, directory) {
+ var monitors = Settings.data.wallpaper.monitorDirectories || [];
+ var found = false;
+
+ // Create a new array with updated values
+ var newMonitors = monitors.map(function (monitor) {
+ if (monitor.name === screenName) {
+ found = true;
+ return {
+ "name": screenName,
+ "directory": directory,
+ "wallpaper": monitor.wallpaper || ""
+ };
+ }
+ return monitor;
+ });
+
+ if (!found) {
+ newMonitors.push({
+ "name": screenName,
+ "directory": directory,
+ "wallpaper": ""
+ });
+ }
+
+ // Update Settings with new array to ensure proper persistence
+ Settings.data.wallpaper.monitorDirectories = newMonitors.slice();
+ root.wallpaperDirectoryChanged(screenName, Settings.preprocessPath(directory));
+ }
+
+ // -------------------------------------------------------------------
+ // Get specific monitor wallpaper - now from cache
+ function getWallpaper(screenName) {
+ // Return solid color path when in solid color mode
+ if (Settings.data.wallpaper.useSolidColor) {
+ return createSolidColorPath(Settings.data.wallpaper.solidColor.toString());
+ }
+ var entry = currentWallpapers[screenName];
+ if (entry) {
+ var effective = _entryToEffectivePath(entry);
+ if (effective) {
+ return effective;
+ }
+ }
+
+ // Try to inherit wallpaper from another active screen
+ var inherited = _inheritWallpaperFromExistingScreen(screenName);
+ if (inherited) {
+ return inherited;
+ }
+
+ return root.defaultWallpaper;
+ }
+
+ // -------------------------------------------------------------------
+ function changeWallpaper(path, screenName, appearanceSlot) {
+ // Turn off solid color mode when selecting a wallpaper
+ if (Settings.data.wallpaper.useSolidColor) {
+ Settings.data.wallpaper.useSolidColor = false;
+ }
+
+ var slot = _defaultAppearanceSlotForChange(appearanceSlot);
+
+ // Save current favorite color schemes before switching away.
+ // This must happen before applyFavoriteTheme (called by the UI)
+ // overwrites the settings that _createFavoriteEntry reads.
+ _saveOutgoingFavorites(path, screenName, slot);
+
+ if (screenName !== undefined) {
+ _setWallpaper(screenName, path, slot);
+ } else {
+ var allScreenNames = new Set(Object.keys(currentWallpapers));
+ for (var i = 0; i < Quickshell.screens.length; i++) {
+ allScreenNames.add(Quickshell.screens[i].name);
+ }
+ allScreenNames.forEach(name => _setWallpaper(name, path, slot));
+ }
+ }
+
+ // -------------------------------------------------------------------
+ // Save the color scheme of any favorited wallpapers that are about
+ // to be replaced, while the current settings still reflect them.
+ function _saveOutgoingFavorites(newPath, screenName, appearanceSlot) {
+ var paths = [];
+ var slot = _normalizeAppearanceSlot(appearanceSlot);
+
+ function collectFromEntry(e) {
+ if (!e) {
+ return;
+ }
+ if (typeof e === "string") {
+ if (e && e !== newPath) {
+ paths.push(e);
+ }
+ return;
+ }
+ if (!_isSplitWallpaperEntry(e)) {
+ return;
+ }
+ var p = _pathsFromEntry(e);
+ if (Settings.data.wallpaper.linkLightAndDarkWallpapers) {
+ if (p.light && p.light !== newPath) {
+ paths.push(p.light);
+ }
+ if (p.dark && p.dark !== newPath && p.dark !== p.light) {
+ paths.push(p.dark);
+ }
+ } else {
+ var old = slot === "dark" ? p.dark : p.light;
+ if (old && old !== newPath) {
+ paths.push(old);
+ }
+ }
+ }
+
+ if (screenName !== undefined) {
+ collectFromEntry(currentWallpapers[screenName]);
+ } else {
+ var names = new Set(Object.keys(currentWallpapers));
+ for (var i = 0; i < Quickshell.screens.length; i++) {
+ names.add(Quickshell.screens[i].name);
+ }
+ names.forEach(function (name) {
+ collectFromEntry(currentWallpapers[name]);
+ });
+ }
+
+ var unique = [];
+ for (var j = 0; j < paths.length; j++) {
+ if (unique.indexOf(paths[j]) === -1) {
+ unique.push(paths[j]);
+ }
+ }
+
+ unique.forEach(function (path) {
+ if (!path || path === newPath) {
+ return;
+ }
+ var favIdx = _findAnyFavoriteIndexForPath(path);
+ if (favIdx === _favoriteNotFound) {
+ return;
+ }
+ var app = _favoriteAppearanceSlot(Settings.data.wallpaper.favorites[favIdx]);
+ updateFavoriteColorScheme(path, app);
+ });
+ }
+
+ // -------------------------------------------------------------------
+ function _inheritWallpaperFromExistingScreen(screenName) {
+ for (var i = 0; i < Quickshell.screens.length; i++) {
+ var otherName = Quickshell.screens[i].name;
+ if (otherName === screenName) {
+ continue;
+ }
+ var entry = currentWallpapers[otherName];
+ if (!entry) {
+ continue;
+ }
+ var cloned = _cloneWallpaperEntry(entry);
+ if (_entriesEqual(currentWallpapers[screenName], cloned)) {
+ return _entryToEffectivePath(cloned);
+ }
+ currentWallpapers[screenName] = cloned;
+ saveTimer.restart();
+ root.wallpaperChanged(screenName, _entryToEffectivePath(cloned));
+ if (randomWallpaperTimer.running) {
+ randomWallpaperTimer.restart();
+ }
+ return _entryToEffectivePath(cloned);
+ }
+ return "";
+ }
+
+ function _syncWallpaperSlotsWhenLinking() {
+ var names = new Set(Object.keys(currentWallpapers));
+ for (var i = 0; i < Quickshell.screens.length; i++) {
+ names.add(Quickshell.screens[i].name);
+ }
+ names.forEach(function (name) {
+ var e = currentWallpapers[name];
+ if (!e) {
+ return;
+ }
+ var eff = _entryToEffectivePath(e);
+ if (!eff) {
+ return;
+ }
+ var merged = {
+ light: eff,
+ dark: eff
+ };
+ if (_entriesEqual(e, merged)) {
+ return;
+ }
+ currentWallpapers[name] = merged;
+ saveTimer.restart();
+ root.wallpaperChanged(name, eff);
+ });
+ if (randomWallpaperTimer.running) {
+ randomWallpaperTimer.restart();
+ }
+ }
+
+ function _setWallpaper(screenName, path, appearanceSlot) {
+ if (path === "" || path === undefined) {
+ return;
+ }
+
+ if (screenName === undefined) {
+ Logger.w("Wallpaper", "setWallpaper", "no screen specified");
+ return;
+ }
+
+ var slot = _normalizeAppearanceSlot(appearanceSlot);
+ var oldEntry = currentWallpapers[screenName];
+ var p = _pathsFromEntry(oldEntry);
+ var newEntry;
+ if (Settings.data.wallpaper.linkLightAndDarkWallpapers) {
+ newEntry = {
+ light: path,
+ dark: path
+ };
+ } else if (slot === "dark") {
+ newEntry = {
+ light: p.light || "",
+ dark: path
+ };
+ } else {
+ newEntry = {
+ light: path,
+ dark: p.dark || ""
+ };
+ }
+
+ if (_entriesEqual(oldEntry, newEntry)) {
+ return;
+ }
+
+ currentWallpapers[screenName] = newEntry;
+ saveTimer.restart();
+ root.wallpaperChanged(screenName, _entryToEffectivePath(newEntry));
+
+ if (randomWallpaperTimer.running) {
+ randomWallpaperTimer.restart();
+ }
+ }
+
+ // -------------------------------------------------------------------
+ function setRandomWallpaper(screen) {
+ Logger.d("Wallpaper", "setRandomWallpaper");
+
+ if (Settings.data.wallpaper.enableMultiMonitorDirectories) {
+ if (screen === undefined) {
+ // Pick a random wallpaper per screen
+ for (var i = 0; i < Quickshell.screens.length; i++) {
+ var screenName = Quickshell.screens[i].name;
+ var wallpaperList = getWallpapersList(screenName);
+
+ if (wallpaperList.length > 0) {
+ var randomPath = _pickUnusedRandom(screenName, wallpaperList);
+ changeWallpaper(randomPath, screenName);
+ }
+ }
+ } else {
+ // Pick a random wallpaper for the specified screen
+ var wallpaperList = getWallpapersList(screen);
+ if (wallpaperList.length > 0) {
+ var randomPath = _pickUnusedRandom(screen, wallpaperList);
+ changeWallpaper(randomPath, screen);
+ }
+ }
+ } else {
+ // Pick a random wallpaper common to all screens
+ // We can use any screenName here, so we just pick the primary one.
+ var wallpaperList = getWallpapersList(Screen.name);
+ if (wallpaperList.length > 0) {
+ var randomPath = _pickUnusedRandom("all", wallpaperList);
+ changeWallpaper(randomPath, screen);
+ }
+ }
+ }
+
+ // -------------------------------------------------------------------
+ // Pick a random wallpaper that hasn't been used yet in the current cycle.
+ // Once all wallpapers have been shown, resets the pool (keeping only the
+ // last-shown wallpaper to avoid an immediate repeat).
+ function _pickUnusedRandom(key, wallpaperList) {
+ var used = usedRandomWallpapers[key] || [];
+
+ // Clean stale entries (files that were removed from the directory)
+ var wallpaperSet = new Set(wallpaperList);
+ used = used.filter(function (path) {
+ return wallpaperSet.has(path);
+ });
+
+ // Filter to wallpapers that haven't been used yet
+ var unused = wallpaperList.filter(function (path) {
+ return used.indexOf(path) === -1;
+ });
+
+ // If all have been used, reset but keep the last one to avoid immediate repeat
+ if (unused.length === 0) {
+ var lastUsed = used.length > 0 ? used[used.length - 1] : "";
+ used = lastUsed ? [lastUsed] : [];
+ unused = wallpaperList.filter(function (path) {
+ return used.indexOf(path) === -1;
+ });
+ // Edge case: only one wallpaper in the directory
+ if (unused.length === 0) {
+ unused = wallpaperList;
+ }
+ Logger.d("Wallpaper", "All wallpapers used for", key, "- resetting pool");
+ }
+
+ // Pick randomly from unused
+ var randomIndex = Math.floor(Math.random() * unused.length);
+ var picked = unused[randomIndex];
+
+ // Record as used
+ used.push(picked);
+ usedRandomWallpapers[key] = used;
+
+ // Persist
+ saveTimer.restart();
+
+ return picked;
+ }
+
+ // -------------------------------------------------------------------
+ function setAlphabeticalWallpaper() {
+ Logger.d("Wallpaper", "setAlphabeticalWallpaper");
+
+ if (Settings.data.wallpaper.enableMultiMonitorDirectories) {
+ // Pick next alphabetical wallpaper per screen
+ for (var i = 0; i < Quickshell.screens.length; i++) {
+ var screenName = Quickshell.screens[i].name;
+ var wallpaperList = getWallpapersList(screenName);
+
+ if (wallpaperList.length > 0) {
+ // Get or initialize index for this screen
+ if (alphabeticalIndices[screenName] === undefined) {
+ // Find current wallpaper in list to set initial index
+ var currentWallpaper = getWallpaper(screenName) || "";
+ var foundIndex = wallpaperList.indexOf(currentWallpaper);
+ alphabeticalIndices[screenName] = (foundIndex >= 0) ? foundIndex : 0;
+ }
+
+ // Get next index (wrap around)
+ var currentIndex = alphabeticalIndices[screenName];
+ var nextIndex = (currentIndex + 1) % wallpaperList.length;
+ alphabeticalIndices[screenName] = nextIndex;
+
+ var nextPath = wallpaperList[nextIndex];
+ changeWallpaper(nextPath, screenName);
+ }
+ }
+ } else {
+ // Pick next alphabetical wallpaper common to all screens
+ var wallpaperList = getWallpapersList(Screen.name);
+ if (wallpaperList.length > 0) {
+ // Use primary screen name as key for single directory mode
+ var key = "all";
+ if (alphabeticalIndices[key] === undefined) {
+ // Find current wallpaper in list to set initial index
+ var currentWallpaper = getWallpaper(Screen.name) || "";
+ var foundIndex = wallpaperList.indexOf(currentWallpaper);
+ alphabeticalIndices[key] = (foundIndex >= 0) ? foundIndex : 0;
+ }
+
+ // Get next index (wrap around)
+ var currentIndex = alphabeticalIndices[key];
+ var nextIndex = (currentIndex + 1) % wallpaperList.length;
+ alphabeticalIndices[key] = nextIndex;
+
+ var nextPath = wallpaperList[nextIndex];
+ changeWallpaper(nextPath, undefined);
+ }
+ }
+ }
+
+ // -------------------------------------------------------------------
+ function toggleRandomWallpaper() {
+ Logger.d("Wallpaper", "toggleRandomWallpaper");
+ if (Settings.data.wallpaper.automationEnabled) {
+ restartRandomWallpaperTimer();
+ setNextWallpaper();
+ }
+ }
+
+ // -------------------------------------------------------------------
+ function setNextWallpaper() {
+ var mode = Settings.data.wallpaper.wallpaperChangeMode || "random";
+ if (mode === "alphabetical") {
+ setAlphabeticalWallpaper();
+ } else {
+ setRandomWallpaper();
+ }
+ }
+
+ // -------------------------------------------------------------------
+ function restartRandomWallpaperTimer() {
+ if (Settings.data.wallpaper.automationEnabled) {
+ randomWallpaperTimer.restart();
+ }
+ }
+
+ // -------------------------------------------------------------------
+ function getWallpapersList(screenName) {
+ if (screenName != undefined && wallpaperLists[screenName] != undefined) {
+ return wallpaperLists[screenName];
+ }
+ return [];
+ }
+
+ // -------------------------------------------------------------------
+ // Browse mode helper functions
+ // -------------------------------------------------------------------
+ function getCurrentBrowsePath(screenName) {
+ if (currentBrowsePaths[screenName] !== undefined) {
+ var stored = currentBrowsePaths[screenName];
+ var root = getMonitorDirectory(screenName);
+ if (root && stored.startsWith(root)) {
+ return stored;
+ }
+ // Stored path is outside the root directory, reset it
+ delete currentBrowsePaths[screenName];
+ }
+ return getMonitorDirectory(screenName);
+ }
+
+ function setBrowsePath(screenName, path) {
+ if (!screenName)
+ return;
+ currentBrowsePaths[screenName] = path;
+ browsePathChanged(screenName, path);
+ }
+
+ function navigateUp(screenName) {
+ if (!screenName)
+ return;
+ var currentPath = getCurrentBrowsePath(screenName);
+ var rootPath = getMonitorDirectory(screenName);
+
+ // Don't navigate if root is invalid or we're already at root
+ if (!rootPath || currentPath === rootPath)
+ return;
+
+ // Get parent directory
+ var parentPath = currentPath.replace(/\/[^\/]+\/?$/, "");
+ if (parentPath === "")
+ parentPath = rootPath;
+
+ // Don't go above root
+ if (!parentPath.startsWith(rootPath)) {
+ parentPath = rootPath;
+ }
+
+ setBrowsePath(screenName, parentPath);
+ }
+
+ function navigateToRoot(screenName) {
+ if (!screenName)
+ return;
+ var rootPath = getMonitorDirectory(screenName);
+ setBrowsePath(screenName, rootPath);
+ }
+
+ // Scan directory with optional directory listing (for browse mode)
+ // callback receives { files: [], directories: [] }
+ function scanDirectoryWithDirs(screenName, directory, callback) {
+ if (!directory || directory === "") {
+ callback({
+ files: [],
+ directories: []
+ });
+ return;
+ }
+
+ var result = {
+ files: [],
+ directories: []
+ };
+ var pendingScans = 2;
+
+ function checkComplete() {
+ pendingScans--;
+ if (pendingScans === 0) {
+ // Files are already sorted by _scanDirectoryInternal according to sortOrder setting
+ // Only sort directories alphabetically
+ result.directories.sort();
+ callback(result);
+ }
+ }
+
+ // Scan for files
+ _scanDirectoryInternal(screenName, directory, false, false, function (files) {
+ result.files = files;
+ checkComplete();
+ });
+
+ // Scan for directories
+ _scanForDirectories(directory, function (dirs) {
+ result.directories = dirs;
+ checkComplete();
+ });
+ }
+
+ function _scanForDirectories(directory, callback) {
+ var findArgs = ["find", "-L", directory, "-maxdepth", "1", "-mindepth", "1", "-type", "d"];
+
+ var processString = `
+ import QtQuick
+ import Quickshell.Io
+ Process {
+ id: process
+ command: ${JSON.stringify(findArgs)}
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+ }
+ `;
+
+ var processObject = Qt.createQmlObject(processString, root, "DirScan");
+
+ processObject.exited.connect(function (exitCode) {
+ var dirs = [];
+ if (exitCode === 0) {
+ var lines = processObject.stdout.text.split('\n');
+ for (var i = 0; i < lines.length; i++) {
+ var line = lines[i].trim();
+ if (line !== '') {
+ var showHidden = Settings.data.wallpaper.showHiddenFiles;
+ var name = line.split('/').pop();
+ if (showHidden || !name.startsWith('.')) {
+ dirs.push(line);
+ }
+ }
+ }
+ }
+ callback(dirs);
+ processObject.destroy();
+ });
+
+ processObject.running = true;
+ }
+
+ // -------------------------------------------------------------------
+ function refreshWallpapersList() {
+ // Wait for imageMagickAvailable to be correctly set for ImageCacheService.imageFilters
+ if (!ImageCacheService.initialized) {
+ Qt.callLater(refreshWallpapersList);
+ return;
+ }
+
+ var mode = Settings.data.wallpaper.viewMode;
+ Logger.d("Wallpaper", "refreshWallpapersList", "viewMode:", mode);
+ scanningCount = 0;
+
+ if (mode === "recursive") {
+ // Use Process-based recursive search for all screens
+ for (var i = 0; i < Quickshell.screens.length; i++) {
+ var screenName = Quickshell.screens[i].name;
+ var directory = getMonitorDirectory(screenName);
+ scanDirectoryRecursive(screenName, directory);
+ }
+ } else if (mode === "browse") {
+ // Browse mode: scan current browse path (non-recursive)
+ // Note: The actual directory+subdirectory scanning happens in WallpaperPanel
+ // Here we just scan the current browse path for files
+ for (var i = 0; i < Quickshell.screens.length; i++) {
+ var screenName = Quickshell.screens[i].name;
+ var directory = getCurrentBrowsePath(screenName);
+ _scanDirectoryInternal(screenName, directory, false, true, null);
+ }
+ } else {
+ // Single directory mode (non-recursive)
+ for (var i = 0; i < Quickshell.screens.length; i++) {
+ var screenName = Quickshell.screens[i].name;
+ var directory = getMonitorDirectory(screenName);
+ _scanDirectoryInternal(screenName, directory, false, true, null);
+ }
+ }
+ }
+
+ // Internal scan function
+ // recursive: whether to scan subdirectories
+ // updateList: whether to update wallpaperLists and emit signal
+ // callback: optional callback with files array
+ function _scanDirectoryInternal(screenName, directory, recursive, updateList, callback) {
+ if (!directory || directory === "") {
+ Logger.w("Wallpaper", "Empty directory for", screenName);
+ if (updateList) {
+ wallpaperLists[screenName] = [];
+ wallpaperListChanged(screenName, 0);
+ }
+ if (callback)
+ callback([]);
+ return;
+ }
+
+ // Cancel any existing scan for this screen
+ if (recursiveProcesses[screenName]) {
+ Logger.d("Wallpaper", "Cancelling existing scan for", screenName);
+ recursiveProcesses[screenName].running = false;
+ recursiveProcesses[screenName].destroy();
+ delete recursiveProcesses[screenName];
+ if (updateList)
+ scanningCount--;
+ }
+
+ if (updateList)
+ scanningCount++;
+ Logger.i("Wallpaper", "Starting scan for", screenName, "in", directory, "recursive:", recursive);
+
+ // Build find command args dynamically from ImageCacheService filters
+ var filters = ImageCacheService.imageFilters;
+ var findArgs = ["find", "-L", directory];
+
+ // Add depth limit for non-recursive
+ if (!recursive) {
+ findArgs.push("-maxdepth", "1", "-mindepth", "1");
+ }
+
+ findArgs.push("-type", "f", "(");
+ for (var i = 0; i < filters.length; i++) {
+ if (i > 0) {
+ findArgs.push("-o");
+ }
+ findArgs.push("-iname");
+ findArgs.push(filters[i]);
+ }
+ findArgs.push(")");
+ // Add printf to get modification time
+ findArgs.push("-printf", "%T@|%p\\n");
+
+ // Create Process component inline
+ var processString = `
+ import QtQuick
+ import Quickshell.Io
+ Process {
+ id: process
+ command: ${JSON.stringify(findArgs)}
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+ }
+ `;
+
+ var processObject = Qt.createQmlObject(processString, root, "Scan_" + screenName);
+
+ // Store reference to avoid garbage collection
+ if (updateList) {
+ recursiveProcesses[screenName] = processObject;
+ }
+
+ var handler = function (exitCode) {
+ if (updateList)
+ scanningCount--;
+ Logger.d("Wallpaper", "Process exited with code", exitCode, "for", screenName);
+
+ var files = [];
+ if (exitCode === 0) {
+ var lines = processObject.stdout.text.split('\n');
+ var parsedFiles = [];
+
+ for (var i = 0; i < lines.length; i++) {
+ var line = lines[i].trim();
+ if (line !== '') {
+ var parts = line.split('|');
+ if (parts.length >= 2) { // Handle potential extra pipes in filename by joining rest
+ var timestamp = parseFloat(parts[0]);
+ var path = parts.slice(1).join('|');
+
+ var showHidden = Settings.data.wallpaper.showHiddenFiles;
+ var name = path.split('/').pop();
+ if (showHidden || !name.startsWith('.')) {
+ parsedFiles.push({
+ path: path,
+ time: timestamp,
+ name: name
+ });
+ }
+ } else if (line.indexOf('|') === -1) {
+ // Fallback for unexpected output format or old find versions (unlikely but safe)
+ var path = line;
+ var showHidden = Settings.data.wallpaper.showHiddenFiles;
+ var name = path.split('/').pop();
+ if (showHidden || !name.startsWith('.')) {
+ parsedFiles.push({
+ path: path,
+ time: 0,
+ name: name
+ });
+ }
+ }
+ }
+ }
+ // Sort files based on settings
+ var sortOrder = Settings.data.wallpaper.sortOrder || "name";
+
+ // Fischer-Yates shuffle
+ if (sortOrder === "random") {
+ for (let i = parsedFiles.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ const temp = parsedFiles[i];
+ parsedFiles[i] = parsedFiles[j];
+ parsedFiles[j] = temp;
+ }
+ } else {
+ parsedFiles.sort(function (a, b) {
+ if (sortOrder === "date_desc") { // Newest first
+ return b.time - a.time;
+ } else if (sortOrder === "date_asc") { // Oldest first
+ return a.time - b.time;
+ } else if (sortOrder === "name_desc") {
+ return b.name.localeCompare(a.name);
+ } else { // name (asc)
+ return a.name.localeCompare(b.name);
+ }
+ });
+ }
+
+ // Map back to string array
+ files = parsedFiles.map(f => f.path);
+
+ if (updateList) {
+ wallpaperLists[screenName] = files;
+
+ // Reset alphabetical indices when list changes
+ if (alphabeticalIndices[screenName] !== undefined) {
+ var currentWallpaper = getWallpaper(screenName) || "";
+ var foundIndex = files.indexOf(currentWallpaper);
+ alphabeticalIndices[screenName] = (foundIndex >= 0) ? foundIndex : 0;
+ }
+
+ Logger.i("Wallpaper", "Scan completed for", screenName, "found", files.length, "files");
+ wallpaperListChanged(screenName, files.length);
+ }
+ } else {
+ Logger.w("Wallpaper", "Scan failed for", screenName, "exit code:", exitCode, "(directory might not exist)");
+ if (updateList) {
+ wallpaperLists[screenName] = [];
+ if (alphabeticalIndices[screenName] !== undefined) {
+ alphabeticalIndices[screenName] = 0;
+ }
+ wallpaperListChanged(screenName, 0);
+ }
+ }
+
+ // Clean up
+ if (updateList) {
+ delete recursiveProcesses[screenName];
+ }
+
+ if (callback)
+ callback(files);
+ processObject.destroy();
+ };
+
+ processObject.exited.connect(handler);
+ Logger.d("Wallpaper", "Starting process for", screenName);
+ processObject.running = true;
+ }
+
+ // Process instances for scanning (one per screen)
+ property var recursiveProcesses: ({})
+
+ // -------------------------------------------------------------------
+ function scanDirectoryRecursive(screenName, directory) {
+ _scanDirectoryInternal(screenName, directory, true, true, null);
+ }
+
+ // -------------------------------------------------------------------
+ // Favorites
+ // -------------------------------------------------------------------
+ // TODO (~few weeks): Remove per-favorite `darkMode` (the boolean on each
+ // Settings.data.wallpaper.favorites[] entry). It duplicates `appearance` and is
+ // unrelated to Settings.data.colorSchemes.darkMode (global shell light/dark).
+ // Plan: one-time migration, then drop writes and the fallback in _favoriteAppearanceSlot.
+ // -------------------------------------------------------------------
+ readonly property int _favoriteNotFound: -1
+
+ // -------------------------------------------------------------------
+ function _favoriteAppearanceSlot(f) {
+ if (f.appearance === "light" || f.appearance === "dark") {
+ return f.appearance;
+ }
+ return f.darkMode ? "dark" : "light";
+ }
+
+ function _findFavoriteIndex(path, appearanceSlot) {
+ var favorites = Settings.data.wallpaper.favorites;
+ var searchPath = Settings.preprocessPath(path);
+ var slot = _normalizeAppearanceSlot(appearanceSlot);
+ for (var i = 0; i < favorites.length; i++) {
+ if (Settings.preprocessPath(favorites[i].path) !== searchPath) {
+ continue;
+ }
+ if (_favoriteAppearanceSlot(favorites[i]) === slot) {
+ return i;
+ }
+ }
+ return _favoriteNotFound;
+ }
+
+ function _findAnyFavoriteIndexForPath(path) {
+ var favorites = Settings.data.wallpaper.favorites;
+ var searchPath = Settings.preprocessPath(path);
+ for (var i = 0; i < favorites.length; i++) {
+ if (Settings.preprocessPath(favorites[i].path) === searchPath) {
+ return i;
+ }
+ }
+ return _favoriteNotFound;
+ }
+
+ function _dedupeWallpaperFavoritesByPath() {
+ var favorites = Settings.data.wallpaper.favorites;
+ if (!favorites || !favorites.length) {
+ return;
+ }
+ var seen = {};
+ var out = [];
+ for (var i = 0; i < favorites.length; i++) {
+ var key = Settings.preprocessPath(favorites[i].path);
+ if (!key || seen[key]) {
+ continue;
+ }
+ seen[key] = true;
+ out.push(favorites[i]);
+ }
+ if (out.length !== favorites.length) {
+ Settings.data.wallpaper.favorites = out;
+ root.favoritesRevision++;
+ }
+ }
+
+ // -------------------------------------------------------------------
+ function _createFavoriteEntry(path, appearanceSlot) {
+ var app = _normalizeAppearanceSlot(appearanceSlot);
+ return {
+ "path": path,
+ "appearance": app,
+ "colorScheme": Settings.data.colorSchemes.predefinedScheme,
+ "darkMode": app === "dark" // TODO: remove per-favorite field (see Favorites section note)
+ ,
+ "useWallpaperColors": Settings.data.colorSchemes.useWallpaperColors,
+ "generationMethod": Settings.data.colorSchemes.generationMethod,
+ "paletteColors": [Color.mPrimary.toString(), Color.mSecondary.toString(), Color.mTertiary.toString(), Color.mError.toString()]
+ };
+ }
+
+ // -------------------------------------------------------------------
+ // Favorites are per (path, light|dark): at most one entry per path, tagged with the tab you starred from.
+ function isFavorite(path, appearanceSlot) {
+ if (appearanceSlot === undefined || appearanceSlot === null || appearanceSlot === "") {
+ return _findAnyFavoriteIndexForPath(path) !== _favoriteNotFound;
+ }
+ return _findFavoriteIndex(path, appearanceSlot) !== _favoriteNotFound;
+ }
+
+ // Single favorite entry per path; use _favoriteAppearanceSlot(entry) for light vs dark it was starred under.
+ function favoriteEntryForPath(path) {
+ var idx = _findAnyFavoriteIndexForPath(path);
+ if (idx === _favoriteNotFound) {
+ return null;
+ }
+ return Settings.data.wallpaper.favorites[idx];
+ }
+
+ function getFavoriteForDisplay(path) {
+ return favoriteEntryForPath(path);
+ }
+
+ // -------------------------------------------------------------------
+ function toggleFavorite(path, appearanceSlot, screenName) {
+ var slot;
+ if (appearanceSlot !== undefined && appearanceSlot !== null && appearanceSlot !== "") {
+ slot = _normalizeAppearanceSlot(appearanceSlot);
+ } else {
+ slot = _normalizeAppearanceSlot(root.wallpaperSelectionAppearance);
+ }
+ var favorites = Settings.data.wallpaper.favorites.slice();
+ var anyIdx = _findAnyFavoriteIndexForPath(path);
+ var applyWallpaperForSlot = false;
+
+ if (anyIdx !== _favoriteNotFound) {
+ var existingSlot = _favoriteAppearanceSlot(favorites[anyIdx]);
+ if (existingSlot === slot) {
+ favorites.splice(anyIdx, 1);
+ Logger.d("Wallpaper", "Removed favorite:", path, slot);
+ if (favoriteSchemeDebounceTimer.pendingPath === path && favoriteSchemeDebounceTimer.pendingSlot === slot) {
+ favoriteSchemeDebounceTimer.stop();
+ favoriteSchemeDebounceTimer.pendingPath = "";
+ favoriteSchemeDebounceTimer.pendingSlot = "";
+ }
+ if (root.pendingFavoriteSchemeRefresh && root.pendingFavoriteSchemeRefresh.path === path && root.pendingFavoriteSchemeRefresh.slot === slot) {
+ root.pendingFavoriteSchemeRefresh = null;
+ }
+ } else if (Settings.data.wallpaper.linkLightAndDarkWallpapers) {
+ favorites[anyIdx] = _createFavoriteEntry(path, slot);
+ Logger.d("Wallpaper", "Moved favorite to other appearance:", path, slot);
+ root.scheduleFavoriteSchemeSnapshot(path, slot);
+ applyWallpaperForSlot = true;
+ if (root.pendingFavoriteSchemeRefresh && root.pendingFavoriteSchemeRefresh.path === path) {
+ root.pendingFavoriteSchemeRefresh = {
+ "path": path,
+ "slot": slot
+ };
+ }
+ } else {
+ // Separate light/dark wallpapers: star is remove-only (no sun/moon hint for "move" vs unfavorite).
+ favorites.splice(anyIdx, 1);
+ Logger.d("Wallpaper", "Removed favorite (star on other appearance tab, separate wallpapers):", path);
+ if (favoriteSchemeDebounceTimer.pendingPath === path) {
+ favoriteSchemeDebounceTimer.stop();
+ favoriteSchemeDebounceTimer.pendingPath = "";
+ favoriteSchemeDebounceTimer.pendingSlot = "";
+ }
+ if (root.pendingFavoriteSchemeRefresh && root.pendingFavoriteSchemeRefresh.path === path) {
+ root.pendingFavoriteSchemeRefresh = null;
+ }
+ }
+ } else {
+ favorites.push(_createFavoriteEntry(path, slot));
+ Logger.d("Wallpaper", "Added favorite:", path, slot);
+ root.scheduleFavoriteSchemeSnapshot(path, slot);
+ applyWallpaperForSlot = true;
+ }
+
+ Settings.data.wallpaper.favorites = favorites;
+ root.favoritesRevision++;
+
+ if (applyWallpaperForSlot) {
+ var scr;
+ if (Settings.data.wallpaper.setWallpaperOnAllMonitors) {
+ scr = undefined;
+ } else if (screenName !== undefined && screenName !== null && screenName !== "") {
+ scr = screenName;
+ } else if (Quickshell.screens.length > 0) {
+ scr = Quickshell.screens[0].name;
+ } else {
+ scr = undefined;
+ }
+ root.changeWallpaper(path, scr, slot);
+ root.applyFavoriteTheme(path, scr, slot);
+ }
+
+ favoritesChanged(path);
+ }
+
+ // Apply saved scheme from a favorite. Optional appearanceSlotOverride sets light vs dark target (UI tab or system mode).
+ function _applyFavoriteThemeFromEntry(favorite, appearanceSlotOverride) {
+ if (!favorite) {
+ return;
+ }
+
+ var favApp;
+ if (appearanceSlotOverride !== undefined && appearanceSlotOverride !== null && appearanceSlotOverride !== "") {
+ favApp = _normalizeAppearanceSlot(appearanceSlotOverride);
+ } else {
+ favApp = _favoriteAppearanceSlot(favorite);
+ }
+ var targetDark = favApp === "dark";
+
+ var generationMethodChanging = Settings.data.colorSchemes.generationMethod !== favorite.generationMethod;
+ var darkModeChanging = Settings.data.colorSchemes.darkMode !== targetDark;
+ var useWallpaperColorsChanging = Settings.data.colorSchemes.useWallpaperColors !== favorite.useWallpaperColors;
+
+ Settings.data.colorSchemes.useWallpaperColors = favorite.useWallpaperColors;
+ Settings.data.colorSchemes.predefinedScheme = favorite.colorScheme;
+ Settings.data.colorSchemes.generationMethod = favorite.generationMethod;
+ Settings.data.colorSchemes.darkMode = targetDark;
+
+ // If nothing triggered AppThemeService via change handlers, regenerate once.
+ if (!generationMethodChanging && !darkModeChanging && !useWallpaperColorsChanging) {
+ AppThemeService.generate();
+ } else if (!generationMethodChanging && !darkModeChanging && useWallpaperColorsChanging) {
+ AppThemeService.generate();
+ }
+ }
+
+ // When light/dark changes (or on startup), re-load scheme from the favorite for the wallpaper now shown for that slot.
+ function reapplyFavoriteThemeForActiveWallpaper() {
+ var effectiveMonitor = Settings.data.colorSchemes.monitorForColors;
+ if (effectiveMonitor === "" || effectiveMonitor === undefined) {
+ effectiveMonitor = Quickshell.screens.length > 0 ? Quickshell.screens[0].name : "";
+ }
+ var wp = getWallpaper(effectiveMonitor);
+ if (!wp || isSolidColorPath(wp)) {
+ return;
+ }
+ var slot = Settings.data.colorSchemes.darkMode ? "dark" : "light";
+ var favorite = favoriteEntryForPath(wp);
+ if (!favorite) {
+ return;
+ }
+ _applyFavoriteThemeFromEntry(favorite, slot);
+ }
+
+ // -------------------------------------------------------------------
+ function applyFavoriteTheme(path, screenName, appearanceSlot) {
+ // Only apply theme if the wallpaper is on the monitor driving colors
+ var effectiveMonitor = Settings.data.colorSchemes.monitorForColors;
+ if (effectiveMonitor === "" || effectiveMonitor === undefined) {
+ effectiveMonitor = Quickshell.screens.length > 0 ? Quickshell.screens[0].name : "";
+ }
+ if (screenName !== undefined && screenName !== effectiveMonitor) {
+ return;
+ }
+
+ var slot;
+ if (appearanceSlot !== undefined && appearanceSlot !== null && appearanceSlot !== "") {
+ slot = _normalizeAppearanceSlot(appearanceSlot);
+ } else {
+ slot = _normalizeAppearanceSlot(root.wallpaperSelectionAppearance);
+ }
+ var favorite = favoriteEntryForPath(path);
+ if (!favorite) {
+ return;
+ }
+
+ var schemeSlot = slot;
+ if (Settings.data.wallpaper.linkLightAndDarkWallpapers) {
+ schemeSlot = _favoriteAppearanceSlot(favorite);
+ }
+ _applyFavoriteThemeFromEntry(favorite, schemeSlot);
+ }
+
+ // -------------------------------------------------------------------
+ function updateFavoriteColorScheme(path, appearanceSlot) {
+ var slot = _normalizeAppearanceSlot(appearanceSlot);
+ var existingIndex = _findFavoriteIndex(path, slot);
+ if (existingIndex === _favoriteNotFound) {
+ return;
+ }
+
+ var favorites = Settings.data.wallpaper.favorites.slice();
+ favorites[existingIndex] = _createFavoriteEntry(favorites[existingIndex].path, slot);
+ Settings.data.wallpaper.favorites = favorites;
+ Logger.d("Wallpaper", "Updated color scheme for favorite:", path, slot);
+ favoriteDataUpdated(path);
+ }
+
+ signal favoritesChanged(string path)
+ signal favoriteDataUpdated(string path)
+
+ // Auto-update favorite palette colors when theme colors finish transitioning
+ Connections {
+ target: Color
+ function onIsTransitioningChanged() {
+ if (!Color.isTransitioning) {
+ _updateCurrentWallpaperFavorites();
+ }
+ }
+ }
+
+ function _updateCurrentWallpaperFavorites() {
+ var effectiveMonitor = Settings.data.colorSchemes.monitorForColors;
+ if (effectiveMonitor === "" || effectiveMonitor === undefined) {
+ effectiveMonitor = Quickshell.screens.length > 0 ? Quickshell.screens[0].name : "";
+ }
+ var wp = getWallpaper(effectiveMonitor);
+ if (!wp) {
+ return;
+ }
+ var favIdx = _findAnyFavoriteIndexForPath(wp);
+ if (favIdx === _favoriteNotFound) {
+ return;
+ }
+ var app = _favoriteAppearanceSlot(Settings.data.wallpaper.favorites[favIdx]);
+ updateFavoriteColorScheme(wp, app);
+ }
+
+ // -------------------------------------------------------------------
+ // -------------------------------------------------------------------
+ // -------------------------------------------------------------------
+ Timer {
+ id: randomWallpaperTimer
+ interval: Settings.data.wallpaper.randomIntervalSec * 1000
+ running: Settings.data.wallpaper.automationEnabled && !PowerProfileService.noctaliaPerformanceMode
+ repeat: true
+ onTriggered: setNextWallpaper()
+ triggeredOnStart: false
+ }
+
+ // -------------------------------------------------------------------
+ // Cache file persistence
+ // -------------------------------------------------------------------
+ FileView {
+ id: wallpaperCacheView
+ printErrors: false
+ watchChanges: false
+
+ adapter: JsonAdapter {
+ id: wallpaperCacheAdapter
+ property var wallpapers: ({})
+ property string defaultWallpaper: root.noctaliaDefaultWallpaper
+ property var usedRandomWallpapers: ({})
+ }
+
+ onLoaded: {
+ // Load wallpapers from cache file
+ root.currentWallpapers = wallpaperCacheAdapter.wallpapers || {};
+ root.usedRandomWallpapers = wallpaperCacheAdapter.usedRandomWallpapers || {};
+
+ root._ensureObjectWallpaperEntries();
+
+ if (Settings.data.wallpaper.linkLightAndDarkWallpapers) {
+ root.wallpaperSelectionAppearance = Settings.data.colorSchemes.darkMode ? "dark" : "light";
+ root._syncWallpaperSlotsWhenLinking();
+ }
+
+ // Load default wallpaper from cache if it exists, otherwise use Noctalia default
+ if (wallpaperCacheAdapter.defaultWallpaper && wallpaperCacheAdapter.defaultWallpaper !== "") {
+ root.defaultWallpaper = wallpaperCacheAdapter.defaultWallpaper;
+ Logger.d("Wallpaper", "Loaded default wallpaper from cache:", wallpaperCacheAdapter.defaultWallpaper);
+ } else {
+ root.defaultWallpaper = root.noctaliaDefaultWallpaper;
+ Logger.d("Wallpaper", "Using Noctalia default wallpaper");
+ }
+
+ Logger.d("Wallpaper", "Loaded wallpapers from cache file:", Object.keys(root.currentWallpapers).length, "screens");
+ root.isInitialized = true;
+ root._scheduleThemeSyncFromCachedWallpaper();
+ }
+
+ onLoadFailed: error => {
+ // File doesn't exist yet or failed to load - initialize with empty state
+ root.currentWallpapers = {};
+ Logger.d("Wallpaper", "Cache file doesn't exist or failed to load, starting with empty wallpapers");
+ root.isInitialized = true;
+ root._scheduleThemeSyncFromCachedWallpaper();
+ }
+ }
+
+ Timer {
+ id: saveTimer
+ interval: 500
+ repeat: false
+ onTriggered: {
+ wallpaperCacheAdapter.wallpapers = root.currentWallpapers;
+ wallpaperCacheAdapter.defaultWallpaper = root.defaultWallpaper;
+ wallpaperCacheAdapter.usedRandomWallpapers = root.usedRandomWallpapers;
+ wallpaperCacheView.writeAdapter();
+ Logger.d("Wallpaper", "Saved wallpapers to cache file");
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Shaders/frag/appicon_colorize.frag b/arch/.config/quickshell/noctalia-shell/Shaders/frag/appicon_colorize.frag
new file mode 100644
index 0000000..7cd2824
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Shaders/frag/appicon_colorize.frag
@@ -0,0 +1,40 @@
+#version 450
+layout(location = 0) in vec2 qt_TexCoord0;
+layout(location = 0) out vec4 fragColor;
+layout(binding = 1) uniform sampler2D source;
+layout(std140, binding = 0) uniform buf {
+ mat4 qt_Matrix;
+ float qt_Opacity;
+ vec4 targetColor;
+ float colorizeMode; // 0.0 = dock mode (grayscale), 1.0 = tray mode (intensity), 2.0 = distro mode (luminance with better contrast)
+} ubuf;
+
+void main() {
+ vec4 tex = texture(source, qt_TexCoord0);
+
+ float intensity;
+
+ if (ubuf.colorizeMode < 0.5) {
+ // Dock mode: Convert to grayscale using proper luminance weights
+ intensity = dot(tex.rgb, vec3(0.299, 0.587, 0.114));
+ } else if (ubuf.colorizeMode < 1.5) {
+ // Tray mode: Use the maximum RGB channel value as intensity
+ intensity = max(max(tex.r, tex.g), tex.b);
+
+ // Normalize intensity to make all icons more uniform
+ intensity = smoothstep(0.1, 0.9, intensity);
+ } else {
+ // Distro mode: Brightness boost with proper alpha handling
+ float maxChannel = max(max(tex.r, tex.g), tex.b);
+
+ intensity = maxChannel * 1.5;
+ intensity = min(intensity, 1.0);
+ intensity = intensity * 0.7 + 0.3;
+
+ intensity = intensity * tex.a;
+
+ fragColor = vec4(ubuf.targetColor.rgb * intensity, tex.a) * ubuf.qt_Opacity;
+}
+
+ fragColor = vec4(ubuf.targetColor.rgb * intensity, tex.a) * ubuf.qt_Opacity;
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Shaders/frag/color_picker.frag b/arch/.config/quickshell/noctalia-shell/Shaders/frag/color_picker.frag
new file mode 100644
index 0000000..3168a3b
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Shaders/frag/color_picker.frag
@@ -0,0 +1,59 @@
+#version 450
+
+layout(location = 0) in vec2 qt_TexCoord0;
+layout(location = 0) out vec4 fragColor;
+
+layout(std140, binding = 0) uniform buf
+{
+ mat4 qt_Matrix;
+ vec4 params; // x=opacity, y=fixedVal, z=mode, w=padding
+};
+
+// Compatibility function for GLSL ES to avoid % operator issues
+int mod_compat(float a, float b) {
+ return int(a - floor(a / b) * b);
+}
+
+vec3 hsv2rgb(vec3 c)
+{
+ vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
+ vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
+ return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
+}
+
+void main()
+{
+ float x = qt_TexCoord0.x;
+ float y = 1.0 - qt_TexCoord0.y; // Flip Y direction
+
+ // Unpack vector
+ float opacity = params.x;
+ float fixedVal = params.y;
+ float mode = params.z + 0.1; // +0.1 safely handles float rounding
+
+ // Build permutation matrices for swizzling
+ vec3 base = vec3(x, y, fixedVal);
+
+ // Determine Mode Logic
+ // 0: (Red/Hue) 1: (Green/Sat) 2: (Blue/Val)
+ int perm = mod_compat(mode, 3.0);
+
+ float isHSV = step(3.0, mode);
+
+ // Branchless Selection
+ vec3 mask = vec3(
+ float(perm == 0),
+ float(perm == 1),
+ float(perm == 2));
+
+ // Swizzle fo shizzle
+ // If perm 0: base.zxy -> (fixedVal, x, y)
+ // If perm 1: base.xzy -> (x, fixedVal, y)
+ // If perm 2: base.xyz -> (x, y, fixedVal)
+ vec3 rgb_base = (base.zxy * mask.x) + (base.xzy * mask.y) + (base.xyz * mask.z);
+
+ // Final mix
+ vec3 finalColor = mix(rgb_base, hsv2rgb(rgb_base), isHSV);
+
+ fragColor = vec4(finalColor, 1.0) * opacity;
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Shaders/frag/graph.frag b/arch/.config/quickshell/noctalia-shell/Shaders/frag/graph.frag
new file mode 100644
index 0000000..940f208
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Shaders/frag/graph.frag
@@ -0,0 +1,147 @@
+#version 450
+
+layout(location = 0) in vec2 qt_TexCoord0;
+layout(location = 0) out vec4 fragColor;
+
+layout(binding = 1) uniform sampler2D dataSource;
+
+layout(std140, binding = 0) uniform buf {
+ mat4 qt_Matrix;
+ float qt_Opacity;
+ vec4 lineColor1;
+ vec4 lineColor2;
+ float count1;
+ float count2;
+ float scroll1;
+ float scroll2;
+ float lineWidth;
+ float graphFillOpacity;
+ float texWidth;
+ float resY;
+ float aaSize;
+};
+
+// Sample normalized value from data texture
+// channel 0 = primary (R), channel 1 = secondary (G)
+float fetchData(float idx, int ch) {
+ float i = clamp(idx, 0.0, texWidth - 1.0);
+ float u = (floor(i) + 0.5) / texWidth;
+ vec4 t = texture(dataSource, vec2(u, 0.5));
+ return ch == 0 ? t.r : t.g;
+}
+
+// Cubic Hermite interpolation with reduced tangent scale for smooth curves
+float cubicHermite(float y0, float y1, float y2, float y3, float t) {
+ float m1 = (y2 - y0) * 0.25;
+ float m2 = (y3 - y1) * 0.25;
+ float t2 = t * t;
+ float t3 = t2 * t;
+ return (2.0 * t3 - 3.0 * t2 + 1.0) * y1
+ + (t3 - 2.0 * t2 + t) * m1
+ + (-2.0 * t3 + 3.0 * t2) * y2
+ + (t3 - t2) * m2;
+}
+
+// Evaluate curve at fractional data index
+float evalCurve(float dataIdx, int ch) {
+ float i = floor(dataIdx);
+ float t = dataIdx - i;
+ return cubicHermite(
+ fetchData(i - 1.0, ch),
+ fetchData(i, ch),
+ fetchData(i + 1.0, ch),
+ fetchData(i + 2.0, ch),
+ t
+ );
+}
+
+// Squared distance from point p to line segment aโb
+float segDistSq(vec2 p, vec2 a, vec2 b) {
+ vec2 ab = b - a;
+ float len2 = dot(ab, ab);
+ float t = len2 > 0.0 ? clamp(dot(p - a, ab) / len2, 0.0, 1.0) : 0.0;
+ vec2 proj = a + t * ab;
+ vec2 d = p - proj;
+ return dot(d, d);
+}
+
+// Minimum distance from fragment to curve via multi-segment sampling.
+// Samples the curve at 9 half-pixel-spaced x-positions (ยฑ2px neighborhood)
+// and returns the minimum distance to the 8 line segments between them.
+float curveDistance(float dataIdx, float pixStep, float normY, int ch) {
+ vec2 frag = vec2(0.0, normY * resY);
+
+ float px = -2.0;
+ float py = evalCurve(dataIdx - 2.0 * pixStep, ch) * resY;
+ vec2 d0 = frag - vec2(px, py);
+ float best = dot(d0, d0);
+
+ for (int i = 1; i <= 8; i++) {
+ float cx = -2.0 + float(i) * 0.5;
+ float cy = evalCurve(dataIdx + cx * pixStep, ch) * resY;
+ best = min(best, segDistSq(frag, vec2(px, py), vec2(cx, cy)));
+ px = cx;
+ py = cy;
+ }
+
+ return sqrt(best);
+}
+
+// Premultiplied alpha over compositing
+vec4 blendOver(vec4 src, vec4 dst) {
+ return src + dst * (1.0 - src.a);
+}
+
+void main() {
+ vec2 uv = qt_TexCoord0;
+ float normY = 1.0 - uv.y; // 0 = bottom, 1 = top
+
+ vec4 result = vec4(0.0);
+ float halfW = lineWidth * 0.5;
+
+ // Primary line
+ if (count1 >= 4.0) {
+ float segs = count1 - 3.0;
+ float di = 2.0 + scroll1 + uv.x * segs;
+ float pixStep = dFdx(di);
+ float cy = evalCurve(di, 0);
+ float cyNext = evalCurve(di + pixStep, 0);
+
+ // Fill below curve (gradient: opaque at top, transparent at bottom)
+ if (graphFillOpacity > 0.0 && normY <= cy) {
+ float a = graphFillOpacity * normY * lineColor1.a;
+ result = blendOver(vec4(lineColor1.rgb * a, a), result);
+ }
+
+ // Multi-segment distance for accurate AA at peaks and steep sections.
+ // AA width derived analytically from curve slope: (|sinฮธ|+|cosฮธ|)
+ // gives the ideal SDF fwidth (~1.0โ1.41) without GPU derivative noise.
+ float dist = curveDistance(di, pixStep, normY, 0);
+ float slope1 = (cyNext - cy) * resY;
+ float aa = (abs(slope1) + 1.0) * inversesqrt(slope1 * slope1 + 1.0) * aaSize * 2.0;
+ float sa = smoothstep(halfW + aa, halfW, dist) * lineColor1.a;
+ result = blendOver(vec4(lineColor1.rgb * sa, sa), result);
+ }
+
+ // Secondary line
+ if (count2 >= 4.0) {
+ float segs = count2 - 3.0;
+ float di = 2.0 + scroll2 + uv.x * segs;
+ float pixStep = dFdx(di);
+ float cy = evalCurve(di, 1);
+ float cyNext = evalCurve(di + pixStep, 1);
+
+ if (graphFillOpacity > 0.0 && normY <= cy) {
+ float a = graphFillOpacity * normY * lineColor2.a;
+ result = blendOver(vec4(lineColor2.rgb * a, a), result);
+ }
+
+ float dist = curveDistance(di, pixStep, normY, 1);
+ float slope2 = (cyNext - cy) * resY;
+ float aa = (abs(slope2) + 1.0) * inversesqrt(slope2 * slope2 + 1.0) * aaSize * 2.0;
+ float sa = smoothstep(halfW + aa, halfW, dist) * lineColor2.a;
+ result = blendOver(vec4(lineColor2.rgb * sa, sa), result);
+ }
+
+ fragColor = result * qt_Opacity;
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Shaders/frag/progress_border.frag b/arch/.config/quickshell/noctalia-shell/Shaders/frag/progress_border.frag
new file mode 100644
index 0000000..331386c
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Shaders/frag/progress_border.frag
@@ -0,0 +1,67 @@
+#version 450
+
+layout(location = 0) in vec2 qt_TexCoord0;
+layout(location = 0) out vec4 fragColor;
+
+layout(binding = 1) uniform sampler2D source;
+
+layout(std140, binding = 0) uniform buf {
+ mat4 qt_Matrix;
+ float qt_Opacity;
+ float progress;
+ float borderWidth;
+ vec4 progressColor;
+ vec4 borderColor;
+ vec4 backgroundColor;
+ float borderRadius;
+} ubuf;
+
+void main() {
+ vec2 coord = qt_TexCoord0;
+ float p = clamp(ubuf.progress, 0.0, 1.0);
+
+ if (ubuf.borderRadius > 0.0) {
+ // Circular progress
+ vec2 center = vec2(0.5, 0.5);
+ vec2 dir = coord - center;
+ float dist = length(dir);
+
+ float outerRadius = 0.5;
+ float innerRadius = outerRadius - ubuf.borderWidth;
+
+ float angle = atan(dir.y, dir.x) + radians(90.0);
+ if (angle < 0.0) angle += radians(360.0);
+ float maxAngle = radians(360.0) * p;
+
+ bool inBorder = dist >= innerRadius && dist <= outerRadius;
+ bool inProgress = inBorder && angle <= maxAngle;
+
+ if (inProgress) {
+ fragColor = ubuf.progressColor * ubuf.qt_Opacity;
+ } else if (inBorder) {
+ fragColor = ubuf.borderColor * ubuf.qt_Opacity;
+ } else if (dist < innerRadius) {
+ fragColor = ubuf.backgroundColor * ubuf.qt_Opacity;
+ } else {
+ fragColor = vec4(0.0, 0.0, 0.0, 0.0);
+ }
+ } else {
+ // Rectangular progress
+ bool inBorder =
+ coord.x < ubuf.borderWidth ||
+ coord.x > (1.0 - ubuf.borderWidth) ||
+ coord.y < ubuf.borderWidth ||
+ coord.y > (1.0 - ubuf.borderWidth);
+
+ float progressPos = p;
+ bool inProgress = inBorder && coord.x <= progressPos;
+
+ if (inProgress) {
+ fragColor = ubuf.progressColor * ubuf.qt_Opacity;
+ } else if (inBorder) {
+ fragColor = ubuf.borderColor * ubuf.qt_Opacity;
+ } else {
+ fragColor = ubuf.backgroundColor * ubuf.qt_Opacity;
+ }
+ }
+}
\ No newline at end of file
diff --git a/arch/.config/quickshell/noctalia-shell/Shaders/frag/rounded_image.frag b/arch/.config/quickshell/noctalia-shell/Shaders/frag/rounded_image.frag
new file mode 100644
index 0000000..dc5c4f9
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Shaders/frag/rounded_image.frag
@@ -0,0 +1,101 @@
+#version 450
+
+layout(location = 0) in vec2 qt_TexCoord0;
+layout(location = 0) out vec4 fragColor;
+
+layout(binding = 1) uniform sampler2D source;
+
+layout(std140, binding = 0) uniform buf {
+ mat4 qt_Matrix;
+ float qt_Opacity;
+ // Custom properties with non-conflicting names
+ float itemWidth;
+ float itemHeight;
+ float sourceWidth;
+ float sourceHeight;
+ float cornerRadius;
+ float imageOpacity;
+ int fillMode;
+} ubuf;
+
+// Function to calculate the signed distance from a point to a rounded box
+float roundedBoxSDF(vec2 centerPos, vec2 boxSize, float radius) {
+ vec2 d = abs(centerPos) - boxSize + radius;
+ return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0) - radius;
+}
+
+void main() {
+ // Get size from uniforms
+ vec2 itemSize = vec2(ubuf.itemWidth, ubuf.itemHeight);
+ vec2 sourceSize = vec2(ubuf.sourceWidth, ubuf.sourceHeight);
+ float cornerRadius = ubuf.cornerRadius;
+ float itemOpacity = ubuf.imageOpacity;
+ int fillMode = ubuf.fillMode;
+
+ // Work in pixel space for accurate rounded rectangle calculation
+ vec2 pixelPos = qt_TexCoord0 * itemSize;
+
+ // Calculate UV coordinates based on fill mode
+ vec2 imageUV = qt_TexCoord0;
+
+ // fillMode constants from Qt:
+ // Image.Stretch = 0
+ // Image.PreserveAspectFit = 1
+ // Image.PreserveAspectCrop = 2
+ // Image.Tile = 3
+ // Image.TileVertically = 4
+ // Image.TileHorizontally = 5
+ // Image.Pad = 6
+
+ // Rounded corners always apply to full item bounds
+ vec2 roundedSize = itemSize;
+ vec2 roundedCenter = itemSize * 0.5;
+
+ // Track if pixel is in letterbox area (for PreserveAspectFit)
+ bool inLetterbox = false;
+
+ if (fillMode == 1) { // PreserveAspectFit
+ float itemAspect = itemSize.x / itemSize.y;
+ float sourceAspect = sourceSize.x / sourceSize.y;
+
+ if (sourceAspect > itemAspect) {
+ // Image is wider than item, letterbox top/bottom
+ imageUV.y = (qt_TexCoord0.y - 0.5) * (sourceAspect / itemAspect) + 0.5;
+ } else {
+ // Image is taller than item, letterbox left/right
+ imageUV.x = (qt_TexCoord0.x - 0.5) * (itemAspect / sourceAspect) + 0.5;
+ }
+
+ // Check if in letterbox area
+ inLetterbox = (imageUV.x < 0.0 || imageUV.x > 1.0 || imageUV.y < 0.0 || imageUV.y > 1.0);
+ } else if (fillMode == 2) { // PreserveAspectCrop
+ float itemAspect = itemSize.x / itemSize.y;
+ float sourceAspect = sourceSize.x / sourceSize.y;
+
+ if (sourceAspect > itemAspect) {
+ // Image is wider than item, crop left/right.
+ imageUV.x = (qt_TexCoord0.x - 0.5) * (itemAspect / sourceAspect) + 0.5;
+ } else {
+ // Image is taller than item, crop top/bottom.
+ imageUV.y = (qt_TexCoord0.y - 0.5) * (sourceAspect / itemAspect) + 0.5;
+ }
+ }
+ // For Stretch (0) or other modes, use qt_TexCoord0 as-is
+
+ // Calculate distance to rounded rectangle edge using the correct bounds
+ vec2 centerOffset = pixelPos - roundedCenter;
+ float distance = roundedBoxSDF(centerOffset, roundedSize * 0.5, cornerRadius);
+
+ // Create smooth alpha mask for edge with anti-aliasing
+ float alpha = 1.0 - smoothstep(-0.5, 0.5, distance);
+
+ // Sample the texture (or use transparent for letterbox)
+ // Clamp UV to prevent texture wrapping artifacts from floating-point imprecision
+ vec4 color = inLetterbox ? vec4(0.0) : texture(source, clamp(imageUV, vec2(0.0), vec2(1.0)));
+
+ // Apply the rounded mask and opacity
+ // Qt textures use premultiplied alpha (color.rgb already contains rgb * alpha),
+ // so we only multiply by the external mask factors, not by color.a again
+ float mask = alpha * itemOpacity * ubuf.qt_Opacity;
+ fragColor = color * mask;
+}
\ No newline at end of file
diff --git a/arch/.config/quickshell/noctalia-shell/Shaders/frag/wave_spectrum.frag b/arch/.config/quickshell/noctalia-shell/Shaders/frag/wave_spectrum.frag
new file mode 100644
index 0000000..af1b905
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Shaders/frag/wave_spectrum.frag
@@ -0,0 +1,82 @@
+#version 450
+
+layout(location = 0) in vec2 qt_TexCoord0;
+layout(location = 0) out vec4 fragColor;
+
+layout(binding = 1) uniform sampler2D dataSource;
+
+layout(std140, binding = 0) uniform buf {
+ mat4 qt_Matrix;
+ float qt_Opacity;
+ vec4 fillColor;
+ float count;
+ float texWidth;
+ float vertical;
+ float mirrored;
+};
+
+// Sample amplitude from data texture (R channel)
+float fetchData(float idx) {
+ float i = clamp(idx, 0.0, texWidth - 1.0);
+ float u = (floor(i) + 0.5) / texWidth;
+ return texture(dataSource, vec2(u, 0.5)).r;
+}
+
+// Cubic Hermite interpolation for smooth wave curves
+float cubicHermite(float y0, float y1, float y2, float y3, float t) {
+ float m1 = (y2 - y0) * 0.25;
+ float m2 = (y3 - y1) * 0.25;
+ float t2 = t * t;
+ float t3 = t2 * t;
+ return (2.0 * t3 - 3.0 * t2 + 1.0) * y1
+ + (t3 - 2.0 * t2 + t) * m1
+ + (-2.0 * t3 + 3.0 * t2) * y2
+ + (t3 - t2) * m2;
+}
+
+// Evaluate interpolated amplitude at fractional data index
+float evalCurve(float dataIdx) {
+ float i = floor(dataIdx);
+ float t = dataIdx - i;
+ return cubicHermite(
+ fetchData(i - 1.0),
+ fetchData(i),
+ fetchData(i + 1.0),
+ fetchData(i + 2.0),
+ t
+ );
+}
+
+void main() {
+ vec2 uv = qt_TexCoord0;
+
+ // Swap axes for vertical mode
+ float axisPos = (vertical > 0.5) ? uv.y : uv.x;
+ float crossPos = (vertical > 0.5) ? uv.x : uv.y;
+
+ // Map axis position to data index
+ float dataIdx;
+ if (mirrored > 0.5) {
+ // Mirror: value[0] at center, value[count-1] at edges
+ float distFromCenter = abs(axisPos - 0.5) * 2.0;
+ dataIdx = distFromCenter * max(count - 1.0, 1.0);
+ } else {
+ // Linear: value[0] at left/top, value[count-1] at right/bottom
+ dataIdx = axisPos * max(count - 1.0, 1.0);
+ }
+
+ // Interpolated amplitude, clamped to valid range
+ float amplitude = clamp(evalCurve(dataIdx), 0.0, 1.0);
+
+ // Wave fills center ยฑ amplitude/2 in the cross axis
+ float halfAmp = amplitude * 0.5;
+ float distFromMid = abs(crossPos - 0.5);
+
+ // Antialiased edge (~1px smooth transition)
+ float edge = fwidth(crossPos) * 1.5;
+ float mask = smoothstep(halfAmp + edge, halfAmp - edge, distFromMid);
+
+ // Premultiplied alpha output
+ float a = mask * fillColor.a;
+ fragColor = vec4(fillColor.rgb * a, a) * qt_Opacity;
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Shaders/frag/weather_cloud.frag b/arch/.config/quickshell/noctalia-shell/Shaders/frag/weather_cloud.frag
new file mode 100644
index 0000000..1991ce3
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Shaders/frag/weather_cloud.frag
@@ -0,0 +1,123 @@
+#version 450
+layout(location = 0) in vec2 qt_TexCoord0;
+layout(location = 0) out vec4 fragColor;
+layout(std140, binding = 0) uniform buf {
+ mat4 qt_Matrix;
+ float qt_Opacity;
+ float time;
+ float itemWidth;
+ float itemHeight;
+ vec4 bgColor;
+ float cornerRadius;
+ float alternative;
+} ubuf;
+
+// Signed distance function for rounded rectangle
+float roundedBoxSDF(vec2 center, vec2 size, float radius) {
+ vec2 q = abs(center) - size + radius;
+ return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - radius;
+}
+
+float hash(vec2 p) {
+ p = fract(p * vec2(234.34, 435.345));
+ p += dot(p, p + 34.23);
+ return fract(p.x * p.y);
+}
+
+// Perlin-like noise
+float noise(vec2 p) {
+ vec2 i = floor(p);
+ vec2 f = fract(p);
+ f = f * f * (3.0 - 2.0 * f); // Smooth interpolation
+ float a = hash(i);
+ float b = hash(i + vec2(1.0, 0.0));
+ float c = hash(i + vec2(0.0, 1.0));
+ float d = hash(i + vec2(1.0, 1.0));
+ return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
+}
+
+// Turbulent noise for natural fog
+float turbulence(vec2 p, float iTime) {
+ float t = 0.0;
+ float scale = 1.0;
+ for(int i = 0; i < 5; i++) {
+ t += abs(noise(p * scale + iTime * 0.1 * scale)) / scale;
+ scale *= 2.0;
+ }
+ return t;
+}
+
+void main() {
+ vec2 uv = qt_TexCoord0;
+
+ vec4 col = vec4(ubuf.bgColor.rgb, 1.0);
+
+ // Different parameters for fog vs clouds
+ float timeSpeed, layerScale1, layerScale2, layerScale3;
+ float flowSpeed1, flowSpeed2;
+ float densityMin, densityMax;
+ float baseOpacity;
+ float pulseAmount;
+
+ if (ubuf.alternative > 0.5) {
+ // Fog: slower, larger scale, more uniform
+ timeSpeed = 0.03;
+ layerScale1 = 1.0;
+ layerScale2 = 2.5;
+ layerScale3 = 2.0;
+ flowSpeed1 = 0.00;
+ flowSpeed2 = 0.02;
+ densityMin = 0.1;
+ densityMax = 0.9;
+ baseOpacity = 0.75;
+ pulseAmount = 0.05;
+ } else {
+ // Clouds: faster, smaller scale, puffier
+ timeSpeed = 0.08;
+ layerScale1 = 2.0;
+ layerScale2 = 4.0;
+ layerScale3 = 6.0;
+ flowSpeed1 = 0.03;
+ flowSpeed2 = 0.04;
+ densityMin = 0.35;
+ densityMax = 0.75;
+ baseOpacity = 0.4;
+ pulseAmount = 0.15;
+ }
+
+ float iTime = ubuf.time * timeSpeed;
+
+ // Create flowing patterns with multiple layers
+ vec2 flow1 = vec2(iTime * flowSpeed1, iTime * flowSpeed1 * 0.7);
+ vec2 flow2 = vec2(-iTime * flowSpeed2, iTime * flowSpeed2 * 0.8);
+
+ float fog1 = noise(uv * layerScale1 + flow1);
+ float fog2 = noise(uv * layerScale2 + flow2);
+ float fog3 = turbulence(uv * layerScale3, iTime);
+
+ float fogPattern = fog1 * 0.5 + fog2 * 0.3 + fog3 * 0.2;
+ float fogDensity = smoothstep(densityMin, densityMax, fogPattern);
+
+ // Gentle pulsing
+ float pulse = sin(iTime * 0.4) * pulseAmount + (1.0 - pulseAmount);
+ fogDensity *= pulse;
+
+ vec3 hazeColor = vec3(0.88, 0.90, 0.93);
+ float hazeOpacity = fogDensity * baseOpacity;
+ vec3 fogContribution = hazeColor * hazeOpacity;
+ float fogAlpha = hazeOpacity;
+
+ vec3 resultRGB = fogContribution + col.rgb * (1.0 - fogAlpha);
+ float resultAlpha = fogAlpha + col.a * (1.0 - fogAlpha);
+
+ // Calculate corner mask
+ vec2 pixelPos = qt_TexCoord0 * vec2(ubuf.itemWidth, ubuf.itemHeight);
+ vec2 center = pixelPos - vec2(ubuf.itemWidth, ubuf.itemHeight) * 0.5;
+ vec2 halfSize = vec2(ubuf.itemWidth, ubuf.itemHeight) * 0.5;
+ float dist = roundedBoxSDF(center, halfSize, ubuf.cornerRadius);
+ float cornerMask = 1.0 - smoothstep(-1.0, 0.0, dist);
+
+ // Apply global opacity and corner mask
+ float finalAlpha = resultAlpha * ubuf.qt_Opacity * cornerMask;
+ fragColor = vec4(resultRGB * (finalAlpha / max(resultAlpha, 0.001)), finalAlpha);
+}
\ No newline at end of file
diff --git a/arch/.config/quickshell/noctalia-shell/Shaders/frag/weather_rain.frag b/arch/.config/quickshell/noctalia-shell/Shaders/frag/weather_rain.frag
new file mode 100644
index 0000000..bd3bc39
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Shaders/frag/weather_rain.frag
@@ -0,0 +1,84 @@
+#version 450
+
+layout(location = 0) in vec2 qt_TexCoord0;
+layout(location = 0) out vec4 fragColor;
+
+layout(binding = 1) uniform sampler2D source;
+
+layout(std140, binding = 0) uniform buf {
+ mat4 qt_Matrix;
+ float qt_Opacity;
+ float time;
+ float itemWidth;
+ float itemHeight;
+ vec4 bgColor;
+ float cornerRadius;
+} ubuf;
+
+// Signed distance function for rounded rectangle
+float roundedBoxSDF(vec2 center, vec2 size, float radius) {
+ vec2 q = abs(center) - size + radius;
+ return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - radius;
+}
+
+vec3 hash3(vec2 p) {
+ vec3 q = vec3(dot(p, vec2(127.1, 311.7)),
+ dot(p, vec2(269.5, 183.3)),
+ dot(p, vec2(419.2, 371.9)));
+ return fract(sin(q) * 43758.5453);
+}
+
+float noise(vec2 x, float iTime) {
+ vec2 p = floor(x);
+ vec2 f = fract(x);
+
+ float va = 0.0;
+ for (int j = -2; j <= 2; j++) {
+ for (int i = -2; i <= 2; i++) {
+ vec2 g = vec2(float(i), float(j));
+ vec3 o = hash3(p + g);
+ vec2 r = g - f + o.xy;
+ float d = sqrt(dot(r, r));
+ float ripple = max(mix(smoothstep(0.99, 0.999, max(cos(d - iTime * 2.0 + (o.x + o.y) * 5.0), 0.0)), 0.0, d), 0.0);
+ va += ripple;
+ }
+ }
+
+ return va;
+}
+
+void main() {
+ vec2 uv = qt_TexCoord0;
+ float iTime = ubuf.time * 0.07;
+
+ // Aspect ratio correction for circular ripples
+ float aspect = ubuf.itemWidth / ubuf.itemHeight;
+ vec2 uvAspect = vec2(uv.x * aspect, uv.y);
+
+ float f = noise(6.0 * uvAspect, iTime) * smoothstep(0.0, 0.2, sin(uv.x * 3.141592) * sin(uv.y * 3.141592));
+
+ // Calculate normal from noise for distortion
+ float normalScale = 0.5;
+ vec2 e = normalScale / vec2(ubuf.itemWidth, ubuf.itemHeight);
+ vec2 eAspect = vec2(e.x * aspect, e.y);
+ float cx = noise(6.0 * (uvAspect + eAspect), iTime) * smoothstep(0.0, 0.2, sin((uv.x + e.x) * 3.141592) * sin(uv.y * 3.141592));
+ float cy = noise(6.0 * (uvAspect + eAspect.yx), iTime) * smoothstep(0.0, 0.2, sin(uv.x * 3.141592) * sin((uv.y + e.y) * 3.141592));
+ vec2 n = vec2(cx - f, cy - f);
+
+ // Scale distortion back to texture space (undo aspect correction for X)
+ vec2 distortion = vec2(n.x / aspect, n.y);
+
+ // Sample source with distortion
+ vec4 col = texture(source, uv + distortion);
+
+ // Apply rounded corner mask
+ vec2 pixelPos = qt_TexCoord0 * vec2(ubuf.itemWidth, ubuf.itemHeight);
+ vec2 center = pixelPos - vec2(ubuf.itemWidth, ubuf.itemHeight) * 0.5;
+ vec2 halfSize = vec2(ubuf.itemWidth, ubuf.itemHeight) * 0.5;
+ float dist = roundedBoxSDF(center, halfSize, ubuf.cornerRadius);
+ float cornerMask = 1.0 - smoothstep(-1.0, 0.0, dist);
+
+ // Output with premultiplied alpha
+ float finalAlpha = col.a * ubuf.qt_Opacity * cornerMask;
+ fragColor = vec4(col.rgb * finalAlpha, finalAlpha);
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Shaders/frag/weather_snow.frag b/arch/.config/quickshell/noctalia-shell/Shaders/frag/weather_snow.frag
new file mode 100644
index 0000000..e00a952
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Shaders/frag/weather_snow.frag
@@ -0,0 +1,75 @@
+#version 450
+
+layout(location = 0) in vec2 qt_TexCoord0;
+layout(location = 0) out vec4 fragColor;
+
+layout(std140, binding = 0) uniform buf {
+ mat4 qt_Matrix;
+ float qt_Opacity;
+ float time;
+ float itemWidth;
+ float itemHeight;
+ vec4 bgColor;
+ float cornerRadius;
+} ubuf;
+
+// Signed distance function for rounded rectangle
+float roundedBoxSDF(vec2 center, vec2 size, float radius) {
+ vec2 q = abs(center) - size + radius;
+ return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - radius;
+}
+
+void main() {
+ // Aspect ratio correction
+ float aspect = ubuf.itemWidth / ubuf.itemHeight;
+ vec2 uv = qt_TexCoord0;
+ uv.x *= aspect;
+ uv.y = 1.0 - uv.y;
+
+ float iTime = ubuf.time * 0.15;
+
+ float snow = 0.0;
+
+ for (int k = 0; k < 6; k++) {
+ for (int i = 0; i < 12; i++) {
+ float cellSize = 2.0 + (float(i) * 3.0);
+ float downSpeed = 0.3 + (sin(iTime * 0.4 + float(k + i * 20)) + 1.0) * 0.00008;
+
+ vec2 uvAnim = uv + vec2(
+ 0.01 * sin((iTime + float(k * 6185)) * 0.6 + float(i)) * (5.0 / float(i + 1)),
+ downSpeed * (iTime + float(k * 1352)) * (1.0 / float(i + 1))
+ );
+
+ vec2 uvStep = (ceil((uvAnim) * cellSize - vec2(0.5, 0.5)) / cellSize);
+ float x = fract(sin(dot(uvStep.xy, vec2(12.9898 + float(k) * 12.0, 78.233 + float(k) * 315.156))) * 43758.5453 + float(k) * 12.0) - 0.5;
+ float y = fract(sin(dot(uvStep.xy, vec2(62.2364 + float(k) * 23.0, 94.674 + float(k) * 95.0))) * 62159.8432 + float(k) * 12.0) - 0.5;
+
+ float randomMagnitude1 = sin(iTime * 2.5) * 0.7 / cellSize;
+ float randomMagnitude2 = cos(iTime * 1.65) * 0.7 / cellSize;
+
+ float d = 5.0 * distance((uvStep.xy + vec2(x * sin(y), y) * randomMagnitude1 + vec2(y, x) * randomMagnitude2), uvAnim.xy);
+
+ float omiVal = fract(sin(dot(uvStep.xy, vec2(32.4691, 94.615))) * 31572.1684);
+ if (omiVal < 0.03) {
+ float newd = (x + 1.0) * 0.4 * clamp(1.9 - d * (15.0 + (x * 6.3)) * (cellSize / 1.4), 0.0, 1.0);
+ snow += newd;
+ }
+ }
+ }
+
+ // Blend white snow over background color
+ float snowAlpha = clamp(snow * 2.0, 0.0, 1.0);
+ vec3 snowColor = vec3(1.0);
+ vec3 blended = mix(ubuf.bgColor.rgb, snowColor, snowAlpha);
+
+ // Apply rounded corner mask
+ vec2 pixelPos = qt_TexCoord0 * vec2(ubuf.itemWidth, ubuf.itemHeight);
+ vec2 center = pixelPos - vec2(ubuf.itemWidth, ubuf.itemHeight) * 0.5;
+ vec2 halfSize = vec2(ubuf.itemWidth, ubuf.itemHeight) * 0.5;
+ float dist = roundedBoxSDF(center, halfSize, ubuf.cornerRadius);
+ float cornerMask = 1.0 - smoothstep(-1.0, 0.0, dist);
+
+ // Output with premultiplied alpha
+ float finalAlpha = ubuf.qt_Opacity * cornerMask;
+ fragColor = vec4(blended * finalAlpha, finalAlpha);
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Shaders/frag/weather_stars.frag b/arch/.config/quickshell/noctalia-shell/Shaders/frag/weather_stars.frag
new file mode 100644
index 0000000..1048cae
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Shaders/frag/weather_stars.frag
@@ -0,0 +1,130 @@
+#version 450
+layout(location = 0) in vec2 qt_TexCoord0;
+layout(location = 0) out vec4 fragColor;
+layout(std140, binding = 0) uniform buf {
+ mat4 qt_Matrix;
+ float qt_Opacity;
+ float time;
+ float itemWidth;
+ float itemHeight;
+ vec4 bgColor;
+ float cornerRadius;
+} ubuf;
+
+// Signed distance function for rounded rectangle
+float roundedBoxSDF(vec2 center, vec2 size, float radius) {
+ vec2 q = abs(center) - size + radius;
+ return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - radius;
+}
+
+float hash(vec2 p) {
+ p = fract(p * vec2(234.34, 435.345));
+ p += dot(p, p + 34.23);
+ return fract(p.x * p.y);
+}
+
+vec2 hash2(vec2 p) {
+ p = fract(p * vec2(234.34, 435.345));
+ p += dot(p, p + 34.23);
+ return fract(vec2(p.x * p.y, p.y * p.x));
+}
+
+float stars(vec2 uv, float density, float iTime) {
+ vec2 gridUV = uv * density;
+ vec2 gridID = floor(gridUV);
+ vec2 gridPos = fract(gridUV);
+
+ float starField = 0.0;
+
+ // Check neighboring cells for stars
+ for (int y = -1; y <= 1; y++) {
+ for (int x = -1; x <= 1; x++) {
+ vec2 offset = vec2(float(x), float(y));
+ vec2 cellID = gridID + offset;
+
+ // Random position within cell
+ vec2 starPos = hash2(cellID);
+
+ // Only create a star for some cells (sparse distribution)
+ float starChance = hash(cellID + vec2(12.345, 67.890));
+ if (starChance > 0.85) {
+ // Star position in grid space
+ vec2 toStar = (offset + starPos - gridPos);
+ float dist = length(toStar) * density; // Scale distance to pixel space
+
+ float starSize = 1.5;
+
+ // Star brightness variation
+ float brightness = hash(cellID + vec2(23.456, 78.901)) * 0.6 + 0.4;
+
+ // Twinkling effect
+ float twinkleSpeed = hash(cellID + vec2(34.567, 89.012)) * 3.0 + 2.0;
+ float twinklePhase = iTime * twinkleSpeed + hash(cellID) * 6.28;
+ float twinkle = pow(sin(twinklePhase) * 0.5 + 0.5, 3.0); // Sharp on/off
+
+ // Sharp star core
+ float star = 0.0;
+ if (dist < starSize) {
+ star = 1.0 * brightness * (0.3 + twinkle * 0.7);
+
+ // Add tiny cross-shaped glow for brighter stars
+ if (brightness > 0.7) {
+ float crossGlow = max(
+ exp(-abs(toStar.x) * density * 5.0),
+ exp(-abs(toStar.y) * density * 5.0)
+ ) * 0.3 * twinkle;
+ star += crossGlow;
+ }
+ }
+
+ starField += star;
+ }
+ }
+ }
+
+ return starField;
+}
+
+void main() {
+ vec2 uv = qt_TexCoord0;
+ float iTime = ubuf.time * 0.01;
+
+ // Base background color
+ vec4 col = vec4(ubuf.bgColor.rgb, 1.0);
+
+ // Aspect ratio for consistent stars
+ float aspect = ubuf.itemWidth / ubuf.itemHeight;
+ vec2 uvAspect = vec2(uv.x * aspect, uv.y);
+
+ // Generate multiple layers of stars at different densities
+ float stars1 = stars(uvAspect, 40.0, iTime); // Tiny distant stars
+ float stars2 = stars(uvAspect + vec2(0.5, 0.3), 25.0, iTime * 1.3); // Small stars
+ float stars3 = stars(uvAspect + vec2(0.25, 0.7), 15.0, iTime * 0.9); // Bigger stars
+
+ // Star colors with slight variation
+ vec3 starColor1 = vec3(0.85, 0.9, 1.0); // Faint blue-white
+ vec3 starColor2 = vec3(0.95, 0.97, 1.0); // White
+ vec3 starColor3 = vec3(1.0, 0.98, 0.95); // Warm white
+
+ // Combine star layers
+ vec3 starsRGB = starColor1 * stars1 * 0.6 +
+ starColor2 * stars2 * 0.8 +
+ starColor3 * stars3 * 1.0;
+
+ float starsAlpha = clamp(stars1 * 0.6 + stars2 * 0.8 + stars3, 0.0, 1.0);
+
+ // Apply rounded corner mask
+ vec2 pixelPos = qt_TexCoord0 * vec2(ubuf.itemWidth, ubuf.itemHeight);
+ vec2 center = pixelPos - vec2(ubuf.itemWidth, ubuf.itemHeight) * 0.5;
+ vec2 halfSize = vec2(ubuf.itemWidth, ubuf.itemHeight) * 0.5;
+ float dist = roundedBoxSDF(center, halfSize, ubuf.cornerRadius);
+ float cornerMask = 1.0 - smoothstep(-1.0, 0.0, dist);
+
+ // Add stars on top
+ vec3 resultRGB = starsRGB * starsAlpha + col.rgb * (1.0 - starsAlpha);
+ float resultAlpha = starsAlpha + col.a * (1.0 - starsAlpha);
+
+ // Apply global opacity and corner mask
+ float finalAlpha = resultAlpha * ubuf.qt_Opacity * cornerMask;
+ fragColor = vec4(resultRGB * (finalAlpha / max(resultAlpha, 0.001)), finalAlpha);
+}
\ No newline at end of file
diff --git a/arch/.config/quickshell/noctalia-shell/Shaders/frag/weather_sun.frag b/arch/.config/quickshell/noctalia-shell/Shaders/frag/weather_sun.frag
new file mode 100644
index 0000000..bfc0cd9
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Shaders/frag/weather_sun.frag
@@ -0,0 +1,148 @@
+#version 450
+layout(location = 0) in vec2 qt_TexCoord0;
+layout(location = 0) out vec4 fragColor;
+
+layout(std140, binding = 0) uniform buf {
+ mat4 qt_Matrix;
+ float qt_Opacity;
+ float time;
+ float itemWidth;
+ float itemHeight;
+ vec4 bgColor;
+ float cornerRadius;
+} ubuf;
+
+// Signed distance function for rounded rectangle
+float roundedBoxSDF(vec2 center, vec2 size, float radius) {
+ vec2 q = abs(center) - size + radius;
+ return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - radius;
+}
+
+float hash(vec2 p) {
+ p = fract(p * vec2(234.34, 435.345));
+ p += dot(p, p + 34.23);
+ return fract(p.x * p.y);
+}
+
+float noise(vec2 p) {
+ vec2 i = floor(p);
+ vec2 f = fract(p);
+ f = f * f * (3.0 - 2.0 * f);
+
+ float a = hash(i);
+ float b = hash(i + vec2(1.0, 0.0));
+ float c = hash(i + vec2(0.0, 1.0));
+ float d = hash(i + vec2(1.0, 1.0));
+
+ return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
+}
+
+// God rays originating from sun position
+float sunRays(vec2 uv, vec2 sunPos, float iTime) {
+ vec2 toSun = uv - sunPos;
+ float angle = atan(toSun.y, toSun.x);
+ float dist = length(toSun);
+
+ float rayCount = 7;
+
+ // Radial pattern
+ float rays = sin(angle * rayCount + sin(iTime * 0.25)) * 0.5 + 0.5;
+ rays = pow(rays, 3.0);
+
+ // Fade with distance
+ float falloff = 1.0 - smoothstep(0.0, 1.2, dist);
+
+ return rays * falloff * 0.15;
+}
+
+// Atmospheric shimmer / heat haze
+float atmosphericShimmer(vec2 uv, float iTime) {
+ // Multiple layers of noise for complexity
+ float n1 = noise(uv * 5.0 + vec2(iTime * 0.1, iTime * 0.05));
+ float n2 = noise(uv * 8.0 - vec2(iTime * 0.08, iTime * 0.12));
+ float n3 = noise(uv * 12.0 + vec2(iTime * 0.15, -iTime * 0.1));
+
+ return (n1 * 0.5 + n2 * 0.3 + n3 * 0.2) * 0.15;
+}
+
+float sunCore(vec2 uv, vec2 sunPos, float iTime) {
+ vec2 toSun = uv - sunPos;
+ float dist = length(toSun);
+
+ // Main bright spot
+ float mainFlare = exp(-dist * 15.0) * 2.0;
+
+ // Secondary reflection spots along the line
+ float flares = 0.0;
+ for (int i = 1; i <= 3; i++) {
+ vec2 flarePos = sunPos + toSun * float(i) * 0.3;
+ float flareDist = length(uv - flarePos);
+ float flareSize = 0.02 + float(i) * 0.01;
+ flares += smoothstep(flareSize * 2.0, flareSize * 0.5, flareDist) * (0.3 / float(i));
+ }
+
+ // Pulsing effect
+ float pulse = sin(iTime) * 0.1 + 0.9;
+
+ return (mainFlare + flares) * pulse;
+}
+
+void main() {
+ vec2 uv = qt_TexCoord0;
+ float iTime = ubuf.time * 0.08;
+
+ // Sample the source
+ vec4 col = vec4(ubuf.bgColor.rgb, 1.0);
+
+ vec2 sunPos = vec2(0.85, 0.2);
+
+ // Aspect ratio correction
+ float aspect = ubuf.itemWidth / ubuf.itemHeight;
+ vec2 uvAspect = vec2(uv.x * aspect, uv.y);
+ vec2 sunPosAspect = vec2(sunPos.x * aspect, sunPos.y);
+
+ // Generate sunny effects
+ float rays = sunRays(uvAspect, sunPosAspect, iTime);
+ float shimmerEffect = atmosphericShimmer(uv, iTime);
+ float flare = sunCore(uvAspect, sunPosAspect, iTime);
+
+ // Warm sunny colors
+ vec3 sunColor = vec3(1.0, 0.95, 0.7); // Warm golden yellow
+ vec3 skyColor = vec3(0.9, 0.95, 1.0); // Light blue tint
+ vec3 shimmerColor = vec3(1.0, 0.98, 0.85); // Subtle warm shimmer
+
+ // Apply rounded corner mask
+ vec2 pixelPos = qt_TexCoord0 * vec2(ubuf.itemWidth, ubuf.itemHeight);
+ vec2 center = pixelPos - vec2(ubuf.itemWidth, ubuf.itemHeight) * 0.5;
+ vec2 halfSize = vec2(ubuf.itemWidth, ubuf.itemHeight) * 0.5;
+ float dist = roundedBoxSDF(center, halfSize, ubuf.cornerRadius);
+ float cornerMask = 1.0 - smoothstep(-1.0, 0.0, dist);
+
+ vec3 resultRGB = col.rgb;
+ float resultAlpha = col.a;
+
+ // Add sun rays
+ vec3 raysContribution = sunColor * rays;
+ float raysAlpha = rays * 0.4;
+ resultRGB = raysContribution + resultRGB * (1.0 - raysAlpha);
+ resultAlpha = raysAlpha + resultAlpha * (1.0 - raysAlpha);
+
+ // Add atmospheric shimmer
+ vec3 shimmerContribution = shimmerColor * shimmerEffect;
+ float shimmerAlpha = shimmerEffect * 0.1;
+ resultRGB = shimmerContribution + resultRGB * (1.0 - shimmerAlpha);
+ resultAlpha = shimmerAlpha + resultAlpha * (1.0 - shimmerAlpha);
+
+ // Add bright sun core
+ vec3 flareContribution = sunColor * flare;
+ float flareAlpha = clamp(flare, 0.0, 1.0) * 0.6;
+ resultRGB = flareContribution + resultRGB * (1.0 - flareAlpha);
+ resultAlpha = flareAlpha + resultAlpha * (1.0 - flareAlpha);
+
+ // Overall warm sunny tint
+ resultRGB = mix(resultRGB, resultRGB * vec3(1.08, 1.04, 0.98), 0.15);
+
+ // Apply global opacity and corner mask
+ float finalAlpha = resultAlpha * ubuf.qt_Opacity * cornerMask;
+ fragColor = vec4(resultRGB * (finalAlpha / max(resultAlpha, 0.001)), finalAlpha);
+}
\ No newline at end of file
diff --git a/arch/.config/quickshell/noctalia-shell/Shaders/frag/wp_disc.frag b/arch/.config/quickshell/noctalia-shell/Shaders/frag/wp_disc.frag
new file mode 100644
index 0000000..3add2f1
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Shaders/frag/wp_disc.frag
@@ -0,0 +1,149 @@
+// ===== wp_disc.frag =====
+#version 450
+
+layout(location = 0) in vec2 qt_TexCoord0;
+layout(location = 0) out vec4 fragColor;
+
+layout(binding = 1) uniform sampler2D source1; // Current wallpaper
+layout(binding = 2) uniform sampler2D source2; // Next wallpaper
+
+layout(std140, binding = 0) uniform buf {
+ mat4 qt_Matrix;
+ float qt_Opacity;
+ float progress; // Transition progress (0.0 to 1.0)
+ float centerX; // X coordinate of disc center (0.0 to 1.0)
+ float centerY; // Y coordinate of disc center (0.0 to 1.0)
+ float smoothness; // Edge smoothness (0.0 to 1.0, 0=sharp, 1=very smooth)
+ float aspectRatio; // Width / Height of the screen
+
+ // Fill mode parameters
+ float fillMode; // 0=center, 1=crop, 2=fit, 3=stretch, 4=repeat
+ float imageWidth1; // Width of source1 image
+ float imageHeight1; // Height of source1 image
+ float imageWidth2; // Width of source2 image
+ float imageHeight2; // Height of source2 image
+ float screenWidth; // Screen width
+ float screenHeight; // Screen height
+ vec4 fillColor; // Fill color for empty areas (default: black)
+
+ // Solid color mode
+ float isSolid1; // 1.0 if source1 is solid color, 0.0 otherwise
+ float isSolid2; // 1.0 if source2 is solid color, 0.0 otherwise
+ vec4 solidColor1; // Solid color for source1
+ vec4 solidColor2; // Solid color for source2
+} ubuf;
+
+// Calculate UV coordinates based on fill mode
+vec2 calculateUV(vec2 uv, float imgWidth, float imgHeight) {
+ float imageAspect = imgWidth / imgHeight;
+ float screenAspect = ubuf.screenWidth / ubuf.screenHeight;
+ vec2 transformedUV = uv;
+
+ if (ubuf.fillMode < 0.5) {
+ // Mode 0: no (center) - No resize, center image at original size
+ // Convert UV to pixel coordinates, offset, then back to UV in image space
+ vec2 screenPixel = uv * vec2(ubuf.screenWidth, ubuf.screenHeight);
+ vec2 imageOffset = (vec2(ubuf.screenWidth, ubuf.screenHeight) - vec2(imgWidth, imgHeight)) * 0.5;
+ vec2 imagePixel = screenPixel - imageOffset;
+ transformedUV = imagePixel / vec2(imgWidth, imgHeight);
+ }
+ else if (ubuf.fillMode < 1.5) {
+ // Mode 1: crop (fill/cover) - Fill screen, crop excess (default)
+ float scale = max(ubuf.screenWidth / imgWidth, ubuf.screenHeight / imgHeight);
+ vec2 scaledImageSize = vec2(imgWidth, imgHeight) * scale;
+ vec2 offset = (scaledImageSize - vec2(ubuf.screenWidth, ubuf.screenHeight)) / scaledImageSize;
+ transformedUV = uv * (vec2(1.0) - offset) + offset * 0.5;
+ }
+ else if (ubuf.fillMode < 2.5) {
+ // Mode 2: fit (contain) - Fit inside screen, maintain aspect ratio
+ float scale = min(ubuf.screenWidth / imgWidth, ubuf.screenHeight / imgHeight);
+ vec2 scaledImageSize = vec2(imgWidth, imgHeight) * scale;
+ vec2 offset = (vec2(ubuf.screenWidth, ubuf.screenHeight) - scaledImageSize) * 0.5;
+
+ // Convert screen UV to pixel coordinates
+ vec2 screenPixel = uv * vec2(ubuf.screenWidth, ubuf.screenHeight);
+ // Adjust for offset and scale
+ vec2 imagePixel = (screenPixel - offset) / scale;
+ // Convert back to UV coordinates in image space
+ transformedUV = imagePixel / vec2(imgWidth, imgHeight);
+ }
+ else if (ubuf.fillMode < 3.5) {
+ // Mode 3: stretch - Use original UV (stretches to fit)
+ // No transformation needed for stretch mode
+ }
+ else {
+ // Mode 4: repeat (tile) - Tile image at original size
+ vec2 screenPixel = uv * vec2(ubuf.screenWidth, ubuf.screenHeight);
+ transformedUV = screenPixel / vec2(imgWidth, imgHeight);
+ }
+
+ return transformedUV;
+}
+
+// Sample texture with fill mode and handle out-of-bounds
+// If isSolid > 0.5, returns solidColor instead of sampling texture
+vec4 sampleWithFillMode(sampler2D tex, vec2 uv, float imgWidth, float imgHeight, float isSolid, vec4 solidColor) {
+ // Return solid color if in solid color mode
+ if (isSolid > 0.5) {
+ return solidColor;
+ }
+
+ vec2 transformedUV = calculateUV(uv, imgWidth, imgHeight);
+
+ // Mode 4 (repeat): use fract() to tile the image
+ if (ubuf.fillMode > 3.5) {
+ return texture(tex, fract(transformedUV));
+ }
+
+ // Check if UV is out of bounds
+ if (transformedUV.x < 0.0 || transformedUV.x > 1.0 ||
+ transformedUV.y < 0.0 || transformedUV.y > 1.0) {
+ return ubuf.fillColor;
+ }
+
+ return texture(tex, transformedUV);
+}
+
+void main() {
+ vec2 uv = qt_TexCoord0;
+
+ // Sample textures with fill mode (handles solid colors)
+ vec4 color1 = sampleWithFillMode(source1, uv, ubuf.imageWidth1, ubuf.imageHeight1, ubuf.isSolid1, ubuf.solidColor1);
+ vec4 color2 = sampleWithFillMode(source2, uv, ubuf.imageWidth2, ubuf.imageHeight2, ubuf.isSolid2, ubuf.solidColor2);
+
+ // Map smoothness from 0.0-1.0 to 0.001-0.5 range
+ // Using a non-linear mapping for better control
+ float mappedSmoothness = mix(0.001, 0.5, ubuf.smoothness * ubuf.smoothness);
+
+ // Adjust UV coordinates to compensate for aspect ratio
+ // This makes distances circular instead of elliptical
+ vec2 adjustedUV = vec2(uv.x * ubuf.aspectRatio, uv.y);
+ vec2 adjustedCenter = vec2(ubuf.centerX * ubuf.aspectRatio, ubuf.centerY);
+
+ // Calculate distance in aspect-corrected space
+ float dist = distance(adjustedUV, adjustedCenter);
+
+ // Calculate the maximum possible distance (corner to corner)
+ // This ensures the disc can cover the entire screen
+ float maxDistX = max(ubuf.centerX * ubuf.aspectRatio,
+ (1.0 - ubuf.centerX) * ubuf.aspectRatio);
+ float maxDistY = max(ubuf.centerY, 1.0 - ubuf.centerY);
+ float maxDist = length(vec2(maxDistX, maxDistY));
+
+ // Scale progress to cover the maximum distance
+ // Add extra range for smoothness to ensure complete coverage
+ // Adjust smoothness for aspect ratio to maintain consistent visual appearance
+ float adjustedSmoothness = mappedSmoothness * max(1.0, ubuf.aspectRatio);
+
+ // Start the radius from -adjustedSmoothness, this ensures the disc is completely hidden when progress=0
+ float totalDistance = maxDist + 2.0 * adjustedSmoothness;
+ float radius = -adjustedSmoothness + ubuf.progress * totalDistance;
+
+ // Use smoothstep for a smooth edge transition
+ float factor = smoothstep(radius - adjustedSmoothness, radius + adjustedSmoothness, dist);
+
+ // Mix the textures (factor = 0 inside disc, 1 outside)
+ fragColor = mix(color2, color1, factor);
+
+ fragColor *= ubuf.qt_Opacity;
+}
\ No newline at end of file
diff --git a/arch/.config/quickshell/noctalia-shell/Shaders/frag/wp_fade.frag b/arch/.config/quickshell/noctalia-shell/Shaders/frag/wp_fade.frag
new file mode 100644
index 0000000..6fbc9a1
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Shaders/frag/wp_fade.frag
@@ -0,0 +1,112 @@
+// ===== wp_fade.frag =====
+#version 450
+
+layout(location = 0) in vec2 qt_TexCoord0;
+layout(location = 0) out vec4 fragColor;
+
+layout(binding = 1) uniform sampler2D source1;
+layout(binding = 2) uniform sampler2D source2;
+
+layout(std140, binding = 0) uniform buf {
+ mat4 qt_Matrix;
+ float qt_Opacity;
+ float progress;
+
+ // Fill mode parameters
+ float fillMode; // 0=center, 1=crop, 2=fit, 3=stretch, 4=repeat
+ float imageWidth1; // Width of source1 image
+ float imageHeight1; // Height of source1 image
+ float imageWidth2; // Width of source2 image
+ float imageHeight2; // Height of source2 image
+ float screenWidth; // Screen width
+ float screenHeight; // Screen height
+ vec4 fillColor; // Fill color for empty areas (default: black)
+
+ // Solid color mode
+ float isSolid1; // 1.0 if source1 is solid color, 0.0 otherwise
+ float isSolid2; // 1.0 if source2 is solid color, 0.0 otherwise
+ vec4 solidColor1; // Solid color for source1
+ vec4 solidColor2; // Solid color for source2
+} ubuf;
+
+// Calculate UV coordinates based on fill mode
+vec2 calculateUV(vec2 uv, float imgWidth, float imgHeight) {
+ float imageAspect = imgWidth / imgHeight;
+ float screenAspect = ubuf.screenWidth / ubuf.screenHeight;
+ vec2 transformedUV = uv;
+
+ if (ubuf.fillMode < 0.5) {
+ // Mode 0: no (center) - No resize, center image at original size
+ // Convert UV to pixel coordinates, offset, then back to UV in image space
+ vec2 screenPixel = uv * vec2(ubuf.screenWidth, ubuf.screenHeight);
+ vec2 imageOffset = (vec2(ubuf.screenWidth, ubuf.screenHeight) - vec2(imgWidth, imgHeight)) * 0.5;
+ vec2 imagePixel = screenPixel - imageOffset;
+ transformedUV = imagePixel / vec2(imgWidth, imgHeight);
+ }
+ else if (ubuf.fillMode < 1.5) {
+ // Mode 1: crop (fill/cover) - Fill screen, crop excess (default)
+ float scale = max(ubuf.screenWidth / imgWidth, ubuf.screenHeight / imgHeight);
+ vec2 scaledImageSize = vec2(imgWidth, imgHeight) * scale;
+ vec2 offset = (scaledImageSize - vec2(ubuf.screenWidth, ubuf.screenHeight)) / scaledImageSize;
+ transformedUV = uv * (vec2(1.0) - offset) + offset * 0.5;
+ }
+ else if (ubuf.fillMode < 2.5) {
+ // Mode 2: fit (contain) - Fit inside screen, maintain aspect ratio
+ float scale = min(ubuf.screenWidth / imgWidth, ubuf.screenHeight / imgHeight);
+ vec2 scaledImageSize = vec2(imgWidth, imgHeight) * scale;
+ vec2 offset = (vec2(ubuf.screenWidth, ubuf.screenHeight) - scaledImageSize) * 0.5;
+
+ // Convert screen UV to pixel coordinates
+ vec2 screenPixel = uv * vec2(ubuf.screenWidth, ubuf.screenHeight);
+ // Adjust for offset and scale
+ vec2 imagePixel = (screenPixel - offset) / scale;
+ // Convert back to UV coordinates in image space
+ transformedUV = imagePixel / vec2(imgWidth, imgHeight);
+ }
+ else if (ubuf.fillMode < 3.5) {
+ // Mode 3: stretch - Use original UV (stretches to fit)
+ // No transformation needed for stretch mode
+ }
+ else {
+ // Mode 4: repeat (tile) - Tile image at original size
+ vec2 screenPixel = uv * vec2(ubuf.screenWidth, ubuf.screenHeight);
+ transformedUV = screenPixel / vec2(imgWidth, imgHeight);
+ }
+
+ return transformedUV;
+}
+
+// Sample texture with fill mode and handle out-of-bounds
+// If isSolid > 0.5, returns solidColor instead of sampling texture
+vec4 sampleWithFillMode(sampler2D tex, vec2 uv, float imgWidth, float imgHeight, float isSolid, vec4 solidColor) {
+ // Return solid color if in solid color mode
+ if (isSolid > 0.5) {
+ return solidColor;
+ }
+
+ vec2 transformedUV = calculateUV(uv, imgWidth, imgHeight);
+
+ // Mode 4 (repeat): use fract() to tile the image
+ if (ubuf.fillMode > 3.5) {
+ return texture(tex, fract(transformedUV));
+ }
+
+ // Check if UV is out of bounds
+ if (transformedUV.x < 0.0 || transformedUV.x > 1.0 ||
+ transformedUV.y < 0.0 || transformedUV.y > 1.0) {
+ return ubuf.fillColor;
+ }
+
+ return texture(tex, transformedUV);
+}
+
+void main() {
+ vec2 uv = qt_TexCoord0;
+
+ // Sample textures with fill mode (handles solid colors)
+ vec4 color1 = sampleWithFillMode(source1, uv, ubuf.imageWidth1, ubuf.imageHeight1, ubuf.isSolid1, ubuf.solidColor1);
+ vec4 color2 = sampleWithFillMode(source2, uv, ubuf.imageWidth2, ubuf.imageHeight2, ubuf.isSolid2, ubuf.solidColor2);
+
+ // Mix the two textures based on progress value
+ fragColor = mix(color1, color2, ubuf.progress) * ubuf.qt_Opacity;
+}
\ No newline at end of file
diff --git a/arch/.config/quickshell/noctalia-shell/Shaders/frag/wp_honeycomb.frag b/arch/.config/quickshell/noctalia-shell/Shaders/frag/wp_honeycomb.frag
new file mode 100644
index 0000000..ef7d2ac
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Shaders/frag/wp_honeycomb.frag
@@ -0,0 +1,170 @@
+// ===== wp_honeycomb.frag =====
+#version 450
+
+layout(location = 0) in vec2 qt_TexCoord0;
+layout(location = 0) out vec4 fragColor;
+
+layout(binding = 1) uniform sampler2D source1;
+layout(binding = 2) uniform sampler2D source2;
+
+layout(std140, binding = 0) uniform buf {
+ mat4 qt_Matrix;
+ float qt_Opacity;
+ float progress;
+ float cellSize; // Size of hexagonal cells in UV space (default 0.04)
+ float centerX; // X coordinate of wave origin (0.0 to 1.0)
+ float centerY; // Y coordinate of wave origin (0.0 to 1.0)
+ float aspectRatio; // Width / Height of the screen
+
+ // Fill mode parameters
+ float fillMode; // 0=center, 1=crop, 2=fit, 3=stretch, 4=repeat
+ float imageWidth1; // Width of source1 image
+ float imageHeight1; // Height of source1 image
+ float imageWidth2; // Width of source2 image
+ float imageHeight2; // Height of source2 image
+ float screenWidth; // Screen width
+ float screenHeight; // Screen height
+ vec4 fillColor; // Fill color for empty areas (default: black)
+
+ // Solid color mode
+ float isSolid1; // 1.0 if source1 is solid color, 0.0 otherwise
+ float isSolid2; // 1.0 if source2 is solid color, 0.0 otherwise
+ vec4 solidColor1; // Solid color for source1
+ vec4 solidColor2; // Solid color for source2
+} ubuf;
+
+// Calculate UV coordinates based on fill mode
+vec2 calculateUV(vec2 uv, float imgWidth, float imgHeight) {
+ float imageAspect = imgWidth / imgHeight;
+ float screenAspect = ubuf.screenWidth / ubuf.screenHeight;
+ vec2 transformedUV = uv;
+
+ if (ubuf.fillMode < 0.5) {
+ // Mode 0: no (center) - No resize, center image at original size
+ vec2 screenPixel = uv * vec2(ubuf.screenWidth, ubuf.screenHeight);
+ vec2 imageOffset = (vec2(ubuf.screenWidth, ubuf.screenHeight) - vec2(imgWidth, imgHeight)) * 0.5;
+ vec2 imagePixel = screenPixel - imageOffset;
+ transformedUV = imagePixel / vec2(imgWidth, imgHeight);
+ }
+ else if (ubuf.fillMode < 1.5) {
+ // Mode 1: crop (fill/cover) - Fill screen, crop excess (default)
+ float scale = max(ubuf.screenWidth / imgWidth, ubuf.screenHeight / imgHeight);
+ vec2 scaledImageSize = vec2(imgWidth, imgHeight) * scale;
+ vec2 offset = (scaledImageSize - vec2(ubuf.screenWidth, ubuf.screenHeight)) / scaledImageSize;
+ transformedUV = uv * (vec2(1.0) - offset) + offset * 0.5;
+ }
+ else if (ubuf.fillMode < 2.5) {
+ // Mode 2: fit (contain) - Fit inside screen, maintain aspect ratio
+ float scale = min(ubuf.screenWidth / imgWidth, ubuf.screenHeight / imgHeight);
+ vec2 scaledImageSize = vec2(imgWidth, imgHeight) * scale;
+ vec2 offset = (vec2(ubuf.screenWidth, ubuf.screenHeight) - scaledImageSize) * 0.5;
+
+ vec2 screenPixel = uv * vec2(ubuf.screenWidth, ubuf.screenHeight);
+ vec2 imagePixel = (screenPixel - offset) / scale;
+ transformedUV = imagePixel / vec2(imgWidth, imgHeight);
+ }
+ else if (ubuf.fillMode < 3.5) {
+ // Mode 3: stretch - Use original UV (stretches to fit)
+ }
+ else {
+ // Mode 4: repeat (tile) - Tile image at original size
+ vec2 screenPixel = uv * vec2(ubuf.screenWidth, ubuf.screenHeight);
+ transformedUV = screenPixel / vec2(imgWidth, imgHeight);
+ }
+
+ return transformedUV;
+}
+
+// Sample texture with fill mode and handle out-of-bounds
+vec4 sampleWithFillMode(sampler2D tex, vec2 uv, float imgWidth, float imgHeight, float isSolid, vec4 solidColor) {
+ if (isSolid > 0.5) {
+ return solidColor;
+ }
+
+ vec2 transformedUV = calculateUV(uv, imgWidth, imgHeight);
+
+ if (ubuf.fillMode > 3.5) {
+ return texture(tex, fract(transformedUV));
+ }
+
+ if (transformedUV.x < 0.0 || transformedUV.x > 1.0 ||
+ transformedUV.y < 0.0 || transformedUV.y > 1.0) {
+ return ubuf.fillColor;
+ }
+
+ return texture(tex, transformedUV);
+}
+
+// Convert cartesian to axial hex coordinates and round to nearest hex center
+vec2 hexRound(vec2 axial) {
+ // Convert axial (q,r) to cube (x,y,z) where x+y+z=0
+ float x = axial.x;
+ float z = axial.y;
+ float y = -x - z;
+
+ // Round each
+ float rx = round(x);
+ float ry = round(y);
+ float rz = round(z);
+
+ // Fix rounding errors by resetting the component with largest diff
+ float dx = abs(rx - x);
+ float dy = abs(ry - y);
+ float dz = abs(rz - z);
+
+ if (dx > dy && dx > dz) {
+ rx = -ry - rz;
+ } else if (dy > dz) {
+ ry = -rx - rz;
+ } else {
+ rz = -rx - ry;
+ }
+
+ return vec2(rx, rz);
+}
+
+void main() {
+ vec2 uv = qt_TexCoord0;
+
+ // Sample both textures at original UV
+ vec4 color1 = sampleWithFillMode(source1, uv, ubuf.imageWidth1, ubuf.imageHeight1, ubuf.isSolid1, ubuf.solidColor1);
+ vec4 color2 = sampleWithFillMode(source2, uv, ubuf.imageWidth2, ubuf.imageHeight2, ubuf.isSolid2, ubuf.solidColor2);
+
+ // Aspect-correct the UV for hex grid so cells appear as regular hexagons
+ vec2 aspectUV = vec2(uv.x * ubuf.aspectRatio, uv.y);
+
+ // Convert to axial hex coordinates
+ // Hex grid: q axis along x, r axis at 60 degrees
+ float size = max(ubuf.cellSize, 0.01);
+ float q = (aspectUV.x * (2.0 / 3.0)) / size;
+ float r = ((-aspectUV.x / 3.0) + (sqrt(3.0) / 3.0) * aspectUV.y) / size;
+
+ // Round to nearest hex center
+ vec2 hex = hexRound(vec2(q, r));
+
+ // Convert hex center back to aspect-corrected UV space
+ vec2 hexCenter;
+ hexCenter.x = size * (3.0 / 2.0) * hex.x;
+ hexCenter.y = size * (sqrt(3.0) * (hex.y + 0.5 * hex.x));
+
+ // Calculate distance from this cell's center to the wave origin (aspect-corrected)
+ vec2 origin = vec2(ubuf.centerX * ubuf.aspectRatio, ubuf.centerY);
+ float dist = distance(hexCenter, origin);
+
+ // Maximum distance from origin to any corner (for normalization)
+ float maxDistX = max(ubuf.centerX * ubuf.aspectRatio, (1.0 - ubuf.centerX) * ubuf.aspectRatio);
+ float maxDistY = max(ubuf.centerY, 1.0 - ubuf.centerY);
+ float maxDist = length(vec2(maxDistX, maxDistY));
+
+ // Wave expansion (same approach as disc shader):
+ // Start radius behind the origin so the smoothstep zone is fully off-screen at progress=0
+ float softEdge = 0.15 * maxDist;
+ float totalDistance = maxDist + 2.0 * softEdge;
+ float radius = -softEdge + ubuf.progress * totalDistance;
+
+ // factor = 0 inside the wave (revealed), 1 outside (not yet reached)
+ float factor = smoothstep(radius - softEdge, radius + softEdge, dist);
+ float cellProgress = 1.0 - factor;
+
+ fragColor = mix(color1, color2, cellProgress) * ubuf.qt_Opacity;
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Shaders/frag/wp_pixelate.frag b/arch/.config/quickshell/noctalia-shell/Shaders/frag/wp_pixelate.frag
new file mode 100644
index 0000000..32ac40e
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Shaders/frag/wp_pixelate.frag
@@ -0,0 +1,119 @@
+// ===== wp_pixelate.frag =====
+#version 450
+
+layout(location = 0) in vec2 qt_TexCoord0;
+layout(location = 0) out vec4 fragColor;
+
+layout(binding = 1) uniform sampler2D source1;
+layout(binding = 2) uniform sampler2D source2;
+
+layout(std140, binding = 0) uniform buf {
+ mat4 qt_Matrix;
+ float qt_Opacity;
+ float progress;
+ float maxBlockSize; // Maximum block size in pixels (default 64)
+
+ // Fill mode parameters
+ float fillMode; // 0=center, 1=crop, 2=fit, 3=stretch, 4=repeat
+ float imageWidth1; // Width of source1 image
+ float imageHeight1; // Height of source1 image
+ float imageWidth2; // Width of source2 image
+ float imageHeight2; // Height of source2 image
+ float screenWidth; // Screen width
+ float screenHeight; // Screen height
+ vec4 fillColor; // Fill color for empty areas (default: black)
+
+ // Solid color mode
+ float isSolid1; // 1.0 if source1 is solid color, 0.0 otherwise
+ float isSolid2; // 1.0 if source2 is solid color, 0.0 otherwise
+ vec4 solidColor1; // Solid color for source1
+ vec4 solidColor2; // Solid color for source2
+} ubuf;
+
+// Calculate UV coordinates based on fill mode
+vec2 calculateUV(vec2 uv, float imgWidth, float imgHeight) {
+ float imageAspect = imgWidth / imgHeight;
+ float screenAspect = ubuf.screenWidth / ubuf.screenHeight;
+ vec2 transformedUV = uv;
+
+ if (ubuf.fillMode < 0.5) {
+ // Mode 0: no (center) - No resize, center image at original size
+ vec2 screenPixel = uv * vec2(ubuf.screenWidth, ubuf.screenHeight);
+ vec2 imageOffset = (vec2(ubuf.screenWidth, ubuf.screenHeight) - vec2(imgWidth, imgHeight)) * 0.5;
+ vec2 imagePixel = screenPixel - imageOffset;
+ transformedUV = imagePixel / vec2(imgWidth, imgHeight);
+ }
+ else if (ubuf.fillMode < 1.5) {
+ // Mode 1: crop (fill/cover) - Fill screen, crop excess (default)
+ float scale = max(ubuf.screenWidth / imgWidth, ubuf.screenHeight / imgHeight);
+ vec2 scaledImageSize = vec2(imgWidth, imgHeight) * scale;
+ vec2 offset = (scaledImageSize - vec2(ubuf.screenWidth, ubuf.screenHeight)) / scaledImageSize;
+ transformedUV = uv * (vec2(1.0) - offset) + offset * 0.5;
+ }
+ else if (ubuf.fillMode < 2.5) {
+ // Mode 2: fit (contain) - Fit inside screen, maintain aspect ratio
+ float scale = min(ubuf.screenWidth / imgWidth, ubuf.screenHeight / imgHeight);
+ vec2 scaledImageSize = vec2(imgWidth, imgHeight) * scale;
+ vec2 offset = (vec2(ubuf.screenWidth, ubuf.screenHeight) - scaledImageSize) * 0.5;
+
+ vec2 screenPixel = uv * vec2(ubuf.screenWidth, ubuf.screenHeight);
+ vec2 imagePixel = (screenPixel - offset) / scale;
+ transformedUV = imagePixel / vec2(imgWidth, imgHeight);
+ }
+ else if (ubuf.fillMode < 3.5) {
+ // Mode 3: stretch - Use original UV (stretches to fit)
+ }
+ else {
+ // Mode 4: repeat (tile) - Tile image at original size
+ vec2 screenPixel = uv * vec2(ubuf.screenWidth, ubuf.screenHeight);
+ transformedUV = screenPixel / vec2(imgWidth, imgHeight);
+ }
+
+ return transformedUV;
+}
+
+// Sample texture with fill mode and handle out-of-bounds
+vec4 sampleWithFillMode(sampler2D tex, vec2 uv, float imgWidth, float imgHeight, float isSolid, vec4 solidColor) {
+ if (isSolid > 0.5) {
+ return solidColor;
+ }
+
+ vec2 transformedUV = calculateUV(uv, imgWidth, imgHeight);
+
+ if (ubuf.fillMode > 3.5) {
+ return texture(tex, fract(transformedUV));
+ }
+
+ if (transformedUV.x < 0.0 || transformedUV.x > 1.0 ||
+ transformedUV.y < 0.0 || transformedUV.y > 1.0) {
+ return ubuf.fillColor;
+ }
+
+ return texture(tex, transformedUV);
+}
+
+void main() {
+ vec2 uv = qt_TexCoord0;
+
+ // Triangle wave: pixelation peaks at progress=0.5
+ float pixelIntensity = 1.0 - abs(2.0 * ubuf.progress - 1.0);
+
+ // Compute the sampling UV โ snap to block grid when pixelating, pass through otherwise
+ vec2 sampleUV = uv;
+ if (pixelIntensity > 0.001) {
+ float blockSize = ubuf.maxBlockSize * pixelIntensity;
+ vec2 screenSize = vec2(ubuf.screenWidth, ubuf.screenHeight);
+ vec2 pixelCoord = uv * screenSize;
+ vec2 snappedPixel = floor(pixelCoord / blockSize) * blockSize + blockSize * 0.5;
+ sampleUV = snappedPixel / screenSize;
+ }
+
+ // Sample both textures at the (possibly snapped) UV
+ vec4 color1 = sampleWithFillMode(source1, sampleUV, ubuf.imageWidth1, ubuf.imageHeight1, ubuf.isSolid1, ubuf.solidColor1);
+ vec4 color2 = sampleWithFillMode(source2, sampleUV, ubuf.imageWidth2, ubuf.imageHeight2, ubuf.isSolid2, ubuf.solidColor2);
+
+ // Crossfade concentrated around the peak pixelation (progress ~0.5)
+ float blend = smoothstep(0.4, 0.6, ubuf.progress);
+
+ fragColor = mix(color1, color2, blend) * ubuf.qt_Opacity;
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Shaders/frag/wp_stripes.frag b/arch/.config/quickshell/noctalia-shell/Shaders/frag/wp_stripes.frag
new file mode 100644
index 0000000..1136bc6
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Shaders/frag/wp_stripes.frag
@@ -0,0 +1,224 @@
+// ===== wp_stripes.frag =====
+#version 450
+
+layout(location = 0) in vec2 qt_TexCoord0;
+layout(location = 0) out vec4 fragColor;
+
+layout(binding = 1) uniform sampler2D source1; // Current wallpaper
+layout(binding = 2) uniform sampler2D source2; // Next wallpaper
+
+layout(std140, binding = 0) uniform buf {
+ mat4 qt_Matrix;
+ float qt_Opacity;
+ float progress; // Transition progress (0.0 to 1.0)
+ float stripeCount; // Number of stripes (default 12.0)
+ float angle; // Angle of stripes in degrees (default 30.0)
+ float smoothness; // Edge smoothness (0.0 to 1.0, 0=sharp, 1=very smooth)
+ float aspectRatio; // Width / Height of the screen
+
+ // Fill mode parameters
+ float fillMode; // 0=center, 1=crop, 2=fit, 3=stretch, 4=repeat
+ float imageWidth1; // Width of source1 image
+ float imageHeight1; // Height of source1 image
+ float imageWidth2; // Width of source2 image
+ float imageHeight2; // Height of source2 image
+ float screenWidth; // Screen width
+ float screenHeight; // Screen height
+ vec4 fillColor; // Fill color for empty areas (default: black)
+
+ // Solid color mode
+ float isSolid1; // 1.0 if source1 is solid color, 0.0 otherwise
+ float isSolid2; // 1.0 if source2 is solid color, 0.0 otherwise
+ vec4 solidColor1; // Solid color for source1
+ vec4 solidColor2; // Solid color for source2
+} ubuf;
+
+// Calculate UV coordinates based on fill mode
+vec2 calculateUV(vec2 uv, float imgWidth, float imgHeight) {
+ float imageAspect = imgWidth / imgHeight;
+ float screenAspect = ubuf.screenWidth / ubuf.screenHeight;
+ vec2 transformedUV = uv;
+
+ if (ubuf.fillMode < 0.5) {
+ // Mode 0: no (center) - No resize, center image at original size
+ // Convert UV to pixel coordinates, offset, then back to UV in image space
+ vec2 screenPixel = uv * vec2(ubuf.screenWidth, ubuf.screenHeight);
+ vec2 imageOffset = (vec2(ubuf.screenWidth, ubuf.screenHeight) - vec2(imgWidth, imgHeight)) * 0.5;
+ vec2 imagePixel = screenPixel - imageOffset;
+ transformedUV = imagePixel / vec2(imgWidth, imgHeight);
+ }
+ else if (ubuf.fillMode < 1.5) {
+ // Mode 1: crop (fill/cover) - Fill screen, crop excess (default)
+ float scale = max(ubuf.screenWidth / imgWidth, ubuf.screenHeight / imgHeight);
+ vec2 scaledImageSize = vec2(imgWidth, imgHeight) * scale;
+ vec2 offset = (scaledImageSize - vec2(ubuf.screenWidth, ubuf.screenHeight)) / scaledImageSize;
+ transformedUV = uv * (vec2(1.0) - offset) + offset * 0.5;
+ }
+ else if (ubuf.fillMode < 2.5) {
+ // Mode 2: fit (contain) - Fit inside screen, maintain aspect ratio
+ float scale = min(ubuf.screenWidth / imgWidth, ubuf.screenHeight / imgHeight);
+ vec2 scaledImageSize = vec2(imgWidth, imgHeight) * scale;
+ vec2 offset = (vec2(ubuf.screenWidth, ubuf.screenHeight) - scaledImageSize) * 0.5;
+
+ // Convert screen UV to pixel coordinates
+ vec2 screenPixel = uv * vec2(ubuf.screenWidth, ubuf.screenHeight);
+ // Adjust for offset and scale
+ vec2 imagePixel = (screenPixel - offset) / scale;
+ // Convert back to UV coordinates in image space
+ transformedUV = imagePixel / vec2(imgWidth, imgHeight);
+ }
+ else if (ubuf.fillMode < 3.5) {
+ // Mode 3: stretch - Use original UV (stretches to fit)
+ // No transformation needed for stretch mode
+ }
+ else {
+ // Mode 4: repeat (tile) - Tile image at original size
+ vec2 screenPixel = uv * vec2(ubuf.screenWidth, ubuf.screenHeight);
+ transformedUV = screenPixel / vec2(imgWidth, imgHeight);
+ }
+
+ return transformedUV;
+}
+
+// Sample texture with fill mode and handle out-of-bounds
+// If isSolid > 0.5, returns solidColor instead of sampling texture
+vec4 sampleWithFillMode(sampler2D tex, vec2 uv, float imgWidth, float imgHeight, float isSolid, vec4 solidColor) {
+ // Return solid color if in solid color mode
+ if (isSolid > 0.5) {
+ return solidColor;
+ }
+
+ vec2 transformedUV = calculateUV(uv, imgWidth, imgHeight);
+
+ // Mode 4 (repeat): use fract() to tile the image
+ if (ubuf.fillMode > 3.5) {
+ return texture(tex, fract(transformedUV));
+ }
+
+ // Check if UV is out of bounds
+ if (transformedUV.x < 0.0 || transformedUV.x > 1.0 ||
+ transformedUV.y < 0.0 || transformedUV.y > 1.0) {
+ return ubuf.fillColor;
+ }
+
+ return texture(tex, transformedUV);
+}
+
+void main() {
+ vec2 uv = qt_TexCoord0;
+
+ // Sample textures with fill mode (handles solid colors)
+ vec4 color1 = sampleWithFillMode(source1, uv, ubuf.imageWidth1, ubuf.imageHeight1, ubuf.isSolid1, ubuf.solidColor1);
+ vec4 color2 = sampleWithFillMode(source2, uv, ubuf.imageWidth2, ubuf.imageHeight2, ubuf.isSolid2, ubuf.solidColor2);
+
+ // Map smoothness from 0.0-1.0 to 0.001-0.3 range
+ // Using a non-linear mapping for better control at low values
+ float mappedSmoothness = mix(0.001, 0.3, ubuf.smoothness * ubuf.smoothness);
+
+ // Use values directly without forcing defaults
+ float stripes = (ubuf.stripeCount > 0.0) ? ubuf.stripeCount : 12.0;
+ float angleRad = radians(ubuf.angle);
+ float edgeSmooth = mappedSmoothness;
+
+ // Create a coordinate system for stripes based on angle
+ // At 0ยฐ: vertical stripes (divide by x)
+ // At 45ยฐ: diagonal stripes
+ // At 90ยฐ: horizontal stripes (divide by y)
+
+ // Transform coordinates based on angle
+ float cosA = cos(angleRad);
+ float sinA = sin(angleRad);
+
+ // Project the UV position onto the stripe direction
+ // This gives us the position along the stripe direction
+ float stripeCoord = uv.x * cosA + uv.y * sinA;
+
+ // Perpendicular coordinate (for edge movement)
+ float perpCoord = -uv.x * sinA + uv.y * cosA;
+
+ // Calculate the range of perpCoord based on angle
+ // This determines how far edges need to travel to fully cover the screen
+ float minPerp = min(min(0.0 * -sinA + 0.0 * cosA, 1.0 * -sinA + 0.0 * cosA),
+ min(0.0 * -sinA + 1.0 * cosA, 1.0 * -sinA + 1.0 * cosA));
+ float maxPerp = max(max(0.0 * -sinA + 0.0 * cosA, 1.0 * -sinA + 0.0 * cosA),
+ max(0.0 * -sinA + 1.0 * cosA, 1.0 * -sinA + 1.0 * cosA));
+
+ // Determine which stripe we're in
+ float stripePos = stripeCoord * stripes;
+ int stripeIndex = int(floor(stripePos));
+
+ // Determine if this is an odd or even stripe
+ bool isOddStripe = mod(float(stripeIndex), 2.0) != 0.0;
+
+ // Calculate the progress for this specific stripe with wave delay
+ // Use absolute stripe position for consistent delay across all stripes
+ float normalizedStripePos = clamp(stripePos / stripes, 0.0, 1.0);
+
+ // Increased delay and better distribution
+ float maxDelay = 0.1;
+ float stripeDelay = normalizedStripePos * maxDelay;
+
+ // Better progress mapping that uses the full 0.0-1.0 range
+ // Map progress so that:
+ // - First stripe starts at progress = 0.0
+ // - Last stripe finishes at progress = 1.0
+ float stripeProgress;
+ if (ubuf.progress <= stripeDelay) {
+ stripeProgress = 0.0;
+ } else if (ubuf.progress >= (stripeDelay + (1.0 - maxDelay))) {
+ stripeProgress = 1.0;
+ } else {
+ // Scale the progress within the active window for this stripe
+ float activeStart = stripeDelay;
+ float activeEnd = stripeDelay + (1.0 - maxDelay);
+ stripeProgress = (ubuf.progress - activeStart) / (activeEnd - activeStart);
+ }
+
+ // Use the perpendicular coordinate for edge comparison
+ float yPos = perpCoord;
+
+ // Calculate edge position for this stripe
+ // Use the actual perpendicular coordinate range for this angle
+ float perpRange = maxPerp - minPerp;
+ float margin = edgeSmooth; // Just enough buffer for the smoothstep zone to clear the edge
+ float edgePosition;
+ if (isOddStripe) {
+ // Odd stripes: edge moves from max to min
+ edgePosition = maxPerp + margin - stripeProgress * (perpRange + margin * 2.0);
+ } else {
+ // Even stripes: edge moves from min to max
+ edgePosition = minPerp - margin + stripeProgress * (perpRange + margin * 2.0);
+ }
+
+ // Determine which wallpaper to show based on rotated position
+ float mask;
+ if (isOddStripe) {
+ // Odd stripes reveal new wallpaper from bottom
+ mask = smoothstep(edgePosition - edgeSmooth, edgePosition + edgeSmooth, yPos);
+ } else {
+ // Even stripes reveal new wallpaper from top
+ mask = 1.0 - smoothstep(edgePosition - edgeSmooth, edgePosition + edgeSmooth, yPos);
+ }
+
+ // Mix the wallpapers
+ fragColor = mix(color1, color2, mask);
+
+ // Force exact values at start and end to prevent any bleed-through
+ if (ubuf.progress <= 0.0) {
+ fragColor = color1; // Only show old wallpaper at start
+ } else if (ubuf.progress >= 1.0) {
+ fragColor = color2; // Only show new wallpaper at end
+ } else {
+ // Add manga-style edge shadow only during transition
+ float edgeDist = abs(yPos - edgePosition);
+ float shadowStrength = 1.0 - smoothstep(0.0, edgeSmooth * 2.5, edgeDist);
+ shadowStrength *= 0.2 * (1.0 - abs(stripeProgress - 0.5) * 2.0);
+ fragColor.rgb *= (1.0 - shadowStrength);
+
+ // Add slight vignette during transition for dramatic effect
+ float vignette = 1.0 - ubuf.progress * 0.1 * (1.0 - abs(stripeProgress - 0.5) * 2.0);
+ fragColor.rgb *= vignette;
+ }
+
+ fragColor *= ubuf.qt_Opacity;
+}
\ No newline at end of file
diff --git a/arch/.config/quickshell/noctalia-shell/Shaders/frag/wp_wipe.frag b/arch/.config/quickshell/noctalia-shell/Shaders/frag/wp_wipe.frag
new file mode 100644
index 0000000..19ab477
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Shaders/frag/wp_wipe.frag
@@ -0,0 +1,151 @@
+// ===== wp_wipe.frag =====
+#version 450
+
+layout(location = 0) in vec2 qt_TexCoord0;
+layout(location = 0) out vec4 fragColor;
+
+layout(binding = 1) uniform sampler2D source1; // Current wallpaper
+layout(binding = 2) uniform sampler2D source2; // Next wallpaper
+
+layout(std140, binding = 0) uniform buf {
+ mat4 qt_Matrix;
+ float qt_Opacity;
+ float progress; // Transition progress (0.0 to 1.0)
+ float direction; // 0=left, 1=right, 2=up, 3=down
+ float smoothness; // Edge smoothness (0.0 to 1.0, 0=sharp, 1=very smooth)
+
+ // Fill mode parameters
+ float fillMode; // 0=center, 1=crop, 2=fit, 3=stretch, 4=repeat
+ float imageWidth1; // Width of source1 image
+ float imageHeight1; // Height of source1 image
+ float imageWidth2; // Width of source2 image
+ float imageHeight2; // Height of source2 image
+ float screenWidth; // Screen width
+ float screenHeight; // Screen height
+ vec4 fillColor; // Fill color for empty areas (default: black)
+
+ // Solid color mode
+ float isSolid1; // 1.0 if source1 is solid color, 0.0 otherwise
+ float isSolid2; // 1.0 if source2 is solid color, 0.0 otherwise
+ vec4 solidColor1; // Solid color for source1
+ vec4 solidColor2; // Solid color for source2
+} ubuf;
+
+// Calculate UV coordinates based on fill mode
+vec2 calculateUV(vec2 uv, float imgWidth, float imgHeight) {
+ float imageAspect = imgWidth / imgHeight;
+ float screenAspect = ubuf.screenWidth / ubuf.screenHeight;
+ vec2 transformedUV = uv;
+
+ if (ubuf.fillMode < 0.5) {
+ // Mode 0: no (center) - No resize, center image at original size
+ // Convert UV to pixel coordinates, offset, then back to UV in image space
+ vec2 screenPixel = uv * vec2(ubuf.screenWidth, ubuf.screenHeight);
+ vec2 imageOffset = (vec2(ubuf.screenWidth, ubuf.screenHeight) - vec2(imgWidth, imgHeight)) * 0.5;
+ vec2 imagePixel = screenPixel - imageOffset;
+ transformedUV = imagePixel / vec2(imgWidth, imgHeight);
+ }
+ else if (ubuf.fillMode < 1.5) {
+ // Mode 1: crop (fill/cover) - Fill screen, crop excess (default)
+ float scale = max(ubuf.screenWidth / imgWidth, ubuf.screenHeight / imgHeight);
+ vec2 scaledImageSize = vec2(imgWidth, imgHeight) * scale;
+ vec2 offset = (scaledImageSize - vec2(ubuf.screenWidth, ubuf.screenHeight)) / scaledImageSize;
+ transformedUV = uv * (vec2(1.0) - offset) + offset * 0.5;
+ }
+ else if (ubuf.fillMode < 2.5) {
+ // Mode 2: fit (contain) - Fit inside screen, maintain aspect ratio
+ float scale = min(ubuf.screenWidth / imgWidth, ubuf.screenHeight / imgHeight);
+ vec2 scaledImageSize = vec2(imgWidth, imgHeight) * scale;
+ vec2 offset = (vec2(ubuf.screenWidth, ubuf.screenHeight) - scaledImageSize) * 0.5;
+
+ // Convert screen UV to pixel coordinates
+ vec2 screenPixel = uv * vec2(ubuf.screenWidth, ubuf.screenHeight);
+ // Adjust for offset and scale
+ vec2 imagePixel = (screenPixel - offset) / scale;
+ // Convert back to UV coordinates in image space
+ transformedUV = imagePixel / vec2(imgWidth, imgHeight);
+ }
+ else if (ubuf.fillMode < 3.5) {
+ // Mode 3: stretch - Use original UV (stretches to fit)
+ // No transformation needed for stretch mode
+ }
+ else {
+ // Mode 4: repeat (tile) - Tile image at original size
+ vec2 screenPixel = uv * vec2(ubuf.screenWidth, ubuf.screenHeight);
+ transformedUV = screenPixel / vec2(imgWidth, imgHeight);
+ }
+
+ return transformedUV;
+}
+
+// Sample texture with fill mode and handle out-of-bounds
+// If isSolid > 0.5, returns solidColor instead of sampling texture
+vec4 sampleWithFillMode(sampler2D tex, vec2 uv, float imgWidth, float imgHeight, float isSolid, vec4 solidColor) {
+ // Return solid color if in solid color mode
+ if (isSolid > 0.5) {
+ return solidColor;
+ }
+
+ vec2 transformedUV = calculateUV(uv, imgWidth, imgHeight);
+
+ // Mode 4 (repeat): use fract() to tile the image
+ if (ubuf.fillMode > 3.5) {
+ return texture(tex, fract(transformedUV));
+ }
+
+ // Check if UV is out of bounds
+ if (transformedUV.x < 0.0 || transformedUV.x > 1.0 ||
+ transformedUV.y < 0.0 || transformedUV.y > 1.0) {
+ return ubuf.fillColor;
+ }
+
+ return texture(tex, transformedUV);
+}
+
+void main() {
+ vec2 uv = qt_TexCoord0;
+
+ // Sample textures with fill mode (handles solid colors)
+ vec4 color1 = sampleWithFillMode(source1, uv, ubuf.imageWidth1, ubuf.imageHeight1, ubuf.isSolid1, ubuf.solidColor1);
+ vec4 color2 = sampleWithFillMode(source2, uv, ubuf.imageWidth2, ubuf.imageHeight2, ubuf.isSolid2, ubuf.solidColor2);
+
+ // Map smoothness from 0.0-1.0 to 0.001-0.5 range
+ // Using a non-linear mapping for better control
+ float mappedSmoothness = mix(0.001, 0.5, ubuf.smoothness * ubuf.smoothness);
+
+ float edge = 0.0;
+ float factor = 0.0;
+
+ // Extend the progress range to account for smoothness
+ // This ensures the transition completes fully at the edges
+ float extendedProgress = ubuf.progress * (1.0 + 2.0 * mappedSmoothness) - mappedSmoothness;
+
+ // Calculate edge position based on direction
+ // As progress goes from 0 to 1, we reveal source2 (new wallpaper)
+ if (ubuf.direction < 0.5) {
+ // Wipe from right to left (new image enters from right)
+ edge = 1.0 - extendedProgress;
+ factor = smoothstep(edge - mappedSmoothness, edge + mappedSmoothness, uv.x);
+ fragColor = mix(color1, color2, factor);
+ }
+ else if (ubuf.direction < 1.5) {
+ // Wipe from left to right (new image enters from left)
+ edge = extendedProgress;
+ factor = smoothstep(edge - mappedSmoothness, edge + mappedSmoothness, uv.x);
+ fragColor = mix(color2, color1, factor);
+ }
+ else if (ubuf.direction < 2.5) {
+ // Wipe from bottom to top (new image enters from bottom)
+ edge = 1.0 - extendedProgress;
+ factor = smoothstep(edge - mappedSmoothness, edge + mappedSmoothness, uv.y);
+ fragColor = mix(color1, color2, factor);
+ }
+ else {
+ // Wipe from top to bottom (new image enters from top)
+ edge = extendedProgress;
+ factor = smoothstep(edge - mappedSmoothness, edge + mappedSmoothness, uv.y);
+ fragColor = mix(color2, color1, factor);
+ }
+
+ fragColor *= ubuf.qt_Opacity;
+}
\ No newline at end of file
diff --git a/arch/.config/quickshell/noctalia-shell/Shaders/qsb/appicon_colorize.frag.qsb b/arch/.config/quickshell/noctalia-shell/Shaders/qsb/appicon_colorize.frag.qsb
new file mode 100644
index 0000000..ea0abbe
Binary files /dev/null and b/arch/.config/quickshell/noctalia-shell/Shaders/qsb/appicon_colorize.frag.qsb differ
diff --git a/arch/.config/quickshell/noctalia-shell/Shaders/qsb/color_picker.frag.qsb b/arch/.config/quickshell/noctalia-shell/Shaders/qsb/color_picker.frag.qsb
new file mode 100644
index 0000000..cfbbb50
Binary files /dev/null and b/arch/.config/quickshell/noctalia-shell/Shaders/qsb/color_picker.frag.qsb differ
diff --git a/arch/.config/quickshell/noctalia-shell/Shaders/qsb/graph.frag.qsb b/arch/.config/quickshell/noctalia-shell/Shaders/qsb/graph.frag.qsb
new file mode 100644
index 0000000..16cdee2
Binary files /dev/null and b/arch/.config/quickshell/noctalia-shell/Shaders/qsb/graph.frag.qsb differ
diff --git a/arch/.config/quickshell/noctalia-shell/Shaders/qsb/progress_border.frag.qsb b/arch/.config/quickshell/noctalia-shell/Shaders/qsb/progress_border.frag.qsb
new file mode 100644
index 0000000..60fda9f
Binary files /dev/null and b/arch/.config/quickshell/noctalia-shell/Shaders/qsb/progress_border.frag.qsb differ
diff --git a/arch/.config/quickshell/noctalia-shell/Shaders/qsb/rounded_image.frag.qsb b/arch/.config/quickshell/noctalia-shell/Shaders/qsb/rounded_image.frag.qsb
new file mode 100644
index 0000000..8942a67
Binary files /dev/null and b/arch/.config/quickshell/noctalia-shell/Shaders/qsb/rounded_image.frag.qsb differ
diff --git a/arch/.config/quickshell/noctalia-shell/Shaders/qsb/wave_spectrum.frag.qsb b/arch/.config/quickshell/noctalia-shell/Shaders/qsb/wave_spectrum.frag.qsb
new file mode 100644
index 0000000..6a680c5
Binary files /dev/null and b/arch/.config/quickshell/noctalia-shell/Shaders/qsb/wave_spectrum.frag.qsb differ
diff --git a/arch/.config/quickshell/noctalia-shell/Shaders/qsb/weather_cloud.frag.qsb b/arch/.config/quickshell/noctalia-shell/Shaders/qsb/weather_cloud.frag.qsb
new file mode 100644
index 0000000..2fe9734
Binary files /dev/null and b/arch/.config/quickshell/noctalia-shell/Shaders/qsb/weather_cloud.frag.qsb differ
diff --git a/arch/.config/quickshell/noctalia-shell/Shaders/qsb/weather_rain.frag.qsb b/arch/.config/quickshell/noctalia-shell/Shaders/qsb/weather_rain.frag.qsb
new file mode 100644
index 0000000..5c06dd6
Binary files /dev/null and b/arch/.config/quickshell/noctalia-shell/Shaders/qsb/weather_rain.frag.qsb differ
diff --git a/arch/.config/quickshell/noctalia-shell/Shaders/qsb/weather_snow.frag.qsb b/arch/.config/quickshell/noctalia-shell/Shaders/qsb/weather_snow.frag.qsb
new file mode 100644
index 0000000..e466857
Binary files /dev/null and b/arch/.config/quickshell/noctalia-shell/Shaders/qsb/weather_snow.frag.qsb differ
diff --git a/arch/.config/quickshell/noctalia-shell/Shaders/qsb/weather_stars.frag.qsb b/arch/.config/quickshell/noctalia-shell/Shaders/qsb/weather_stars.frag.qsb
new file mode 100644
index 0000000..544c2b3
Binary files /dev/null and b/arch/.config/quickshell/noctalia-shell/Shaders/qsb/weather_stars.frag.qsb differ
diff --git a/arch/.config/quickshell/noctalia-shell/Shaders/qsb/weather_sun.frag.qsb b/arch/.config/quickshell/noctalia-shell/Shaders/qsb/weather_sun.frag.qsb
new file mode 100644
index 0000000..2b231fa
Binary files /dev/null and b/arch/.config/quickshell/noctalia-shell/Shaders/qsb/weather_sun.frag.qsb differ
diff --git a/arch/.config/quickshell/noctalia-shell/Shaders/qsb/wp_disc.frag.qsb b/arch/.config/quickshell/noctalia-shell/Shaders/qsb/wp_disc.frag.qsb
new file mode 100644
index 0000000..d324e00
Binary files /dev/null and b/arch/.config/quickshell/noctalia-shell/Shaders/qsb/wp_disc.frag.qsb differ
diff --git a/arch/.config/quickshell/noctalia-shell/Shaders/qsb/wp_fade.frag.qsb b/arch/.config/quickshell/noctalia-shell/Shaders/qsb/wp_fade.frag.qsb
new file mode 100644
index 0000000..6778626
Binary files /dev/null and b/arch/.config/quickshell/noctalia-shell/Shaders/qsb/wp_fade.frag.qsb differ
diff --git a/arch/.config/quickshell/noctalia-shell/Shaders/qsb/wp_honeycomb.frag.qsb b/arch/.config/quickshell/noctalia-shell/Shaders/qsb/wp_honeycomb.frag.qsb
new file mode 100644
index 0000000..4e3542c
Binary files /dev/null and b/arch/.config/quickshell/noctalia-shell/Shaders/qsb/wp_honeycomb.frag.qsb differ
diff --git a/arch/.config/quickshell/noctalia-shell/Shaders/qsb/wp_pixelate.frag.qsb b/arch/.config/quickshell/noctalia-shell/Shaders/qsb/wp_pixelate.frag.qsb
new file mode 100644
index 0000000..9751a0f
Binary files /dev/null and b/arch/.config/quickshell/noctalia-shell/Shaders/qsb/wp_pixelate.frag.qsb differ
diff --git a/arch/.config/quickshell/noctalia-shell/Shaders/qsb/wp_stripes.frag.qsb b/arch/.config/quickshell/noctalia-shell/Shaders/qsb/wp_stripes.frag.qsb
new file mode 100644
index 0000000..70b529f
Binary files /dev/null and b/arch/.config/quickshell/noctalia-shell/Shaders/qsb/wp_stripes.frag.qsb differ
diff --git a/arch/.config/quickshell/noctalia-shell/Shaders/qsb/wp_wipe.frag.qsb b/arch/.config/quickshell/noctalia-shell/Shaders/qsb/wp_wipe.frag.qsb
new file mode 100644
index 0000000..f878f35
Binary files /dev/null and b/arch/.config/quickshell/noctalia-shell/Shaders/qsb/wp_wipe.frag.qsb differ
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/AudioSpectrum/NLinearSpectrum.qml b/arch/.config/quickshell/noctalia-shell/Widgets/AudioSpectrum/NLinearSpectrum.qml
new file mode 100644
index 0000000..c30c834
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/AudioSpectrum/NLinearSpectrum.qml
@@ -0,0 +1,49 @@
+import QtQuick
+import qs.Commons
+
+Item {
+ id: root
+ property color fillColor: Color.mPrimary
+ property color strokeColor: Color.mOnSurface
+ property int strokeWidth: 0
+ property var values: []
+ property bool vertical: false
+ property string barPosition: "top" // "top", "bottom", "left", "right"
+ property bool mirrored: true
+
+ // Minimum signal properties
+ property bool showMinimumSignal: false
+ property real minimumSignalValue: 0.01 // Default to 1% of height
+
+ // Pre compute horizontal mirroring
+ readonly property int valuesCount: (values && values.length !== undefined) ? values.length : 0
+ readonly property int totalBars: mirrored ? valuesCount * 2 : valuesCount
+ readonly property real barSlotSize: totalBars > 0 ? (vertical ? height : width) / totalBars : 0
+ readonly property bool highQuality: (Settings.data.audio.visualizerType === "low") ? false : true
+
+ Repeater {
+ model: root.totalBars
+
+ Rectangle {
+ property int valueIndex: root.mirrored ? (index < root.valuesCount ? root.valuesCount - 1 - index : index - root.valuesCount) : index
+
+ property real rawAmp: (root.values && root.values[valueIndex] !== undefined) ? root.values[valueIndex] : 0
+ property real amp: (root.showMinimumSignal && rawAmp === 0) ? root.minimumSignalValue : rawAmp
+
+ color: root.fillColor
+ border.color: root.strokeColor
+ border.width: root.strokeWidth
+ antialiasing: root.highQuality
+ smooth: root.highQuality
+
+ // Only update when value actually changes - reduces GPU load
+ width: vertical ? root.width * amp : root.barSlotSize * 0.5
+ height: vertical ? root.barSlotSize * 0.5 : root.height * amp
+ x: vertical ? (root.barPosition === "left" ? 0 : root.width - width) : index * root.barSlotSize + (root.barSlotSize * 0.25)
+ y: vertical ? index * root.barSlotSize + (root.barSlotSize * 0.25) : root.height - height
+
+ // Disable updates when invisible to save GPU
+ visible: root.visible
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/AudioSpectrum/NMirroredSpectrum.qml b/arch/.config/quickshell/noctalia-shell/Widgets/AudioSpectrum/NMirroredSpectrum.qml
new file mode 100644
index 0000000..658299a
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/AudioSpectrum/NMirroredSpectrum.qml
@@ -0,0 +1,51 @@
+import QtQuick
+import qs.Commons
+
+Item {
+ id: root
+ property color fillColor: Color.mPrimary
+ property color strokeColor: Color.mOnSurface
+ property int strokeWidth: 0
+ property var values: []
+ property bool vertical: false
+ property bool mirrored: true
+
+ // Minimum signal properties
+ property bool showMinimumSignal: false
+ property real minimumSignalValue: 0.01 // Default to 1% of height
+
+ // Pre-compute mirroring
+ readonly property int valuesCount: (values && values.length !== undefined) ? values.length : 0
+ readonly property int totalBars: mirrored ? valuesCount * 2 : valuesCount
+ readonly property real barSlotSize: totalBars > 0 ? (vertical ? height : width) / totalBars : 0
+ readonly property bool highQuality: (Settings.data.audio.visualizerType === "low") ? false : true
+ readonly property real centerY: height / 2
+ readonly property real centerX: width / 2
+
+ Repeater {
+ model: root.totalBars
+
+ Rectangle {
+ property int valueIndex: root.mirrored ? (index < root.valuesCount ? root.valuesCount - 1 - index : index - root.valuesCount) : index
+
+ property real rawAmp: (root.values && root.values[valueIndex] !== undefined) ? root.values[valueIndex] : 0
+ property real amp: (root.showMinimumSignal && rawAmp === 0) ? root.minimumSignalValue : rawAmp
+
+ property real barSize: (vertical ? root.width : root.height) * amp
+
+ color: root.fillColor
+ border.color: root.strokeColor
+ border.width: root.strokeWidth
+ antialiasing: root.highQuality
+ smooth: root.highQuality
+
+ width: vertical ? barSize : root.barSlotSize * 0.8
+ height: vertical ? root.barSlotSize * 0.8 : barSize
+ x: vertical ? root.centerX - (barSize / 2) : index * root.barSlotSize + (root.barSlotSize * 0.25)
+ y: vertical ? index * root.barSlotSize + (root.barSlotSize * 0.25) : root.centerY - (barSize / 2)
+
+ // Disable updates when invisible to save GPU
+ visible: root.visible
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/AudioSpectrum/NWaveSpectrum.qml b/arch/.config/quickshell/noctalia-shell/Widgets/AudioSpectrum/NWaveSpectrum.qml
new file mode 100644
index 0000000..329b201
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/AudioSpectrum/NWaveSpectrum.qml
@@ -0,0 +1,72 @@
+import QtQuick
+import Quickshell
+import qs.Commons
+
+Item {
+ id: root
+ property color fillColor: Color.mPrimary
+ property color strokeColor: Color.mOnSurface
+ property int strokeWidth: 0
+ property var values: []
+ property bool vertical: false
+ property bool mirrored: true
+
+ // Minimum signal properties
+ property bool showMinimumSignal: false
+ property real minimumSignalValue: 0.01 // Default to 1% of height
+
+ readonly property int valuesCount: (values && values.length !== undefined) ? values.length : 0
+ readonly property bool hasData: valuesCount >= 2
+
+ // Data texture: one pixel per value, R channel = amplitude
+ Item {
+ id: dataRow
+ width: Math.max(root.valuesCount, 4)
+ height: 1
+
+ Repeater {
+ model: dataRow.width
+
+ Rectangle {
+ required property int index
+ x: index
+ width: 1
+ height: 1
+ color: {
+ if (index >= root.valuesCount)
+ return Qt.rgba(0, 0, 0, 1);
+ var v = root.values[index];
+ if (v === undefined || v === null || !isFinite(v))
+ v = 0;
+ if (root.showMinimumSignal && v === 0)
+ v = root.minimumSignalValue;
+ return Qt.rgba(Math.max(0, Math.min(1, v)), 0, 0, 1);
+ }
+ }
+ }
+ }
+
+ ShaderEffectSource {
+ id: dataTex
+ sourceItem: dataRow
+ textureSize: Qt.size(dataRow.width, 1)
+ live: true
+ smooth: false
+ hideSource: true
+ }
+
+ ShaderEffect {
+ anchors.fill: parent
+ visible: root.hasData && root.width > 0 && root.height > 0
+
+ property variant dataSource: dataTex
+ property color fillColor: root.fillColor
+ property real count: root.valuesCount
+ property real texWidth: dataRow.width
+ property real vertical: root.vertical ? 1.0 : 0.0
+ property real mirrored: root.mirrored ? 1.0 : 0.0
+
+ fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/wave_spectrum.frag.qsb")
+ blending: true
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NBattery.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NBattery.qml
new file mode 100644
index 0000000..fea02b3
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NBattery.qml
@@ -0,0 +1,205 @@
+import QtQuick
+import QtQuick.Layouts
+import qs.Commons
+import qs.Widgets
+
+// Battery widget with Android 16 style rendering (horizontal or vertical)
+Item {
+ id: root
+
+ // Data (must be provided by parent)
+ required property real percentage
+ required property bool charging
+ required property bool pluggedIn
+ required property bool ready
+ required property bool low
+ required property bool critical
+
+ // Sizing - baseSize controls overall scaleFactor for bar/panel usage
+ property real baseSize: Style.fontSizeM
+
+ // Styling - no hardcoded colors, only theme colors
+ property color baseColor: Color.mOnSurface
+ property color lowColor: Color.mError
+ property color chargingColor: Color.mPrimary
+ property color textColor: Color.mSurface
+
+ // Display options
+ property bool showPercentageText: true
+ property bool vertical: false
+
+ // Alternating state icon display (toggles between percentage and icon when charging)
+ property bool showStateIcon: false
+
+ onChargingChanged: {
+ if (!charging)
+ showStateIcon = false;
+ }
+
+ // Internal sizing calculations based on baseSize
+ readonly property real scaleFactor: baseSize / Style.fontSizeM
+ readonly property real bodyWidth: {
+ const min = Style.toOdd(22 * scaleFactor);
+ if (!showPercentageText) {
+ return min;
+ }
+
+ // increase length when showing 100%
+ if (percentage > 99) {
+ const max = Style.toOdd(30 * scaleFactor);
+ return max;
+ }
+ return min;
+ }
+
+ readonly property real bodyHeight: Style.toOdd(14 * scaleFactor)
+ readonly property real terminalWidth: Math.round(2.5 * scaleFactor)
+ readonly property real terminalHeight: Math.round(7 * scaleFactor)
+ readonly property real cornerRadius: Math.round(3 * scaleFactor)
+
+ // Total size is just body + terminal (no external icon)
+ readonly property real totalWidth: vertical ? bodyHeight : bodyWidth + terminalWidth
+ readonly property real totalHeight: vertical ? bodyWidth + terminalWidth : bodyHeight
+
+ // Determine active color based on state
+ readonly property color activeColor: {
+ if (!ready) {
+ return Qt.alpha(baseColor, Style.opacityMedium);
+ }
+ if (charging) {
+ return chargingColor;
+ }
+ if (low || critical) {
+ return lowColor;
+ }
+ return baseColor;
+ }
+
+ // Background color for empty portion (semi-transparent)
+ readonly property color emptyColor: Qt.alpha(baseColor, 0.66)
+
+ // State icon logic
+ readonly property string stateIcon: {
+ if (!ready)
+ return "x";
+ if (charging)
+ return "bolt-filled";
+ if (pluggedIn)
+ return "plug-filled";
+ return "";
+ }
+
+ // Animated percentage for smooth transitions
+ property real animatedPercentage: percentage
+
+ Behavior on animatedPercentage {
+ enabled: !Settings.data.general.animationDisabled
+ NumberAnimation {
+ duration: Style.animationNormal
+ easing.type: Easing.OutCubic
+ }
+ }
+
+ // Timer to alternate between percentage text and state icon when charging/plugged
+ Timer {
+ id: alternateTimer
+ interval: 4000
+ repeat: true
+ running: root.charging && root.showPercentageText
+ onTriggered: root.showStateIcon = !root.showStateIcon
+ }
+
+ implicitWidth: Math.round(totalWidth)
+ implicitHeight: Math.round(totalHeight)
+ Layout.maximumWidth: implicitWidth
+ Layout.maximumHeight: implicitHeight
+
+ // Battery body container
+ Item {
+ id: batteryBody
+ width: root.vertical ? root.bodyHeight : root.bodyWidth + root.terminalWidth
+ height: root.vertical ? root.bodyWidth + root.terminalWidth : root.bodyHeight
+ anchors.left: root.vertical ? undefined : parent.left
+ anchors.bottom: root.vertical ? parent.bottom : undefined
+ anchors.horizontalCenter: root.vertical ? parent.horizontalCenter : undefined
+ anchors.verticalCenter: root.vertical ? undefined : parent.verticalCenter
+
+ // Battery body background
+ Rectangle {
+ id: bodyBackground
+ y: root.vertical ? root.terminalWidth : 0
+ width: root.vertical ? root.bodyHeight : root.bodyWidth
+ height: root.vertical ? root.bodyWidth : root.bodyHeight
+ radius: root.cornerRadius
+ color: root.emptyColor
+ }
+
+ // Terminal cap
+ Rectangle {
+ x: root.vertical ? (root.bodyHeight - root.terminalHeight) / 2 : root.bodyWidth
+ y: root.vertical ? 0 : (root.bodyHeight - root.terminalHeight) / 2
+ width: root.vertical ? root.terminalHeight : root.terminalWidth
+ height: root.vertical ? root.terminalWidth : root.terminalHeight
+ radius: root.cornerRadius / 2
+ color: root.critical ? root.lowColor : root.emptyColor
+ }
+
+ // Fill level
+ Rectangle {
+ id: fillRect
+ visible: root.ready && (root.animatedPercentage > 0 || root.critical)
+ x: 0
+ y: root.vertical ? root.terminalWidth + root.bodyWidth * (1 - (root.critical ? 1 : root.animatedPercentage / 100)) : 0
+ width: root.vertical ? root.bodyHeight : root.bodyWidth * (root.critical ? 1 : root.animatedPercentage / 100)
+ height: root.vertical ? root.bodyWidth * (root.critical ? 1 : root.animatedPercentage / 100) : root.bodyHeight
+ radius: root.cornerRadius
+ color: root.activeColor
+ }
+ }
+
+ // Percentage text overlaid on battery center
+ NText {
+ id: percentageText
+ visible: opacity > 0
+ opacity: root.showPercentageText && root.ready && (root.charging ? !root.showStateIcon : !root.pluggedIn) ? 1 : 0
+ x: batteryBody.x + Style.pixelAlignCenter(bodyBackground.width, width)
+ y: batteryBody.y + bodyBackground.y + Style.pixelAlignCenter(bodyBackground.height, height)
+ font.family: Settings.data.ui.fontFixed
+ font.weight: Style.fontWeightBold
+ text: root.vertical ? String(Math.round(root.animatedPercentage)).split('').join('\n') : Math.round(root.animatedPercentage)
+ pointSize: root.baseSize * (root.vertical ? 0.82 : 0.82)
+ color: Qt.alpha(root.textColor, 0.75)
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ lineHeight: root.vertical ? 0.7 : 1.0
+ lineHeightMode: Text.ProportionalHeight
+
+ Behavior on opacity {
+ enabled: !Settings.data.general.animationDisabled
+ NumberAnimation {
+ duration: Style.animationFast
+ easing.type: Easing.InOutQuad
+ }
+ }
+ }
+
+ // State icon centered inside battery body (shown when alternating)
+ NIcon {
+ id: stateIconOverlay
+ visible: opacity > 0
+ opacity: !root.ready || (root.charging ? (root.showStateIcon || !root.showPercentageText) : root.pluggedIn) ? 1 : 0
+ x: batteryBody.x + Style.pixelAlignCenter(bodyBackground.width, width)
+ y: batteryBody.y + bodyBackground.y + Style.pixelAlignCenter(bodyBackground.height, height)
+ icon: root.stateIcon
+ pointSize: Style.toOdd(root.baseSize)
+ color: Qt.alpha(root.textColor, 0.75)
+
+ Behavior on opacity {
+ enabled: !Settings.data.general.animationDisabled
+ NumberAnimation {
+ duration: Style.animationFast
+ easing.type: Easing.InOutQuad
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NBox.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NBox.qml
new file mode 100644
index 0000000..16a0107
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NBox.qml
@@ -0,0 +1,30 @@
+import QtQuick
+import qs.Commons
+
+// Rounded group container using the variant surface color.
+// To be used in side panels and settings panes to group fields or buttons.
+// Opacity is based on panelBackgroundOpacity but clamped to a minimum to avoid full transparency.
+
+Item {
+ id: root
+
+ property color color: Color.mSurfaceVariant
+ property bool forceOpaque: false
+ property alias radius: bg.radius
+ property alias border: bg.border
+
+ Rectangle {
+ id: bg
+ anchors.fill: parent
+ radius: Style.radiusM
+ border.color: Style.boxBorderColor
+ border.width: Style.borderS
+ color: {
+ if (forceOpaque) {
+ return root.color;
+ }
+
+ return Color.smartAlpha(root.color);
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NBusyIndicator.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NBusyIndicator.qml
new file mode 100644
index 0000000..48413b9
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NBusyIndicator.qml
@@ -0,0 +1,64 @@
+import QtQuick
+import qs.Commons
+
+Item {
+ id: root
+
+ property bool running: true
+ property color color: Color.mPrimary
+ property int size: Style.baseWidgetSize
+ property int strokeWidth: Style.borderL
+ property int duration: Style.animationSlow * 2
+
+ implicitWidth: size
+ implicitHeight: size
+
+ // GPU-optimized spinner - draw once, rotate with GPU transform
+ Item {
+ id: spinner
+ anchors.fill: parent
+
+ // Static canvas - drawn ONCE, then cached
+ Canvas {
+ id: canvas
+ anchors.fill: parent
+ renderStrategy: Canvas.Cooperative // Better performance than Threaded for simple shapes
+ renderTarget: Canvas.FramebufferObject // GPU texture
+
+ // Enable layer caching - critical for performance!
+ layer.enabled: true
+ layer.smooth: true
+
+ Component.onCompleted: {
+ requestPaint();
+ }
+
+ onPaint: {
+ var ctx = getContext("2d");
+ ctx.reset();
+
+ var centerX = width / 2;
+ var centerY = height / 2;
+ var radius = Math.min(width, height) / 2 - strokeWidth / 2;
+
+ ctx.strokeStyle = root.color;
+ ctx.lineWidth = Math.max(1, root.strokeWidth);
+ ctx.lineCap = "round";
+
+ // Draw arc with gap (270 degrees = 3/4 of circle)
+ ctx.beginPath();
+ ctx.arc(centerX, centerY, radius, -Math.PI / 2, -Math.PI / 2 + Math.PI * 1.5);
+ ctx.stroke();
+ }
+ }
+
+ // Smooth rotation animation - uses GPU transform, NO canvas repaints!
+ RotationAnimation on rotation {
+ running: root.running
+ from: 0
+ to: 360
+ duration: root.duration
+ loops: Animation.Infinite
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NButton.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NButton.qml
new file mode 100644
index 0000000..45a857e
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NButton.qml
@@ -0,0 +1,184 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import qs.Commons
+import qs.Services.UI
+
+Item {
+ id: root
+
+ // Public properties
+ property string text: ""
+ property string icon: ""
+ property var tooltipText
+ property color backgroundColor: Color.mPrimary
+ property color textColor: Color.mOnPrimary
+ property color hoverColor: Color.mHover
+ property color textHoverColor: Color.mOnHover
+ property real fontSize: Style.fontSizeM
+ property int fontWeight: Style.fontWeightSemiBold
+ property real iconSize: Style.fontSizeL
+ property bool outlined: false
+ property int horizontalAlignment: Qt.AlignHCenter
+ property real buttonRadius: Style.iRadiusS
+
+ // Signals
+ signal clicked
+ signal rightClicked
+ signal middleClicked
+ signal entered
+ signal exited
+
+ // Internal properties
+ property bool hovered: false
+ readonly property color contentColor: {
+ if (!root.enabled) {
+ return Color.mOnSurfaceVariant;
+ }
+ if (root.hovered) {
+ return root.textHoverColor;
+ }
+ if (root.outlined) {
+ return root.backgroundColor;
+ }
+ return root.textColor;
+ }
+
+ // Dimensions - include margin so border renders cleanly at fractional scales
+ implicitWidth: bg.implicitWidth + 2 * Style.borderS
+ implicitHeight: bg.implicitHeight + 2 * Style.borderS
+
+ opacity: enabled ? 1.0 : 0.6
+
+ Rectangle {
+ id: bg
+ anchors.fill: parent
+ anchors.margins: Style.borderS
+
+ implicitWidth: contentRow.implicitWidth + (root.fontSize * 2)
+ implicitHeight: contentRow.implicitHeight + (root.fontSize)
+
+ radius: root.buttonRadius
+ color: {
+ if (!root.enabled)
+ return root.outlined ? "transparent" : Qt.lighter(Color.mSurfaceVariant, 1.2);
+ if (root.hovered)
+ return root.hoverColor;
+ return root.outlined ? "transparent" : root.backgroundColor;
+ }
+
+ border.width: root.outlined ? Style.borderS : 0
+ border.color: {
+ if (!root.enabled)
+ return Color.mOutline;
+ if (root.hovered)
+ return root.hoverColor;
+ return root.outlined ? root.backgroundColor : "transparent";
+ }
+
+ Behavior on color {
+ enabled: !Color.isTransitioning
+ ColorAnimation {
+ duration: Style.animationFast
+ easing.type: Easing.OutCubic
+ }
+ }
+
+ Behavior on border.color {
+ enabled: !Color.isTransitioning
+ ColorAnimation {
+ duration: Style.animationFast
+ easing.type: Easing.OutCubic
+ }
+ }
+
+ // Content
+ RowLayout {
+ id: contentRow
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: root.horizontalAlignment === Qt.AlignLeft ? parent.left : undefined
+ anchors.horizontalCenter: root.horizontalAlignment === Qt.AlignHCenter ? parent.horizontalCenter : undefined
+ anchors.leftMargin: root.horizontalAlignment === Qt.AlignLeft ? Style.marginL : 0
+ spacing: Style.marginXS
+
+ // Icon (optional)
+ NIcon {
+ Layout.alignment: Qt.AlignVCenter
+ visible: root.icon !== ""
+ icon: root.icon
+ pointSize: root.iconSize
+ color: root.contentColor
+
+ Behavior on color {
+ enabled: !Color.isTransitioning
+ ColorAnimation {
+ duration: Style.animationFast
+ easing.type: Easing.OutCubic
+ }
+ }
+ }
+
+ // Text
+ NText {
+ Layout.alignment: Qt.AlignVCenter
+ visible: root.text !== ""
+ text: root.text
+ pointSize: root.fontSize
+ font.weight: root.fontWeight
+ color: root.contentColor
+
+ Behavior on color {
+ enabled: !Color.isTransitioning
+ ColorAnimation {
+ duration: Style.animationFast
+ easing.type: Easing.OutCubic
+ }
+ }
+ }
+ }
+
+ // Mouse interaction
+ MouseArea {
+ id: mouseArea
+ anchors.fill: parent
+ enabled: root.enabled
+ hoverEnabled: true
+ acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
+ cursorShape: root.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
+
+ onEntered: {
+ root.hovered = root.enabled ? true : false;
+ root.entered();
+ if (root.hovered && root.tooltipText && (!Array.isArray(root.tooltipText) || root.tooltipText.length > 0)) {
+ TooltipService.show(root, root.tooltipText);
+ }
+ }
+ onExited: {
+ root.hovered = false;
+ root.exited();
+ if (root.tooltipText && (!Array.isArray(root.tooltipText) || root.tooltipText.length > 0)) {
+ TooltipService.hide();
+ }
+ }
+ onPressed: mouse => {
+ if (root.tooltipText && (!Array.isArray(root.tooltipText) || root.tooltipText.length > 0)) {
+ TooltipService.hide();
+ }
+ if (mouse.button === Qt.LeftButton) {
+ root.clicked();
+ } else if (mouse.button == Qt.RightButton) {
+ root.rightClicked();
+ } else if (mouse.button == Qt.MiddleButton) {
+ root.middleClicked();
+ }
+ }
+
+ onCanceled: {
+ root.hovered = false;
+ if (root.tooltipText && (!Array.isArray(root.tooltipText) || root.tooltipText.length > 0)) {
+ TooltipService.hide();
+ }
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NCheckbox.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NCheckbox.qml
new file mode 100644
index 0000000..30e51d0
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NCheckbox.qml
@@ -0,0 +1,87 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import qs.Commons
+
+RowLayout {
+ id: root
+
+ // Public API
+ property string label: ""
+ property string description: ""
+ property bool checked: false
+ property bool hovering: false
+ property color activeColor: Color.mPrimary
+ property color activeOnColor: Color.mOnPrimary
+ property int baseSize: root.defaultSize
+ property real labelSize: Style.fontSizeL
+
+ readonly property int defaultSize: Style.baseWidgetSize * 0.7
+
+ signal toggled(bool checked)
+ signal entered
+ signal exited
+
+ Layout.fillWidth: true
+
+ NLabel {
+ label: root.label
+ labelSize: root.labelSize
+ description: root.description
+ visible: root.label !== "" || root.description !== ""
+ }
+
+ // Spacer to push the checkbox to the far right
+ Item {
+ Layout.fillWidth: true
+ }
+
+ Rectangle {
+ id: box
+
+ opacity: enabled ? 1.0 : 0.6
+ Layout.margins: Style.borderS
+ implicitWidth: Style.toOdd(root.baseSize)
+ implicitHeight: Style.toOdd(root.baseSize)
+ radius: Style.iRadiusXS * (root.baseSize / root.defaultSize)
+ color: root.checked ? root.activeColor : Color.mSurface
+ border.color: Color.mOutline
+ border.width: Style.borderS
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+
+ Behavior on border.color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+
+ NIcon {
+ visible: root.checked
+ x: Style.pixelAlignCenter(parent.width, width)
+ y: Style.pixelAlignCenter(parent.height, height)
+ icon: "check"
+ color: root.activeOnColor
+ pointSize: Style.toOdd(root.baseSize * 0.5)
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ hoverEnabled: true
+ onEntered: {
+ hovering = true;
+ root.entered();
+ }
+ onExited: {
+ hovering = false;
+ root.exited();
+ }
+ onClicked: root.toggled(!root.checked)
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NCircleStat.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NCircleStat.qml
new file mode 100644
index 0000000..cb3341a
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NCircleStat.qml
@@ -0,0 +1,164 @@
+import QtQuick
+import QtQuick.Layouts
+import qs.Commons
+import qs.Services.UI
+import qs.Widgets
+
+// Compact circular statistic display using Layout management
+Item {
+ id: root
+
+ property real ratio: 0 // 0..1 range
+ property string icon: ""
+ property string suffix: "%"
+ property real contentScale: 1.0
+ property color fillColor: Color.mPrimary
+ property var tooltipText
+ property string tooltipDirection: "top"
+
+ // Arc geometry constants
+ readonly property real _gaugeSize: 60 * contentScale
+ readonly property real _lineWidth: 6 * contentScale
+ readonly property real _arcRadius: _gaugeSize / 2 - 5 * contentScale
+ // Arc goes from 150ยฐ to 390ยฐ (30ยฐ), gap at bottom
+ // Bottom of arc is at y = center + radius * sin(30ยฐ) = center + radius * 0.5
+ // Plus half line width for stroke
+ readonly property real _arcBottomY: _gaugeSize / 2 + _arcRadius * 0.5 + _lineWidth / 2
+ // Height needs to include the icon which sits inside the arc gap
+ // Icon is ~12px tall, positioned 4px below text center, need ~4px more padding
+ readonly property real _contentHeight: _arcBottomY + 4 * contentScale
+
+ implicitWidth: Math.round(_gaugeSize)
+ implicitHeight: Math.round(_contentHeight)
+ Layout.maximumWidth: implicitWidth
+ Layout.maximumHeight: implicitHeight
+
+ // Animated ratio for smooth transitions - reduces repaint frequency
+ property real animatedRatio: ratio
+
+ Behavior on animatedRatio {
+ enabled: !Settings.data.general.animationDisabled
+ NumberAnimation {
+ duration: Style.animationNormal
+ easing.type: Easing.OutCubic
+ }
+ }
+
+ // Repaint gauge when animated ratio changes (throttled by animation)
+ onAnimatedRatioChanged: {
+ if (!repaintTimer.running) {
+ repaintTimer.start();
+ }
+ }
+ onFillColorChanged: gauge.requestPaint()
+
+ // Throttle timer to limit repaint frequency during animation (~30 FPS)
+ Timer {
+ id: repaintTimer
+ interval: 33
+ repeat: true
+ onTriggered: {
+ gauge.requestPaint();
+ // Stop repeating once animation settles
+ if (Math.abs(root.animatedRatio - root.ratio) < 0.001) {
+ stop();
+ }
+ }
+ }
+
+ Canvas {
+ id: gauge
+ width: root._gaugeSize
+ height: root._gaugeSize
+ anchors.horizontalCenter: parent.horizontalCenter
+ y: 0
+
+ // Optimized Canvas settings for better GPU performance
+ renderStrategy: Canvas.Cooperative
+ renderTarget: Canvas.FramebufferObject
+
+ // Enable layer caching - critical for performance!
+ layer.enabled: true
+ layer.smooth: true
+
+ // Hide until first paint to avoid white FBO flash
+ visible: false
+ onPainted: visible = true
+
+ Component.onCompleted: {
+ requestPaint();
+ }
+
+ onPaint: {
+ const ctx = getContext("2d");
+ const w = width, h = height;
+ const cx = w / 2, cy = h / 2;
+ const r = root._arcRadius;
+
+ // Rotated 90ยฐ to the right: gap at the bottom
+ // Start at 150ยฐ and end at 390ยฐ (30ยฐ) โ bottom opening
+ const start = Math.PI * 5 / 6; // 150ยฐ
+ const endBg = Math.PI * 13 / 6; // 390ยฐ (equivalent to 30ยฐ)
+
+ ctx.reset();
+ ctx.lineWidth = root._lineWidth;
+ ctx.lineCap = Settings.data.general.iRadiusRatio > 0 ? "round" : "butt";
+
+ // Track uses outline for contrast against surfaceVariant backgrounds
+ ctx.strokeStyle = Color.mSurface;
+ ctx.beginPath();
+ ctx.arc(cx, cy, r, start, endBg);
+ ctx.stroke();
+
+ // Value arc - only draw if ratio is meaningful (> 0.5%)
+ const r2 = Math.max(0, Math.min(1, root.animatedRatio));
+ if (r2 > 0.005) {
+ const end = start + (endBg - start) * r2;
+ ctx.strokeStyle = root.fillColor;
+ ctx.beginPath();
+ ctx.arc(cx, cy, r, start, end);
+ ctx.stroke();
+ }
+ }
+ }
+
+ // Percent centered in the circle
+ NText {
+ id: valueLabel
+ anchors.horizontalCenter: gauge.horizontalCenter
+ anchors.verticalCenter: gauge.verticalCenter
+ anchors.verticalCenterOffset: -4 * root.contentScale
+ text: `${Math.round(root.animatedRatio * 100)}${root.suffix}`
+ pointSize: Style.fontSizeM * root.contentScale * 0.9
+ font.weight: Style.fontWeightBold
+ color: root.fillColor
+ horizontalAlignment: Text.AlignHCenter
+ }
+
+ NIcon {
+ id: iconText
+ anchors.horizontalCenter: gauge.horizontalCenter
+ anchors.top: valueLabel.bottom
+ anchors.topMargin: 4 * root.contentScale
+ icon: root.icon
+ color: root.fillColor
+ pointSize: Style.fontSizeM * root.contentScale
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ onEntered: {
+ if (root.tooltipText && (!Array.isArray(root.tooltipText) || root.tooltipText.length > 0)) {
+ TooltipService.show(root, root.tooltipText, root.tooltipDirection);
+ }
+ }
+ onExited: {
+ if (root.tooltipText && (!Array.isArray(root.tooltipText) || root.tooltipText.length > 0)) {
+ TooltipService.hide();
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NClock.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NClock.qml
new file mode 100644
index 0000000..20bb30a
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NClock.qml
@@ -0,0 +1,442 @@
+import QtQuick
+import QtQuick.Layouts
+import Quickshell
+import "../Helpers/ColorsConvert.js" as ColorsConvert
+import qs.Commons
+import qs.Widgets
+
+Item {
+ id: root
+
+ property var now: Time.now
+
+ // Style: "analog" or "digital"
+ property string clockStyle: "analog"
+
+ // Show seconds progress ring (digital only)
+ property bool showProgress: true
+
+ // Color properties
+ property color backgroundColor: Color.mPrimary
+ property color clockColor: Color.mOnPrimary
+
+ property color secondHandColor: {
+ var defaultColor = Color.mError;
+ var bestContrast = 1.0; // 1.0 is "no contrast"
+ var bestColor = defaultColor;
+ var candidates = [Color.mSecondary, Color.mTertiary, Color.mError];
+
+ const minContrast = 1.149;
+
+ for (var i = 0; i < candidates.length; i++) {
+ var candidate = candidates[i];
+ var contrastClock = ColorsConvert.getContrastRatio(candidate.toString(), clockColor.toString());
+ if (contrastClock < minContrast) {
+ continue;
+ }
+ var contrastBg = ColorsConvert.getContrastRatio(candidate.toString(), backgroundColor.toString());
+ if (contrastBg < minContrast) {
+ continue;
+ }
+
+ var currentWorstContrast = Math.min(contrastBg, contrastClock);
+
+ if (currentWorstContrast > bestContrast) {
+ bestContrast = currentWorstContrast;
+ bestColor = candidate;
+ }
+ }
+
+ return bestColor;
+ }
+
+ property color progressColor: root.secondHandColor
+
+ // Font size properties for digital clock
+ property real hoursFontSize: Style.fontSizeXS
+ property real minutesFontSize: Style.fontSizeXXS
+ property int hoursFontWeight: Style.fontWeightBold
+ property int minutesFontWeight: Style.fontWeightBold
+
+ // Scale ratio for canvas line widths (used by desktop widget scaling)
+ property real scaleRatio: Style.uiScaleRatio
+
+ height: Math.round((Style.fontSizeXXXL * 1.9) / 2 * Style.uiScaleRatio) * 2
+ width: root.height
+
+ Loader {
+ id: clockLoader
+ anchors.fill: parent
+
+ sourceComponent: {
+ if (root.clockStyle === "analog")
+ return analogClockComponent;
+ if (root.clockStyle === "binary")
+ return binaryClockComponent;
+ return digitalClockComponent;
+ }
+
+ onLoaded: {
+ item.now = Qt.binding(function () {
+ return root.now;
+ });
+ item.backgroundColor = Qt.binding(function () {
+ return root.backgroundColor;
+ });
+ item.clockColor = Qt.binding(function () {
+ return root.clockColor;
+ });
+ if (item.hasOwnProperty("secondHandColor")) {
+ item.secondHandColor = Qt.binding(function () {
+ return root.secondHandColor;
+ });
+ }
+ if (item.hasOwnProperty("progressColor")) {
+ item.progressColor = Qt.binding(function () {
+ return root.progressColor;
+ });
+ }
+ if (item.hasOwnProperty("hoursFontSize")) {
+ item.hoursFontSize = Qt.binding(function () {
+ return root.hoursFontSize;
+ });
+ }
+ if (item.hasOwnProperty("minutesFontSize")) {
+ item.minutesFontSize = Qt.binding(function () {
+ return root.minutesFontSize;
+ });
+ }
+ if ("hoursFontWeight" in item) {
+ item.hoursFontWeight = Qt.binding(function () {
+ return root.hoursFontWeight;
+ });
+ }
+ if ("minutesFontWeight" in item) {
+ item.minutesFontWeight = Qt.binding(function () {
+ return root.minutesFontWeight;
+ });
+ }
+ if (item.hasOwnProperty("scaleRatio")) {
+ item.scaleRatio = Qt.binding(function () {
+ return root.scaleRatio;
+ });
+ }
+ if ("showProgress" in item) {
+ item.showProgress = Qt.binding(function () {
+ return root.showProgress;
+ });
+ }
+ }
+ }
+
+ // Analog Clock Component
+ component NClockAnalog: Item {
+ property var now
+ property color backgroundColor: Color.mPrimary
+ property color clockColor: Color.mOnPrimary
+ property color secondHandColor: Color.mError
+ property real scaleRatio: Style.uiScaleRatio
+ anchors.fill: parent
+
+ Canvas {
+ id: clockCanvas
+ anchors.fill: parent
+
+ Connections {
+ target: Time
+ function onNowChanged() {
+ clockCanvas.requestPaint();
+ }
+ }
+
+ onPaint: {
+ var currentTime = Time.now;
+ var hours = currentTime.getHours();
+ var minutes = currentTime.getMinutes();
+ var seconds = currentTime.getSeconds();
+
+ const markAlpha = 0.7;
+ var ctx = getContext("2d");
+ ctx.reset();
+ ctx.translate(width / 2, height / 2);
+ var radius = Math.min(width, height) / 2;
+
+ // Hour marks
+ ctx.strokeStyle = Qt.alpha(clockColor, markAlpha);
+ ctx.lineWidth = 2 * scaleRatio;
+ var scaleFactor = 0.7;
+
+ for (var i = 0; i < 12; i++) {
+ var scaleFactor = 0.8;
+ if (i % 3 === 0) {
+ scaleFactor = 0.65;
+ }
+ ctx.save();
+ ctx.rotate(i * Math.PI / 6);
+ ctx.beginPath();
+ ctx.moveTo(0, -radius * scaleFactor);
+ ctx.lineTo(0, -radius);
+ ctx.stroke();
+ ctx.restore();
+ }
+
+ // Hour hand
+ ctx.save();
+ var hourAngle = (hours % 12 + minutes / 60) * Math.PI / 6;
+ ctx.rotate(hourAngle);
+ ctx.strokeStyle = clockColor;
+ ctx.lineWidth = 3 * scaleRatio;
+ ctx.lineCap = "round";
+ ctx.beginPath();
+ ctx.moveTo(0, 0);
+ ctx.lineTo(0, -radius * 0.6);
+ ctx.stroke();
+ ctx.restore();
+
+ // Minute hand
+ ctx.save();
+ var minuteAngle = (minutes + seconds / 60) * Math.PI / 30;
+ ctx.rotate(minuteAngle);
+ ctx.strokeStyle = clockColor;
+ ctx.lineWidth = 2 * scaleRatio;
+ ctx.lineCap = "round";
+ ctx.beginPath();
+ ctx.moveTo(0, 0);
+ ctx.lineTo(0, -radius * 0.9);
+ ctx.stroke();
+ ctx.restore();
+
+ // Second hand
+ ctx.save();
+ var secondAngle = seconds * Math.PI / 30;
+ ctx.rotate(secondAngle);
+ ctx.strokeStyle = secondHandColor;
+ ctx.lineWidth = 1.6 * scaleRatio;
+ ctx.lineCap = "round";
+ ctx.beginPath();
+ ctx.moveTo(0, 0);
+ ctx.lineTo(0, -radius);
+ ctx.stroke();
+ ctx.restore();
+
+ // Center dot
+ ctx.beginPath();
+ ctx.arc(0, 0, 3 * scaleRatio, 0, 2 * Math.PI);
+ ctx.fillStyle = clockColor;
+ ctx.fill();
+ }
+
+ Component.onCompleted: requestPaint()
+ }
+ }
+
+ // Digital Clock Component
+ component NClockDigital: Item {
+ property var now
+ property color backgroundColor: Color.mPrimary
+ property color clockColor: Color.mOnPrimary
+ property color progressColor: Color.mError
+ property real hoursFontSize: Style.fontSizeXS
+ property real minutesFontSize: Style.fontSizeXXS
+ property int hoursFontWeight: Style.fontWeightBold
+ property int minutesFontWeight: Style.fontWeightBold
+ property real scaleRatio: Style.uiScaleRatio
+ property bool showProgress: true
+
+ anchors.fill: parent
+
+ // Digital clock's seconds circular progress
+ Canvas {
+ id: secondsProgress
+ anchors.fill: parent
+ visible: showProgress
+ property real progress: now.getSeconds() / 60
+ onProgressChanged: requestPaint()
+ Connections {
+ target: Time
+ function onNowChanged() {
+ const total = now.getSeconds() * 1000 + now.getMilliseconds();
+ secondsProgress.progress = total / 60000;
+ }
+ }
+ onPaint: {
+ var ctx = getContext("2d");
+ var centerX = width / 2;
+ var centerY = height / 2;
+ var radius = Math.min(width, height) / 2 - 3 * scaleRatio;
+ ctx.reset();
+
+ // Background circle
+ ctx.beginPath();
+ ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI);
+ ctx.lineWidth = 2.5 * scaleRatio;
+ ctx.strokeStyle = Qt.alpha(clockColor, 0.15);
+ ctx.stroke();
+
+ // Progress arc
+ ctx.beginPath();
+ ctx.arc(centerX, centerY, radius, -Math.PI / 2, -Math.PI / 2 + progress * 2 * Math.PI);
+ ctx.lineWidth = 2.5 * scaleRatio;
+ ctx.strokeStyle = progressColor;
+ ctx.lineCap = "round";
+ ctx.stroke();
+ }
+ }
+
+ // Digital clock
+ ColumnLayout {
+ anchors.centerIn: parent
+ spacing: -Style.marginXXS
+
+ NText {
+ text: {
+ var t = Settings.data.location.use12hourFormat ? I18n.locale.toString(now, "hh AP") : I18n.locale.toString(now, "HH");
+ return t.split(" ")[0];
+ }
+
+ pointSize: hoursFontSize
+ font.weight: hoursFontWeight
+ color: clockColor
+ family: Settings.data.ui.fontFixed
+ Layout.alignment: Qt.AlignHCenter
+ }
+
+ NText {
+ text: Qt.formatTime(now, "mm")
+ pointSize: minutesFontSize
+ font.weight: minutesFontWeight
+ color: clockColor
+ family: Settings.data.ui.fontFixed
+ Layout.alignment: Qt.AlignHCenter
+ }
+ }
+ }
+
+ // Binary Clock Component
+ component NClockBinary: Item {
+ property var now
+ property color backgroundColor
+ property color clockColor: Color.mOnPrimary
+
+ anchors.fill: parent
+
+ readonly property int h: now.getHours()
+ readonly property int m: now.getMinutes()
+ readonly property int s: now.getSeconds()
+
+ // BCD (Binary Coded Decimal) Format:
+ // H1 H2 : M1 M2 : S1 S2
+ // H1 (tens): 0-2 (2 bits)
+ // H2 (ones): 0-9 (4 bits)
+ // M1 (tens): 0-5 (3 bits)
+ // M2 (ones): 0-9 (4 bits)
+ // S1 (tens): 0-5 (3 bits)
+ // S2 (ones): 0-9 (4 bits)
+
+ RowLayout {
+ anchors.centerIn: parent
+ spacing: parent.width * 0.05
+
+ // Hours
+ RowLayout {
+ spacing: parent.parent.width * 0.02
+ BinaryColumn {
+ value: Math.floor(h / 10)
+ bits: 2
+ dotSize: root.width * 0.08
+ activeColor: clockColor
+ Layout.alignment: Qt.AlignBottom
+ }
+ BinaryColumn {
+ value: h % 10
+ bits: 4
+ dotSize: root.width * 0.08
+ activeColor: clockColor
+ Layout.alignment: Qt.AlignBottom
+ }
+ }
+
+ // Minutes
+ RowLayout {
+ spacing: parent.parent.width * 0.02
+ BinaryColumn {
+ value: Math.floor(m / 10)
+ bits: 3
+ dotSize: root.width * 0.08
+ activeColor: clockColor
+ Layout.alignment: Qt.AlignBottom
+ }
+ BinaryColumn {
+ value: m % 10
+ bits: 4
+ dotSize: root.width * 0.08
+ activeColor: clockColor
+ Layout.alignment: Qt.AlignBottom
+ }
+ }
+
+ // Seconds
+ RowLayout {
+ spacing: parent.parent.width * 0.02
+ BinaryColumn {
+ value: Math.floor(s / 10)
+ bits: 3
+ dotSize: root.width * 0.08
+ activeColor: clockColor
+ Layout.alignment: Qt.AlignBottom
+ }
+ BinaryColumn {
+ value: s % 10
+ bits: 4
+ dotSize: root.width * 0.08
+ activeColor: clockColor
+ Layout.alignment: Qt.AlignBottom
+ }
+ }
+ }
+ }
+
+ component BinaryColumn: Column {
+ property int value: 0
+ property int bits: 4
+ property real dotSize: 10
+ property color activeColor: "white"
+
+ spacing: dotSize * 0.4
+
+ Repeater {
+ model: bits
+
+ Rectangle {
+ property int bitIndex: (bits - 1) - index
+ property bool isActive: (value >> bitIndex) & 1
+
+ width: dotSize
+ height: dotSize
+ radius: dotSize / 2
+ color: isActive ? activeColor : Qt.alpha(activeColor, 0.2)
+
+ Behavior on color {
+ ColorAnimation {
+ duration: 200
+ }
+ }
+ }
+ }
+ }
+
+ Component {
+ id: analogClockComponent
+ NClockAnalog {}
+ }
+
+ Component {
+ id: digitalClockComponent
+ NClockDigital {}
+ }
+
+ Component {
+ id: binaryClockComponent
+ NClockBinary {}
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NCollapsible.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NCollapsible.qml
new file mode 100644
index 0000000..de51933
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NCollapsible.qml
@@ -0,0 +1,191 @@
+import QtQuick
+import QtQuick.Layouts
+import qs.Commons
+
+ColumnLayout {
+ id: root
+
+ property string label: ""
+ property string description: ""
+ property bool expanded: false
+ property real contentSpacing: Style.marginM
+ property bool _userInteracted: false
+
+ signal toggled(bool expanded)
+
+ Layout.fillWidth: true
+ spacing: 0
+
+ // Default property to accept children
+ default property alias content: contentLayout.children
+
+ // Header with clickable area
+ Rectangle {
+ id: headerContainer
+ Layout.fillWidth: true
+ Layout.preferredHeight: headerContent.implicitHeight + Style.margin2M
+ color: root.expanded ? Color.mSecondary : Color.mPrimary
+ radius: Style.iRadiusM
+ border.color: root.expanded ? Color.mOnSecondary : Color.mOutline
+ border.width: Style.borderS
+
+ // Smooth color transitions
+ Behavior on color {
+ enabled: root._userInteracted
+ ColorAnimation {
+ duration: Style.animationNormal
+ easing.type: Easing.OutCubic
+ }
+ }
+
+ Behavior on border.color {
+ enabled: root._userInteracted
+ ColorAnimation {
+ duration: Style.animationNormal
+ easing.type: Easing.OutCubic
+ }
+ }
+
+ MouseArea {
+ id: headerArea
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ hoverEnabled: true
+
+ onClicked: {
+ root._userInteracted = true;
+ root.expanded = !root.expanded;
+ root.toggled(root.expanded);
+ }
+
+ // Hover effect overlay
+ Rectangle {
+ anchors.fill: parent
+ color: headerArea.containsMouse ? Color.mOnSurface : "transparent"
+ opacity: headerArea.containsMouse ? 0.08 : 0
+ radius: headerContainer.radius // Reference the container's radius directly
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Style.animationFast
+ }
+ }
+ }
+ }
+
+ RowLayout {
+ id: headerContent
+ anchors.fill: parent
+ anchors.margins: Style.marginM
+ spacing: Style.marginM
+
+ // Expand/collapse icon with rotation animation
+ NIcon {
+ id: chevronIcon
+ icon: "chevron-right"
+ pointSize: Style.fontSizeL
+ color: root.expanded ? Color.mOnSecondary : Color.mOnPrimary
+ Layout.alignment: Qt.AlignVCenter
+
+ rotation: root.expanded ? 90 : 0
+ Behavior on rotation {
+ enabled: root._userInteracted
+ NumberAnimation {
+ duration: Style.animationNormal
+ easing.type: Easing.OutCubic
+ }
+ }
+
+ Behavior on color {
+ enabled: root._userInteracted
+ ColorAnimation {
+ duration: Style.animationNormal
+ }
+ }
+ }
+
+ // Header text content - properly contained
+ RowLayout {
+ Layout.fillWidth: true
+ Layout.alignment: Qt.AlignVCenter
+ spacing: Style.marginL
+
+ NText {
+ text: root.label
+ pointSize: Style.fontSizeL
+ font.weight: Style.fontWeightSemiBold
+ color: root.expanded ? Color.mOnSecondary : Color.mOnPrimary
+ wrapMode: Text.WordWrap
+
+ Behavior on color {
+ enabled: root._userInteracted
+ ColorAnimation {
+ duration: Style.animationNormal
+ }
+ }
+ }
+
+ NText {
+ text: root.description
+ pointSize: Style.fontSizeS
+ font.weight: Style.fontWeightRegular
+ color: root.expanded ? Color.mOnSecondary : Color.mOnPrimary
+ Layout.fillWidth: true
+ wrapMode: Text.WordWrap
+ visible: root.description !== ""
+ opacity: 0.87
+
+ Behavior on color {
+ enabled: root._userInteracted
+ ColorAnimation {
+ duration: Style.animationNormal
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Collapsible content with Material 3 styling
+ Rectangle {
+ id: contentContainer
+ Layout.fillWidth: true
+ Layout.topMargin: Style.marginS
+
+ visible: root.expanded
+ color: Color.mSurface
+ radius: Style.iRadiusL
+ border.color: Color.mOutline
+ border.width: Style.borderS
+
+ // Dynamic height based on content
+ Layout.preferredHeight: expanded ? contentLayout.implicitHeight + Style.margin2L : 0
+
+ // Smooth height animation
+ Behavior on Layout.preferredHeight {
+ enabled: root._userInteracted
+ NumberAnimation {
+ duration: Style.animationNormal
+ easing.type: Easing.OutCubic
+ }
+ }
+
+ // Content layout
+ ColumnLayout {
+ id: contentLayout
+ anchors.fill: parent
+ anchors.margins: Style.marginL
+ spacing: root.contentSpacing
+ }
+
+ // Fade in animation for content
+ opacity: root.expanded ? 1.0 : 0.0
+ Behavior on opacity {
+ enabled: root._userInteracted
+ NumberAnimation {
+ duration: Style.animationNormal
+ easing.type: Easing.OutCubic
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NColorChoice.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NColorChoice.qml
new file mode 100644
index 0000000..6d8cd29
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NColorChoice.qml
@@ -0,0 +1,89 @@
+import QtQuick
+import QtQuick.Layouts
+import qs.Commons
+import qs.Services.UI
+
+RowLayout {
+ id: root
+
+ property string label: I18n.tr("common.select-color")
+ property string description: I18n.tr("common.select-color-description")
+ property string tooltip: ""
+ property string currentKey: ""
+ property var defaultValue: undefined
+ property var noneColor: undefined // color declared as var so we can nullify
+ property var noneOnColor: undefined // color declared as var so we can nullify
+
+ readonly property bool isValueChanged: (defaultValue !== undefined) && (currentKey !== defaultValue)
+ readonly property string indicatorTooltip: {
+ I18n.tr("panels.indicator.default-value", {
+ "value": defaultValue === "" ? "(empty)" : String(defaultValue)
+ });
+ }
+
+ readonly property int diameter: Style.baseWidgetSize * 0.9 * Style.uiScaleRatio
+
+ signal selected(string key)
+
+ NLabel {
+ label: root.label
+ description: root.description
+ showIndicator: root.isValueChanged
+ indicatorTooltip: root.indicatorTooltip
+ }
+
+ RowLayout {
+ id: colourRow
+
+ opacity: enabled ? 1.0 : 0.6
+ Layout.minimumWidth: root.diameter * Color.colorKeyModel.length
+
+ Repeater {
+ model: Color.colorKeyModel
+
+ Rectangle {
+ id: colorCircle
+
+ property bool isSelected: root.currentKey === modelData.key
+ property bool isHovered: circleMouseArea.containsMouse
+
+ Layout.alignment: Qt.AlignHCenter
+ implicitWidth: root.diameter
+ implicitHeight: root.diameter
+ radius: root.diameter * 0.5
+ color: (modelData.key === "none" && root.noneColor !== undefined) ? root.noneColor : Color.resolveColorKey(modelData.key)
+ border.color: (isSelected || isHovered) ? Color.mOnSurface : Color.mOutline
+ border.width: Style.borderM
+
+ MouseArea {
+ id: circleMouseArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onEntered: TooltipService.show(parent, modelData.name)
+ onExited: TooltipService.hide()
+ onClicked: {
+ root.currentKey = modelData.key;
+ root.selected(modelData.key);
+ }
+ }
+
+ NIcon {
+ anchors.centerIn: parent
+ icon: "check"
+ pointSize: Math.max(Style.fontSizeXS, colorCircle.width * 0.4)
+ color: (modelData.key === "none" && root.noneOnColor !== undefined) ? root.noneOnColor : Color.resolveOnColorKey(modelData.key)
+ font.weight: Style.fontWeightBold
+ visible: colorCircle.isSelected
+ }
+
+ Behavior on border.color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NColorPicker.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NColorPicker.qml
new file mode 100644
index 0000000..c005240
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NColorPicker.qml
@@ -0,0 +1,76 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import qs.Commons
+import qs.Widgets
+
+Rectangle {
+ id: root
+
+ property var screen
+ property color selectedColor: "black"
+
+ signal colorSelected(color color)
+
+ Layout.margins: Style.borderS
+ implicitWidth: 150
+ implicitHeight: Math.round(Style.baseWidgetSize * 1.1)
+
+ radius: Style.iRadiusM
+ color: Color.mSurface
+ border.color: Color.mOutline
+ border.width: Style.borderS
+
+ // Minimized Look
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ var dialog = Qt.createComponent("NColorPickerDialog.qml").createObject(root, {
+ "selectedColor": selectedColor,
+ "parent": Overlay.overlay,
+ "screen": root.screen
+ });
+ // Connect the dialog's signal to the picker's signal
+ dialog.colorSelected.connect(function (color) {
+ root.selectedColor = color;
+ root.colorSelected(color);
+ });
+
+ dialog.open();
+ }
+
+ RowLayout {
+ anchors.fill: parent
+ anchors {
+ leftMargin: Style.marginL
+ rightMargin: Style.marginL
+ }
+ spacing: Style.marginS
+
+ // Color preview circle
+ Rectangle {
+ Layout.preferredWidth: root.height * 0.6
+ Layout.preferredHeight: root.height * 0.6
+ radius: Math.min(Style.iRadiusL, Layout.preferredWidth / 2)
+ color: root.selectedColor
+ border.color: Color.mOutline
+ border.width: Style.borderS
+ }
+
+ NText {
+ text: root.selectedColor.toString().toUpperCase()
+ family: Settings.data.ui.fontFixed
+ Layout.fillWidth: true
+ Layout.alignment: Qt.AlignVCenter
+ }
+
+ NIcon {
+ icon: "color-picker"
+ color: Color.mOnSurfaceVariant
+ Layout.fillWidth: true
+ Layout.alignment: Qt.AlignVCenter
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NColorPickerDialog.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NColorPickerDialog.qml
new file mode 100644
index 0000000..d731eb4
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NColorPickerDialog.qml
@@ -0,0 +1,775 @@
+import QtQml
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import "../Helpers/ColorList.js" as ColorList
+import qs.Commons
+import qs.Services.UI
+import qs.Widgets
+
+Popup {
+ id: root
+
+ property var screen
+ property color selectedColor: "black"
+
+ enum EditMode {
+ R,
+ G,
+ B,
+ H,
+ S,
+ V
+ }
+ property int editMode: NColorPickerDialog.EditMode.R
+
+ // Code to deal with Hue when color is achromatic
+ property real stableHue: 0
+ onSelectedColorChanged: {
+ if (selectedColor.hsvHue >= 0) {
+ stableHue = selectedColor.hsvHue;
+ }
+ if (liveMode && visible) {
+ colorSelected(selectedColor);
+ }
+ }
+ readonly property real displayHue: selectedColor.hsvHue < 0 ? stableHue : selectedColor.hsvHue
+
+ signal colorSelected(color color)
+
+ // When true: hides Cancel/Apply, emits colorSelected on every color change
+ property bool liveMode: false
+
+ width: 580
+ padding: Style.marginXL
+
+ modal: true
+ closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
+
+ onOpened: {
+ // Center on screen after popup is opened and parent position is known
+ if (screen && parent) {
+ var parentPos = parent.mapToItem(null, 0, 0);
+ x = Math.round((screen.width - width) / 2 - parentPos.x);
+ y = Math.round((screen.height - height) / 2 - parentPos.y);
+ }
+ }
+
+ background: Rectangle {
+ color: Color.mSurface
+ radius: Style.iRadiusS
+ border.color: Color.mPrimary
+ border.width: Style.borderM
+ }
+
+ contentItem: ColumnLayout {
+ id: mainContent
+ spacing: Style.marginL
+
+ // Header
+ RowLayout {
+ Layout.fillWidth: true
+
+ RowLayout {
+ spacing: Style.marginS
+
+ NIcon {
+ icon: "color-picker"
+ pointSize: Style.fontSizeXXL
+ color: Color.mPrimary
+ }
+
+ NText {
+ text: I18n.tr("widgets.color-picker.title")
+ pointSize: Style.fontSizeXL
+ font.weight: Style.fontWeightBold
+ color: Color.mPrimary
+ }
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ NIconButton {
+ icon: "close"
+ onClicked: root.close()
+ }
+ }
+
+ // Color preview section
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.preferredHeight: 80
+ radius: Style.iRadiusS
+ color: root.selectedColor
+ border.color: Color.mOutline
+ border.width: Style.borderS
+
+ ColumnLayout {
+ spacing: 0
+ anchors.fill: parent
+
+ Item {
+ Layout.fillHeight: true
+ }
+
+ NText {
+ text: root.selectedColor.toString().toUpperCase()
+ family: Settings.data.ui.fontFixed
+ pointSize: Style.fontSizeL
+ font.weight: Style.fontWeightMedium
+ color: root.selectedColor.r + root.selectedColor.g + root.selectedColor.b > 1.5 ? "black" : "white"
+ Layout.alignment: Qt.AlignHCenter
+ }
+
+ NText {
+ text: "RGB(" + Math.round(root.selectedColor.r * 255) + ", " + Math.round(root.selectedColor.g * 255) + ", " + Math.round(root.selectedColor.b * 255) + ")"
+ family: Settings.data.ui.fontFixed
+ pointSize: Style.fontSizeM
+ color: root.selectedColor.r + root.selectedColor.g + root.selectedColor.b > 1.5 ? "black" : "white"
+ Layout.alignment: Qt.AlignHCenter
+ }
+
+ Item {
+ Layout.fillHeight: true
+ }
+ }
+ }
+
+ // Main Box
+ NBox {
+ Layout.fillWidth: true
+ Layout.preferredHeight: controlsOutterColumn.implicitHeight + Style.margin2L
+
+ ButtonGroup {
+ id: colorValues
+ }
+
+ ColumnLayout {
+ id: controlsOutterColumn
+ anchors.fill: parent
+ anchors.margins: Style.marginL
+ spacing: Style.marginM
+
+ RowLayout {
+ spacing: Style.marginL // Ensure nice gap between Left and Right groups
+
+ // SpinBoxes Column
+ ColumnLayout {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ Layout.alignment: Qt.AlignTop
+ Layout.preferredWidth: 3
+
+ // --- RED ---
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Style.marginM
+
+ NRadioButton {
+ ButtonGroup.group: colorValues
+ text: "R"
+ font.weight: Style.fontWeightMedium
+ checked: true
+ onClicked: root.editMode = NColorPickerDialog.EditMode.R
+ Layout.fillWidth: false
+ }
+
+ NSpinBox {
+ id: redSpinBox
+ from: 0
+ to: 255
+
+ onValueChanged: {
+ if (!selectedSlider.pressed && !fieldMouse.pressed && !hexInput.activeFocus && value !== Math.round(root.selectedColor.r * 255)) {
+ root.selectedColor = Qt.rgba(value / 255, root.selectedColor.g, root.selectedColor.b, 1);
+ }
+ }
+ Binding {
+ target: redSpinBox
+ property: "value"
+ value: Math.round(root.selectedColor.r * 255)
+ }
+ }
+ }
+
+ // --- GREEN ---
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Style.marginM
+
+ NRadioButton {
+ ButtonGroup.group: colorValues
+ text: "G"
+ font.weight: Style.fontWeightMedium
+ onClicked: root.editMode = NColorPickerDialog.EditMode.G
+ Layout.fillWidth: false
+ }
+
+ NSpinBox {
+ id: greenSpinBox
+ from: 0
+ to: 255
+
+ onValueChanged: {
+ if (!selectedSlider.pressed && !fieldMouse.pressed && !hexInput.activeFocus && value !== Math.round(root.selectedColor.g * 255)) {
+ root.selectedColor = Qt.rgba(root.selectedColor.r, value / 255, root.selectedColor.b, 1);
+ }
+ }
+ Binding {
+ target: greenSpinBox
+ property: "value"
+ value: Math.round(root.selectedColor.g * 255)
+ }
+ }
+ }
+
+ // --- BLUE ---
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Style.marginM
+
+ NRadioButton {
+ ButtonGroup.group: colorValues
+ text: "B"
+ font.weight: Style.fontWeightMedium
+ onClicked: root.editMode = NColorPickerDialog.EditMode.B
+ Layout.fillWidth: false
+ }
+
+ NSpinBox {
+ id: blueSpinBox
+ from: 0
+ to: 255
+
+ onValueChanged: {
+ if (!selectedSlider.pressed && !fieldMouse.pressed && !hexInput.activeFocus && value !== Math.round(root.selectedColor.b * 255)) {
+ root.selectedColor = Qt.rgba(root.selectedColor.r, root.selectedColor.g, value / 255, 1);
+ }
+ }
+ Binding {
+ target: blueSpinBox
+ property: "value"
+ value: Math.round(root.selectedColor.b * 255)
+ }
+ }
+ }
+
+ // Spacer
+ Item {
+ Layout.fillHeight: true
+ Layout.fillWidth: true
+ }
+
+ // --- HUE ---
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Style.marginM
+
+ NRadioButton {
+ ButtonGroup.group: colorValues
+ text: "H"
+ font.weight: Style.fontWeightMedium
+ checked: true
+ onClicked: root.editMode = NColorPickerDialog.EditMode.H
+ Layout.fillWidth: false
+ }
+
+ NSpinBox {
+ id: hueSpinBox
+ from: 0
+ to: 360
+
+ onValueChanged: {
+ if (!selectedSlider.pressed && !fieldMouse.pressed && !hexInput.activeFocus && value !== Math.round(root.displayHue * 360)) {
+ var newHue = value / 360;
+ root.selectedColor = Qt.hsva(newHue, root.selectedColor.hsvSaturation, root.selectedColor.hsvValue, 1);
+ root.stableHue = newHue;
+ }
+ }
+
+ Binding {
+ target: hueSpinBox
+ property: "value"
+ value: Math.round(root.displayHue * 360)
+ }
+ }
+ }
+
+ // --- SATURATION ---
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Style.marginM
+
+ NRadioButton {
+ ButtonGroup.group: colorValues
+ text: "S"
+ font.weight: Style.fontWeightMedium
+ onClicked: root.editMode = NColorPickerDialog.EditMode.S
+ Layout.fillWidth: false
+ }
+
+ NSpinBox {
+ id: satSpinBox
+ from: 0
+ to: 100
+
+ onValueChanged: {
+ if (!selectedSlider.pressed && !fieldMouse.pressed && !hexInput.activeFocus && value !== Math.round(root.selectedColor.hsvSaturation * 100)) {
+ root.selectedColor = Qt.hsva(root.selectedColor.hsvHue, value / 100, root.selectedColor.hsvValue, 1);
+ }
+ }
+ Binding {
+ target: satSpinBox
+ property: "value"
+ value: Math.round(root.selectedColor.hsvSaturation * 100)
+ }
+ }
+ }
+
+ // --- VALUE ---
+ RowLayout {
+ spacing: Style.marginM
+
+ NRadioButton {
+ ButtonGroup.group: colorValues
+ text: "V"
+ font.weight: Style.fontWeightMedium
+ onClicked: root.editMode = NColorPickerDialog.EditMode.V
+ Layout.fillWidth: false
+ }
+
+ NSpinBox {
+ id: valSpinBox
+ from: 0
+ to: 100
+
+ onValueChanged: {
+ if (!selectedSlider.pressed && !fieldMouse.pressed && !hexInput.activeFocus && value !== Math.round(root.selectedColor.hsvValue * 100)) {
+ root.selectedColor = Qt.hsva(root.selectedColor.hsvHue, root.selectedColor.hsvSaturation, value / 100, 1);
+ }
+ }
+ Binding {
+ target: valSpinBox
+ property: "value"
+ value: Math.round(root.selectedColor.hsvValue * 100)
+ }
+ }
+ }
+
+ // Spacer
+ Item {
+ Layout.fillHeight: true
+ Layout.fillWidth: true
+ }
+
+ // Hex input
+ RowLayout {
+ id: hexInput
+ Layout.fillWidth: true
+ spacing: Style.marginM
+
+ NLabel {
+ label: "Hex:"
+ Layout.alignment: Qt.AlignVCenter
+ }
+
+ NTextInput {
+ text: root.selectedColor.toString().toUpperCase()
+ fontFamily: Settings.data.ui.fontFixed
+ Layout.fillWidth: true
+ Layout.minimumWidth: implicitWidth
+ onEditingFinished: {
+ if (/^#[0-9A-F]{6}$/i.test(text)) {
+ root.selectedColor = text;
+ }
+ }
+ }
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ Layout.preferredWidth: 5
+ Layout.alignment: Qt.AlignTop
+ spacing: Style.marginS
+
+ Layout.topMargin: Math.round(2 * Style.uiScaleRatio) //Shim to try and line up colorField with SpinBoxes
+
+ // --- SLIDER ---
+ NColorSlider {
+ id: selectedSlider
+ Layout.fillHeight: true
+ rainbowMode: root.editMode === NColorPickerDialog.EditMode.H
+ topColor: {
+ if (rainbowMode)
+ return "transparent";
+ switch (root.editMode) {
+ case NColorPickerDialog.EditMode.R:
+ return "#FF0000";
+ case NColorPickerDialog.EditMode.G:
+ return "#00FF00";
+ case NColorPickerDialog.EditMode.B:
+ return "#0000FF";
+ default:
+ return "#FFFFFF";
+ }
+ }
+
+ Binding {
+ target: selectedSlider
+ property: "value"
+ when: !selectedSlider.pressed // Only update from model when NOT dragging
+ value: {
+ switch (root.editMode) {
+ case NColorPickerDialog.EditMode.R:
+ return root.selectedColor.r;
+ case NColorPickerDialog.EditMode.G:
+ return root.selectedColor.g;
+ case NColorPickerDialog.EditMode.B:
+ return root.selectedColor.b;
+ case NColorPickerDialog.EditMode.H:
+ return root.displayHue;
+ case NColorPickerDialog.EditMode.S:
+ return root.selectedColor.hsvSaturation;
+ case NColorPickerDialog.EditMode.V:
+ return root.selectedColor.hsvValue;
+ default:
+ return 0;
+ }
+ }
+ }
+
+ onMoved: {
+ var v = value;
+ switch (root.editMode) {
+ case NColorPickerDialog.EditMode.R:
+ root.selectedColor = Qt.rgba(v, root.selectedColor.g, root.selectedColor.b, 1);
+ break;
+ case NColorPickerDialog.EditMode.G:
+ root.selectedColor = Qt.rgba(root.selectedColor.r, v, root.selectedColor.b, 1);
+ break;
+ case NColorPickerDialog.EditMode.B:
+ root.selectedColor = Qt.rgba(root.selectedColor.r, root.selectedColor.g, v, 1);
+ break;
+ case NColorPickerDialog.EditMode.H:
+ root.selectedColor = Qt.hsva(v, root.selectedColor.hsvSaturation, root.selectedColor.hsvValue, 1);
+ root.stableHue = v;
+ break;
+ case NColorPickerDialog.EditMode.S:
+ root.selectedColor = Qt.hsva(root.selectedColor.hsvHue, v, root.selectedColor.hsvValue, 1);
+ break;
+ case NColorPickerDialog.EditMode.V:
+ root.selectedColor = Qt.hsva(root.selectedColor.hsvHue, root.selectedColor.hsvSaturation, v, 1);
+ break;
+ }
+ }
+ }
+
+ // Color Field
+ Rectangle {
+ id: colorField
+ Layout.fillWidth: true
+
+ Layout.preferredHeight: width
+ Layout.alignment: Qt.AlignTop
+
+ radius: 0
+ border.color: Color.mOutline
+ border.width: Style.borderS
+ clip: true
+
+ ShaderEffect {
+ anchors.fill: parent
+ anchors.margins: 1 // Avoid drawing over the border
+
+ fragmentShader: "../Shaders/qsb/color_picker.frag.qsb"
+
+ // Pass which radio is selected
+ readonly property real fixedVal: {
+ switch (root.editMode) {
+ case NColorPickerDialog.EditMode.R:
+ return root.selectedColor.r;
+ case NColorPickerDialog.EditMode.G:
+ return root.selectedColor.g;
+ case NColorPickerDialog.EditMode.B:
+ return root.selectedColor.b;
+ case NColorPickerDialog.EditMode.H:
+ return root.displayHue;
+ case NColorPickerDialog.EditMode.S:
+ return root.selectedColor.hsvSaturation;
+ case NColorPickerDialog.EditMode.V:
+ return root.selectedColor.hsvValue;
+ default:
+ return 0;
+ }
+ }
+
+ // Send as one vector because GPUs are so damn picky
+ property vector4d params: Qt.vector4d(1.0, fixedVal, root.editMode, 0)
+ }
+
+ MouseArea {
+ id: fieldMouse
+ anchors.fill: parent
+ cursorShape: Qt.CrossCursor
+
+ // Update color when clicking or dragging
+ function updateColor(mouse) {
+ // Normalize X and Y (0.0 to 1.0)
+ var xVal = Math.max(0, Math.min(1, mouse.x / width));
+ var yVal = Math.max(0, Math.min(1, 1.0 - (mouse.y / height))); // Flip Y (0 at bottom)
+
+ // Get the current fixed value (from the slider)
+ var fixed = 0.0;
+
+ switch (root.editMode) {
+ case NColorPickerDialog.EditMode.R:
+ fixed = root.selectedColor.r;
+ root.selectedColor = Qt.rgba(fixed, xVal, yVal, 1);
+ break;
+ case NColorPickerDialog.EditMode.G:
+ fixed = root.selectedColor.g;
+ root.selectedColor = Qt.rgba(xVal, fixed, yVal, 1);
+ break;
+ case NColorPickerDialog.EditMode.B:
+ fixed = root.selectedColor.b;
+ root.selectedColor = Qt.rgba(xVal, yVal, fixed, 1);
+ break;
+ case NColorPickerDialog.EditMode.H:
+ // Use stableHue to prevent flipping to -1
+ fixed = root.displayHue;
+ root.selectedColor = Qt.hsva(fixed, xVal, yVal, 1);
+ root.stableHue = fixed;
+ break;
+ case NColorPickerDialog.EditMode.S:
+ fixed = root.selectedColor.hsvSaturation;
+ root.selectedColor = Qt.hsva(xVal, fixed, yVal, 1);
+ // If we dragged Hue (xVal), update stableHue
+ root.stableHue = yVal;
+ break;
+ case NColorPickerDialog.EditMode.V:
+ fixed = root.selectedColor.hsvValue;
+ root.selectedColor = Qt.hsva(xVal, yVal, fixed, 1);
+ // If we dragged Hue (xVal), update stableHue
+ root.stableHue = xVal;
+ break;
+ }
+ }
+ onPressed: mouse => updateColor(mouse)
+ onPositionChanged: mouse => updateColor(mouse)
+ }
+
+ // Color Indicator
+ Rectangle {
+ width: 10
+ height: 10
+ radius: Math.min(Style.iRadiusXS, width / 2)
+ color: "transparent"
+ border.color: root.selectedColor.hsvValue < 0.5 ? "white" : "black"
+ border.width: 1
+
+ // Find position based on the current color
+ readonly property point selectedPos: {
+ switch (root.editMode) {
+ case NColorPickerDialog.EditMode.R:
+ return Qt.point(root.selectedColor.g, root.selectedColor.b);
+ case NColorPickerDialog.EditMode.G:
+ return Qt.point(root.selectedColor.r, root.selectedColor.b);
+ case NColorPickerDialog.EditMode.B:
+ return Qt.point(root.selectedColor.r, root.selectedColor.g);
+ case NColorPickerDialog.EditMode.H:
+ return Qt.point(root.selectedColor.hsvSaturation, root.selectedColor.hsvValue);
+ case NColorPickerDialog.EditMode.S:
+ return Qt.point(root.displayHue, root.selectedColor.hsvValue);
+ case NColorPickerDialog.EditMode.V:
+ return Qt.point(root.displayHue, root.selectedColor.hsvSaturation);
+ default:
+ return Qt.point(0, 0);
+ }
+ }
+
+ // Convert values to pixel position
+ x: (selectedPos.x * parent.width) - width / 2
+ y: ((1.0 - selectedPos.y) * parent.height) - height / 2
+ }
+
+ // Redraw the border in case Color Indicator is near the edge
+ Rectangle {
+ anchors.fill: parent
+ color: "transparent"
+ border.color: Color.mOutline
+ border.width: Style.borderS
+ antialiasing: false
+ }
+ }
+ }
+ }
+
+ NLabel {
+ label: I18n.tr("widgets.color-picker.palette-label")
+ Layout.fillWidth: true
+ }
+
+ NScrollView {
+ id: paletteScrollView
+ Layout.fillWidth: true
+ Layout.preferredHeight: Math.min(paletteGrid.implicitHeight, 200)
+ verticalPolicy: paletteGrid.implicitHeight > 200 ? ScrollBar.AsNeeded : ScrollBar.AlwaysOff
+ horizontalPolicy: ScrollBar.AlwaysOff
+
+ GridLayout {
+ id: paletteGrid
+ width: paletteScrollView.availableWidth
+ columns: 17
+ columnSpacing: 6
+ rowSpacing: 6
+
+ NLabel {
+ Layout.columnSpan: 17
+ Layout.fillWidth: true
+ description: I18n.tr("widgets.color-picker.palette-theme-colors")
+ }
+
+ Repeater {
+ model: [
+ {
+ name: "mPrimary",
+ color: Color.mPrimary
+ },
+ {
+ name: "mSecondary",
+ color: Color.mSecondary
+ },
+ {
+ name: "mTertiary",
+ color: Color.mTertiary
+ },
+ {
+ name: "mError",
+ color: Color.mError
+ },
+ {
+ name: "mSurface",
+ color: Color.mSurface
+ },
+ {
+ name: "mSurfaceVariant",
+ color: Color.mSurfaceVariant
+ },
+ {
+ name: "mOutline",
+ color: Color.mOutline
+ }
+ ]
+
+ Rectangle {
+ width: 24
+ height: 24
+ radius: Style.iRadiusXXS
+ color: modelData.color
+ border.color: root.selectedColor.toString() === modelData.color.toString() ? Color.mPrimary : Color.mOutline
+ border.width: Math.max(1, root.selectedColor.toString() === modelData.color.toString() ? Style.borderM : Style.borderS)
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ hoverEnabled: true
+
+ onEntered: {
+ TooltipService.show(parent, modelData.name + "\n" + parent.color.toString().toUpperCase());
+ }
+ onExited: {
+ TooltipService.hide();
+ }
+ onClicked: {
+ root.selectedColor = modelData.color;
+ TooltipService.hide();
+ }
+ }
+ }
+ }
+
+ NDivider {
+ Layout.columnSpan: 17
+ Layout.fillWidth: true
+ Layout.topMargin: Style.marginXS
+ Layout.bottomMargin: 0
+ }
+
+ NLabel {
+ Layout.columnSpan: 17
+ Layout.fillWidth: true
+ description: I18n.tr("widgets.color-picker.palette-description")
+ }
+
+ Repeater {
+ model: ColorList.colors
+
+ Rectangle {
+ width: 24
+ height: 24
+ radius: Math.min(Style.iRadiusXS, width / 2)
+ color: modelData.color
+ border.color: root.selectedColor.toString() === modelData.color.toString() ? Color.mPrimary : Color.mOutline
+ border.width: root.selectedColor.toString() === modelData.color.toString() ? 2 : 1
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ hoverEnabled: true
+
+ onEntered: {
+ TooltipService.show(parent, modelData.name + "\n" + parent.color.toString().toUpperCase(), "auto");
+ }
+ onExited: {
+ TooltipService.hide();
+ }
+ onClicked: {
+ root.selectedColor = modelData.color;
+ TooltipService.hide();
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ RowLayout {
+ visible: !root.liveMode
+ Layout.fillWidth: true
+ Layout.topMargin: 1
+ Layout.bottomMargin: 1
+ spacing: 10
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ NButton {
+ id: cancelButton
+ text: I18n.tr("common.cancel")
+ outlined: cancelButton.hovered ? false : true
+ onClicked: {
+ root.close();
+ }
+ }
+
+ NButton {
+ text: I18n.tr("common.apply")
+ icon: "check"
+ onClicked: {
+ root.colorSelected(root.selectedColor);
+ // Delay close to prevent click propagation to elements behind the dialog
+ Qt.callLater(() => {
+ root.close();
+ });
+ }
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NColorSlider.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NColorSlider.qml
new file mode 100644
index 0000000..b08aaf3
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NColorSlider.qml
@@ -0,0 +1,238 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Shapes
+import qs.Commons
+import qs.Services.UI
+
+Slider {
+ id: root
+
+ property color fillColor: "transparent"
+ property var cutoutColor: Color.mSurface
+ property bool snapAlways: true
+ property real widthRatio: 0.7
+ property var tooltipText
+ property string tooltipDirection: "auto"
+ property bool hovering: false
+ property color topColor: "white"
+ property color bottomColor: "black"
+ property bool rainbowMode: false
+
+ readonly property real knobDiameter: Math.round((Style.baseWidgetSize * widthRatio * Style.uiScaleRatio) / 2) * 2
+ readonly property real trackWidth: Math.round((knobDiameter * 0.4 * Style.uiScaleRatio) / 2) * 2
+ readonly property real trackRadius: Math.min(Style.iRadiusL, trackWidth / 2)
+ readonly property real cutoutExtra: Math.round((Style.baseWidgetSize * 0.1 * Style.uiScaleRatio) / 2) * 2
+
+ orientation: Qt.Vertical
+
+ padding: cutoutExtra / 2
+
+ snapMode: snapAlways ? Slider.SnapAlways : Slider.SnapOnRelease
+ implicitWidth: Math.max(trackWidth, knobDiameter)
+
+ background: Item {
+ id: bgContainer
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.verticalCenter: parent.verticalCenter
+ width: root.trackWidth
+ height: root.availableHeight
+
+ LinearGradient {
+ id: standardLinearGradient
+ x1: 0
+ y1: 0
+ x2: 0
+ y2: root.availableHeight
+ GradientStop {
+ position: 0.0
+ color: root.topColor
+ }
+ GradientStop {
+ position: 1.0
+ color: root.bottomColor
+ }
+ }
+
+ LinearGradient {
+ id: rainbowLinearGradient
+ x1: 0
+ y1: 0
+ x2: 0
+ y2: root.availableHeight
+ GradientStop {
+ position: 0.000
+ color: "#FF0000"
+ }
+ GradientStop {
+ position: 0.167
+ color: "#FF00FF"
+ }
+ GradientStop {
+ position: 0.333
+ color: "#0000FF"
+ }
+ GradientStop {
+ position: 0.500
+ color: "#00FFFF"
+ }
+ GradientStop {
+ position: 0.667
+ color: "#00FF00"
+ }
+ GradientStop {
+ position: 0.833
+ color: "#FFFF00"
+ }
+ GradientStop {
+ position: 1.000
+ color: "#FF0000"
+ }
+ }
+
+ Shape {
+ anchors.centerIn: parent
+ width: root.trackWidth
+ height: bgContainer.height
+ visible: root.trackWidth > 0 && bgContainer.height > 0
+ preferredRendererType: Shape.CurveRenderer
+ asynchronous: true
+
+ ShapePath {
+ id: trackPath
+ strokeColor: Qt.alpha(Color.mOutline, 0.5)
+ strokeWidth: Style.borderS
+ fillGradient: root.rainbowMode ? rainbowLinearGradient : standardLinearGradient
+
+ readonly property real w: root.trackWidth
+ readonly property real h: root.availableHeight
+ readonly property real r: root.trackRadius
+
+ startX: r
+ startY: 0
+
+ PathLine {
+ x: trackPath.w - trackPath.r
+ y: 0
+ }
+ PathArc {
+ x: trackPath.w
+ y: trackPath.r
+ radiusX: trackPath.r
+ radiusY: trackPath.r
+ }
+ PathLine {
+ x: trackPath.w
+ y: trackPath.h - trackPath.r
+ }
+ PathArc {
+ x: trackPath.w - trackPath.r
+ y: trackPath.h
+ radiusX: trackPath.r
+ radiusY: trackPath.r
+ }
+ PathLine {
+ x: trackPath.r
+ y: trackPath.h
+ }
+ PathArc {
+ x: 0
+ y: trackPath.h - trackPath.r
+ radiusX: trackPath.r
+ radiusY: trackPath.r
+ }
+ PathLine {
+ x: 0
+ y: trackPath.r
+ }
+ PathArc {
+ x: trackPath.r
+ y: 0
+ radiusX: trackPath.r
+ radiusY: trackPath.r
+ }
+ }
+ }
+
+ // Circular cutout
+ Rectangle {
+ id: knobCutout
+ implicitWidth: root.knobDiameter + root.cutoutExtra
+ implicitHeight: root.knobDiameter + root.cutoutExtra
+ radius: Math.min(Style.iRadiusL, width / 2)
+ color: root.cutoutColor !== undefined ? root.cutoutColor : Color.mSurface
+ y: root.visualPosition * (root.availableHeight - root.knobDiameter) - root.cutoutExtra / 2
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+ }
+
+ handle: Item {
+ implicitWidth: root.knobDiameter
+ implicitHeight: root.knobDiameter
+ y: root.topPadding + root.visualPosition * (root.availableHeight - height)
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ Rectangle {
+ id: knob
+ implicitWidth: root.knobDiameter
+ implicitHeight: root.knobDiameter
+ radius: Math.min(Style.iRadiusL, width / 2)
+ color: {
+ if (root.rainbowMode) {
+ // Hue Logic: Map position (0.0 to 1.0) directly to Hue
+ return Qt.hsva(1 - root.visualPosition, 1, 1, 1);
+ } else {
+ // Linear Interpolation for Standard Gradients
+ // visualPosition 0.0 = Top, 1.0 = Bottom
+ var t = root.visualPosition;
+ var r = root.topColor.r * (1 - t) + root.bottomColor.r * t;
+ var g = root.topColor.g * (1 - t) + root.bottomColor.g * t;
+ var b = root.topColor.b * (1 - t) + root.bottomColor.b * t;
+ return Qt.rgba(r, g, b, 1);
+ }
+ }
+
+ border.color: root.pressed ? Color.mHover : Color.mPrimary
+ border.width: Style.borderL
+ anchors.centerIn: parent
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+ }
+
+ MouseArea {
+ enabled: true
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ hoverEnabled: true
+ acceptedButtons: Qt.NoButton // Don't accept any mouse buttons - only hover
+ propagateComposedEvents: true
+
+ onEntered: {
+ root.hovering = true;
+ if (root.tooltipText && (!Array.isArray(root.tooltipText) || root.tooltipText.length > 0)) {
+ TooltipService.show(knob, root.tooltipText, root.tooltipDirection);
+ }
+ }
+
+ onExited: {
+ root.hovering = false;
+ if (root.tooltipText && (!Array.isArray(root.tooltipText) || root.tooltipText.length > 0)) {
+ TooltipService.hide();
+ }
+ }
+ }
+
+ // Hide tooltip when slider is pressed (anywhere on the slider)
+ Connections {
+ target: root
+ function onPressedChanged() {
+ if (root.pressed && root.tooltipText && (!Array.isArray(root.tooltipText) || root.tooltipText.length > 0)) {
+ TooltipService.hide();
+ }
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NComboBox.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NComboBox.qml
new file mode 100644
index 0000000..a669696
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NComboBox.qml
@@ -0,0 +1,342 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import qs.Commons
+import qs.Services.UI
+import qs.Widgets
+
+RowLayout {
+ id: root
+
+ property real minimumWidth: 200
+ property real popupHeight: 180
+
+ property string label: ""
+ property string description: ""
+ property string tooltip: ""
+ property var model
+ property string currentKey: ""
+ property string placeholder: ""
+ property var defaultValue: undefined
+ property string settingsPath: ""
+ property real baseSize: 1.0
+
+ readonly property real preferredHeight: Math.round(Style.baseWidgetSize * 1.1 * root.baseSize)
+ readonly property var comboBox: combo
+
+ signal selected(string key)
+
+ spacing: Style.marginL
+
+ // Less strict comparison with != (instead of !==) so it can properly compare int vs string (ex for FPS: 30 and "30")
+ readonly property bool isValueChanged: (defaultValue !== undefined) && (currentKey != defaultValue)
+
+ readonly property string indicatorTooltip: {
+ if (defaultValue === undefined)
+ return "";
+ var displayValue = "";
+ if (defaultValue === "") {
+ // Try to find the display name for empty key in the model
+ var found = false;
+ if (root.model) {
+ if (Array.isArray(root.model)) {
+ for (var i = 0; i < root.model.length; i++) {
+ var item = root.model[i];
+ if (item && item.key === "") {
+ displayValue = item.name || I18n.tr("panels.indicator.system-default");
+ found = true;
+ break;
+ }
+ }
+ } else if (typeof root.model.get === 'function') {
+ for (var i = 0; i < root.itemCount(); i++) {
+ var item = root.getItem(i);
+ if (item && item.key === "") {
+ displayValue = item.name || I18n.tr("panels.indicator.system-default");
+ found = true;
+ break;
+ }
+ }
+ }
+ }
+ // If not found in model, show "System Default" instead of "(empty)"
+ if (!found) {
+ displayValue = I18n.tr("panels.indicator.system-default");
+ }
+ } else {
+ // Try to find the display name for the default key in the model
+ var found = false;
+ if (root.model) {
+ if (Array.isArray(root.model)) {
+ for (var i = 0; i < root.model.length; i++) {
+ var item = root.model[i];
+ if (item && item.key === defaultValue) {
+ displayValue = item.name || String(defaultValue);
+ found = true;
+ break;
+ }
+ }
+ } else if (typeof root.model.get === 'function') {
+ for (var i = 0; i < root.itemCount(); i++) {
+ var item = root.getItem(i);
+ if (item && item.key === defaultValue) {
+ displayValue = item.name || String(defaultValue);
+ found = true;
+ break;
+ }
+ }
+ }
+ }
+ if (!found) {
+ displayValue = String(defaultValue);
+ }
+ }
+ return I18n.tr("panels.indicator.default-value", {
+ "value": displayValue
+ });
+ }
+
+ function itemCount() {
+ if (!root.model)
+ return 0;
+ if (typeof root.model.count === 'number')
+ return root.model.count;
+ if (Array.isArray(root.model))
+ return root.model.length;
+ return 0;
+ }
+
+ function getItem(index) {
+ if (!root.model)
+ return null;
+ if (typeof root.model.get === 'function')
+ return root.model.get(index);
+ if (Array.isArray(root.model))
+ return root.model[index];
+ return null;
+ }
+
+ function findIndexByKey(key) {
+ for (var i = 0; i < itemCount(); i++) {
+ var item = getItem(i);
+ if (item && item.key === key)
+ return i;
+ }
+ return -1;
+ }
+
+ NLabel {
+ label: root.label
+ description: root.description
+ showIndicator: root.isValueChanged
+ indicatorTooltip: root.indicatorTooltip
+ }
+
+ ComboBox {
+ id: combo
+
+ opacity: enabled ? 1.0 : 0.6
+ Layout.margins: Style.borderS
+ Layout.minimumWidth: Math.round(root.minimumWidth * Style.uiScaleRatio)
+ Layout.preferredHeight: Math.round(root.preferredHeight * Style.uiScaleRatio)
+ implicitWidth: Layout.minimumWidth
+ model: root.model
+ textRole: "name"
+ currentIndex: root.findIndexByKey(root.currentKey)
+
+ onActivated: {
+ var item = root.getItem(combo.currentIndex);
+ if (item && item.key !== undefined)
+ root.selected(item.key);
+ }
+
+ Keys.onUpPressed: event => {
+ if (combo.popup.visible) {
+ if (listView.currentIndex > 0) {
+ listView.currentIndex--;
+ listView.positionViewAtIndex(listView.currentIndex, ListView.Contain);
+ }
+ event.accepted = true;
+ } else {
+ event.accepted = false;
+ }
+ }
+
+ Keys.onDownPressed: event => {
+ if (combo.popup.visible) {
+ if (listView.currentIndex < root.itemCount() - 1) {
+ listView.currentIndex++;
+ listView.positionViewAtIndex(listView.currentIndex, ListView.Contain);
+ }
+ event.accepted = true;
+ } else {
+ event.accepted = false;
+ }
+ }
+
+ Keys.onReturnPressed: event => {
+ if (combo.popup.visible) {
+ var item = root.getItem(listView.currentIndex);
+ if (item && item.key !== undefined) {
+ root.selected(item.key);
+ combo.currentIndex = listView.currentIndex;
+ combo.popup.close();
+ }
+ event.accepted = true;
+ } else {
+ event.accepted = false;
+ }
+ }
+
+ Keys.onEnterPressed: event => {
+ combo.Keys.returnPressed(event);
+ }
+
+ background: Rectangle {
+ implicitWidth: Math.round(Style.baseWidgetSize * 3.75 * Style.uiScaleRatio)
+ implicitHeight: Math.round(root.preferredHeight * Style.uiScaleRatio)
+ color: Color.mSurface
+ border.color: combo.activeFocus ? Color.mSecondary : Color.mOutline
+ border.width: Style.borderS
+ radius: Style.iRadiusM
+
+ Behavior on border.color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ acceptedButtons: Qt.NoButton
+ onEntered: {
+ if (root.tooltip != "") {
+ TooltipService.show(root, root.tooltip);
+ }
+ }
+ onExited: {
+ if (root.tooltip != "") {
+ TooltipService.hide();
+ }
+ }
+ }
+ }
+
+ contentItem: NText {
+ leftPadding: Style.marginL
+ rightPadding: combo.indicator.width + Style.marginL
+ pointSize: Style.fontSizeM
+ verticalAlignment: Text.AlignVCenter
+ elide: Text.ElideRight
+ color: combo.currentIndex >= 0 ? Color.mOnSurface : Color.mOnSurfaceVariant
+ text: {
+ if (combo.currentIndex >= 0 && combo.currentIndex < root.itemCount()) {
+ var item = root.getItem(combo.currentIndex);
+ return item ? item.name : root.placeholder;
+ }
+ return root.placeholder;
+ }
+ }
+
+ indicator: NIcon {
+ x: combo.width - width - Style.marginM
+ y: combo.topPadding + (combo.availableHeight - height) / 2
+ icon: "caret-down"
+ pointSize: Style.fontSizeL
+ }
+
+ popup: Popup {
+ y: combo.height + Style.marginS
+ implicitWidth: combo.width
+ implicitHeight: Math.min(Math.round(root.popupHeight * Style.uiScaleRatio), listView.contentHeight + Style.margin2M)
+ padding: Style.marginM
+
+ onOpened: {
+ listView.currentIndex = combo.currentIndex;
+ listView.positionViewAtIndex(combo.currentIndex, ListView.Beginning);
+ }
+
+ contentItem: NListView {
+ id: listView
+ property var comboBox: combo
+ model: combo.popup.visible ? root.model : null
+ highlightMoveDuration: 0
+ //showGradientMasks: false
+
+ delegate: Rectangle {
+ id: delegateRect
+ required property int index
+ property bool isHighlighted: listView.currentIndex === index
+
+ width: listView.availableWidth
+ height: delegateText.implicitHeight + Style.margin2S
+ radius: Style.iRadiusS
+ color: isHighlighted ? Color.mHover : "transparent"
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+
+ NText {
+ id: delegateText
+ anchors.fill: parent
+ anchors.leftMargin: Style.marginM
+ anchors.rightMargin: Style.marginM
+ verticalAlignment: Text.AlignVCenter
+ elide: Text.ElideRight
+ pointSize: Style.fontSizeM
+ color: delegateRect.isHighlighted ? Color.mOnHover : Color.mOnSurface
+ text: {
+ var item = root.getItem(delegateRect.index);
+ return item && item.name ? item.name : "";
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ onContainsMouseChanged: {
+ if (containsMouse) {
+ listView.currentIndex = delegateRect.index;
+ }
+ }
+ onClicked: {
+ var item = root.getItem(delegateRect.index);
+ if (item && item.key !== undefined) {
+ root.selected(item.key);
+ listView.comboBox.currentIndex = delegateRect.index;
+ listView.comboBox.popup.close();
+ }
+ }
+ }
+ }
+ }
+
+ background: Rectangle {
+ color: Color.mSurfaceVariant
+ border.color: Color.mOutline
+ border.width: Style.borderS
+ radius: Style.iRadiusM
+ }
+ }
+
+ Connections {
+ target: root
+ function onCurrentKeyChanged() {
+ combo.currentIndex = root.findIndexByKey(root.currentKey);
+ }
+ function onModelChanged() {
+ combo.currentIndex = root.findIndexByKey(root.currentKey);
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NContextMenu.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NContextMenu.qml
new file mode 100644
index 0000000..0975a12
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NContextMenu.qml
@@ -0,0 +1,182 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import qs.Commons
+
+/*
+* NContextMenu - Popup-based context menu for use inside panels and dialogs
+*
+* Use this component when you need a context menu inside:
+* - Settings panels
+* - Dialogs
+* - Repeater delegates
+* - Any nested component context
+*
+* For bar widgets and top-level window contexts, use NPopupContextMenu instead,
+* which provides better screen boundary handling and compositor integration.
+*
+* Usage:
+* NContextMenu {
+* id: contextMenu
+* parent: Overlay.overlay
+* model: [
+* { "label": "Action 1", "action": "action1", "icon": "icon-name" },
+* { "label": "Action 2", "action": "action2" }
+* ]
+* onTriggered: action => { Logger.i("MyModule", "Selected:", action) }
+* }
+*
+* MouseArea {
+* onClicked: contextMenu.openAtItem(parent, mouse.x, mouse.y)
+* }
+*/
+Popup {
+ id: root
+
+ property var model: []
+ property real itemHeight: 36
+ property real itemPadding: Style.marginM
+ property int verticalPolicy: ScrollBar.AsNeeded
+ property int horizontalPolicy: ScrollBar.AsNeeded
+ // Optional: explicit item whose bounds the menu must stay within.
+ // When unset, openAtItem auto-detects the nearest clipping ancestor.
+ property Item constrainTo: null
+ property Item _detectedConstraint: null
+
+ signal triggered(string action)
+
+ // Filter out hidden items to avoid spacing artifacts from zero-height items
+ readonly property var filteredModel: {
+ if (!model || model.length === 0)
+ return [];
+ var filtered = [];
+ for (var i = 0; i < model.length; i++) {
+ if (model[i].visible !== false) {
+ filtered.push(model[i]);
+ }
+ }
+ return filtered;
+ }
+
+ width: 180
+ padding: Style.marginS
+
+ background: Rectangle {
+ color: Color.mSurfaceVariant
+ border.color: Color.mOutline
+ border.width: Style.borderS
+ radius: Style.iRadiusM
+ }
+
+ contentItem: NListView {
+ id: listView
+ implicitHeight: Math.max(contentHeight, root.itemHeight)
+ spacing: Style.marginXXS
+ interactive: contentHeight > root.height
+ verticalPolicy: root.verticalPolicy
+ horizontalPolicy: root.horizontalPolicy
+ reserveScrollbarSpace: false
+ model: root.filteredModel
+
+ delegate: ItemDelegate {
+ id: menuItem
+ width: listView.availableWidth
+ height: root.itemHeight
+ opacity: modelData.enabled !== false ? 1.0 : 0.5
+ enabled: modelData.enabled !== false
+
+ // Store reference to the popup
+ property var popup: root
+
+ background: Rectangle {
+ color: menuItem.hovered && menuItem.enabled ? Color.mHover : "transparent"
+ radius: Style.iRadiusS
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+ }
+
+ contentItem: RowLayout {
+ spacing: Style.marginS
+
+ // Optional icon
+ NIcon {
+ visible: modelData.icon !== undefined
+ icon: modelData.icon || ""
+ pointSize: Style.fontSizeM
+ color: menuItem.hovered && menuItem.enabled ? Color.mOnHover : Color.mOnSurface
+ Layout.leftMargin: root.itemPadding
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+ }
+
+ NText {
+ text: modelData.label || modelData.text || ""
+ pointSize: Style.fontSizeM
+ color: menuItem.hovered && menuItem.enabled ? Color.mOnHover : Color.mOnSurface
+ verticalAlignment: Text.AlignVCenter
+ Layout.fillWidth: true
+ Layout.leftMargin: modelData.icon === undefined ? root.itemPadding : 0
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+ }
+ }
+
+ onClicked: {
+ if (enabled) {
+ popup.triggered(modelData.action || modelData.key || index.toString());
+ popup.close();
+ }
+ }
+ }
+ }
+
+ // Helper function to open at mouse position
+ function openAt(x, y) {
+ if (root.parent) {
+ var menuWidth = root.width;
+ var itemCount = root.filteredModel.length;
+ var menuHeight = Math.max(itemCount * root.itemHeight + Math.max(0, itemCount - 1) * listView.spacing, root.itemHeight) + root.topPadding + root.bottomPadding;
+ var constraint = root.constrainTo || root._detectedConstraint;
+ if (constraint) {
+ var tl = constraint.mapToItem(root.parent, 0, 0);
+ x = Math.max(tl.x, Math.min(x, tl.x + constraint.width - menuWidth));
+ y = Math.max(tl.y, Math.min(y, tl.y + constraint.height - menuHeight));
+ } else {
+ x = Math.max(0, Math.min(x, root.parent.width - menuWidth));
+ y = Math.max(0, Math.min(y, root.parent.height - menuHeight));
+ }
+ }
+ root.x = x;
+ root.y = y;
+ root.open();
+ }
+
+ // Helper function to open at item
+ function openAtItem(item, mouseX, mouseY) {
+ if (!root.constrainTo) {
+ root._detectedConstraint = null;
+ var p = item;
+ while (p && p !== root.parent) {
+ if (p.clip && p.width > 0 && p.height > 0) {
+ root._detectedConstraint = p;
+ break;
+ }
+ p = p.parent;
+ }
+ }
+ var pos = item.mapToItem(root.parent, mouseX || 0, mouseY || 0);
+ openAt(pos.x, pos.y);
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NDateTimeTokens.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NDateTimeTokens.qml
new file mode 100644
index 0000000..496c70b
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NDateTimeTokens.qml
@@ -0,0 +1,393 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import qs.Commons
+
+Rectangle {
+ id: root
+
+ property date sampleDate: new Date() // Dec 25, 2023, 2:30:45.123 PM
+
+ signal tokenClicked(string token)
+
+ Layout.margins: Style.borderS
+ color: Color.mSurface
+ border.color: Color.mOutline
+ border.width: Style.borderS
+ radius: Style.iRadiusM
+
+ ColumnLayout {
+ id: column
+ anchors.fill: parent
+ anchors.margins: Style.marginS
+ spacing: Style.marginS
+
+ Flickable {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ contentHeight: tokensColumn.implicitHeight
+ clip: true
+
+ Column {
+ id: tokensColumn
+ width: parent.width
+
+ Repeater {
+ model: [
+ // Common format combinations
+ {
+ "category": "Common",
+ "token": "h:mm AP",
+ "description": I18n.tr("widgets.datetime-tokens.common-12hour-time-minutes"),
+ "example": "2:30 PM"
+ },
+ {
+ "category": "Common",
+ "token": "HH:mm",
+ "description": I18n.tr("widgets.datetime-tokens.common-24hour-time-minutes"),
+ "example": "14:30"
+ },
+ {
+ "category": "Common",
+ "token": "HH:mm:ss",
+ "description": I18n.tr("widgets.datetime-tokens.common-24hour-time-seconds"),
+ "example": "14:30:45"
+ },
+ {
+ "category": "Common",
+ "token": "ddd MMM d",
+ "description": I18n.tr("widgets.datetime-tokens.common-weekday-month-day"),
+ "example": "Mon Dec 25"
+ },
+ {
+ "category": "Common",
+ "token": "yyyy-MM-dd",
+ "description": I18n.tr("widgets.datetime-tokens.common-iso-date"),
+ "example": "2023-12-25"
+ },
+ {
+ "category": "Common",
+ "token": "MM/dd/yyyy",
+ "description": I18n.tr("widgets.datetime-tokens.common-us-date"),
+ "example": "12/25/2023"
+ },
+ {
+ "category": "Common",
+ "token": "dd.MM.yyyy",
+ "description": I18n.tr("widgets.datetime-tokens.common-european-date"),
+ "example": "25.12.2023"
+ },
+ {
+ "category": "Common",
+ "token": "ddd, MMM dd",
+ "description": I18n.tr("widgets.datetime-tokens.common-weekday-date"),
+ "example": "Fri, Dec 12"
+ } // Hour tokens
+ ,
+ {
+ "category": "Hour",
+ "token": "H",
+ "description": I18n.tr("widgets.datetime-tokens.hour-no-leading-zero"),
+ "example": "14"
+ },
+ {
+ "category": "Hour",
+ "token": "HH",
+ "description": I18n.tr("widgets.datetime-tokens.hour-leading-zero"),
+ "example": "14"
+ } // Minute tokens
+ ,
+ {
+ "category": "Minute",
+ "token": "m",
+ "description": I18n.tr("widgets.datetime-tokens.minute-no-leading-zero"),
+ "example": "30"
+ },
+ {
+ "category": "Minute",
+ "token": "mm",
+ "description": I18n.tr("widgets.datetime-tokens.minute-leading-zero"),
+ "example": "30"
+ } // Second tokens
+ ,
+ {
+ "category": "Second",
+ "token": "s",
+ "description": I18n.tr("widgets.datetime-tokens.second-no-leading-zero"),
+ "example": "45"
+ },
+ {
+ "category": "Second",
+ "token": "ss",
+ "description": I18n.tr("widgets.datetime-tokens.second-leading-zero"),
+ "example": "45"
+ } // AM/PM tokens
+ ,
+ {
+ "category": "AM/PM",
+ "token": "AP",
+ "description": I18n.tr("widgets.datetime-tokens.ampm-uppercase"),
+ "example": "PM"
+ },
+ {
+ "category": "AM/PM",
+ "token": "ap",
+ "description": I18n.tr("widgets.datetime-tokens.ampm-lowercase"),
+ "example": "pm"
+ } // Timezone tokens
+ ,
+ {
+ "category": "Timezone",
+ "token": "t",
+ "description": I18n.tr("widgets.datetime-tokens.timezone-abbreviation"),
+ "example": "UTC"
+ } // Year tokens
+ ,
+ {
+ "category": "Year",
+ "token": "yy",
+ "description": I18n.tr("widgets.datetime-tokens.year-two-digit"),
+ "example": "23"
+ },
+ {
+ "category": "Year",
+ "token": "yyyy",
+ "description": I18n.tr("widgets.datetime-tokens.year-four-digit"),
+ "example": "2023"
+ } // Month tokens
+ ,
+ {
+ "category": "Month",
+ "token": "M",
+ "description": I18n.tr("widgets.datetime-tokens.month-number-no-zero"),
+ "example": "12"
+ },
+ {
+ "category": "Month",
+ "token": "MM",
+ "description": I18n.tr("widgets.datetime-tokens.month-number-leading-zero"),
+ "example": "12"
+ },
+ {
+ "category": "Month",
+ "token": "MMM",
+ "description": I18n.tr("widgets.datetime-tokens.month-abbreviated"),
+ "example": "Dec"
+ },
+ {
+ "category": "Month",
+ "token": "MMMM",
+ "description": I18n.tr("widgets.datetime-tokens.month-full"),
+ "example": "December"
+ } // Day tokens
+ ,
+ {
+ "category": "Day",
+ "token": "d",
+ "description": I18n.tr("widgets.datetime-tokens.day-no-leading-zero"),
+ "example": "25"
+ },
+ {
+ "category": "Day",
+ "token": "dd",
+ "description": I18n.tr("widgets.datetime-tokens.day-leading-zero"),
+ "example": "25"
+ },
+ {
+ "category": "Day",
+ "token": "ddd",
+ "description": I18n.tr("widgets.datetime-tokens.day-abbreviated"),
+ "example": "Mon"
+ },
+ {
+ "category": "Day",
+ "token": "dddd",
+ "description": I18n.tr("widgets.datetime-tokens.day-full"),
+ "example": "Monday"
+ }
+ ]
+
+ delegate: Rectangle {
+ id: tokenDelegate
+ width: tokensColumn.width
+ height: layout.implicitHeight + Style.marginS
+ radius: Style.iRadiusS
+ color: {
+ if (tokenMouseArea.containsMouse) {
+ return Qt.alpha(Color.mPrimary, 0.1);
+ }
+ return index % 2 === 0 ? Color.mSurfaceVariant : Qt.alpha(Color.mSurfaceVariant, 0.6);
+ }
+
+ // Mouse area for the entire delegate
+ MouseArea {
+ id: tokenMouseArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+
+ onClicked: {
+ root.tokenClicked(modelData.token);
+ clickAnimation.start();
+ }
+ }
+
+ // Click animation
+ SequentialAnimation {
+ id: clickAnimation
+ PropertyAnimation {
+ target: tokenDelegate
+ property: "color"
+ to: Qt.alpha(Color.mPrimary, 0.3)
+ duration: 100
+ }
+ PropertyAnimation {
+ target: tokenDelegate
+ property: "color"
+ to: tokenMouseArea.containsMouse ? Qt.alpha(Color.mPrimary, 0.1) : (index % 2 === 0 ? Color.mSurface : Color.mSurfaceVariant)
+ duration: 200
+ }
+ }
+
+ RowLayout {
+ id: layout
+ anchors.fill: parent
+ anchors.margins: Style.marginXS
+ spacing: Style.marginM
+
+ // Category badge
+ Rectangle {
+ Layout.alignment: Qt.AlignVCenter
+ width: 70
+ height: 22
+ color: getCategoryColor(modelData.category)[0]
+ radius: Style.iRadiusS
+ opacity: tokenMouseArea.containsMouse ? 0.9 : 1.0
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Style.animationFast
+ }
+ }
+
+ NText {
+ anchors.centerIn: parent
+ text: modelData.category
+ color: getCategoryColor(modelData.category)[1]
+ pointSize: Style.fontSizeXS
+ }
+ }
+
+ // Token - Made more prominent and clickable
+ Rectangle {
+ id: tokenButton
+ Layout.alignment: Qt.AlignVCenter // Added this line
+ width: 100
+ height: 22
+ color: tokenMouseArea.containsMouse ? Color.mPrimary : Color.mOnSurface
+ radius: Style.iRadiusS
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+
+ NText {
+ anchors.centerIn: parent
+ text: modelData.token
+ color: tokenMouseArea.containsMouse ? Color.mOnPrimary : Color.mSurface
+ pointSize: Style.fontSizeS
+ font.weight: Style.fontWeightBold
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+ }
+ }
+
+ // Description
+ NText {
+ Layout.fillWidth: true
+ Layout.alignment: Qt.AlignVCenter // Added this line
+ text: modelData.description
+ color: tokenMouseArea.containsMouse ? Color.mOnSurface : Color.mOnSurfaceVariant
+ pointSize: Style.fontSizeS
+ wrapMode: Text.WordWrap
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+ }
+
+ // Live example
+ Rectangle {
+ Layout.alignment: Qt.AlignVCenter // Added this line
+ width: 90
+ height: 22
+ color: tokenMouseArea.containsMouse ? Color.mPrimary : Color.mOnSurfaceVariant
+ radius: Style.iRadiusS
+ border.color: tokenMouseArea.containsMouse ? Color.mPrimary : Color.mOutline
+ border.width: Style.borderS
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+
+ Behavior on border.color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+
+ NText {
+ anchors.centerIn: parent
+ text: I18n.locale.toString(root.sampleDate, modelData.token)
+ color: tokenMouseArea.containsMouse ? Color.mOnPrimary : Color.mSurfaceVariant
+ pointSize: Style.fontSizeS
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ function getCategoryColor(category) {
+ switch (category) {
+ case "Year":
+ return [Color.mPrimary, Color.mOnPrimary];
+ case "Month":
+ return [Color.mSecondary, Color.mOnSecondary];
+ case "Day":
+ return [Color.mTertiary, Color.mOnTertiary];
+ case "Hour":
+ return [Color.mPrimary, Color.mOnPrimary];
+ case "Minute":
+ return [Color.mSecondary, Color.mOnSecondary];
+ case "Second":
+ return [Color.mTertiary, Color.mOnTertiary];
+ case "AM/PM":
+ return [Color.mError, Color.mOnError];
+ case "Timezone":
+ return [Color.mOnSurface, Color.mSurface];
+ case "Common":
+ return [Color.mError, Color.mOnError];
+ default:
+ return [Color.mOnSurfaceVariant, Color.mSurfaceVariant];
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NDivider.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NDivider.qml
new file mode 100644
index 0000000..29753ca
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NDivider.qml
@@ -0,0 +1,30 @@
+import QtQuick
+import Quickshell
+import Quickshell.Widgets
+import qs.Commons
+
+Rectangle {
+ property bool vertical: false
+
+ width: vertical ? Style.borderS : parent.width
+ height: vertical ? parent.height : Style.borderS
+ gradient: Gradient {
+ orientation: vertical ? Gradient.Vertical : Gradient.Horizontal
+ GradientStop {
+ position: 0.0
+ color: "transparent"
+ }
+ GradientStop {
+ position: 0.1
+ color: Color.mOutline
+ }
+ GradientStop {
+ position: 0.9
+ color: Color.mOutline
+ }
+ GradientStop {
+ position: 1.0
+ color: "transparent"
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NDropShadow.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NDropShadow.qml
new file mode 100644
index 0000000..0a223f4
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NDropShadow.qml
@@ -0,0 +1,31 @@
+import QtQuick
+import QtQuick.Effects
+import qs.Commons
+import qs.Services.Power
+
+// Unified shadow system
+Item {
+ id: root
+
+ required property var source
+
+ property bool autoPaddingEnabled: false
+ property real shadowHorizontalOffset: Settings.data.general.shadowOffsetX
+ property real shadowVerticalOffset: Settings.data.general.shadowOffsetY
+ property real shadowOpacity: Style.shadowOpacity
+ property color shadowColor: "black"
+ property real shadowBlur: Style.shadowBlur
+
+ layer.enabled: Settings.data.general.enableShadows && !PowerProfileService.noctaliaPerformanceMode
+ layer.effect: MultiEffect {
+ source: root.source
+ shadowEnabled: true
+ blurMax: Style.shadowBlurMax
+ shadowBlur: root.shadowBlur
+ shadowOpacity: root.shadowOpacity
+ shadowColor: root.shadowColor
+ shadowHorizontalOffset: root.shadowHorizontalOffset
+ shadowVerticalOffset: root.shadowVerticalOffset
+ autoPaddingEnabled: root.autoPaddingEnabled
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NFilePicker.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NFilePicker.qml
new file mode 100644
index 0000000..84395c1
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NFilePicker.qml
@@ -0,0 +1,817 @@
+import Qt.labs.folderlistmodel
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+import qs.Widgets
+
+Popup {
+ id: root
+
+ // Properties
+ property string title: I18n.tr("widget.file-picker.title")
+ property string initialPath: Quickshell.env("HOME") || "/home"
+ property string selectionMode: "files" // "files" or "folders"
+ property var nameFilters: ["*"]
+ property bool showDirs: true
+ property bool showHiddenFiles: false
+ property bool allowMultiSelection: false
+
+ property var selectedPaths: []
+ property string currentPath: initialPath
+ property bool shouldResetSelection: false
+
+ // Signals
+ signal accepted(var paths)
+ signal cancelled
+
+ function openFilePicker() {
+ if (!root.currentPath)
+ root.currentPath = root.initialPath;
+ shouldResetSelection = true;
+ open();
+ }
+
+ function getFileIcon(fileName) {
+ const ext = fileName.split('.').pop().toLowerCase();
+ const iconMap = {
+ "txt": 'filepicker-file-text',
+ "md": 'filepicker-file-text',
+ "log": 'filepicker-file-text',
+ "jpg": 'filepicker-photo',
+ "jpeg": 'filepicker-photo',
+ "png": 'filepicker-photo',
+ "gif": 'filepicker-photo',
+ "bmp": 'filepicker-photo',
+ "svg": 'filepicker-photo',
+ "mp4": 'filepicker-video',
+ "avi": 'filepicker-video',
+ "mkv": 'filepicker-video',
+ "mov": 'filepicker-video',
+ "mp3": 'filepicker-music',
+ "wav": 'filepicker-music',
+ "flac": 'filepicker-music',
+ "ogg": 'filepicker-music',
+ "zip": 'filepicker-archive',
+ "tar": 'filepicker-archive',
+ "gz": 'filepicker-archive',
+ "rar": 'filepicker-archive',
+ "7z": 'filepicker-archive',
+ "pdf": 'filepicker-text',
+ "doc": 'filepicker-text',
+ "docx": 'filepicker-text',
+ "xls": 'filepicker-table',
+ "xlsx": 'filepicker-table',
+ "ppt": 'filepicker-presentation',
+ "pptx": 'filepicker-presentation',
+ "html": 'filepicker-code',
+ "htm": 'filepicker-code',
+ "css": 'filepicker-code',
+ "js": 'filepicker-code',
+ "json": 'filepicker-code',
+ "xml": 'filepicker-code',
+ "exe": 'filepicker-settings',
+ "app": 'filepicker-settings',
+ "deb": 'filepicker-settings',
+ "rpm": 'filepicker-settings'
+ };
+ return iconMap[ext] || 'filepicker-file';
+ }
+
+ function formatFileSize(bytes) {
+ if (bytes === 0)
+ return "0 B";
+ const k = 1024, sizes = ["B", "KB", "MB", "GB", "TB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
+ }
+
+ function confirmSelection() {
+ if (filePickerPanel.currentSelection.length === 0)
+ return;
+ root.selectedPaths = filePickerPanel.currentSelection;
+ root.accepted(filePickerPanel.currentSelection);
+ root.close();
+ }
+
+ function updateFilteredModel() {
+ filteredModel.clear();
+ const searchText = filePickerPanel.filterText.toLowerCase();
+
+ for (var i = 0; i < folderModel.count; i++) {
+ const fileName = folderModel.get(i, "fileName");
+ const filePath = folderModel.get(i, "filePath");
+ const fileIsDir = folderModel.get(i, "fileIsDir");
+ const fileSize = folderModel.get(i, "fileSize");
+
+ // Skip hidden items if showHiddenFiles is false
+ // This additional check ensures hidden files are properly filtered
+ if (!root.showHiddenFiles && fileName.startsWith(".")) {
+ continue;
+ }
+
+ // In folder mode, hide files
+ if (root.selectionMode === "folders" && !fileIsDir)
+ continue;
+ if (searchText === "" || fileName.toLowerCase().includes(searchText)) {
+ filteredModel.append({
+ "fileName": fileName,
+ "filePath": filePath,
+ "fileIsDir": fileIsDir,
+ "fileSize": fileSize
+ });
+ }
+ }
+ }
+
+ width: 900
+ height: 700
+ modal: true
+ closePolicy: Popup.CloseOnEscape
+ anchors.centerIn: Overlay.overlay
+
+ background: Rectangle {
+ color: Color.mSurfaceVariant
+ radius: Style.iRadiusL
+ border.color: Color.mOutline
+ border.width: Style.borderS
+ }
+
+ Rectangle {
+ id: filePickerPanel
+ anchors.fill: parent
+ anchors.margins: Style.marginL
+ color: "transparent"
+
+ property string filterText: ""
+ property var currentSelection: []
+ property bool viewMode: true // true = grid, false = list
+ property string searchText: ""
+ property bool showSearchBar: false
+
+ focus: true
+
+ Keys.onPressed: event => {
+ if (event.modifiers & Qt.ControlModifier && event.key === Qt.Key_F) {
+ filePickerPanel.showSearchBar = !filePickerPanel.showSearchBar;
+ if (filePickerPanel.showSearchBar)
+ Qt.callLater(() => searchInput.forceActiveFocus());
+ event.accepted = true;
+ } else if (event.key === Qt.Key_Escape && filePickerPanel.showSearchBar) {
+ filePickerPanel.showSearchBar = false;
+ filePickerPanel.searchText = "";
+ filePickerPanel.filterText = "";
+ root.updateFilteredModel();
+ event.accepted = true;
+ }
+ }
+
+ // Function when an item is clicked, either a folder or a file since both have the same functionality, reduces repetitive code
+ function itemClicked(modifiers, path) {
+ var list = currentSelection.slice();
+ const index = list.indexOf(path);
+ if (root.allowMultiSelection && (modifiers & Qt.ShiftModifier) && list.length > 0) {
+ if (index > -1) {
+ list.splice(index, 1);
+ currentSelection = list;
+ } else {
+ var i = 0;
+ var toggle = false;
+ const firstItemPath = list[0];
+ while (i < filteredModel.count) {
+ const itemPath = filteredModel.get(i).filePath;
+
+ // This should be called twice, when we get to the item selected and when we get to the starting item.
+ if (itemPath === firstItemPath || itemPath === path) {
+ toggle = !toggle;
+ }
+
+ // Add all files between the starting item and the item selected.
+ if (toggle) {
+ if (!list.includes(itemPath)) {
+ list.push(itemPath);
+ }
+ }
+
+ i++;
+ }
+
+ // Add the path selected as well since it's skipped in the while loop
+ if (!list.includes(path)) {
+ list.push(path);
+ }
+ currentSelection = list;
+ }
+ } else if (root.allowMultiSelection && (modifiers & Qt.ControlModifier)) {
+ if (index > -1) {
+ list.splice(index, 1);
+ currentSelection = list;
+ } else {
+ list.push(path);
+ currentSelection = list;
+ }
+ } else {
+ currentSelection = [path];
+ }
+ }
+
+ ColumnLayout {
+ anchors.fill: parent
+ spacing: Style.marginM
+
+ // Header
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Style.marginM
+
+ NIcon {
+ icon: "filepicker-folder"
+ color: Color.mPrimary
+ pointSize: Style.fontSizeXXL
+ }
+ NText {
+ text: root.title
+ pointSize: Style.fontSizeXL
+ font.weight: Style.fontWeightBold
+ color: Color.mPrimary
+ Layout.fillWidth: true
+ }
+
+ // "Select Current" button only visible in folder selection mode
+ NButton {
+ text: I18n.tr("widgets.file-picker.select-current")
+ icon: "filepicker-folder-current"
+ visible: root.selectionMode === "folders"
+ onClicked: {
+ filePickerPanel.currentSelection = [root.currentPath];
+ root.confirmSelection();
+ }
+ }
+
+ NIconButton {
+ icon: "filepicker-refresh"
+ tooltipText: I18n.tr("common.refresh")
+ onClicked: {
+ // Force a proper refresh by resetting the folder
+ const currentFolder = folderModel.folder;
+ folderModel.folder = "";
+ folderModel.folder = currentFolder;
+ Qt.callLater(root.updateFilteredModel);
+ }
+ }
+ NIconButton {
+ icon: "filepicker-close"
+ tooltipText: I18n.tr("common.close")
+ onClicked: {
+ root.cancelled();
+ root.close();
+ }
+ }
+ }
+
+ NDivider {
+ Layout.fillWidth: true
+ }
+
+ // Navigation toolbar
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.preferredHeight: 45
+ color: Color.mSurfaceVariant
+ radius: Style.iRadiusS
+ border.color: Color.mOutline
+ border.width: Style.borderS
+
+ RowLayout {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.leftMargin: Style.marginS
+ anchors.rightMargin: Style.marginS
+ spacing: Style.marginS
+
+ NIconButton {
+ icon: "filepicker-arrow-up"
+ tooltipText: I18n.tr("tooltips.up")
+ baseSize: Style.baseWidgetSize * 0.8
+ enabled: folderModel.folder.toString() !== "file:///"
+ onClicked: {
+ const parentPath = folderModel.parentFolder.toString().replace("file://", "");
+ folderModel.folder = "file://" + parentPath;
+ root.currentPath = parentPath;
+ }
+ }
+
+ NIconButton {
+ icon: "filepicker-home"
+ tooltipText: I18n.tr("tooltips.home")
+ baseSize: Style.baseWidgetSize * 0.8
+ onClicked: {
+ const homePath = Quickshell.env("HOME") || "/home";
+ folderModel.folder = "file://" + homePath;
+ root.currentPath = homePath;
+ }
+ }
+
+ NIconButton {
+ icon: filePickerPanel.showSearchBar ? "filepicker-x" : "filepicker-search"
+ tooltipText: filePickerPanel.showSearchBar ? I18n.tr("tooltips.search-close") : I18n.tr("common.search")
+ baseSize: Style.baseWidgetSize * 0.8
+ onClicked: {
+ filePickerPanel.showSearchBar = !filePickerPanel.showSearchBar;
+ if (!filePickerPanel.showSearchBar) {
+ filePickerPanel.searchText = "";
+ filePickerPanel.filterText = "";
+ root.updateFilteredModel();
+ }
+ }
+ }
+
+ NTextInput {
+ id: locationInput
+ text: root.currentPath
+ placeholderText: I18n.tr("placeholders.enter-path")
+ Layout.fillWidth: true
+
+ visible: !filePickerPanel.showSearchBar
+ enabled: !filePickerPanel.showSearchBar
+
+ onEditingFinished: {
+ const newPath = text.trim();
+ if (newPath !== "" && newPath !== root.currentPath) {
+ folderModel.folder = "file://" + newPath;
+ root.currentPath = newPath;
+ } else {
+ text = root.currentPath;
+ }
+ }
+ Connections {
+ target: root
+ function onCurrentPathChanged() {
+ if (!locationInput.activeFocus)
+ locationInput.text = root.currentPath;
+ }
+ }
+ }
+
+ // Search bar
+ NTextInput {
+ id: searchInput
+ inputIconName: "search"
+ placeholderText: I18n.tr("placeholders.search")
+ Layout.fillWidth: true
+
+ visible: filePickerPanel.showSearchBar
+ enabled: filePickerPanel.showSearchBar
+
+ text: filePickerPanel.searchText
+ onTextChanged: {
+ filePickerPanel.searchText = text;
+ filePickerPanel.filterText = text;
+ root.updateFilteredModel();
+ }
+ Keys.onEscapePressed: {
+ filePickerPanel.showSearchBar = false;
+ filePickerPanel.searchText = "";
+ filePickerPanel.filterText = "";
+ root.updateFilteredModel();
+ }
+ }
+
+ NIconButton {
+ icon: filePickerPanel.viewMode ? "filepicker-list" : "filepicker-layout-grid"
+ tooltipText: filePickerPanel.viewMode ? I18n.tr("tooltips.list-view") : I18n.tr("tooltips.grid-view")
+ baseSize: Style.baseWidgetSize * 0.8
+ onClicked: filePickerPanel.viewMode = !filePickerPanel.viewMode
+ }
+ NIconButton {
+ icon: root.showHiddenFiles ? "filepicker-eye-off" : "filepicker-eye"
+ tooltipText: root.showHiddenFiles ? I18n.tr("tooltips.hidden-files-hide") : I18n.tr("tooltips.hidden-files-hide")
+ baseSize: Style.baseWidgetSize * 0.8
+ onClicked: {
+ root.showHiddenFiles = !root.showHiddenFiles;
+ // Force model refresh by resetting the folder
+ const currentFolder = folderModel.folder;
+ folderModel.folder = "";
+ folderModel.folder = currentFolder;
+ Qt.callLater(root.updateFilteredModel);
+ }
+ }
+ }
+ }
+
+ // File list area
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ color: Color.mSurface
+ radius: Style.iRadiusM
+ border.color: Color.mOutline
+ border.width: Style.borderS
+
+ FolderListModel {
+ id: folderModel
+ folder: "file://" + root.currentPath
+ // Use wildcard filters including hidden files when showHiddenFiles is true
+ nameFilters: root.showHiddenFiles ? ["*", ".*"] : root.nameFilters
+ showDirs: root.showDirs
+ showHidden: true // Always true, we'll filter in updateFilteredModel
+ showDotAndDotDot: false
+ sortField: FolderListModel.Name
+ sortReversed: false
+
+ onFolderChanged: {
+ root.currentPath = folder.toString().replace("file://", "");
+ filePickerPanel.currentSelection = [];
+ Qt.callLater(root.updateFilteredModel);
+ }
+
+ onStatusChanged: {
+ if (status === FolderListModel.Error) {
+ if (root.currentPath !== Quickshell.env("HOME")) {
+ folder = "file://" + Quickshell.env("HOME");
+ root.currentPath = Quickshell.env("HOME");
+ }
+ } else if (status === FolderListModel.Ready) {
+ root.updateFilteredModel();
+ }
+ }
+ }
+
+ // Update nameFilters when showHiddenFiles changes
+ Connections {
+ target: root
+ function onShowHiddenFilesChanged() {
+ folderModel.nameFilters = root.showHiddenFiles ? ["*", ".*"] : root.nameFilters;
+ }
+ }
+
+ ListModel {
+ id: filteredModel
+ }
+
+ // Grid view
+ NGridView {
+ id: gridView
+ anchors.fill: parent
+ anchors.margins: Style.marginM
+ model: filteredModel
+ visible: filePickerPanel.viewMode
+ reuseItems: true
+ gradientColor: Color.mSurface
+
+ property int columns: Math.max(1, Math.floor(availableWidth / 120))
+ property int itemSize: Math.floor((availableWidth - leftMargin - rightMargin - (columns * Style.marginS)) / columns)
+
+ cellWidth: Math.floor((availableWidth - leftMargin - rightMargin) / columns)
+ cellHeight: Math.floor(itemSize * 0.8) + Style.marginXS + Style.fontSizeS + Style.marginM
+
+ leftMargin: Style.marginS
+ rightMargin: Style.marginS
+ topMargin: Style.marginS
+ bottomMargin: Style.marginS
+
+ delegate: Rectangle {
+ id: gridItem
+ width: gridView.itemSize
+ height: gridView.cellHeight
+ color: "transparent"
+ radius: Style.iRadiusM
+
+ property bool isSelected: filePickerPanel.currentSelection.includes(model.filePath)
+
+ Rectangle {
+ anchors.fill: parent
+ color: "transparent"
+ radius: parent.radius
+ border.color: isSelected ? Color.mSecondary : Color.mSurface
+ border.width: Style.borderL
+ Behavior on color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ color: (mouseArea.containsMouse && !isSelected) ? Color.mHover : "transparent"
+ radius: parent.radius
+ border.color: (mouseArea.containsMouse && !isSelected) ? Color.mHover : "transparent"
+ border.width: Style.borderS
+ Behavior on color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+ Behavior on border.color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+ }
+
+ ColumnLayout {
+ anchors.fill: parent
+ anchors.margins: Style.marginS
+ spacing: Style.marginXS
+
+ Rectangle {
+ id: iconContainer
+ Layout.fillWidth: true
+ Layout.preferredHeight: Math.round(gridView.itemSize * 0.67)
+ color: "transparent"
+
+ property bool isImage: {
+ if (model.fileIsDir)
+ return false;
+ const ext = model.fileName.split('.').pop().toLowerCase();
+ return ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico'].includes(ext);
+ }
+
+ Image {
+ id: thumbnail
+ anchors.fill: parent
+ anchors.margins: Style.marginXS
+ source: iconContainer.isImage ? "file://" + model.filePath : ""
+ fillMode: Image.PreserveAspectFit
+ visible: iconContainer.isImage && status === Image.Ready
+ smooth: false
+ cache: true
+ asynchronous: true
+ sourceSize.width: 120
+ sourceSize.height: 120
+ onStatusChanged: {
+ if (status === Image.Error)
+ visible = false;
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ color: Color.mSurfaceVariant
+ radius: Style.iRadiusS
+ visible: thumbnail.status === Image.Loading
+ NIcon {
+ icon: "filepicker-photo"
+ pointSize: Style.fontSizeL
+ color: Color.mOnSurfaceVariant
+ anchors.centerIn: parent
+ }
+ }
+ }
+
+ NIcon {
+ icon: model.fileIsDir ? "filepicker-folder" : root.getFileIcon(model.fileName)
+ pointSize: Style.fontSizeXXL * 2
+ color: {
+ if (isSelected)
+ return Color.mSecondary;
+ else if (mouseArea.containsMouse)
+ return Color.mOnHover;
+ else
+ return model.fileIsDir ? Color.mPrimary : Color.mOnSurfaceVariant;
+ }
+ anchors.centerIn: parent
+ visible: !iconContainer.isImage || thumbnail.status !== Image.Ready
+ }
+
+ Rectangle {
+ anchors.top: parent.top
+ anchors.right: parent.right
+ anchors.margins: Style.marginS
+ width: 24
+ height: 24
+ radius: Math.min(Style.iRadiusL, width / 2)
+ color: Color.mSecondary
+ border.color: Color.mOutline
+ border.width: Style.borderS
+ visible: isSelected
+ NIcon {
+ icon: "filepicker-check"
+ pointSize: Style.fontSizeS
+ color: Color.mOnSecondary
+ anchors.centerIn: parent
+ }
+ }
+ }
+
+ NText {
+ text: model.fileName
+ color: {
+ if (isSelected)
+ return Color.mSecondary;
+ else if (mouseArea.containsMouse)
+ return Color.mOnHover;
+ else
+ return Color.mOnSurfaceVariant;
+ }
+ pointSize: Style.fontSizeS
+ font.weight: isSelected ? Style.fontWeightBold : Style.fontWeightRegular
+ Layout.fillWidth: true
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.WrapAnywhere
+ elide: Text.ElideRight
+ maximumLineCount: 2
+ }
+ }
+
+ MouseArea {
+ id: mouseArea
+ anchors.fill: parent
+ hoverEnabled: true
+ acceptedButtons: Qt.LeftButton | Qt.RightButton
+
+ onClicked: mouse => {
+ if (mouse.button === Qt.LeftButton) {
+ if (model.fileIsDir) {
+ // In folder mode, single click selects the folder
+ if (root.selectionMode === "folders") {
+ filePickerPanel.itemClicked(mouse.modifiers, model.filePath);
+ }
+ // In file mode, single click on folder does nothing (must double-click to enter)
+ } else {
+ // Single click on file selects it (only in file mode)
+ if (root.selectionMode === "files") {
+ filePickerPanel.itemClicked(mouse.modifiers, model.filePath);
+ }
+ }
+ }
+ }
+
+ onDoubleClicked: mouse => {
+ if (mouse.button === Qt.LeftButton) {
+ if (model.fileIsDir) {
+ // Double-click on folder always navigates into it
+ folderModel.folder = "file://" + model.filePath;
+ root.currentPath = model.filePath;
+ } else {
+ // Double-click on file selects and confirms (only in file mode)
+ if (root.selectionMode === "files") {
+ filePickerPanel.currentSelection = [model.filePath];
+ root.confirmSelection();
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // List view
+ NListView {
+ id: listView
+ anchors.fill: parent
+ anchors.margins: Style.marginS
+ model: filteredModel
+ visible: !filePickerPanel.viewMode
+ gradientColor: Color.mSurface
+
+ delegate: Rectangle {
+ id: listItem
+ width: listView.width
+ height: 40
+ color: {
+ if (filePickerPanel.currentSelection.includes(model.filePath))
+ return Color.mSecondary;
+ if (mouseArea.containsMouse)
+ return Color.mHover;
+ return "transparent";
+ }
+ radius: Style.iRadiusS
+ Behavior on color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+
+ RowLayout {
+ anchors.fill: parent
+ anchors.leftMargin: Style.marginM
+ anchors.rightMargin: Style.marginM
+ spacing: Style.marginM
+
+ NIcon {
+ icon: model.fileIsDir ? "filepicker-folder" : root.getFileIcon(model.fileName)
+ pointSize: Style.fontSizeL
+ color: model.fileIsDir ? (filePickerPanel.currentSelection.includes(model.filePath) ? Color.mOnSecondary : Color.mPrimary) : Color.mOnSurfaceVariant
+ }
+
+ NText {
+ text: model.fileName
+ color: filePickerPanel.currentSelection.includes(model.filePath) ? Color.mOnSecondary : Color.mOnSurface
+ pointSize: Style.fontSizeM
+ font.weight: filePickerPanel.currentSelection.includes(model.filePath) ? Style.fontWeightBold : Style.fontWeightRegular
+ Layout.fillWidth: true
+ elide: Text.ElideRight
+ }
+
+ NText {
+ text: model.fileIsDir ? "" : root.formatFileSize(model.fileSize)
+ color: filePickerPanel.currentSelection.includes(model.filePath) ? Color.mOnSecondary : Color.mOnSurfaceVariant
+ pointSize: Style.fontSizeS
+ visible: !model.fileIsDir
+ Layout.preferredWidth: implicitWidth
+ }
+ }
+
+ MouseArea {
+ id: mouseArea
+ anchors.fill: parent
+ hoverEnabled: true
+ acceptedButtons: Qt.LeftButton | Qt.RightButton
+
+ onClicked: mouse => {
+ if (mouse.button === Qt.LeftButton) {
+ if (model.fileIsDir) {
+ // In folder mode, single click selects the folder
+ if (root.selectionMode === "folders") {
+ filePickerPanel.itemClicked(mouse.modifiers, model.filePath);
+ }
+ // In file mode, single click on folder does nothing (must double-click to enter)
+ } else {
+ // Single click on file selects it (only in file mode)
+ if (root.selectionMode === "files") {
+ filePickerPanel.itemClicked(mouse.modifiers, model.filePath);
+ }
+ }
+ }
+ }
+
+ onDoubleClicked: mouse => {
+ if (mouse.button === Qt.LeftButton) {
+ if (model.fileIsDir) {
+ // Double-click on folder always navigates into it
+ folderModel.folder = "file://" + model.filePath;
+ root.currentPath = model.filePath;
+ } else {
+ // Double-click on file selects and confirms (only in file mode)
+ if (root.selectionMode === "files") {
+ filePickerPanel.currentSelection = [model.filePath];
+ root.confirmSelection();
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Footer
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Style.marginM
+
+ NText {
+ text: {
+ if (filePickerPanel.searchText.length > 0) {
+ return "Searching for: \"" + filePickerPanel.searchText + "\" (" + filteredModel.count + " matches)";
+ } else if (filePickerPanel.currentSelection.length > 0) {
+ const selectedName = filePickerPanel.currentSelection[0].split('/').pop();
+ return I18n.tr("widgets.file-picker.selected") + " " + selectedName;
+ } else {
+ return filteredModel.count + " " + (filteredModel.count === 1 ? I18n.tr("widgets.file-picker.item") : I18n.tr("widgets.file-picker.items"));
+ }
+ }
+ color: filePickerPanel.searchText.length > 0 ? Color.mPrimary : Color.mOnSurfaceVariant
+ pointSize: Style.fontSizeS
+ Layout.fillWidth: true
+ }
+
+ NButton {
+ text: I18n.tr("common.cancel")
+ outlined: true
+ onClicked: {
+ root.cancelled();
+ root.close();
+ }
+ }
+
+ NButton {
+ text: root.selectionMode === "folders" ? I18n.tr("widgets.file-picker.select-folder") : I18n.tr("widgets.file-picker.select-file")
+ icon: "filepicker-check"
+ enabled: filePickerPanel.currentSelection.length > 0
+ onClicked: root.confirmSelection()
+ }
+ }
+ }
+
+ Connections {
+ target: root
+ function onShouldResetSelectionChanged() {
+ if (root.shouldResetSelection) {
+ filePickerPanel.currentSelection = [];
+ root.shouldResetSelection = false;
+ }
+ }
+ }
+
+ Component.onCompleted: {
+ if (!root.currentPath)
+ root.currentPath = root.initialPath;
+ folderModel.folder = "file://" + root.currentPath;
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NGraph.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NGraph.qml
new file mode 100644
index 0000000..839229d
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NGraph.qml
@@ -0,0 +1,234 @@
+import QtQuick
+import Quickshell
+import qs.Commons
+
+Item {
+ id: root
+ clip: true
+
+ // Primary line
+ property var values: []
+ property color color: Color.mPrimary
+
+ // Optional secondary line
+ property var values2: []
+ property color color2: Color.mError
+
+ // Range settings for primary line
+ property real minValue: 0
+ property real maxValue: 100
+
+ // Range settings for secondary line (defaults to primary range)
+ property real minValue2: minValue
+ property real maxValue2: maxValue
+
+ // Style settings
+ property real strokeWidth: 1
+ property bool fill: true
+ property real fillOpacity: 0.15
+ property real antialiasing: 0.5
+
+ // Smooth scrolling interval (how often data updates)
+ property int updateInterval: 1000
+
+ // Animate scale changes (for network graphs with dynamic max)
+ property bool animateScale: false
+
+ // Vertical padding (percentage of range) to keep values from touching edges
+ readonly property real curvePadding: 0.12
+
+ readonly property bool hasData: values.length >= 4
+ readonly property bool hasData2: values2.length >= 4
+
+ // Scale animation state
+ property real _targetMax1: maxValue
+ property real _targetMax2: maxValue2
+ property real _animMax1: maxValue
+ property real _animMax2: maxValue2
+
+ onMaxValueChanged: {
+ _targetMax1 = maxValue;
+ if (animateScale && _ready1) {
+ _scaleTimer.start();
+ } else {
+ _animMax1 = maxValue;
+ }
+ }
+
+ onMaxValue2Changed: {
+ _targetMax2 = maxValue2;
+ if (animateScale && _ready2) {
+ _scaleTimer.start();
+ } else {
+ _animMax2 = maxValue2;
+ }
+ }
+
+ // Effective max values (animated or direct)
+ readonly property real _effectiveMax1: animateScale ? _animMax1 : maxValue
+ readonly property real _effectiveMax2: animateScale ? _animMax2 : maxValue2
+
+ // Scroll state (driven by NumberAnimation)
+ property real _t1: 1.0
+ property bool _ready1: false
+ property real _pred1: 0
+
+ property real _t2: 1.0
+ property bool _ready2: false
+ property real _pred2: 0
+
+ // Frame-accurate scroll animations tied to Qt's render loop
+ NumberAnimation {
+ id: _scrollAnim1
+ target: root
+ property: "_t1"
+ from: 0
+ to: 1
+ duration: root.updateInterval
+ }
+
+ NumberAnimation {
+ id: _scrollAnim2
+ target: root
+ property: "_t2"
+ from: 0
+ to: 1
+ duration: root.updateInterval
+ }
+
+ onValuesChanged: {
+ if (values.length < 4)
+ return;
+
+ const last = values[values.length - 1];
+ const prev = values[values.length - 2];
+ _pred1 = Math.max(minValue, last + (last - prev) * 0.5);
+
+ if (!_ready1)
+ _ready1 = true;
+ _scrollAnim1.restart();
+ }
+
+ onValues2Changed: {
+ if (values2.length < 4)
+ return;
+
+ const last = values2[values2.length - 1];
+ const prev = values2[values2.length - 2];
+ _pred2 = Math.max(minValue2, last + (last - prev) * 0.5);
+
+ if (!_ready2)
+ _ready2 = true;
+ _scrollAnim2.restart();
+ }
+
+ // Scale animation timer (only needed for animateScale mode)
+ Timer {
+ id: _scaleTimer
+ interval: 16
+ repeat: true
+
+ onTriggered: {
+ const scaleLerp = 0.15;
+ const threshold = 0.5;
+ let stillAnimating = false;
+
+ if (Math.abs(root._animMax1 - root._targetMax1) > threshold) {
+ root._animMax1 += (root._targetMax1 - root._animMax1) * scaleLerp;
+ stillAnimating = true;
+ } else if (root._animMax1 !== root._targetMax1) {
+ root._animMax1 = root._targetMax1;
+ }
+
+ if (Math.abs(root._animMax2 - root._targetMax2) > threshold) {
+ root._animMax2 += (root._targetMax2 - root._animMax2) * scaleLerp;
+ stillAnimating = true;
+ } else if (root._animMax2 !== root._targetMax2) {
+ root._animMax2 = root._targetMax2;
+ }
+
+ if (!stillAnimating)
+ stop();
+ }
+ }
+
+ // Normalize a value to [0, 1] with padding applied
+ function _normalize(val, minVal, maxVal) {
+ let range = maxVal - minVal;
+ if (range <= 0)
+ return 0.5;
+ let padding = range * curvePadding;
+ let paddedMin = minVal - padding;
+ let paddedRange = (maxVal + padding) - paddedMin;
+ return Math.max(0, Math.min(1, (val - paddedMin) / paddedRange));
+ }
+
+ // Data texture built from Rectangles instead of Canvas.
+ // Each Rectangle is one data point, color-coded with normalized values.
+ // R channel = primary, G channel = secondary.
+ Item {
+ id: _dataRow
+ width: Math.max(root.values.length + 1, root.values2.length + 1, 4)
+ height: 1
+
+ Repeater {
+ model: _dataRow.width
+
+ Rectangle {
+ required property int index
+ x: index
+ width: 1
+ height: 1
+ color: {
+ let r = 0, g = 0;
+ let n1 = root.values.length;
+ let n2 = root.values2.length;
+ let eMax1 = root._effectiveMax1;
+ let eMax2 = root._effectiveMax2;
+
+ if (index < n1)
+ r = root._normalize(root.values[index], root.minValue, eMax1);
+ else if (n1 > 0)
+ r = root._normalize(root._pred1, root.minValue, eMax1);
+
+ if (index < n2)
+ g = root._normalize(root.values2[index], root.minValue2, eMax2);
+ else if (n2 > 0)
+ g = root._normalize(root._pred2, root.minValue2, eMax2);
+
+ return Qt.rgba(r, g, 0, 1);
+ }
+ }
+ }
+ }
+
+ ShaderEffectSource {
+ id: _dataTex
+ sourceItem: _dataRow
+ textureSize: Qt.size(_dataRow.width, 1)
+ live: true
+ smooth: false
+ hideSource: true
+ }
+
+ ShaderEffect {
+ anchors.fill: parent
+ visible: (root.hasData || root.hasData2) && width > 0 && height > 0
+
+ property variant dataSource: _dataTex
+ property color lineColor1: root.color
+ property color lineColor2: root.color2
+ property real count1: root.values.length
+ property real count2: root.values2.length
+ property real scroll1: root._t1
+ property real scroll2: root._t2
+ property real lineWidth: root.strokeWidth
+ property real graphFillOpacity: root.fill ? root.fillOpacity : 0.0
+ property real texWidth: _dataRow.width
+ property real resY: height
+ property real aaSize: root.antialiasing
+
+ fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/graph.frag.qsb")
+ blending: true
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NGridView.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NGridView.qml
new file mode 100644
index 0000000..b436281
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NGridView.qml
@@ -0,0 +1,430 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Templates as T
+import qs.Commons
+
+Item {
+ id: root
+
+ // Signal for key press events when keyNavigationEnabled is true
+ signal keyPressed(var event)
+
+ property color handleColor: Qt.alpha(Color.mHover, 0.8)
+ property color handleHoverColor: handleColor
+ property color handlePressedColor: handleColor
+ property color trackColor: "transparent"
+ property real handleWidth: 6
+ property real handleRadius: Style.iRadiusM
+ property int verticalPolicy: ScrollBar.AsNeeded
+ property int horizontalPolicy: ScrollBar.AlwaysOff
+ readonly property bool verticalScrollBarActive: {
+ if (gridView.ScrollBar.vertical.policy === ScrollBar.AlwaysOff)
+ return false;
+ return gridView.contentHeight > gridView.height;
+ }
+ readonly property bool contentOverflows: gridView.contentHeight > gridView.height
+
+ // Gradient properties
+ property bool showGradientMasks: true
+ property color gradientColor: Color.mSurfaceVariant
+ property int gradientHeight: 16
+ property bool reserveScrollbarSpace: true
+
+ // Keep scrollbars visible whenever overflow exists (without forcing visibility when not scrollable)
+ property bool showScrollbarWhenScrollable: Settings.data.ui.scrollbarAlwaysVisible
+
+ // Available width for content (excludes scrollbar space when reserveScrollbarSpace is true)
+ // Note: Always reserves space when enabled to avoid binding loops with cellWidth calculations
+ readonly property real availableWidth: width - (reserveScrollbarSpace ? handleWidth + Style.marginXS : 0)
+
+ // Expose activeFocus from internal gridView
+ readonly property bool hasActiveFocus: gridView.activeFocus
+
+ // Forward GridView properties
+ property alias model: gridView.model
+ property alias delegate: gridView.delegate
+ property alias cellWidth: gridView.cellWidth
+ property alias cellHeight: gridView.cellHeight
+ property alias leftMargin: gridView.leftMargin
+ property alias rightMargin: gridView.rightMargin
+ property alias topMargin: gridView.topMargin
+ property alias bottomMargin: gridView.bottomMargin
+ property alias currentIndex: gridView.currentIndex
+ property alias count: gridView.count
+ property alias contentHeight: gridView.contentHeight
+ property alias contentWidth: gridView.contentWidth
+ property alias contentY: gridView.contentY
+ property alias contentX: gridView.contentX
+ property alias currentItem: gridView.currentItem
+ property alias highlightItem: gridView.highlightItem
+ property alias highlightFollowsCurrentItem: gridView.highlightFollowsCurrentItem
+ property alias preferredHighlightBegin: gridView.preferredHighlightBegin
+ property alias preferredHighlightEnd: gridView.preferredHighlightEnd
+ property alias highlightRangeMode: gridView.highlightRangeMode
+ property alias snapMode: gridView.snapMode
+ property alias keyNavigationEnabled: gridView.keyNavigationEnabled
+ property alias keyNavigationWraps: gridView.keyNavigationWraps
+ property alias cacheBuffer: gridView.cacheBuffer
+ property alias displayMarginBeginning: gridView.displayMarginBeginning
+ property alias displayMarginEnd: gridView.displayMarginEnd
+ property alias layoutDirection: gridView.layoutDirection
+ property alias effectiveLayoutDirection: gridView.effectiveLayoutDirection
+ property alias flow: gridView.flow
+ property alias boundsBehavior: gridView.boundsBehavior
+ property alias flickableDirection: gridView.flickableDirection
+ property alias interactive: gridView.interactive
+ property alias moving: gridView.moving
+ property alias flicking: gridView.flicking
+ property alias dragging: gridView.dragging
+ property alias horizontalVelocity: gridView.horizontalVelocity
+ property alias verticalVelocity: gridView.verticalVelocity
+ property alias reuseItems: gridView.reuseItems
+
+ // Animate items when the model is reordered (e.g. ListModel.move())
+ property bool animateMovement: false
+
+ // Scroll speed multiplier for mouse wheel (1.0 = default, higher = faster)
+ property real wheelScrollMultiplier: 2.0
+ property int smoothWheelAnimationDuration: Style.animationNormal
+ property real _wheelTargetY: 0
+
+ function clampScrollY(value) {
+ return Math.max(0, Math.min(value, gridView.contentHeight - gridView.height));
+ }
+
+ function applyWheelScroll(delta) {
+ if (!root.contentOverflows)
+ return;
+
+ const step = delta * root.wheelScrollMultiplier;
+
+ if (!Settings.data.general.smoothScrollEnabled || Settings.data.general.animationDisabled) {
+ gridView.contentY = root.clampScrollY(gridView.contentY - step);
+ root._wheelTargetY = gridView.contentY;
+ return;
+ }
+
+ if (!wheelScrollAnimation.running)
+ root._wheelTargetY = gridView.contentY;
+
+ root._wheelTargetY = root.clampScrollY(root._wheelTargetY - step);
+ wheelScrollAnimation.to = root._wheelTargetY;
+ wheelScrollAnimation.restart();
+ }
+
+ function animateToContentY(targetY) {
+ const clampedY = root.clampScrollY(targetY);
+
+ if (!Settings.data.general.smoothScrollEnabled || Settings.data.general.animationDisabled || gridView.dragging || gridView.flicking) {
+ gridView.contentY = clampedY;
+ root._wheelTargetY = clampedY;
+ return;
+ }
+
+ root._wheelTargetY = clampedY;
+ wheelScrollAnimation.to = clampedY;
+ wheelScrollAnimation.restart();
+ }
+
+ // Track selection index for gradient visibility (set externally)
+ property int trackedSelectionIndex: -1
+
+ // Check if selection is on first visible row
+ readonly property bool selectionOnFirstVisibleRow: {
+ if (trackedSelectionIndex < 0 || cellHeight <= 0 || cellWidth <= 0)
+ return false;
+
+ // Calculate columns per row
+ var cols = Math.floor(gridView.width / cellWidth);
+ if (cols <= 0)
+ cols = 1;
+
+ // Calculate the row of the selection
+ var selectionRow = Math.round(trackedSelectionIndex / cols);
+
+ // Calculate the first visible row
+ var firstVisibleRow = Math.round(gridView.contentY / cellHeight);
+
+ return selectionRow === firstVisibleRow;
+ }
+
+ // Check if selection is on last visible row
+ readonly property bool selectionOnLastVisibleRow: {
+ if (trackedSelectionIndex < 0 || cellHeight <= 0 || cellWidth <= 0)
+ return false;
+
+ // Calculate columns per row
+ var cols = Math.floor(gridView.width / cellWidth);
+ if (cols <= 0)
+ cols = 1;
+
+ // Calculate the row of the selection
+ var selectionRow = Math.round(trackedSelectionIndex / cols);
+
+ // Calculate the last visible row (might be partially visible)
+ var lastVisibleRow = Math.round((gridView.contentY + gridView.height - 1) / cellHeight);
+
+ return selectionRow === lastVisibleRow;
+ }
+
+ // Forward GridView methods
+ function positionViewAtIndex(index, mode) {
+ const shouldAnimate = mode === GridView.Contain;
+ if (!shouldAnimate) {
+ gridView.positionViewAtIndex(index, mode);
+ root._wheelTargetY = gridView.contentY;
+ return;
+ }
+
+ const previousY = gridView.contentY;
+ gridView.positionViewAtIndex(index, mode);
+ const targetY = root.clampScrollY(gridView.contentY);
+
+ if (Math.abs(targetY - previousY) < 0.5) {
+ root._wheelTargetY = targetY;
+ return;
+ }
+
+ gridView.contentY = previousY;
+ root._wheelTargetY = previousY;
+ root.animateToContentY(targetY);
+ }
+
+ function positionViewAtBeginning() {
+ gridView.positionViewAtBeginning();
+ }
+
+ function positionViewAtEnd() {
+ gridView.positionViewAtEnd();
+ }
+
+ function forceLayout() {
+ gridView.forceLayout();
+ }
+
+ function forceActiveFocus() {
+ gridView.forceActiveFocus();
+ }
+
+ function cancelFlick() {
+ gridView.cancelFlick();
+ }
+
+ function flick(xVelocity, yVelocity) {
+ gridView.flick(xVelocity, yVelocity);
+ }
+
+ function incrementCurrentIndex() {
+ gridView.incrementCurrentIndex();
+ }
+
+ function decrementCurrentIndex() {
+ gridView.decrementCurrentIndex();
+ }
+
+ function indexAt(x, y) {
+ return gridView.indexAt(x, y);
+ }
+
+ function itemAt(x, y) {
+ return gridView.itemAt(x, y);
+ }
+
+ function itemAtIndex(index) {
+ return gridView.itemAtIndex(index);
+ }
+
+ function moveCurrentIndexUp() {
+ gridView.moveCurrentIndexUp();
+ }
+
+ function moveCurrentIndexDown() {
+ gridView.moveCurrentIndexDown();
+ }
+
+ function moveCurrentIndexLeft() {
+ gridView.moveCurrentIndexLeft();
+ }
+
+ function moveCurrentIndexRight() {
+ gridView.moveCurrentIndexRight();
+ }
+
+ // Set reasonable implicit sizes for Layout usage
+ implicitWidth: 200
+ implicitHeight: 200
+
+ Component.onCompleted: {
+ _wheelTargetY = gridView.contentY;
+ createGradients();
+ }
+
+ // Dynamically create gradient overlays
+ function createGradients() {
+ if (!showGradientMasks)
+ return;
+
+ Qt.createQmlObject(`
+ import QtQuick
+ import qs.Commons
+ Rectangle {
+ x: 0
+ y: 0
+ width: root.availableWidth
+ height: root.gradientHeight
+ z: 1
+ visible: root.showGradientMasks && root.contentOverflows
+ opacity: (gridView.contentY <= 1 || root.selectionOnFirstVisibleRow) ? 0 : 1
+ Behavior on opacity {
+ NumberAnimation { duration: Style.animationFast; easing.type: Easing.InOutQuad }
+ }
+ gradient: Gradient {
+ GradientStop { position: 0.0; color: root.gradientColor }
+ GradientStop { position: 1.0; color: "transparent" }
+ }
+ }
+ `, root, "topGradient");
+
+ Qt.createQmlObject(`
+ import QtQuick
+ import qs.Commons
+ Rectangle {
+ x: 0
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: -1
+ width: root.availableWidth
+ height: root.gradientHeight + 1
+ z: 1
+ visible: root.showGradientMasks && root.contentOverflows
+ opacity: ((gridView.contentY + gridView.height >= gridView.contentHeight - 1) || root.selectionOnLastVisibleRow) ? 0 : 1
+ Behavior on opacity {
+ NumberAnimation { duration: Style.animationFast; easing.type: Easing.InOutQuad }
+ }
+ gradient: Gradient {
+ GradientStop { position: 0.0; color: "transparent" }
+ GradientStop { position: 1.0; color: root.gradientColor }
+ }
+ }
+ `, root, "bottomGradient");
+ }
+
+ GridView {
+ id: gridView
+ anchors.fill: parent
+ anchors.rightMargin: root.reserveScrollbarSpace ? root.handleWidth + Style.marginXS : 0
+
+ move: root.animateMovement ? moveTransitionImpl : null
+ displaced: root.animateMovement ? displacedTransitionImpl : null
+
+ Transition {
+ id: moveTransitionImpl
+ NumberAnimation {
+ properties: "x,y"
+ duration: Style.animationNormal
+ easing.type: Easing.InOutQuad
+ }
+ }
+ Transition {
+ id: displacedTransitionImpl
+ NumberAnimation {
+ properties: "x,y"
+ duration: Style.animationNormal
+ easing.type: Easing.InOutQuad
+ }
+ }
+
+ // Enable clipping to keep content within bounds
+ clip: true
+
+ // Enable flickable for smooth scrolling
+ boundsBehavior: Flickable.StopAtBounds
+
+ NumberAnimation {
+ id: wheelScrollAnimation
+ target: gridView
+ property: "contentY"
+ duration: root.smoothWheelAnimationDuration
+ easing.type: Easing.OutCubic
+ }
+
+ onDraggingChanged: {
+ if (dragging) {
+ wheelScrollAnimation.stop();
+ root._wheelTargetY = contentY;
+ }
+ }
+
+ onFlickingChanged: {
+ if (flicking) {
+ wheelScrollAnimation.stop();
+ root._wheelTargetY = contentY;
+ }
+ }
+
+ onContentHeightChanged: root._wheelTargetY = root.clampScrollY(root._wheelTargetY)
+ onHeightChanged: root._wheelTargetY = root.clampScrollY(root._wheelTargetY)
+
+ // Focus handling depends on keyNavigationEnabled
+ focus: keyNavigationEnabled
+ activeFocusOnTab: keyNavigationEnabled
+
+ // Emit keyPressed signal for custom key handling
+ Keys.onPressed: event => {
+ if (keyNavigationEnabled) {
+ root.keyPressed(event);
+ }
+ }
+
+ WheelHandler {
+ enabled: root.wheelScrollMultiplier !== 1.0
+ acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
+ onWheel: event => {
+ const delta = event.pixelDelta.y !== 0 ? event.pixelDelta.y : event.angleDelta.y / 2;
+ root.applyWheelScroll(delta);
+ event.accepted = true;
+ }
+ }
+
+ ScrollBar.vertical: ScrollBar {
+ parent: root
+ x: root.mirrored ? 0 : root.width - width
+ y: 0
+ height: root.height
+ policy: root.verticalPolicy
+
+ contentItem: Rectangle {
+ implicitWidth: root.handleWidth
+ implicitHeight: 100
+ radius: root.handleRadius
+ color: parent.pressed ? root.handlePressedColor : parent.hovered ? root.handleHoverColor : root.handleColor
+ opacity: parent.policy === ScrollBar.AlwaysOn ? 1.0 : root.verticalScrollBarActive ? ((root.showScrollbarWhenScrollable || parent.active) ? 1.0 : 0.0) : 0.0
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Style.animationFast
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+ }
+
+ background: Rectangle {
+ implicitWidth: root.handleWidth
+ implicitHeight: 100
+ color: root.trackColor
+ opacity: parent.policy === ScrollBar.AlwaysOn ? 0.3 : root.verticalScrollBarActive ? ((root.showScrollbarWhenScrollable || parent.active) ? 0.3 : 0.0) : 0.0
+ radius: root.handleRadius / 2
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Style.animationFast
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NHeader.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NHeader.qml
new file mode 100644
index 0000000..629c344
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NHeader.qml
@@ -0,0 +1,34 @@
+import QtQuick
+import QtQuick.Layouts
+import qs.Commons
+
+ColumnLayout {
+ id: root
+
+ property string label: ""
+ property string description: ""
+ property bool enableDescriptionRichText: false
+
+ opacity: enabled ? 1.0 : 0.6
+ spacing: Style.marginXXS
+ Layout.fillWidth: true
+ Layout.bottomMargin: Style.marginM
+
+ NText {
+ text: root.label
+ pointSize: Style.fontSizeXL
+ font.weight: Style.fontWeightSemiBold
+ color: Color.mPrimary
+ visible: root.label !== ""
+ }
+
+ NText {
+ text: root.description
+ pointSize: Style.fontSizeM
+ color: Color.mOnSurfaceVariant
+ wrapMode: Text.WordWrap
+ Layout.fillWidth: true
+ visible: root.description !== ""
+ richTextEnabled: root.enableDescriptionRichText
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NIcon.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NIcon.qml
new file mode 100644
index 0000000..834702a
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NIcon.qml
@@ -0,0 +1,30 @@
+import QtQuick
+import QtQuick.Layouts
+import qs.Commons
+import qs.Widgets
+
+Text {
+ id: root
+
+ property string icon: Icons.defaultIcon
+ property real pointSize: Style.fontSizeL
+ property bool applyUiScale: true
+
+ visible: (icon !== undefined) && (icon !== "")
+ text: {
+ if ((icon === undefined) || (icon === "")) {
+ return "";
+ }
+ if (Icons.get(icon) === undefined) {
+ Logger.w("Icon", `"${icon}"`, "doesn't exist in the icons font");
+ Logger.callStack();
+ return Icons.get(Icons.defaultIcon);
+ }
+ return Icons.get(icon);
+ }
+ font.family: Icons.fontFamily
+ font.pointSize: Math.max(1, applyUiScale ? root.pointSize * Style.uiScaleRatio : root.pointSize)
+ color: Color.mOnSurface
+ verticalAlignment: Text.AlignVCenter
+ horizontalAlignment: Text.AlignHCenter
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NIconButton.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NIconButton.qml
new file mode 100644
index 0000000..52f4355
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NIconButton.qml
@@ -0,0 +1,133 @@
+import QtQuick
+import Quickshell
+import Quickshell.Widgets
+import qs.Commons
+import qs.Services.UI
+
+Item {
+ id: root
+
+ property real baseSize: Style.baseWidgetSize
+ property bool applyUiScale: true
+
+ property string icon
+ property var tooltipText
+ property string tooltipDirection: "auto"
+ property bool allowClickWhenDisabled: false
+ property bool handleWheel: false
+ property bool hovering: false
+
+ property color colorBg: Color.smartAlpha(Color.mSurfaceVariant)
+ property color colorFg: Color.mPrimary
+ property color colorBgHover: Color.mHover
+ property color colorFgHover: Color.mOnHover
+ property color colorBorder: Color.mOutline
+ property color colorBorderHover: Color.mOutline
+ property real customRadius: -1 // -1 means use default (iRadiusL), otherwise use this value
+
+ // Expose border properties for backwards compatibility (aliases to visualButton)
+ property alias border: visualButton.border
+ property alias radius: visualButton.radius
+ property alias color: visualButton.color
+
+ signal entered
+ signal exited
+ signal clicked
+ signal rightClicked
+ signal middleClicked
+ signal wheel(int angleDelta)
+
+ // Calculate button size based on settings
+ readonly property real buttonSize: applyUiScale ? Style.toOdd(baseSize * Style.uiScaleRatio) : Style.toOdd(baseSize)
+
+ // Size: use implicit width/height which layout can override
+ // BarWidgetLoader sets explicit width/height to extend click area
+ implicitWidth: buttonSize
+ implicitHeight: buttonSize
+
+ opacity: enabled ? 1.0 : 0.6
+
+ // Visual button - stays at buttonSize, centered in parent
+ Rectangle {
+ id: visualButton
+ width: root.buttonSize
+ height: root.buttonSize
+ anchors.centerIn: parent
+
+ color: root.enabled && root.hovering ? colorBgHover : colorBg
+ radius: Math.min((customRadius >= 0 ? customRadius : Style.iRadiusL), width / 2)
+ border.color: root.enabled && root.hovering ? colorBorderHover : colorBorder
+ border.width: Style.borderS
+
+ Behavior on color {
+ enabled: !Color.isTransitioning
+ ColorAnimation {
+ duration: Style.animationFast
+ easing.type: Easing.InOutQuad
+ }
+ }
+
+ NIcon {
+ icon: root.icon
+ pointSize: Style.toOdd(visualButton.width * 0.48)
+ applyUiScale: root.applyUiScale
+ color: root.enabled && root.hovering ? colorFgHover : colorFg
+ // Pixel-perfect centering
+ x: Style.pixelAlignCenter(visualButton.width, width)
+ y: Style.pixelAlignCenter(visualButton.height, contentHeight)
+
+ Behavior on color {
+ enabled: !Color.isTransitioning
+ ColorAnimation {
+ duration: Style.animationFast
+ easing.type: Easing.InOutQuad
+ }
+ }
+ }
+ }
+
+ // MouseArea fills root (extends beyond visual button for bar click area)
+ MouseArea {
+ // Always enabled to allow hover/tooltip even when the button is disabled
+ enabled: true
+ anchors.fill: parent
+ cursorShape: root.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
+ acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
+ hoverEnabled: true
+ onEntered: {
+ hovering = root.enabled ? true : false;
+ if (hovering && tooltipText && (!Array.isArray(tooltipText) || tooltipText.length > 0)) {
+ TooltipService.show(root, tooltipText, tooltipDirection);
+ }
+ root.entered();
+ }
+ onExited: {
+ hovering = false;
+ if (tooltipText && (!Array.isArray(tooltipText) || tooltipText.length > 0)) {
+ TooltipService.hide(root);
+ }
+ root.exited();
+ }
+ onClicked: mouse => {
+ if (tooltipText && (!Array.isArray(tooltipText) || tooltipText.length > 0)) {
+ TooltipService.hide(root);
+ }
+ if (!root.enabled && !allowClickWhenDisabled) {
+ return;
+ }
+ if (mouse.button === Qt.LeftButton) {
+ root.clicked();
+ } else if (mouse.button === Qt.RightButton) {
+ root.rightClicked();
+ } else if (mouse.button === Qt.MiddleButton) {
+ root.middleClicked();
+ }
+ }
+ onWheel: wheel => {
+ if (root.handleWheel) {
+ root.wheel(wheel.angleDelta.y);
+ }
+ wheel.accepted = false;
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NIconButtonHot.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NIconButtonHot.qml
new file mode 100644
index 0000000..39fdb92
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NIconButtonHot.qml
@@ -0,0 +1,161 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Effects
+import QtQuick.Layouts
+import qs.Commons
+import qs.Services.UI
+
+Rectangle {
+ id: root
+
+ // Public properties
+ property real baseSize: Style.baseWidgetSize
+ property bool applyUiScale: true
+ property string icon
+ property var tooltipText
+ property string tooltipDirection: "auto"
+ property bool allowClickWhenDisabled: false
+ property bool hot: false
+
+ // Internal properties
+ property bool hovering: false
+ property bool pressed: false
+
+ // Color properties
+ property color colorBg: Color.smartAlpha(Color.mSurfaceVariant)
+ property color colorFg: Color.mPrimary
+ property color colorBgHover: Color.mHover
+ property color colorFgHover: Color.mOnHover
+ property color colorBorder: Color.mOutline
+ property color colorBorderHover: Color.mOutline
+
+ // Hot state colors
+ property color colorBgHot: Color.mPrimary
+ property color colorFgHot: Color.mOnPrimary
+
+ // Signals
+ signal entered
+ signal exited
+ signal clicked
+ signal rightClicked
+ signal middleClicked
+
+ // Dimensions
+ implicitWidth: applyUiScale ? Math.round(baseSize * Style.uiScaleRatio) : Math.round(baseSize)
+ implicitHeight: applyUiScale ? Math.round(baseSize * Style.uiScaleRatio) : Math.round(baseSize)
+
+ // Appearance
+ opacity: enabled ? 1.0 : 0.6
+ color: {
+ if (root.enabled && root.hovering || pressed) {
+ return colorBgHover;
+ }
+
+ if (hot) {
+ return colorBgHot;
+ }
+ return colorBg;
+ }
+ radius: Math.min(Style.iRadiusL, width / 2)
+ border.color: root.enabled && root.hovering ? colorBorderHover : colorBorder
+ border.width: Style.borderS
+
+ Behavior on color {
+ enabled: !Color.isTransitioning
+ ColorAnimation {
+ duration: Style.animationFast
+ easing.type: Easing.InOutQuad
+ }
+ }
+
+ // Icon
+ NIcon {
+ icon: root.icon
+ pointSize: Math.max(1, Math.round(root.width * 0.48))
+ applyUiScale: root.applyUiScale
+ color: {
+ if (root.enabled && root.hovering || pressed) {
+ return colorFgHover;
+ }
+ if (hot) {
+ return colorFgHot;
+ }
+ return colorFg;
+ }
+ // Center horizontally
+ x: (root.width - width) / 2
+ // Center vertically accounting for font metrics
+ y: (root.height - height) / 2 + (height - contentHeight) / 2
+
+ Behavior on color {
+ enabled: !Color.isTransitioning
+ ColorAnimation {
+ duration: Style.animationFast
+ easing.type: Easing.InOutQuad
+ }
+ }
+ }
+
+ MouseArea {
+ // Always enabled to allow hover/tooltip even when the button is disabled
+ enabled: true
+ anchors.fill: parent
+ cursorShape: root.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
+ acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
+ hoverEnabled: true
+
+ onEntered: {
+ hovering = root.enabled ? true : false;
+ if (hovering && tooltipText && (!Array.isArray(tooltipText) || tooltipText.length > 0)) {
+ TooltipService.show(parent, tooltipText, tooltipDirection);
+ }
+ root.entered();
+ }
+
+ onExited: {
+ hovering = false;
+ if (tooltipText && (!Array.isArray(tooltipText) || tooltipText.length > 0)) {
+ TooltipService.hide();
+ }
+ root.exited();
+ }
+
+ onPressed: function (mouse) {
+ if (root.enabled) {
+ root.pressed = true;
+ }
+ if (tooltipText && (!Array.isArray(tooltipText) || tooltipText.length > 0)) {
+ TooltipService.hide();
+ }
+ }
+
+ onReleased: function (mouse) {
+ root.scale = 1.0;
+ root.pressed = false;
+
+ if (!root.enabled && !allowClickWhenDisabled) {
+ return;
+ }
+
+ // Only trigger actions if released while hovering
+ if (root.hovering) {
+ if (mouse.button === Qt.LeftButton) {
+ root.clicked();
+ } else if (mouse.button === Qt.RightButton) {
+ root.rightClicked();
+ } else if (mouse.button === Qt.MiddleButton) {
+ root.middleClicked();
+ }
+ }
+ }
+
+ onCanceled: {
+ root.hovering = false;
+ root.pressed = false;
+ root.scale = 1.0;
+ if (tooltipText && (!Array.isArray(tooltipText) || tooltipText.length > 0)) {
+ TooltipService.hide();
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NIconPicker.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NIconPicker.qml
new file mode 100644
index 0000000..20ee31e
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NIconPicker.qml
@@ -0,0 +1,170 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import QtQuick.Window
+import qs.Commons
+import qs.Widgets
+
+Popup {
+ id: root
+ modal: true
+
+ property string selectedIcon: ""
+ property string initialIcon: ""
+
+ signal iconSelected(string iconName)
+
+ width: Math.round(900 * Style.uiScaleRatio)
+ height: Math.round(700 * Style.uiScaleRatio)
+ anchors.centerIn: Overlay.overlay
+ padding: Style.marginXL
+
+ property string query: ""
+ property var allIcons: Object.keys(Icons.icons)
+ property var filteredIcons: {
+ if (query === "")
+ return allIcons;
+ var q = query.toLowerCase();
+ return allIcons.filter(name => name.toLowerCase().includes(q));
+ }
+ readonly property int columns: 6
+ readonly property int cellW: Math.floor(grid.width / columns)
+ readonly property int cellH: Math.round(cellW * 0.7 + 36)
+
+ onOpened: {
+ selectedIcon = initialIcon;
+ query = initialIcon;
+ searchInput.forceActiveFocus();
+ }
+
+ background: Rectangle {
+ color: Color.mSurface
+ radius: Style.iRadiusL
+ border.color: Color.mPrimary
+ border.width: Style.borderM
+ }
+
+ ColumnLayout {
+ anchors.fill: parent
+ spacing: Style.marginM
+
+ // Title row
+ RowLayout {
+ Layout.fillWidth: true
+ NText {
+ text: I18n.tr("widgets.icon-picker.title")
+ pointSize: Style.fontSizeL
+ font.weight: Style.fontWeightBold
+ color: Color.mPrimary
+ Layout.fillWidth: true
+ }
+ NIconButton {
+ icon: "close"
+ tooltipText: I18n.tr("common.close")
+ onClicked: root.close()
+ }
+ }
+
+ NDivider {
+ Layout.fillWidth: true
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Style.marginS
+ NTextInput {
+ id: searchInput
+ Layout.fillWidth: true
+ label: I18n.tr("common.search")
+ placeholderText: I18n.tr("placeholders.search-icons")
+ text: root.query
+ onTextChanged: root.query = text.trim().toLowerCase()
+ }
+ }
+
+ // Icon grid
+ NGridView {
+ id: grid
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ Layout.margins: Style.marginM
+ cellWidth: root.cellW
+ cellHeight: root.cellH
+ model: root.filteredIcons
+ reserveScrollbarSpace: false
+ gradientColor: Color.mSurface
+
+ delegate: Rectangle {
+ width: grid.cellWidth
+ height: grid.cellHeight
+ radius: Style.iRadiusS
+
+ color: (root.selectedIcon === modelData) ? Qt.alpha(Color.mPrimary, 0.15) : "transparent"
+ border.color: (root.selectedIcon === modelData) ? Color.mPrimary : "transparent"
+ border.width: (root.selectedIcon === modelData) ? Style.borderS : 0
+
+ MouseArea {
+ anchors.fill: parent
+ onClicked: root.selectedIcon = modelData
+ onDoubleClicked: {
+ root.selectedIcon = modelData;
+ root.iconSelected(root.selectedIcon);
+ root.close();
+ }
+ }
+
+ ColumnLayout {
+ anchors.fill: parent
+ anchors.margins: Style.marginS
+ spacing: Style.marginS
+ Item {
+ Layout.fillHeight: true
+ Layout.preferredHeight: 4
+ }
+ NIcon {
+ Layout.alignment: Qt.AlignHCenter
+ icon: modelData
+ pointSize: 42
+ }
+ NText {
+ Layout.alignment: Qt.AlignHCenter
+ Layout.fillWidth: true
+ Layout.topMargin: Style.marginXS
+ elide: Text.ElideRight
+ wrapMode: Text.NoWrap
+ maximumLineCount: 1
+ horizontalAlignment: Text.AlignHCenter
+ color: Color.mOnSurfaceVariant
+ pointSize: Style.fontSizeXS
+ text: modelData
+ }
+ Item {
+ Layout.fillHeight: true
+ }
+ }
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Style.marginM
+ Item {
+ Layout.fillWidth: true
+ }
+ NButton {
+ text: I18n.tr("common.cancel")
+ outlined: true
+ onClicked: root.close()
+ }
+ NButton {
+ text: I18n.tr("common.apply")
+ icon: "check"
+ enabled: root.selectedIcon !== ""
+ onClicked: {
+ root.iconSelected(root.selectedIcon);
+ root.close();
+ }
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NImageRounded.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NImageRounded.qml
new file mode 100644
index 0000000..a33ffcf
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NImageRounded.qml
@@ -0,0 +1,103 @@
+import QtQuick
+import QtQuick.Effects
+import Quickshell
+import Quickshell.Widgets
+import qs.Commons
+
+Item {
+ id: root
+
+ property real radius: 0
+ property string imagePath: ""
+ property string fallbackIcon: ""
+ property real fallbackIconSize: Style.fontSizeXXL
+ property real borderWidth: 0
+ property color borderColor: "transparent"
+ property int imageFillMode: Image.PreserveAspectCrop
+
+ readonly property bool _isAnimated: imagePath.toLowerCase().endsWith(".gif")
+ readonly property Item imageSource: imageSourceLoader.item
+ readonly property bool showFallback: fallbackIcon !== "" && (imagePath === "" || (imageSource && imageSource.status === Image.Error))
+ readonly property int status: imageSource ? imageSource.status : Image.Null
+
+ Rectangle {
+ anchors.fill: parent
+ radius: root.radius
+ color: "transparent"
+ border.width: root.borderWidth
+ border.color: root.borderColor
+
+ Loader {
+ id: imageSourceLoader
+ anchors.fill: parent
+ anchors.margins: root.borderWidth
+ active: root.imagePath !== ""
+ sourceComponent: root._isAnimated ? animatedComponent : staticComponent
+ }
+
+ Component {
+ id: staticComponent
+ Image {
+ visible: false
+ source: root.imagePath
+ mipmap: true
+ smooth: true
+ asynchronous: true
+ antialiasing: true
+ fillMode: root.imageFillMode
+ }
+ }
+
+ Component {
+ id: animatedComponent
+ AnimatedImage {
+ visible: false
+ source: root.imagePath
+ mipmap: true
+ smooth: true
+ asynchronous: true
+ antialiasing: true
+ fillMode: root.imageFillMode
+ playing: true
+ }
+ }
+
+ // Fallback texture provider to avoid null source warnings
+ ShaderEffectSource {
+ id: _safeFallback
+ sourceItem: Rectangle {
+ width: 1
+ height: 1
+ color: "transparent"
+ }
+ visible: false
+ live: false
+ }
+
+ ShaderEffect {
+ anchors.fill: parent
+ anchors.margins: root.borderWidth
+ visible: !root.showFallback && root.imageSource !== null && root.status === Image.Ready
+ property var source: root.imageSource ?? _safeFallback
+ property real itemWidth: width
+ property real itemHeight: height
+ property real sourceWidth: root.imageSource?.sourceSize.width ?? 0
+ property real sourceHeight: root.imageSource?.sourceSize.height ?? 0
+ property real cornerRadius: Math.max(0, root.radius - root.borderWidth)
+ property real imageOpacity: 1.0
+ property int fillMode: root.imageFillMode
+
+ fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/rounded_image.frag.qsb")
+ supportsAtlasTextures: false
+ blending: true
+ }
+
+ NIcon {
+ anchors.fill: parent
+ anchors.margins: root.borderWidth
+ visible: root.showFallback
+ icon: root.fallbackIcon
+ pointSize: root.fallbackIconSize
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NInputAction.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NInputAction.qml
new file mode 100644
index 0000000..53025ed
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NInputAction.qml
@@ -0,0 +1,52 @@
+import QtQuick
+import QtQuick.Layouts
+import qs.Commons
+import qs.Widgets
+
+// Input and button row
+RowLayout {
+ id: root
+
+ // Public properties
+ property string label: ""
+ property string description: ""
+ property string placeholderText: ""
+ property string text: ""
+ property string actionButtonText: I18n.tr("common.test")
+ property string actionButtonIcon: "media-play"
+ property bool actionButtonEnabled: text !== ""
+
+ // Signals
+ signal editingFinished
+ signal actionClicked
+
+ // Internal properties
+ spacing: Style.marginM
+
+ NTextInput {
+ id: textInput
+ label: root.label
+ description: root.description
+ placeholderText: root.placeholderText
+ text: root.text
+ onTextChanged: root.text = text
+ onEditingFinished: root.editingFinished()
+ Layout.fillWidth: true
+ }
+
+ NButton {
+ Layout.fillWidth: false
+ Layout.alignment: Qt.AlignBottom
+
+ text: root.actionButtonText
+ icon: root.actionButtonIcon
+ backgroundColor: Color.mSecondary
+ textColor: Color.mOnSecondary
+ hoverColor: Color.mHover
+ enabled: root.actionButtonEnabled
+
+ onClicked: {
+ root.actionClicked();
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NKeybindRecorder.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NKeybindRecorder.qml
new file mode 100644
index 0000000..c19343d
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NKeybindRecorder.qml
@@ -0,0 +1,334 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import qs.Commons
+import qs.Services.UI
+import qs.Widgets
+
+Item {
+ id: root
+
+ property string label: ""
+ property string description: ""
+ property var currentKeybinds: []
+ property string defaultKeybind: ""
+ property bool allowEmpty: false
+ property color labelColor: Color.mOnSurface
+ property color descriptionColor: Color.mOnSurfaceVariant
+ property string settingsPath: ""
+
+ property int maxKeybinds: 2
+ property bool requireModifierForNormalKeys: true
+ signal keybindsChanged(var newKeybinds)
+
+ implicitHeight: contentLayout.implicitHeight
+
+ // -1 = not recording, >= 0 = re-recording at index, -2 = adding new
+ property int recordingIndex: -1
+ property bool hasConflict: false
+
+ onRecordingIndexChanged: {
+ PanelService.isKeybindRecording = recordingIndex !== -1;
+ if (recordingIndex !== -1) {
+ hasConflict = false;
+ }
+ }
+
+ readonly property real _pillHeight: Style.baseWidgetSize * 1.1 * Style.uiScaleRatio
+
+ function _applyKeybind(keyStr) {
+ if (!keyStr)
+ return;
+
+ // 1. Internal duplicate check (same action)
+ for (let i = 0; i < root.currentKeybinds.length; i++) {
+ if (i !== root.recordingIndex && String(root.currentKeybinds[i]).toLowerCase() === keyStr.toLowerCase()) {
+ hasConflict = true;
+ ToastService.showWarning(I18n.tr("panels.general.keybinds-conflict-title"), I18n.tr("panels.general.keybinds-conflict-description", {
+ "action": root.label || "This action"
+ }));
+ conflictTimer.restart();
+ return;
+ }
+ }
+
+ // 2. External conflict check (other actions)
+ const conflict = Keybinds.getKeybindConflict(keyStr, root.settingsPath, Settings.data);
+ if (conflict) {
+ hasConflict = true;
+ ToastService.showWarning(I18n.tr("panels.general.keybinds-conflict-title"), I18n.tr("panels.general.keybinds-conflict-description", {
+ "action": conflict
+ }));
+ conflictTimer.restart();
+ return;
+ }
+
+ var newKeybinds = Array.from(root.currentKeybinds);
+ if (recordingIndex >= 0) {
+ newKeybinds[recordingIndex] = keyStr;
+ }
+ // Ensure array is dense and limited to maxKeybinds
+ newKeybinds = newKeybinds.filter(k => k !== undefined && k !== "").slice(0, root.maxKeybinds);
+ recordingIndex = -1;
+ root.keybindsChanged(newKeybinds);
+ }
+
+ Timer {
+ id: conflictTimer
+ interval: 2000
+ onTriggered: {
+ hasConflict = false;
+ recordingIndex = -1;
+ }
+ }
+
+ RowLayout {
+ id: contentLayout
+ width: parent.width
+ spacing: Style.marginL
+
+ // Label and Description (optional)
+ NLabel {
+ id: labelContainer
+ label: root.label
+ description: root.description
+ labelColor: root.labelColor
+ descriptionColor: root.descriptionColor
+ visible: label !== "" || description !== ""
+ Layout.fillWidth: true
+ Layout.alignment: Qt.AlignVCenter
+ }
+
+ RowLayout {
+ id: slotsRow
+ spacing: Style.marginS
+ Layout.alignment: Qt.AlignVCenter | (labelContainer.visible ? Qt.AlignRight : Qt.AlignLeft)
+
+ Repeater {
+ model: root.maxKeybinds
+ delegate: MouseArea {
+ id: slotArea
+ width: Math.round(180 * Style.uiScaleRatio)
+ height: root._pillHeight
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+
+ readonly property bool isOccupied: index < root.currentKeybinds.length
+ readonly property bool isRecordingThis: root.recordingIndex === index
+ readonly property string keybindText: isRecordingThis ? I18n.tr("placeholders.keybind-recording") : (isOccupied ? root.currentKeybinds[index] : I18n.tr("placeholders.add-new-keybind"))
+
+ onClicked: {
+ if (isRecordingThis) {
+ root.recordingIndex = -1;
+ } else {
+ root.recordingIndex = index;
+ keybindInput.forceActiveFocus();
+ }
+ }
+
+ Rectangle {
+ id: slotBg
+ anchors.fill: parent
+ radius: Style.iRadiusS
+ color: root.hasConflict && slotArea.isRecordingThis ? Color.mError : (slotArea.isRecordingThis ? Color.mSecondary : (slotArea.containsMouse ? Qt.alpha(Color.mSecondary, 0.15) : Color.mSurface))
+ border.color: root.hasConflict && slotArea.isRecordingThis ? Color.mError : (slotArea.isRecordingThis ? Color.mPrimary : (slotArea.containsMouse ? Color.mSecondary : Color.mOutline))
+ border.width: Style.borderS
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+ Behavior on border.color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+
+ RowLayout {
+ anchors.fill: parent
+ anchors.leftMargin: Style.marginM
+ anchors.rightMargin: Style.marginS
+ spacing: Style.marginXS
+
+ NIcon {
+ icon: root.hasConflict && slotArea.isRecordingThis ? "alert-circle" : (slotArea.isRecordingThis ? "circle-dot" : "keyboard")
+ color: slotArea.isRecordingThis ? Color.mOnSecondary : (slotArea.isOccupied ? Color.mOnSurfaceVariant : Qt.alpha(Color.mOnSurfaceVariant, 0.4))
+ opacity: 0.8
+ visible: !slotArea.isRecordingThis || root.hasConflict
+ }
+
+ NText {
+ Layout.fillWidth: true
+ text: slotArea.keybindText
+ color: slotArea.isRecordingThis ? Color.mOnSecondary : (slotArea.isOccupied ? Color.mOnSurface : Color.mOnSurfaceVariant)
+ font.family: slotArea.isOccupied && !slotArea.isRecordingThis ? Settings.data.ui.fontFixed : Settings.data.ui.fontDefault
+ font.pointSize: slotArea.isOccupied ? Style.fontSizeM : Style.fontSizeS
+ font.weight: slotArea.isOccupied ? Style.fontWeightBold : Style.fontWeightRegular
+ elide: Text.ElideRight
+ opacity: slotArea.isOccupied || slotArea.isRecordingThis ? 1.0 : 0.6
+ }
+
+ Item {
+ Layout.preferredWidth: Math.round(root._pillHeight * 0.7)
+ Layout.fillHeight: true
+ visible: slotArea.isOccupied && root.recordingIndex === -1
+
+ NIconButton {
+ anchors.centerIn: parent
+ visible: root.recordingIndex === -1 && (root.currentKeybinds.length > 1 || root.allowEmpty)
+ icon: "x"
+ colorBg: "transparent"
+ colorBgHover: Qt.alpha(Color.mError, 0.1)
+ colorFg: Color.mOnSurfaceVariant
+ colorFgHover: Color.mError
+ border.width: 0
+ baseSize: Style.baseWidgetSize * 0.7
+ onClicked: {
+ var newKeybinds = Array.from(root.currentKeybinds);
+ newKeybinds.splice(index, 1);
+ root.keybindsChanged(newKeybinds);
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Hidden Item to capture keys
+ Item {
+ id: keybindInput
+ width: 0
+ height: 0
+ focus: true
+
+ Keys.onPressed: event => {
+ if (root.recordingIndex === -1 || root.hasConflict)
+ return;
+
+ // Handle Escape specifically to ensure it doesn't close the panel
+ if (event.key === Qt.Key_Escape) {
+ event.accepted = true;
+ root._applyKeybind("Esc");
+ return;
+ }
+
+ // Ignore modifier keys by themselves
+ if (event.key === Qt.Key_Control || event.key === Qt.Key_Shift || event.key === Qt.Key_Alt || event.key === Qt.Key_Meta) {
+ event.accepted = true; // Consume modifiers too while listening
+ return;
+ }
+
+ let keyStr = "";
+ if (event.modifiers & Qt.ControlModifier)
+ keyStr += "Ctrl+";
+ if (event.modifiers & Qt.AltModifier)
+ keyStr += "Alt+";
+ if (event.modifiers & Qt.ShiftModifier)
+ keyStr += "Shift+";
+
+ let keyName = "";
+ let rawText = event.text;
+
+ if (event.key >= Qt.Key_A && event.key <= Qt.Key_Z || event.key >= Qt.Key_0 && event.key <= Qt.Key_9) {
+ keyName = String.fromCharCode(event.key);
+ } else if (event.key >= Qt.Key_F1 && event.key <= Qt.Key_F12) {
+ keyName = "F" + (event.key - Qt.Key_F1 + 1);
+ } else if (rawText && rawText.length > 0 && rawText.charCodeAt(0) > 31 && rawText.charCodeAt(0) !== 127) {
+ keyName = rawText.toUpperCase();
+
+ // Handle shifted digits
+ if (event.modifiers & Qt.ShiftModifier) {
+ const shiftMap = {
+ "!": "1",
+ "\"": "2",
+ "ยง": "3",
+ "$": "4",
+ "%": "5",
+ "&": "6",
+ "/": "7",
+ "(": "8",
+ ")": "9",
+ "=": "0",
+ "@": "2",
+ "#": "3",
+ "^": "6",
+ "*": "8"
+ };
+ if (shiftMap[keyName])
+ keyName = shiftMap[keyName];
+ }
+ } else {
+ switch (event.key) {
+ case Qt.Key_Return:
+ keyName = "Return";
+ break;
+ case Qt.Key_Enter:
+ keyName = "Enter";
+ break;
+ case Qt.Key_Tab:
+ keyName = "Tab";
+ break;
+ case Qt.Key_Backspace:
+ keyName = "Backspace";
+ break;
+ case Qt.Key_Delete:
+ keyName = "Del";
+ break;
+ case Qt.Key_Insert:
+ keyName = "Ins";
+ break;
+ case Qt.Key_Home:
+ keyName = "Home";
+ break;
+ case Qt.Key_End:
+ keyName = "End";
+ break;
+ case Qt.Key_PageUp:
+ keyName = "PgUp";
+ break;
+ case Qt.Key_PageDown:
+ keyName = "PgDn";
+ break;
+ case Qt.Key_Left:
+ keyName = "Left";
+ break;
+ case Qt.Key_Right:
+ keyName = "Right";
+ break;
+ case Qt.Key_Up:
+ keyName = "Up";
+ break;
+ case Qt.Key_Down:
+ keyName = "Down";
+ break;
+ }
+ }
+
+ if (keyName) {
+ // Enforce modifier requirement (Ctrl or Alt) for "normal" keys unless explicitly disabled
+ // Allow Arrows, Nav, Function, and System keys without modifiers
+ const isSpecialKey = (event.key >= Qt.Key_F1 && event.key <= Qt.Key_F35) || (event.key >= Qt.Key_Left && event.key <= Qt.Key_Down) || (event.key === Qt.Key_Home || event.key === Qt.Key_End || event.key === Qt.Key_PageUp || event.key === Qt.Key_PageDown) || (event.key === Qt.Key_Insert || event.key === Qt.Key_Delete || event.key
+ === Qt.Key_Backspace) || (event.key === Qt.Key_Tab || event.key
+ === Qt.Key_Return || event.key === Qt.Key_Enter
+ || event.key === Qt.Key_Escape || event.key
+ === Qt.Key_Space);
+
+ const hasModifier = (event.modifiers & Qt.ControlModifier) || (event.modifiers & Qt.AltModifier);
+
+ if (root.requireModifierForNormalKeys && !hasModifier && !isSpecialKey) {
+ hasConflict = true;
+ ToastService.showWarning(I18n.tr("panels.general.keybinds-modifier-title"), I18n.tr("panels.general.keybinds-modifier-description"));
+ conflictTimer.restart();
+ return;
+ }
+
+ root._applyKeybind(keyStr + keyName);
+ }
+ event.accepted = true;
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NLabel.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NLabel.qml
new file mode 100644
index 0000000..71073fc
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NLabel.qml
@@ -0,0 +1,69 @@
+import QtQuick
+import QtQuick.Layouts
+import qs.Commons
+import qs.Widgets
+
+ColumnLayout {
+ id: root
+
+ property string label: ""
+ property string description: ""
+ property string icon: ""
+ property color labelColor: Color.mOnSurface
+ property color descriptionColor: Color.mOnSurfaceVariant
+ property color iconColor: Color.mOnSurface
+ property bool showIndicator: false
+ property string indicatorTooltip: ""
+ property real labelSize: Style.fontSizeL
+
+ opacity: enabled ? 1.0 : 0.6
+ spacing: Style.marginXXS
+ visible: root.label != "" || root.description != ""
+
+ Layout.fillWidth: true
+
+ RowLayout {
+ spacing: Style.marginXS
+ Layout.fillWidth: true
+ visible: root.label !== ""
+
+ NIcon {
+ visible: root.icon !== ""
+ icon: root.icon
+ pointSize: Style.fontSizeXXL
+ color: root.iconColor
+ Layout.rightMargin: Style.marginS
+ }
+
+ NText {
+ id: labelText
+ Layout.fillWidth: true
+ text: root.label
+ pointSize: root.labelSize
+ font.weight: Style.fontWeightSemiBold
+ color: labelColor
+ wrapMode: Text.WordWrap
+
+ // Settings indicator dot positioned right after the text content
+ Loader {
+ active: root.showIndicator
+ x: labelText.contentWidth + Style.marginXS
+ anchors.verticalCenter: parent.verticalCenter
+ sourceComponent: NSettingsIndicator {
+ show: true
+ tooltipText: root.indicatorTooltip || ""
+ }
+ }
+ }
+ }
+
+ NText {
+ visible: root.description !== ""
+ Layout.fillWidth: true
+ text: root.description
+ pointSize: Style.fontSizeS
+ color: root.descriptionColor
+ wrapMode: Text.WordWrap
+ textFormat: Text.StyledText
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NLinearGauge.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NLinearGauge.qml
new file mode 100644
index 0000000..93a0398
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NLinearGauge.qml
@@ -0,0 +1,28 @@
+import QtQuick
+import qs.Commons
+import qs.Services.UI
+
+Rectangle {
+ id: root
+
+ // Mandatory properties for gauges
+ required property int orientation // Qt.Vertical || Qt.Horizontal
+ required property real ratio // 0..1
+
+ radius: orientation === Qt.Vertical ? width / 2 : height / 2
+ color: Color.mOutline
+ property color fillColor: Color.mPrimary
+
+ // Fill that grows from bottom if vertical and left if horizontal.
+ // Snap to zero if the computed pixel length is sub-pixel (< 1px).
+ Rectangle {
+ readonly property real clampedRatio: Math.min(1, Math.max(0, root.ratio))
+ readonly property real rawFill: (orientation === Qt.Vertical ? root.height : root.width) * clampedRatio
+ width: orientation === Qt.Vertical ? root.width : (rawFill < 1 ? 0 : rawFill)
+ height: orientation === Qt.Vertical ? (rawFill < 1 ? 0 : rawFill) : root.height
+ radius: root.radius
+ color: root.fillColor
+ anchors.bottom: orientation === Qt.Vertical ? parent.bottom : undefined
+ anchors.left: parent.left
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NListView.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NListView.qml
new file mode 100644
index 0000000..7a2ae41
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NListView.qml
@@ -0,0 +1,340 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Templates as T
+import qs.Commons
+
+Item {
+ id: root
+
+ property color handleColor: Qt.alpha(Color.mHover, 0.8)
+ property color handleHoverColor: handleColor
+ property color handlePressedColor: handleColor
+ property color trackColor: "transparent"
+ property real handleWidth: Math.round(6 * Style.uiScaleRatio)
+ property real handleRadius: Style.iRadiusM
+ property int verticalPolicy: ScrollBar.AsNeeded
+ property int horizontalPolicy: ScrollBar.AlwaysOff
+ readonly property bool verticalScrollBarActive: {
+ if (listView.ScrollBar.vertical.policy === ScrollBar.AlwaysOff)
+ return false;
+ return listView.contentHeight > listView.height;
+ }
+ readonly property bool contentOverflows: listView.contentHeight > listView.height
+
+ property bool showGradientMasks: true
+ property color gradientColor: Color.mSurfaceVariant
+ property int gradientHeight: 16
+ property bool reserveScrollbarSpace: true
+
+ // Keep scrollbars visible whenever overflow exists (without forcing visibility when not scrollable)
+ property bool showScrollbarWhenScrollable: Settings.data.ui.scrollbarAlwaysVisible
+
+ // Available width for content (excludes scrollbar space when reserveScrollbarSpace is true)
+ readonly property real availableWidth: width - (reserveScrollbarSpace ? handleWidth + Style.marginXS : 0)
+
+ // Forward ListView properties
+ property alias model: listView.model
+ property alias delegate: listView.delegate
+ property alias spacing: listView.spacing
+ property alias orientation: listView.orientation
+ property alias currentIndex: listView.currentIndex
+ property alias count: listView.count
+ property alias contentHeight: listView.contentHeight
+ property alias contentWidth: listView.contentWidth
+ property alias contentY: listView.contentY
+ property alias contentX: listView.contentX
+ property alias currentItem: listView.currentItem
+ property alias highlightItem: listView.highlightItem
+ property alias headerItem: listView.headerItem
+ property alias footerItem: listView.footerItem
+ property alias section: listView.section
+ property alias highlightFollowsCurrentItem: listView.highlightFollowsCurrentItem
+ property alias highlightMoveDuration: listView.highlightMoveDuration
+ property alias highlightMoveVelocity: listView.highlightMoveVelocity
+ property alias preferredHighlightBegin: listView.preferredHighlightBegin
+ property alias preferredHighlightEnd: listView.preferredHighlightEnd
+ property alias highlightRangeMode: listView.highlightRangeMode
+ property alias snapMode: listView.snapMode
+ property alias keyNavigationWraps: listView.keyNavigationWraps
+ property alias cacheBuffer: listView.cacheBuffer
+ property alias displayMarginBeginning: listView.displayMarginBeginning
+ property alias displayMarginEnd: listView.displayMarginEnd
+ property alias layoutDirection: listView.layoutDirection
+ property alias effectiveLayoutDirection: listView.effectiveLayoutDirection
+ property alias verticalLayoutDirection: listView.verticalLayoutDirection
+ property alias boundsBehavior: listView.boundsBehavior
+ property alias flickableDirection: listView.flickableDirection
+ property alias interactive: listView.interactive
+ property alias moving: listView.moving
+ property alias flicking: listView.flicking
+ property alias dragging: listView.dragging
+ property alias horizontalVelocity: listView.horizontalVelocity
+ property alias verticalVelocity: listView.verticalVelocity
+
+ // Scroll speed multiplier for mouse wheel (1.0 = default, higher = faster)
+ property real wheelScrollMultiplier: 2.0
+ property int smoothWheelAnimationDuration: Style.animationNormal
+ property real _wheelTargetY: 0
+
+ function clampScrollY(value) {
+ return Math.max(0, Math.min(value, listView.contentHeight - listView.height));
+ }
+
+ function applyWheelScroll(delta) {
+ if (!root.contentOverflows)
+ return;
+
+ const step = delta * root.wheelScrollMultiplier;
+
+ if (!Settings.data.general.smoothScrollEnabled || Settings.data.general.animationDisabled) {
+ listView.contentY = root.clampScrollY(listView.contentY - step);
+ root._wheelTargetY = listView.contentY;
+ return;
+ }
+
+ if (!wheelScrollAnimation.running)
+ root._wheelTargetY = listView.contentY;
+
+ root._wheelTargetY = root.clampScrollY(root._wheelTargetY - step);
+ wheelScrollAnimation.to = root._wheelTargetY;
+ wheelScrollAnimation.restart();
+ }
+
+ function animateToContentY(targetY) {
+ const clampedY = root.clampScrollY(targetY);
+
+ if (!Settings.data.general.smoothScrollEnabled || Settings.data.general.animationDisabled || listView.dragging || listView.flicking) {
+ listView.contentY = clampedY;
+ root._wheelTargetY = clampedY;
+ return;
+ }
+
+ root._wheelTargetY = clampedY;
+ wheelScrollAnimation.to = clampedY;
+ wheelScrollAnimation.restart();
+ }
+
+ // Forward ListView methods
+ function positionViewAtIndex(index, mode) {
+ const shouldAnimate = mode === ListView.Contain;
+ if (!shouldAnimate) {
+ listView.positionViewAtIndex(index, mode);
+ root._wheelTargetY = listView.contentY;
+ return;
+ }
+
+ const previousY = listView.contentY;
+ listView.positionViewAtIndex(index, mode);
+ const targetY = root.clampScrollY(listView.contentY);
+
+ if (Math.abs(targetY - previousY) < 0.5) {
+ root._wheelTargetY = targetY;
+ return;
+ }
+
+ listView.contentY = previousY;
+ root._wheelTargetY = previousY;
+ root.animateToContentY(targetY);
+ }
+
+ function positionViewAtBeginning() {
+ listView.positionViewAtBeginning();
+ }
+
+ function positionViewAtEnd() {
+ listView.positionViewAtEnd();
+ }
+
+ function forceLayout() {
+ listView.forceLayout();
+ }
+
+ function cancelFlick() {
+ listView.cancelFlick();
+ }
+
+ function flick(xVelocity, yVelocity) {
+ listView.flick(xVelocity, yVelocity);
+ }
+
+ function incrementCurrentIndex() {
+ listView.incrementCurrentIndex();
+ }
+
+ function decrementCurrentIndex() {
+ listView.decrementCurrentIndex();
+ }
+
+ function indexAt(x, y) {
+ return listView.indexAt(x, y);
+ }
+
+ function itemAt(x, y) {
+ return listView.itemAt(x, y);
+ }
+
+ function itemAtIndex(index) {
+ return listView.itemAtIndex(index);
+ }
+
+ // Set reasonable implicit sizes for Layout usage
+ implicitWidth: 200
+ implicitHeight: 200
+
+ Component.onCompleted: {
+ _wheelTargetY = listView.contentY;
+ createGradients();
+ }
+
+ // Dynamically create gradient overlays
+ function createGradients() {
+ if (!showGradientMasks)
+ return;
+
+ Qt.createQmlObject(`
+ import QtQuick
+ import qs.Commons
+ Rectangle {
+ x: 0
+ y: 0
+ width: root.availableWidth
+ height: root.gradientHeight
+ z: 1
+ visible: root.showGradientMasks && root.contentOverflows
+ opacity: {
+ if (listView.contentY <= 1) return 0;
+ if (listView.currentItem && listView.currentItem.y - listView.contentY < root.gradientHeight) return 0;
+ return 1;
+ }
+ Behavior on opacity {
+ NumberAnimation { duration: Style.animationFast; easing.type: Easing.InOutQuad }
+ }
+ gradient: Gradient {
+ GradientStop { position: 0.0; color: root.gradientColor }
+ GradientStop { position: 1.0; color: "transparent" }
+ }
+ }
+ `, root, "topGradient");
+
+ Qt.createQmlObject(`
+ import QtQuick
+ import qs.Commons
+ Rectangle {
+ x: 0
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: -1
+ width: root.availableWidth
+ height: root.gradientHeight + 1
+ z: 1
+ visible: root.showGradientMasks && root.contentOverflows
+ opacity: {
+ if (listView.contentY + listView.height >= listView.contentHeight - 1) return 0;
+ if (listView.currentItem && listView.currentItem.y + listView.currentItem.height > listView.contentY + listView.height - root.gradientHeight) return 0;
+ return 1;
+ }
+ Behavior on opacity {
+ NumberAnimation { duration: Style.animationFast; easing.type: Easing.InOutQuad }
+ }
+ gradient: Gradient {
+ GradientStop { position: 0.0; color: "transparent" }
+ GradientStop { position: 1.0; color: root.gradientColor }
+ }
+ }
+ `, root, "bottomGradient");
+ }
+
+ ListView {
+ id: listView
+ anchors.fill: parent
+ anchors.rightMargin: root.reserveScrollbarSpace ? root.handleWidth + Style.marginXS : 0
+
+ clip: true
+ boundsBehavior: Flickable.StopAtBounds
+
+ NumberAnimation {
+ id: wheelScrollAnimation
+ target: listView
+ property: "contentY"
+ duration: root.smoothWheelAnimationDuration
+ easing.type: Easing.OutCubic
+ }
+
+ onDraggingChanged: {
+ if (dragging) {
+ wheelScrollAnimation.stop();
+ root._wheelTargetY = contentY;
+ }
+ }
+
+ onFlickingChanged: {
+ if (flicking) {
+ wheelScrollAnimation.stop();
+ root._wheelTargetY = contentY;
+ }
+ }
+
+ onContentHeightChanged: root._wheelTargetY = root.clampScrollY(root._wheelTargetY)
+ onHeightChanged: root._wheelTargetY = root.clampScrollY(root._wheelTargetY)
+
+ WheelHandler {
+ enabled: !root.contentOverflows
+ acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
+ onWheel: event => {
+ event.accepted = true;
+ }
+ }
+
+ WheelHandler {
+ enabled: root.wheelScrollMultiplier !== 1.0 && root.contentOverflows
+ acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
+ onWheel: event => {
+ const delta = event.pixelDelta.y !== 0 ? event.pixelDelta.y : event.angleDelta.y / 2;
+ root.applyWheelScroll(delta);
+ event.accepted = true;
+ }
+ }
+
+ ScrollBar.vertical: ScrollBar {
+ parent: root
+ x: root.mirrored ? 0 : root.width - width
+ y: 0
+ height: root.height
+ policy: root.verticalPolicy
+ visible: policy === ScrollBar.AlwaysOn || root.verticalScrollBarActive
+
+ contentItem: Rectangle {
+ implicitWidth: root.handleWidth
+ implicitHeight: 100
+ radius: root.handleRadius
+ color: parent.pressed ? root.handlePressedColor : parent.hovered ? root.handleHoverColor : root.handleColor
+ opacity: parent.policy === ScrollBar.AlwaysOn ? 1.0 : root.verticalScrollBarActive ? ((root.showScrollbarWhenScrollable || parent.active) ? 1.0 : 0.0) : 0.0
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Style.animationFast
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+ }
+
+ background: Rectangle {
+ implicitWidth: root.handleWidth
+ implicitHeight: 100
+ color: root.trackColor
+ opacity: parent.policy === ScrollBar.AlwaysOn ? 0.3 : root.verticalScrollBarActive ? ((root.showScrollbarWhenScrollable || parent.active) ? 0.3 : 0.0) : 0.0
+ radius: root.handleRadius / 2
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Style.animationFast
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NPluginSettingsPopup.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NPluginSettingsPopup.qml
new file mode 100644
index 0000000..5088681
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NPluginSettingsPopup.qml
@@ -0,0 +1,152 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import qs.Commons
+import qs.Services.Noctalia
+import qs.Services.UI
+import qs.Widgets
+
+Popup {
+ id: root
+ modal: true
+ dim: false
+ anchors.centerIn: parent
+ property var screen: null
+ readonly property real maxHeight: (screen ? screen.height : (parent ? parent.height : 800)) * 0.8
+
+ property real _minWidth: 600 * Style.uiScaleRatio
+ width: _minWidth
+ height: Math.min(settingsContent.implicitHeight + padding * 2, maxHeight)
+ padding: Style.marginXL
+
+ property var currentPlugin: null
+ property var currentPluginApi: null
+ property bool showToastOnSave: false
+
+ background: Rectangle {
+ color: Color.mSurface
+ radius: Style.radiusL
+ border.color: Color.mPrimary
+ border.width: Style.borderM
+ }
+
+ contentItem: FocusScope {
+ focus: true
+
+ ColumnLayout {
+ id: settingsContent
+ anchors.fill: parent
+ spacing: Style.marginM
+
+ // Header
+ RowLayout {
+ Layout.fillWidth: true
+
+ NText {
+ text: I18n.tr("panels.plugins.plugin-settings-title", {
+ "plugin": root.currentPlugin?.name || ""
+ })
+ pointSize: Style.fontSizeL
+ font.weight: Style.fontWeightBold
+ color: Color.mPrimary
+ Layout.fillWidth: true
+ }
+
+ NIconButton {
+ icon: "close"
+ tooltipText: I18n.tr("common.close")
+ onClicked: root.close()
+ }
+ }
+
+ // Separator
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.preferredHeight: 1
+ color: Color.mOutline
+ }
+
+ // Settings loader - pluginApi is passed via setSource() in openPluginSettings()
+ NScrollView {
+ id: settingsScrollView
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ Layout.minimumHeight: 100
+ horizontalPolicy: ScrollBar.AlwaysOff
+ gradientColor: Color.mSurface
+
+ Loader {
+ id: settingsLoader
+ width: settingsScrollView.availableWidth
+ }
+ }
+
+ // Action buttons
+ RowLayout {
+ Layout.fillWidth: true
+ Layout.topMargin: Style.marginM
+ spacing: Style.marginM
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ NButton {
+ text: I18n.tr("common.close")
+ outlined: true
+ onClicked: root.close()
+ }
+
+ NButton {
+ text: I18n.tr("common.apply")
+ icon: "check"
+ onClicked: {
+ if (settingsLoader.item && settingsLoader.item.saveSettings) {
+ settingsLoader.item.saveSettings();
+ if (root.showToastOnSave) {
+ ToastService.showNotice(I18n.tr("panels.plugins.title"), I18n.tr("panels.plugins.settings-saved"));
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ onClosed: {
+ // Clear both source and sourceComponent to ensure full cleanup
+ settingsLoader.sourceComponent = null;
+ settingsLoader.source = "";
+ currentPlugin = null;
+ currentPluginApi = null;
+ }
+
+ function openPluginSettings(pluginManifest, settingsEntryPoint) {
+ currentPlugin = pluginManifest;
+
+ // Use composite key if available (for custom plugins), otherwise use manifest ID (for official plugins)
+ var pluginId = pluginManifest.compositeKey || pluginManifest.id;
+
+ currentPluginApi = PluginService.getPluginAPI(pluginId);
+ if (!currentPluginApi) {
+ Logger.e("NPluginSettingsPopup", "Cannot open settings: plugin not loaded:", pluginId);
+ if (showToastOnSave) {
+ ToastService.showError(I18n.tr("panels.plugins.title"), I18n.tr("panels.plugins.settings-error-not-loaded"));
+ }
+ return;
+ }
+
+ // Get plugin directory
+ var pluginDir = PluginRegistry.getPluginDir(pluginId);
+ var settingsEntry = settingsEntryPoint ? pluginManifest.entryPoints[settingsEntryPoint] : pluginManifest.entryPoints.settings;
+ var settingsPath = pluginDir + "/" + settingsEntry;
+
+ settingsLoader.setSource("file://" + settingsPath, {
+ "pluginApi": currentPluginApi
+ });
+
+ var preferred = (settingsLoader.item && settingsLoader.item.preferredWidth !== undefined) ? settingsLoader.item.preferredWidth + padding * 2 : 0;
+ width = Math.max(preferred, _minWidth);
+ open();
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NPopupContextMenu.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NPopupContextMenu.qml
new file mode 100644
index 0000000..cb1cde8
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NPopupContextMenu.qml
@@ -0,0 +1,372 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import Quickshell
+import qs.Commons
+
+// Simple context menu PopupWindow (similar to TrayMenu)
+// Designed to be rendered inside a PopupMenuWindow for click-outside-to-close
+// Automatically positions itself to respect screen boundaries
+PopupWindow {
+ id: root
+
+ property alias model: repeater.model
+ property real itemHeight: 28 // Match TrayMenu
+ property real itemPadding: Style.marginM
+ property int verticalPolicy: ScrollBar.AsNeeded
+ property int horizontalPolicy: ScrollBar.AsNeeded
+
+ property var anchorItem: null
+ property ShellScreen screen: null
+ property real minWidth: 120
+ property real calculatedWidth: 180
+
+ // Explicit offset for centering on target item (computed from targetItem in openAtItem)
+ property real targetOffsetX: 0
+ property real targetOffsetY: 0
+ property real targetWidth: 0
+ property real targetHeight: 0
+
+ readonly property string barPosition: Settings.getBarPositionForScreen(screen?.name)
+ readonly property real barHeight: Style.getBarHeightForScreen(screen?.name)
+
+ signal triggered(string action, var item)
+
+ implicitWidth: calculatedWidth
+ implicitHeight: Math.min(600, flickable.contentHeight + Style.margin2S)
+ visible: false
+ color: "transparent"
+
+ NText {
+ id: textMeasure
+ visible: false
+ pointSize: Style.fontSizeS
+ wrapMode: Text.NoWrap
+ elide: Text.ElideNone
+ width: undefined
+ }
+
+ NIcon {
+ id: iconMeasure
+ visible: false
+ icon: "bell"
+ pointSize: Style.fontSizeS
+ applyUiScale: false
+ }
+
+ onModelChanged: {
+ Qt.callLater(calculateWidth);
+ }
+
+ function calculateWidth() {
+ let maxWidth = 0;
+ if (model && model.length) {
+ for (let i = 0; i < model.length; i++) {
+ const item = model[i];
+ if (item && item.visible !== false) {
+ const label = item.label || item.text || "";
+ textMeasure.text = label;
+ textMeasure.forceLayout();
+
+ let itemWidth = textMeasure.contentWidth + 8;
+
+ if (item.icon !== undefined) {
+ itemWidth += iconMeasure.width + Style.marginS;
+ }
+
+ itemWidth += Style.margin2M;
+
+ if (itemWidth > maxWidth) {
+ maxWidth = itemWidth;
+ }
+ }
+ }
+ }
+ calculatedWidth = Math.max(maxWidth + Style.margin2S, minWidth);
+ }
+
+ anchor.item: anchorItem
+
+ anchor.rect.x: {
+ if (anchorItem && screen) {
+ const anchorGlobalPos = anchorItem.mapToItem(null, 0, 0);
+
+ // Use stored targetOffsetX and targetWidth for positioning
+ const effectiveWidth = targetWidth > 0 ? targetWidth : anchorItem.width;
+ const targetGlobalX = anchorGlobalPos.x + targetOffsetX;
+
+ // For right bar: position menu to the left of target
+ if (root.barPosition === "right") {
+ let baseX = targetOffsetX - implicitWidth - Style.marginM;
+ return baseX;
+ }
+
+ // For left bar: position menu to the right of target
+ if (root.barPosition === "left") {
+ let baseX = targetOffsetX + effectiveWidth + Style.marginM;
+ return baseX;
+ }
+
+ // For top/bottom bar: center horizontally on target
+ const targetCenterScreenX = targetGlobalX + (effectiveWidth / 2);
+ const menuScreenX = targetCenterScreenX - (implicitWidth / 2);
+ let baseX = menuScreenX - anchorGlobalPos.x;
+
+ const menuRight = menuScreenX + implicitWidth;
+
+ // Adjust if menu would clip on the right
+ if (menuRight > screen.width - Style.marginM) {
+ const overflow = menuRight - (screen.width - Style.marginM);
+ return baseX - overflow;
+ }
+ // Adjust if menu would clip on the left
+ if (menuScreenX < Style.marginM) {
+ return baseX + (Style.marginM - menuScreenX);
+ }
+ return baseX;
+ }
+ return 0;
+ }
+ anchor.rect.y: {
+ if (anchorItem && screen) {
+ // Check if using absolute positioning (small anchor point item)
+ const isAbsolutePosition = anchorItem.width <= 1 && anchorItem.height <= 1;
+
+ if (isAbsolutePosition) {
+ // For absolute positioning, show menu directly at anchor Y
+ // Only adjust if menu would clip at bottom
+ const anchorGlobalPos = anchorItem.mapToItem(null, 0, 0);
+ const menuBottom = anchorGlobalPos.y + implicitHeight;
+
+ if (menuBottom > screen.height - Style.marginM) {
+ // Position above the click point instead
+ return -implicitHeight;
+ }
+ return 0;
+ }
+
+ const anchorGlobalPos = anchorItem.mapToItem(null, 0, 0);
+
+ // Use target offset/height for vertical bars, anchor dimensions otherwise
+ const effectiveHeight = targetHeight > 0 ? targetHeight : anchorItem.height;
+ const effectiveOffsetY = targetOffsetY;
+
+ // Calculate base Y position based on bar orientation
+ let baseY;
+ if (root.barPosition === "bottom") {
+ // For bottom bar: position menu above the bar
+ baseY = -(implicitHeight + Style.marginS);
+ } else if (root.barPosition === "top") {
+ // For top bar: position menu below bar at consistent height
+ // Compensate for anchor's Y position to ensure menu top is always at (barHeight + margin)
+ baseY = barHeight + Style.marginS - anchorGlobalPos.y;
+ } else {
+ // For left/right bar: vertically center on target item
+ const targetCenterY = effectiveOffsetY + (effectiveHeight / 2);
+ baseY = targetCenterY - (implicitHeight / 2);
+ }
+
+ const menuScreenY = anchorGlobalPos.y + baseY;
+ const menuBottom = menuScreenY + implicitHeight;
+
+ // Define clipping boundaries based on bar position
+ const topLimit = Style.marginM;
+ const bottomLimit = root.barPosition === "bottom" ? screen.height - barHeight - Style.marginS : screen.height - Style.marginM;
+
+ // Adjust if menu would clip at top (skip for bottom bar - don't push menu down over bar)
+ if (menuScreenY < topLimit && root.barPosition !== "bottom") {
+ const adjustment = topLimit - menuScreenY;
+ return baseY + adjustment;
+ }
+
+ // Adjust if menu would clip at bottom (or overlap bar for bottom bar)
+ if (menuBottom > bottomLimit) {
+ const overflow = menuBottom - bottomLimit;
+ return baseY - overflow;
+ }
+
+ return baseY;
+ }
+
+ // Fallback if no screen
+ if (root.barPosition === "bottom") {
+ return -implicitHeight - Style.marginS;
+ }
+ return barHeight;
+ }
+
+ Component.onCompleted: {
+ Qt.callLater(calculateWidth);
+ }
+
+ Item {
+ anchors.fill: parent
+ focus: true
+ Keys.onEscapePressed: root.close()
+ }
+
+ Rectangle {
+ id: menuBackground
+ anchors.fill: parent
+ color: Color.mSurface
+ border.color: Color.mOutline
+ border.width: Style.borderS
+ radius: Style.radiusM
+ opacity: root.visible ? 1.0 : 0.0
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Style.animationNormal
+ easing.type: Easing.OutQuad
+ }
+ }
+ }
+
+ Flickable {
+ id: flickable
+ anchors.fill: parent
+ anchors.margins: Style.marginS
+ contentHeight: columnLayout.implicitHeight
+ interactive: true
+ opacity: root.visible ? 1.0 : 0.0
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Style.animationNormal
+ easing.type: Easing.OutQuad
+ }
+ }
+
+ ColumnLayout {
+ id: columnLayout
+ width: flickable.width
+ spacing: 0
+
+ Repeater {
+ id: repeater
+
+ delegate: Rectangle {
+ id: menuItem
+ required property var modelData
+ required property int index
+
+ Layout.preferredWidth: parent.width
+ Layout.preferredHeight: modelData.visible !== false ? root.itemHeight : 0
+ visible: modelData.visible !== false
+ color: "transparent"
+
+ Rectangle {
+ id: innerRect
+ anchors.fill: parent
+ color: mouseArea.containsMouse ? Color.mHover : "transparent"
+ radius: Style.radiusS
+ opacity: modelData.enabled !== false ? 1.0 : 0.5
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+
+ RowLayout {
+ anchors.fill: parent
+ anchors.leftMargin: Style.marginM
+ anchors.rightMargin: Style.marginM
+ spacing: Style.marginS
+
+ NIcon {
+ visible: modelData.icon !== undefined
+ icon: modelData.icon || ""
+ pointSize: Style.fontSizeS
+ applyUiScale: false
+ color: mouseArea.containsMouse ? Color.mOnHover : Color.mOnSurface
+ verticalAlignment: Text.AlignVCenter
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+ }
+
+ NText {
+ text: modelData.label || modelData.text || ""
+ pointSize: Style.fontSizeS
+ color: mouseArea.containsMouse ? Color.mOnHover : Color.mOnSurface
+ verticalAlignment: Text.AlignVCenter
+ Layout.fillWidth: true
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+ }
+ }
+
+ MouseArea {
+ id: mouseArea
+ anchors.fill: parent
+ hoverEnabled: true
+ enabled: (modelData.enabled !== false) && root.visible
+ cursorShape: Qt.PointingHandCursor
+
+ onClicked: {
+ if (menuItem.modelData.enabled !== false) {
+ root.triggered(menuItem.modelData.action || menuItem.modelData.key || menuItem.index.toString(), menuItem.modelData);
+ // Don't call root.close() here - let the parent PopupMenuWindow handle closing
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Helper function to open context menu anchored to an item
+ // Position is calculated automatically based on bar position and screen boundaries
+ // Optional centerOnItem: if provided, menu will be horizontally centered on this item instead of anchorItem
+ function openAtItem(item, itemScreen, centerOnItem) {
+ if (!item) {
+ Logger.w("NPopupContextMenu", "anchorItem is undefined, won't show menu.");
+ return;
+ }
+
+ // Set anchor and screen first
+ anchorItem = item;
+ screen = itemScreen || null;
+
+ // Compute target offset and dimensions from centerOnItem
+ if (centerOnItem && centerOnItem !== item) {
+ const relPos = centerOnItem.mapToItem(item, 0, 0);
+ targetOffsetX = relPos.x;
+ targetOffsetY = relPos.y;
+ targetWidth = centerOnItem.width;
+ targetHeight = centerOnItem.height;
+ } else {
+ targetOffsetX = 0;
+ targetOffsetY = 0;
+ targetWidth = 0;
+ targetHeight = 0;
+ }
+
+ // Calculate menu width after anchor is set
+ calculateWidth();
+
+ visible = true;
+
+ // Force anchor recalculation after showing
+ Qt.callLater(() => {
+ anchor.updateAnchor();
+ });
+ }
+
+ function close() {
+ visible = false;
+ }
+
+ function closeMenu() {
+ close();
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NRadioButton.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NRadioButton.qml
new file mode 100644
index 0000000..326955d
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NRadioButton.qml
@@ -0,0 +1,53 @@
+import QtQuick
+import QtQuick.Controls
+import qs.Commons
+import qs.Widgets
+
+RadioButton {
+ id: root
+
+ property real pointSize: Style.fontSizeM
+
+ implicitWidth: outerCircle.implicitWidth + Style.marginS + contentItem.implicitWidth
+
+ indicator: Rectangle {
+ id: outerCircle
+
+ implicitWidth: Style.baseWidgetSize * 0.625 * pointSize / Style.fontSizeM
+ implicitHeight: Style.baseWidgetSize * 0.625 * pointSize / Style.fontSizeM
+ radius: Math.min(Style.iRadiusL, width / 2)
+ color: "transparent"
+ border.color: root.checked ? Color.mPrimary : Color.mOnSurface
+ border.width: Style.borderM
+ anchors.verticalCenter: parent.verticalCenter
+
+ Rectangle {
+ anchors.fill: parent
+ anchors.margins: parent.width * 0.3
+
+ radius: Math.min(Style.iRadiusL, width / 2)
+ color: Qt.alpha(Color.mPrimary, root.checked ? 1 : 0)
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+ }
+
+ Behavior on border.color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+ }
+
+ contentItem: NText {
+ text: root.text
+ pointSize: root.pointSize
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: outerCircle.right
+ anchors.right: parent.right
+ anchors.leftMargin: Style.marginS
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NReorderCheckboxes.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NReorderCheckboxes.qml
new file mode 100644
index 0000000..494015e
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NReorderCheckboxes.qml
@@ -0,0 +1,310 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import qs.Commons
+
+Item {
+ id: root
+
+ // Public API
+ property var model: []
+ property var disabledIds: []
+ property color activeColor: Color.mPrimary
+ property color activeOnColor: Color.mOnPrimary
+ property color dragHandleColor: Color.mOutline
+ property int baseSize: Style.baseWidgetSize * 0.7
+ property int spacing: Style.marginM
+
+ signal itemToggled(int index, bool enabled)
+ signal itemsReordered(int fromIndex, int toIndex)
+ signal dragPotentialStarted
+ signal dragPotentialEnded
+
+ readonly property real itemHeight: root.baseSize
+ readonly property real contentHeight: root.model.length > 0 ? root.model.length * itemHeight + (root.model.length - 1) * root.spacing : 0
+ implicitHeight: contentHeight
+
+ function toggleItem(index) {
+ if (index < 0 || index >= root.model.length)
+ return;
+ var item = root.model[index];
+ if (item.required)
+ return;
+
+ // Create a new array to trigger binding update
+ var newModel = root.model.slice();
+ newModel[index] = Object.assign({}, item, {
+ "enabled": !item.enabled
+ });
+ root.model = newModel;
+
+ root.itemToggled(index, newModel[index].enabled);
+ }
+
+ function moveItem(fromIndex, toIndex) {
+ if (fromIndex === toIndex)
+ return;
+ if (fromIndex < 0 || fromIndex >= root.model.length)
+ return;
+ if (toIndex < 0 || toIndex >= root.model.length)
+ return;
+
+ // Create a new array with item moved
+ var newModel = root.model.slice();
+ var item = newModel.splice(fromIndex, 1)[0];
+ newModel.splice(toIndex, 0, item);
+ root.model = newModel;
+
+ root.itemsReordered(fromIndex, toIndex);
+ }
+
+ Item {
+ id: itemsContainer
+ anchors.fill: parent
+ clip: true
+
+ Repeater {
+ id: repeater
+ model: root.model
+
+ delegate: Item {
+ id: delegateItem
+
+ width: itemsContainer.width
+ height: checkboxRow.height
+
+ required property int index
+ required property var modelData
+
+ property string text: modelData.text || ""
+ property bool itemEnabled: modelData.enabled || false
+ property bool required: modelData.required || false
+ readonly property bool isDisabled: (root.disabledIds || []).indexOf(modelData.id) !== -1
+ readonly property bool canDrag: !delegateItem.isDisabled
+ property bool dragging: false
+ property int dragStartY: 0
+ property int dragStartIndex: -1
+ property int dragTargetIndex: -1
+ property int itemSpacing: root.spacing
+
+ RowLayout {
+ id: checkboxRow
+
+ width: parent.width
+ spacing: Style.marginS
+ opacity: isDisabled ? 0.5 : 1.0
+
+ // Drag handle
+ Rectangle {
+ id: dragHandle
+
+ Layout.preferredWidth: root.baseSize
+ Layout.preferredHeight: root.baseSize
+ radius: Style.iRadiusXS
+ color: dragHandleMouseArea.containsMouse ? Color.mSurfaceVariant : "transparent"
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+
+ ColumnLayout {
+ anchors.centerIn: parent
+ spacing: Style.marginS
+
+ Repeater {
+ model: 3
+ Rectangle {
+ Layout.preferredWidth: root.baseSize * 0.4
+ Layout.preferredHeight: 2
+ radius: 1
+ color: root.dragHandleColor
+ }
+ }
+ }
+
+ MouseArea {
+ id: dragHandleMouseArea
+
+ anchors.fill: parent
+ cursorShape: delegateItem.canDrag ? Qt.SizeVerCursor : Qt.ArrowCursor
+ hoverEnabled: true
+ preventStealing: false
+ enabled: delegateItem.canDrag
+ z: 1000
+
+ onPressed: mouse => {
+ if (!delegateItem.canDrag) {
+ return;
+ }
+ delegateItem.dragStartIndex = delegateItem.index;
+ delegateItem.dragTargetIndex = delegateItem.index;
+ delegateItem.dragStartY = delegateItem.y;
+ delegateItem.dragging = true;
+ delegateItem.z = 999;
+
+ // Signal that interaction started (prevents panel close)
+ preventStealing = true;
+ root.dragPotentialStarted();
+ }
+
+ onPositionChanged: mouse => {
+ if (delegateItem.dragging) {
+ var dy = mouse.y - dragHandle.height / 2;
+ var newY = delegateItem.y + dy;
+
+ // Constrain within bounds
+ newY = Math.max(0, Math.min(newY, root.contentHeight - delegateItem.height));
+ delegateItem.y = newY;
+
+ // Calculate target index (but don't apply yet)
+ var targetIndex = Math.floor((newY + delegateItem.height / 2) / (delegateItem.height + delegateItem.itemSpacing));
+ targetIndex = Math.max(0, Math.min(targetIndex, repeater.count - 1));
+
+ delegateItem.dragTargetIndex = targetIndex;
+ }
+ }
+
+ onReleased: {
+ // Always signal end of interaction
+ preventStealing = false;
+ root.dragPotentialEnded();
+
+ // Apply the model change now that drag is complete
+ if (delegateItem.dragStartIndex !== -1 && delegateItem.dragTargetIndex !== -1 && delegateItem.dragStartIndex !== delegateItem.dragTargetIndex) {
+ root.moveItem(delegateItem.dragStartIndex, delegateItem.dragTargetIndex);
+ }
+
+ delegateItem.dragging = false;
+ delegateItem.dragStartIndex = -1;
+ delegateItem.dragTargetIndex = -1;
+ delegateItem.z = 0;
+ }
+
+ onCanceled: {
+ // Handle cancel (e.g., ESC key pressed during drag)
+ preventStealing = false;
+ root.dragPotentialEnded();
+
+ delegateItem.dragging = false;
+ delegateItem.dragStartIndex = -1;
+ delegateItem.dragTargetIndex = -1;
+ delegateItem.z = 0;
+ }
+ }
+ }
+
+ // Checkbox
+ Rectangle {
+ id: box
+
+ Layout.preferredWidth: root.baseSize
+ Layout.preferredHeight: root.baseSize
+ radius: Style.iRadiusXS
+ color: delegateItem.itemEnabled ? root.activeColor : Color.mSurface
+ border.color: delegateItem.required ? root.activeColor : Color.mOutline
+ border.width: Style.borderS
+ opacity: delegateItem.required ? 0.7 : 1.0
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+
+ Behavior on border.color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+
+ NIcon {
+ visible: delegateItem.itemEnabled
+ anchors.centerIn: parent
+ anchors.horizontalCenterOffset: -1
+ icon: "check"
+ color: root.activeOnColor
+ pointSize: Math.max(Style.fontSizeXS, root.baseSize * 0.5)
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: (!delegateItem.required && !delegateItem.isDisabled) ? Qt.PointingHandCursor : Qt.ArrowCursor
+ enabled: !delegateItem.required && !delegateItem.isDisabled
+
+ onClicked: {
+ if (!delegateItem.required && !delegateItem.isDisabled) {
+ root.toggleItem(delegateItem.index);
+ }
+ }
+ }
+ }
+
+ // Label
+ NText {
+ Layout.fillWidth: true
+ text: delegateItem.text
+ color: Color.mOnSurface
+ verticalAlignment: Text.AlignVCenter
+ elide: Text.ElideRight
+ }
+
+ // Required indicator
+ NText {
+ visible: delegateItem.required
+ text: I18n.tr("common.required")
+ color: Color.mOnSurfaceVariant
+ verticalAlignment: Text.AlignVCenter
+ }
+ }
+
+ // Position binding for non-dragging state
+ y: {
+ if (delegateItem.dragging) {
+ return delegateItem.y;
+ }
+
+ // Check if any item is being dragged
+ var draggedIndex = -1;
+ var targetIndex = -1;
+ for (var i = 0; i < repeater.count; i++) {
+ var item = repeater.itemAt(i);
+ if (item && item.dragging) {
+ draggedIndex = item.dragStartIndex;
+ targetIndex = item.dragTargetIndex;
+ break;
+ }
+ }
+
+ // If an item is being dragged, adjust positions
+ if (draggedIndex !== -1 && targetIndex !== -1 && draggedIndex !== targetIndex) {
+ var currentIndex = delegateItem.index;
+
+ if (draggedIndex < targetIndex) {
+ // Dragging down: shift items up between draggedIndex and targetIndex
+ if (currentIndex > draggedIndex && currentIndex <= targetIndex) {
+ return (currentIndex - 1) * (delegateItem.height + delegateItem.itemSpacing);
+ }
+ } else {
+ // Dragging up: shift items down between targetIndex and draggedIndex
+ if (currentIndex >= targetIndex && currentIndex < draggedIndex) {
+ return (currentIndex + 1) * (delegateItem.height + delegateItem.itemSpacing);
+ }
+ }
+ }
+
+ return delegateItem.index * (delegateItem.height + delegateItem.itemSpacing);
+ }
+
+ Behavior on y {
+ enabled: !delegateItem.dragging
+ NumberAnimation {
+ duration: Style.animationNormal
+ easing.type: Easing.OutQuad
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NScrollText.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NScrollText.qml
new file mode 100644
index 0000000..0360ea7
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NScrollText.qml
@@ -0,0 +1,218 @@
+import QtQuick
+import QtQuick.Effects
+import QtQuick.Layouts
+import qs.Commons
+
+/*
+NScrollText {
+NText {
+pointSize: Style.fontSizeS
+// here any NText properties can be used
+}
+maxWidth: 200
+text: "Some long long long text"
+scrollMode: NScrollText.ScrollMode.Always
+}
+*/
+
+Item {
+ id: root
+
+ required property string text
+ default property Component delegate: NText {
+ pointSize: Style.fontSizeS
+ }
+
+ property real maxWidth: Infinity
+
+ enum ScrollMode {
+ Never = 0,
+ Always = 1,
+ Hover = 2
+ }
+
+ property int scrollMode: NScrollText.ScrollMode.Never
+ property bool alwaysMaxWidth: false
+ property bool forcedHover: false
+ property int cursorShape: Qt.ArrowCursor
+
+ property real waitBeforeScrolling: 1000
+ property real scrollCycleDuration: Math.max(4000, root.text.length * 120)
+ property real resettingDuration: 300
+
+ // Fade controls (fadeExtent: 0.0โ0.5, fraction of width that fades)
+ property real fadeExtent: 0.1
+ property real fadeCornerRadius: 0
+ property bool fadeRoundLeftCorners: true
+
+ readonly property real contentWidth: {
+ if (!titleText.item)
+ return 0;
+ const implicit = titleText.item.implicitWidth;
+ return implicit > 0 ? implicit : titleText.item.width;
+ }
+ readonly property real measuredWidth: scrollContainer.width
+
+ implicitWidth: alwaysMaxWidth ? maxWidth : Math.min(maxWidth, contentWidth)
+ implicitHeight: titleText.height
+
+ layer.enabled: contentWidth > maxWidth
+ layer.effect: MultiEffect {
+ maskEnabled: true
+ maskThresholdMin: 0.5
+ maskSpreadAtMin: 1.0
+ maskSource: fadeMask
+ }
+
+ enum ScrollState {
+ None = 0,
+ Scrolling = 1,
+ Resetting = 2
+ }
+
+ property int state: NScrollText.ScrollState.None
+
+ onTextChanged: {
+ if (titleText.item)
+ titleText.item.text = text;
+ if (loopingText.item)
+ loopingText.item.text = text;
+
+ // reset state
+ resetState();
+ }
+ onMaxWidthChanged: resetState()
+ onContentWidthChanged: root.updateState()
+ onForcedHoverChanged: updateState()
+
+ function resetState() {
+ root.state = NScrollText.ScrollState.None;
+ scrollContainer.x = 0;
+ scrollTimer.restart();
+ root.updateState();
+ }
+
+ Timer {
+ id: scrollTimer
+ interval: root.waitBeforeScrolling
+ onTriggered: {
+ root.state = NScrollText.ScrollState.Scrolling;
+ root.updateState();
+ }
+ }
+
+ MouseArea {
+ id: hoverArea
+ anchors.fill: parent
+ hoverEnabled: true
+ acceptedButtons: Qt.NoButton
+ onEntered: root.updateState()
+ onExited: root.updateState()
+ cursorShape: root.cursorShape
+ }
+
+ function ensureReset() {
+ if (state === NScrollText.ScrollState.Scrolling)
+ state = NScrollText.ScrollState.Resetting;
+ }
+
+ function updateState() {
+ if (contentWidth <= root.maxWidth || scrollMode === NScrollText.ScrollMode.Never) {
+ state = NScrollText.ScrollState.None;
+ return;
+ }
+ if (scrollMode === NScrollText.ScrollMode.Always) {
+ if (hoverArea.containsMouse) {
+ ensureReset();
+ } else {
+ scrollTimer.restart();
+ }
+ } else if (scrollMode === NScrollText.ScrollMode.Hover) {
+ if (hoverArea.containsMouse || forcedHover)
+ state = NScrollText.ScrollState.Scrolling;
+ else
+ ensureReset();
+ }
+ }
+
+ RowLayout {
+ id: scrollContainer
+ height: parent.height
+ x: 0
+ spacing: 50
+
+ Loader {
+ id: titleText
+ sourceComponent: root.delegate
+ Layout.fillHeight: true
+ onLoaded: {
+ this.item.text = root.text;
+ // Bind height to container to enable vertical centering of overly high text
+ this.item.height = Qt.binding(() => titleText.height);
+ }
+ }
+
+ Loader {
+ id: loopingText
+ sourceComponent: root.delegate
+ Layout.fillHeight: true
+ visible: root.state !== NScrollText.ScrollState.None
+ onLoaded: {
+ this.item.text = root.text;
+ this.item.height = Qt.binding(() => loopingText.height);
+ }
+ }
+
+ NumberAnimation on x {
+ running: root.state === NScrollText.ScrollState.Resetting
+ to: 0
+ duration: root.resettingDuration
+ easing.type: Easing.OutQuad
+ onFinished: {
+ root.state = NScrollText.ScrollState.None;
+ root.updateState();
+ }
+ }
+
+ NumberAnimation on x {
+ running: root.state === NScrollText.ScrollState.Scrolling
+ to: -(titleText.width + scrollContainer.spacing)
+ duration: root.scrollCycleDuration
+ loops: Animation.Infinite
+ easing.type: Easing.Linear
+ }
+ }
+
+ // Transparency Fade Rectangle
+ Rectangle {
+ id: fadeMask
+ width: root.width
+ height: root.height
+ topLeftRadius: fadeRoundLeftCorners ? fadeCornerRadius : 0
+ bottomLeftRadius: fadeRoundLeftCorners ? fadeCornerRadius : 0
+ topRightRadius: fadeCornerRadius
+ bottomRightRadius: fadeCornerRadius
+ gradient: Gradient {
+ GradientStop {
+ position: 0.0
+ color: "transparent"
+ }
+ GradientStop {
+ position: fadeExtent
+ color: "white"
+ }
+ GradientStop {
+ position: 1 - fadeExtent
+ color: "white"
+ }
+ GradientStop {
+ position: 1.0
+ color: "transparent"
+ }
+ orientation: Gradient.Horizontal
+ }
+ layer.enabled: true
+ layer.smooth: true
+ opacity: 0
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NScrollView.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NScrollView.qml
new file mode 100644
index 0000000..d0d1556
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NScrollView.qml
@@ -0,0 +1,280 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Templates as T
+import qs.Commons
+
+ScrollView {
+ id: root
+
+ property color handleColor: Qt.alpha(Color.mHover, 0.8)
+ property color handleHoverColor: handleColor
+ property color handlePressedColor: handleColor
+ property color trackColor: "transparent"
+ property real handleWidth: Math.round(6 * Style.uiScaleRatio)
+ property real handleRadius: Style.iRadiusM
+ property int verticalPolicy: ScrollBar.AsNeeded
+ property int horizontalPolicy: ScrollBar.AsNeeded
+ property bool preventHorizontalScroll: horizontalPolicy === ScrollBar.AlwaysOff
+ property int boundsBehavior: Flickable.StopAtBounds
+ readonly property bool verticalScrollable: (contentItem.contentHeight > contentItem.height) || (verticalPolicy == ScrollBar.AlwaysOn)
+ readonly property bool horizontalScrollable: (contentItem.contentWidth > contentItem.width) || (horizontalPolicy == ScrollBar.AlwaysOn)
+ property bool showGradientMasks: true
+ property color gradientColor: Color.mSurfaceVariant
+ property int gradientHeight: 16
+ property bool reserveScrollbarSpace: true
+ property real userRightPadding: 0
+ // Keep scrollbars visible whenever overflow exists (without forcing visibility when not scrollable)
+ property bool showScrollbarWhenScrollable: Settings.data.ui.scrollbarAlwaysVisible
+
+ // Scroll speed multiplier for mouse wheel (1.0 = default, higher = faster)
+ property real wheelScrollMultiplier: 2.0
+ property int smoothWheelAnimationDuration: Style.animationNormal
+ property real _wheelTargetY: 0
+
+ function clampScrollY(value) {
+ if (!root._internalFlickable)
+ return 0;
+ const flickable = root._internalFlickable;
+ return Math.max(0, Math.min(value, flickable.contentHeight - flickable.height));
+ }
+
+ function applyWheelScroll(delta) {
+ if (!root._internalFlickable)
+ return;
+
+ const flickable = root._internalFlickable;
+ const step = delta * root.wheelScrollMultiplier;
+
+ if (!Settings.data.general.smoothScrollEnabled || Settings.data.general.animationDisabled) {
+ flickable.contentY = root.clampScrollY(flickable.contentY - step);
+ root._wheelTargetY = flickable.contentY;
+ return;
+ }
+
+ if (!wheelScrollAnimation.running)
+ root._wheelTargetY = flickable.contentY;
+
+ root._wheelTargetY = root.clampScrollY(root._wheelTargetY - step);
+ wheelScrollAnimation.to = root._wheelTargetY;
+ wheelScrollAnimation.restart();
+ }
+
+ rightPadding: userRightPadding + (reserveScrollbarSpace && verticalScrollable ? handleWidth + Style.marginXS : 0)
+
+ implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, contentWidth + leftPadding + rightPadding)
+ implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, contentHeight + topPadding + bottomPadding)
+
+ // Configure the internal flickable when it becomes available
+ Component.onCompleted: {
+ configureFlickable();
+ createGradients();
+ }
+
+ // Dynamically create gradient overlays to avoid interfering with ScrollView content management
+ function createGradients() {
+ if (!showGradientMasks)
+ return;
+
+ Qt.createQmlObject(`
+ import QtQuick
+ import qs.Commons
+ Rectangle {
+ x: root.leftPadding
+ y: root.topPadding
+ width: root.availableWidth
+ height: root.gradientHeight
+ z: 1
+ visible: root.showGradientMasks && root.verticalScrollable
+ opacity: root.contentItem.contentY <= 1 ? 0 : 1
+ Behavior on opacity {
+ NumberAnimation { duration: Style.animationFast; easing.type: Easing.InOutQuad }
+ }
+ gradient: Gradient {
+ GradientStop { position: 0.0; color: root.gradientColor }
+ GradientStop { position: 1.0; color: "transparent" }
+ }
+ }
+ `, root, "topGradient");
+
+ Qt.createQmlObject(`
+ import QtQuick
+ import qs.Commons
+ Rectangle {
+ x: root.leftPadding
+ y: root.height - root.bottomPadding - height + 1
+ width: root.availableWidth
+ height: root.gradientHeight + 1
+ z: 1
+ visible: root.showGradientMasks && root.verticalScrollable
+ opacity: (root.contentItem.contentY + root.contentItem.height >= root.contentItem.contentHeight - 1) ? 0 : 1
+ Behavior on opacity {
+ NumberAnimation { duration: Style.animationFast; easing.type: Easing.InOutQuad }
+ }
+ gradient: Gradient {
+ GradientStop { position: 0.0; color: "transparent" }
+ GradientStop { position: 1.0; color: root.gradientColor }
+ }
+ }
+ `, root, "bottomGradient");
+ }
+
+ // Reference to the internal Flickable for wheel handling
+ property Flickable _internalFlickable: null
+
+ NumberAnimation {
+ id: wheelScrollAnimation
+ target: root._internalFlickable
+ property: "contentY"
+ duration: root.smoothWheelAnimationDuration
+ easing.type: Easing.OutCubic
+ }
+
+ Connections {
+ target: root._internalFlickable
+
+ function onDraggingChanged() {
+ if (!root._internalFlickable || !root._internalFlickable.dragging)
+ return;
+ wheelScrollAnimation.stop();
+ root._wheelTargetY = root._internalFlickable.contentY;
+ }
+
+ function onFlickingChanged() {
+ if (!root._internalFlickable || !root._internalFlickable.flicking)
+ return;
+ wheelScrollAnimation.stop();
+ root._wheelTargetY = root._internalFlickable.contentY;
+ }
+
+ function onContentHeightChanged() {
+ root._wheelTargetY = root.clampScrollY(root._wheelTargetY);
+ }
+
+ function onHeightChanged() {
+ root._wheelTargetY = root.clampScrollY(root._wheelTargetY);
+ }
+ }
+
+ // Function to configure the underlying Flickable
+ function configureFlickable() {
+ // Find the internal Flickable (it's usually the first child)
+ for (var i = 0; i < children.length; i++) {
+ var child = children[i];
+ if (child.toString().indexOf("Flickable") !== -1) {
+ // Configure the flickable to prevent horizontal scrolling
+ child.boundsBehavior = root.boundsBehavior;
+ root._internalFlickable = child;
+
+ if (root.preventHorizontalScroll) {
+ child.flickableDirection = Flickable.VerticalFlick;
+ child.contentWidth = Qt.binding(() => child.width);
+ }
+
+ root._wheelTargetY = child.contentY;
+ break;
+ }
+ }
+ }
+
+ WheelHandler {
+ enabled: root.wheelScrollMultiplier !== 1.0 && root._internalFlickable !== null
+ acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
+ onWheel: event => {
+ const delta = event.pixelDelta.y !== 0 ? event.pixelDelta.y : event.angleDelta.y / 2;
+ root.applyWheelScroll(delta);
+ event.accepted = true;
+ }
+ }
+
+ // Watch for changes in horizontalPolicy
+ onHorizontalPolicyChanged: {
+ preventHorizontalScroll = (horizontalPolicy === ScrollBar.AlwaysOff);
+ configureFlickable();
+ }
+
+ ScrollBar.vertical: ScrollBar {
+ parent: root
+ x: root.mirrored ? 0 : root.width - width
+ y: root.topPadding
+ height: root.availableHeight
+ policy: root.verticalPolicy
+ interactive: root.verticalScrollable
+
+ contentItem: Rectangle {
+ implicitWidth: root.handleWidth
+ implicitHeight: 100
+ radius: root.handleRadius
+ color: parent.pressed ? root.handlePressedColor : parent.hovered ? root.handleHoverColor : root.handleColor
+ opacity: parent.policy === ScrollBar.AlwaysOn ? 1.0 : root.verticalScrollable ? ((root.showScrollbarWhenScrollable || parent.active) ? 1.0 : 0.0) : 0.0
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Style.animationFast
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+ }
+
+ background: Rectangle {
+ implicitWidth: root.handleWidth
+ implicitHeight: 100
+ color: root.trackColor
+ opacity: parent.policy === ScrollBar.AlwaysOn ? 0.3 : root.verticalScrollable ? ((root.showScrollbarWhenScrollable || parent.active) ? 0.3 : 0.0) : 0.0
+ radius: root.handleRadius / 2
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Style.animationFast
+ }
+ }
+ }
+ }
+
+ ScrollBar.horizontal: ScrollBar {
+ parent: root
+ x: root.leftPadding
+ y: root.height - height
+ width: root.availableWidth
+ policy: root.horizontalPolicy
+ interactive: root.horizontalScrollable
+
+ contentItem: Rectangle {
+ implicitWidth: 100
+ implicitHeight: root.handleWidth
+ radius: root.handleRadius
+ color: parent.pressed ? root.handlePressedColor : parent.hovered ? root.handleHoverColor : root.handleColor
+ opacity: parent.policy === ScrollBar.AlwaysOn ? 1.0 : root.horizontalScrollable ? ((root.showScrollbarWhenScrollable || parent.active) ? 1.0 : 0.0) : 0.0
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Style.animationFast
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+ }
+
+ background: Rectangle {
+ implicitWidth: 100
+ implicitHeight: root.handleWidth
+ color: root.trackColor
+ opacity: parent.policy === ScrollBar.AlwaysOn ? 0.3 : root.horizontalScrollable ? ((root.showScrollbarWhenScrollable || parent.active) ? 0.3 : 0.0) : 0.0
+ radius: root.handleRadius / 2
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Style.animationFast
+ }
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NSearchableComboBox.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NSearchableComboBox.qml
new file mode 100644
index 0000000..7e58930
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NSearchableComboBox.qml
@@ -0,0 +1,446 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import qs.Commons
+import qs.Widgets
+
+RowLayout {
+ id: root
+
+ property real minimumWidth: 280
+ property real popupHeight: 180
+
+ property bool selectOnNavigation: true
+ property string label: ""
+ property string description: ""
+ property ListModel model: {}
+ property string currentKey: ""
+ property string placeholder: ""
+ property string searchPlaceholder: I18n.tr("placeholders.search")
+ property Component delegate: null
+ property var defaultValue: undefined
+ property string settingsPath: ""
+
+ readonly property real preferredHeight: Math.round(Style.baseWidgetSize * 1.1)
+
+ signal selected(string key)
+
+ spacing: Style.marginL
+ Layout.fillWidth: true
+
+ readonly property bool isValueChanged: (defaultValue !== undefined) && (currentKey !== defaultValue)
+ readonly property string indicatorTooltip: {
+ if (defaultValue === undefined)
+ return "";
+ var displayValue = "";
+ if (defaultValue === "") {
+ // Try to find the display name for empty key in the model
+ if (model && model.count > 0) {
+ for (var i = 0; i < model.count; i++) {
+ var item = model.get(i);
+ if (item && item.key === "") {
+ displayValue = item.name || I18n.tr("panels.indicator.system-default");
+ break;
+ }
+ }
+ // If not found in model, show "System Default" instead of "(empty)"
+ if (displayValue === "") {
+ displayValue = I18n.tr("panels.indicator.system-default");
+ }
+ } else {
+ displayValue = I18n.tr("panels.indicator.system-default");
+ }
+ } else {
+ // Try to find the display name for the default key in the model
+ if (model && model.count > 0) {
+ for (var i = 0; i < model.count; i++) {
+ var item = model.get(i);
+ if (item && item.key === defaultValue) {
+ displayValue = item.name || String(defaultValue);
+ break;
+ }
+ }
+ if (displayValue === "") {
+ displayValue = String(defaultValue);
+ }
+ } else {
+ displayValue = String(defaultValue);
+ }
+ }
+ return I18n.tr("panels.indicator.default-value", {
+ "value": displayValue
+ });
+ }
+
+ // Filtered model for search results
+ property ListModel filteredModel: ListModel {}
+ property string searchText: ""
+
+ function findIndexByKey(key) {
+ if (!root.model)
+ return -1;
+ for (var i = 0; i < root.model.count; i++) {
+ if (root.model.get(i).key === key) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ // The active model used for the popup list (source model or filtered results)
+ readonly property var activeModel: isFiltered ? filteredModel : root.model
+
+ function findIndexInActiveModel(key) {
+ if (!activeModel || activeModel.count === undefined)
+ return -1;
+ for (var i = 0; i < activeModel.count; i++) {
+ if (activeModel.get(i).key === key) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ // Whether we're using filtered results or the source model directly
+ property bool isFiltered: false
+
+ function filterModel() {
+ // Check if model exists and has items
+ if (!root.model || root.model.count === undefined || root.model.count === 0) {
+ filteredModel.clear();
+ isFiltered = false;
+ return;
+ }
+
+ var query = searchText.trim();
+ if (query === "") {
+ // No search text - use source model directly, don't copy
+ filteredModel.clear();
+ isFiltered = false;
+ return;
+ }
+
+ // We have search text - need to filter
+ isFiltered = true;
+ filteredModel.clear();
+
+ // Convert ListModel to array for fuzzy search
+ var items = [];
+ for (var i = 0; i < root.model.count; i++) {
+ items.push(root.model.get(i));
+ }
+
+ // Use fuzzy search if available, fallback to simple search
+ if (typeof FuzzySort !== 'undefined') {
+ var fuzzyResults = FuzzySort.go(query, items, {
+ "key": "name",
+ "limit": 50
+ });
+
+ // Add results in order of relevance
+ for (var j = 0; j < fuzzyResults.length; j++) {
+ filteredModel.append(fuzzyResults[j].obj);
+ }
+ } else {
+ // Fallback to simple search
+ var searchLower = query.toLowerCase();
+ for (var i = 0; i < items.length; i++) {
+ var item = items[i];
+ if (item.name.toLowerCase().includes(searchLower)) {
+ filteredModel.append(item);
+ }
+ }
+ }
+ }
+
+ onSearchTextChanged: {
+ filterModel();
+ listView.currentIndex = 0;
+ }
+
+ NLabel {
+ label: root.label
+ description: root.description
+ showIndicator: root.isValueChanged
+ indicatorTooltip: root.indicatorTooltip
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ ComboBox {
+ id: combo
+
+ opacity: enabled ? 1.0 : 0.6
+ Layout.margins: Style.borderS
+ Layout.minimumWidth: Math.round(root.minimumWidth * Style.uiScaleRatio)
+ Layout.preferredHeight: Math.round(root.preferredHeight * Style.uiScaleRatio)
+ implicitWidth: Layout.minimumWidth
+ model: root.activeModel
+ textRole: "name"
+ currentIndex: findIndexInActiveModel(currentKey)
+ onActivated: {
+ if (combo.currentIndex >= 0 && root.activeModel && combo.currentIndex < root.activeModel.count) {
+ root.selected(root.activeModel.get(combo.currentIndex).key);
+ }
+ }
+
+ background: Rectangle {
+ implicitWidth: Math.round(Style.baseWidgetSize * 3.75 * Style.uiScaleRatio)
+ implicitHeight: Math.round(root.preferredHeight * Style.uiScaleRatio)
+ color: Color.mSurface
+ border.color: combo.activeFocus ? Color.mSecondary : Color.mOutline
+ border.width: Style.borderS
+ radius: Style.iRadiusM
+
+ Behavior on border.color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+ }
+
+ contentItem: NText {
+ leftPadding: Style.marginL
+ rightPadding: combo.indicator.width + Style.marginL
+ pointSize: Style.fontSizeM
+ verticalAlignment: Text.AlignVCenter
+ elide: Text.ElideRight
+
+ // Look up current selection directly in source model by key
+ readonly property int sourceIndex: root.findIndexByKey(root.currentKey)
+ readonly property bool hasSelection: root.model && sourceIndex >= 0 && sourceIndex < root.model.count
+
+ color: hasSelection ? Color.mOnSurface : Color.mOnSurfaceVariant
+ text: hasSelection ? root.model.get(sourceIndex).name : root.placeholder
+ }
+
+ indicator: NIcon {
+ x: combo.width - width - Style.marginM
+ y: combo.topPadding + (combo.availableHeight - height) / 2
+ icon: "caret-down"
+ pointSize: Style.fontSizeL
+ }
+
+ popup: Popup {
+ y: combo.height + Style.marginS
+ width: combo.width
+ height: Math.round((root.popupHeight + 60) * Style.uiScaleRatio)
+ padding: Style.marginM
+
+ contentItem: ColumnLayout {
+ spacing: Style.marginS
+
+ // Search input
+ NTextInput {
+ id: searchInput
+ inputIconName: "search"
+ Layout.fillWidth: true
+ placeholderText: root.searchPlaceholder
+ text: root.searchText
+ onTextChanged: root.searchText = text
+ fontSize: Style.fontSizeS
+
+ Keys.onPressed: event => {
+ if (Keybinds.checkKey(event, 'enter', Settings)) {
+ selectHighlighted();
+ combo.popup.close();
+ event.accepted = true;
+ return;
+ }
+
+ if (Keybinds.checkKey(event, 'escape', Settings)) {
+ combo.popup.close();
+ event.accepted = true;
+ return;
+ }
+
+ if (Keybinds.checkKey(event, 'up', Settings)) {
+ if (listView.currentIndex > 0) {
+ listView.currentIndex--;
+ if (root.selectOnNavigation)
+ selectHighlighted();
+ }
+ event.accepted = true;
+ return;
+ }
+
+ if (Keybinds.checkKey(event, 'down', Settings)) {
+ if (listView.currentIndex < listView.count - 1) {
+ listView.currentIndex++;
+ if (root.selectOnNavigation)
+ selectHighlighted();
+ }
+ event.accepted = true;
+ return;
+ }
+ }
+
+ function selectHighlighted() {
+ if (listView.currentIndex >= 0 && listView.model && listView.currentIndex < listView.model.count) {
+ var selectedKey = listView.model.get(listView.currentIndex).key;
+ root.selected(selectedKey);
+ }
+ }
+ }
+
+ NListView {
+ id: listView
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ // Use activeModel (source model when not filtering, filtered results when searching)
+ model: combo.popup.visible ? root.activeModel : null
+ horizontalPolicy: ScrollBar.AlwaysOff
+ verticalPolicy: ScrollBar.AsNeeded
+
+ onCurrentIndexChanged: {
+ if (currentIndex >= 0)
+ positionViewAtIndex(currentIndex, ListView.Contain);
+ }
+
+ delegate: root.delegate ? root.delegate : defaultDelegate
+
+ Component {
+ id: defaultDelegate
+ ItemDelegate {
+ id: delegateRoot
+ width: listView.availableWidth
+ leftPadding: Style.marginM
+ rightPadding: Style.marginM
+ topPadding: Style.marginS
+ bottomPadding: Style.marginS
+ hoverEnabled: true
+ highlighted: ListView.view.currentIndex === index
+
+ onHoveredChanged: {
+ if (hovered) {
+ ListView.view.currentIndex = index;
+ }
+ }
+
+ onClicked: {
+ var selectedKey = listView.model.get(index).key;
+ root.selected(selectedKey);
+ combo.popup.close();
+ }
+
+ contentItem: RowLayout {
+ width: delegateRoot.width - delegateRoot.leftPadding - delegateRoot.rightPadding
+ spacing: Style.marginM
+
+ NText {
+ text: name
+ pointSize: Style.fontSizeM
+ color: highlighted ? Color.mOnHover : Color.mOnSurface
+ verticalAlignment: Text.AlignVCenter
+ elide: Text.ElideRight
+ Layout.fillWidth: true
+ Behavior on color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+ }
+
+ RowLayout {
+ spacing: Style.marginXXS
+ Layout.alignment: Qt.AlignRight
+
+ // Generic badge renderer
+ Repeater {
+ model: {
+ if (typeof badges === 'undefined' || badges === null)
+ return 0;
+ // Handle both arrays and ListModels
+ if (typeof badges.length !== 'undefined')
+ return badges.length;
+ if (typeof badges.count !== 'undefined')
+ return badges.count;
+ return 0;
+ }
+
+ delegate: NIcon {
+ required property int index
+ readonly property var badgeData: {
+ if (typeof badges === 'undefined' || badges === null)
+ return null;
+ // Handle both arrays and ListModels
+ if (typeof badges.length !== 'undefined')
+ return badges[index];
+ if (typeof badges.get !== 'undefined')
+ return badges.get(index);
+ return null;
+ }
+
+ icon: badgeData && badgeData.icon ? badgeData.icon : ""
+ pointSize: {
+ if (!badgeData || !badgeData.size)
+ return Style.fontSizeXS;
+ if (badgeData.size === "xsmall")
+ return Style.fontSizeXXS;
+ else if (badgeData.size === "medium")
+ return Style.fontSizeM;
+ else
+ return Style.fontSizeXS;
+ }
+ color: highlighted ? Color.mOnHover : (badgeData && badgeData.color ? badgeData.color : Color.mOnSurface)
+ Layout.preferredWidth: Math.round(Style.baseWidgetSize * 0.6)
+ Layout.preferredHeight: Math.round(Style.baseWidgetSize * 0.6)
+ visible: badgeData && badgeData.icon !== undefined && badgeData.icon !== ""
+ }
+ }
+ }
+ }
+ background: Rectangle {
+ anchors.fill: parent
+ color: highlighted ? Color.mHover : "transparent"
+ radius: Style.iRadiusS
+ Behavior on color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ background: Rectangle {
+ color: Color.mSurfaceVariant
+ border.color: Color.mOutline
+ border.width: Style.borderS
+ radius: Style.iRadiusM
+ }
+ }
+
+ // Update the currentIndex if the currentKey is changed externally
+ Connections {
+ target: root
+ function onCurrentKeyChanged() {
+ combo.currentIndex = root.findIndexInActiveModel(root.currentKey);
+ }
+ }
+
+ // Focus search input when popup opens and ensure model is filtered
+ Connections {
+ target: combo.popup
+ function onVisibleChanged() {
+ if (combo.popup.visible) {
+ // Ensure the model is filtered when popup opens
+ filterModel();
+ listView.currentIndex = Math.max(0, root.findIndexInActiveModel(root.currentKey));
+ // Small delay to ensure the popup is fully rendered
+ Qt.callLater(() => {
+ if (searchInput && searchInput.inputItem) {
+ searchInput.inputItem.forceActiveFocus();
+ }
+ });
+ } else {
+ root.searchText = "";
+ }
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NSectionEditor.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NSectionEditor.qml
new file mode 100644
index 0000000..f204546
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NSectionEditor.qml
@@ -0,0 +1,957 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Effects
+import QtQuick.Layouts
+import qs.Commons
+import qs.Services.Noctalia
+import qs.Widgets
+
+NBox {
+ id: root
+
+ property string sectionName: ""
+ property string sectionSubtitle: ""
+ property string sectionId: ""
+ property var widgetModel: []
+ property var availableWidgets: []
+ property var availableSections: ["left", "center", "right"]
+ property var sectionLabels: ({}) // Map of sectionId -> display label
+ property var sectionIcons: ({}) // Map of sectionId -> icon name
+ property bool barIsVertical: false // When true, map left/right to top/bottom in labels
+ property int maxWidgets: -1 // -1 means unlimited
+ property bool draggable: true // Enable/disable drag reordering
+ property bool crossSectionDraggable: false
+ property alias dropTargetArea: gridContainer
+
+ // Get display label for a section
+ function getSectionLabel(sectionId) {
+ if (sectionLabels && sectionLabels[sectionId]) {
+ return sectionLabels[sectionId];
+ }
+ return sectionId; // Fallback to section ID
+ }
+
+ // Get icon for a section
+ function getSectionIcon(sectionId) {
+ if (sectionIcons && sectionIcons[sectionId]) {
+ return sectionIcons[sectionId];
+ }
+ return "arrow-right"; // Default fallback icon
+ }
+
+ property var pluginSettingsEntryPoints: ["settings"]
+ property var widgetRegistry: null
+ property string settingsDialogComponent: "invalid-settings-dialog"
+ property var screen: null // Screen reference for per-screen widget settings
+ property var _activeDialog: null
+ property bool crossDropHoverActive: false
+
+ function clearCrossSectionHover() {
+ crossDropHoverActive = false;
+
+ var parentItem = root.parent;
+ if (!parentItem || !parentItem.children) {
+ return;
+ }
+
+ for (var i = 0; i < parentItem.children.length; i++) {
+ var candidate = parentItem.children[i];
+ if (!candidate || candidate.crossDropHoverActive === undefined)
+ continue;
+ candidate.crossDropHoverActive = false;
+ }
+ }
+
+ function updateCrossSectionHover(globalX, globalY) {
+ var parentItem = root.parent;
+ if (!parentItem || !parentItem.children) {
+ crossDropHoverActive = false;
+ return;
+ }
+
+ crossDropHoverActive = false;
+
+ for (var i = 0; i < parentItem.children.length; i++) {
+ var candidate = parentItem.children[i];
+ if (!candidate || candidate.sectionId === undefined || candidate.crossDropHoverActive === undefined) {
+ continue;
+ }
+
+ var shouldHighlight = false;
+ if (candidate !== root && candidate.visible && candidate.enabled) {
+ var localPoint = candidate.mapFromGlobal(globalX, globalY);
+ var area = candidate.dropTargetArea ? candidate.dropTargetArea : candidate;
+ shouldHighlight = localPoint.x >= area.x && localPoint.y >= area.y && localPoint.x <= area.x + area.width && localPoint.y <= area.y + area.height;
+ }
+
+ candidate.crossDropHoverActive = shouldHighlight;
+ }
+ }
+
+ function isPointInsideSelf(globalX, globalY) {
+ if (globalX < 0 || globalY < 0)
+ return false;
+ var localPoint = root.mapFromGlobal(globalX, globalY);
+ return localPoint.x >= 0 && localPoint.y >= 0 && localPoint.x <= root.width && localPoint.y <= root.height;
+ }
+
+ readonly property bool showCrossSectionDropHint: crossDropHoverActive
+
+ Component.onDestruction: {
+ if (_activeDialog && _activeDialog.close) {
+ var dialog = _activeDialog;
+ _activeDialog = null;
+ dialog.close();
+ dialog.destroy();
+ }
+ }
+
+ readonly property int gridColumns: 3
+ readonly property real miniButtonSize: Style.baseWidgetSize * 0.65
+ readonly property bool isAtMaxCapacity: maxWidgets >= 0 && widgetModel.length >= maxWidgets
+ readonly property real widgetItemHeight: Style.baseWidgetSize * 1.3 * Style.uiScaleRatio
+
+ signal addWidget(string widgetId, string section)
+ signal removeWidget(string section, int index)
+ signal reorderWidget(string section, int fromIndex, int toIndex)
+ signal updateWidgetSettings(string section, int index, var settings)
+ signal moveWidget(string fromSection, int index, string toSection)
+ signal dragPotentialStarted
+ signal dragPotentialEnded
+ signal openPluginSettingsRequested(var pluginManifest, string settingsEntryPoint)
+
+ color: Color.mSurface
+ Layout.fillWidth: true
+ z: flowDragArea.dragStarted ? 5000 : 0
+
+ // Calculate width to fit gridColumns widgets with spacing
+ function calculateWidgetWidth(gridWidth) {
+ var columnSpacing = (root.gridColumns - 1) * Style.marginM;
+ var widgetWidth = (gridWidth - columnSpacing) / root.gridColumns;
+ return Math.floor(widgetWidth);
+ }
+
+ function findSectionAtGlobalPosition(globalX, globalY) {
+ var parentItem = root.parent;
+ if (!parentItem || !parentItem.children) {
+ return "";
+ }
+
+ for (var i = 0; i < parentItem.children.length; i++) {
+ var candidate = parentItem.children[i];
+ if (!candidate || candidate === root) {
+ continue;
+ }
+
+ // Only consider sibling section editors
+ if (candidate.sectionId === undefined || !candidate.visible || !candidate.enabled) {
+ continue;
+ }
+
+ var localPoint = candidate.mapFromGlobal(globalX, globalY);
+ var area = candidate.dropTargetArea ? candidate.dropTargetArea : candidate;
+ if (localPoint.x >= area.x && localPoint.y >= area.y && localPoint.x <= area.x + area.width && localPoint.y <= area.y + area.height) {
+ return candidate.sectionId;
+ }
+ }
+
+ return "";
+ }
+
+ Layout.minimumHeight: {
+ // header + minimal content area
+ var absoluteMin = Style.margin2L + (Style.fontSizeL * 2) + Style.marginM + (65 * Style.uiScaleRatio);
+
+ var widgetCount = widgetModel.length;
+ if (widgetCount === 0) {
+ return absoluteMin;
+ }
+
+ // Calculate rows based on grid layout
+ // Use actual parent width if available, otherwise estimate
+ var availableWidth = (parent && parent.width > 0) ? (parent.width - Style.margin2L) : 400;
+ var rows = Math.ceil(widgetCount / root.gridColumns);
+
+ // Calculate widget width for height calculation
+ var containerWidth = availableWidth;
+ var widgetWidth = calculateWidgetWidth(containerWidth);
+
+ // Header height + spacing + (rows * widget height) + (spacing between rows) + margins
+ var headerHeight = Style.fontSizeL * 2;
+ // Account for grid margins
+ var gridTopMargin = Style.marginS;
+ var gridBottomMargin = Style.marginL;
+ var widgetAreaHeight = gridTopMargin + (rows * widgetItemHeight) + ((rows - 1) * Style.marginL) + gridBottomMargin;
+
+ return Math.max(absoluteMin, Style.margin2L + headerHeight + Style.marginM + widgetAreaHeight);
+ }
+
+ // Generate widget color from name checksum
+ function getWidgetColor(widget) {
+ if (widget.id.startsWith('plugin:')) {
+ return [Color.mSecondary, Color.mOnSecondary];
+ }
+ return [Color.mPrimary, Color.mOnPrimary];
+ }
+
+ // Check if widget has settings (either core widget with metadata or plugin with settings entry point)
+ function widgetHasSettings(widgetId) {
+ // Check if it's a plugin with settings
+ if (root.widgetRegistry && root.widgetRegistry.isPluginWidget(widgetId)) {
+ var pluginId = widgetId.replace("plugin:", "");
+ var manifest = PluginRegistry.getPluginManifest(pluginId);
+ if (!manifest?.entryPoints)
+ return false;
+ for (var i = 0; i < root.pluginSettingsEntryPoints.length; i++) {
+ if (manifest.entryPoints[root.pluginSettingsEntryPoints[i]] !== undefined)
+ return true;
+ }
+ return false;
+ }
+
+ // Check if it's a core widget with user settings
+ if (root.widgetRegistry && root.widgetRegistry.widgetHasUserSettings(widgetId)) {
+ return true;
+ }
+
+ return false;
+ }
+
+ // Open settings for a widget
+ function openWidgetSettings(index, widgetData) {
+ // Check if this is a plugin widget with a generic "settings" entry point
+ var isPlugin = root.widgetRegistry && root.widgetRegistry.isPluginWidget(widgetData.id);
+
+ if (isPlugin) {
+ var pluginId = widgetData.id.replace("plugin:", "");
+ var manifest = PluginRegistry.getPluginManifest(pluginId);
+
+ var settingsKey = null;
+ if (manifest?.entryPoints) {
+ for (var i = 0; i < root.pluginSettingsEntryPoints.length; i++) {
+ if (manifest.entryPoints[root.pluginSettingsEntryPoints[i]] !== undefined) {
+ settingsKey = root.pluginSettingsEntryPoints[i];
+ break;
+ }
+ }
+ }
+ if (!manifest || !settingsKey) {
+ Logger.e("NSectionEditor", "Plugin settings not found for:", pluginId);
+ return;
+ }
+
+ // "desktopWidgetSettings" is handled by the settingsDialogComponent
+ // (DesktopWidgetSettingsDialog) which passes widgetSettings/save properly.
+ // Only generic "settings" goes through the plugin settings popup.
+ if (settingsKey !== "desktopWidgetSettings") {
+ root.openPluginSettingsRequested(manifest, settingsKey);
+ return;
+ }
+ }
+
+ // Handle core widgets and plugin desktop widget settings
+ {
+ var component = Qt.createComponent(Qt.resolvedUrl(root.settingsDialogComponent));
+
+ function instantiateAndOpen() {
+ if (root._activeDialog) {
+ try {
+ root._activeDialog.close();
+ root._activeDialog.destroy();
+ } catch (e) {}
+ root._activeDialog = null;
+ }
+
+ var dialog = component.createObject(Overlay.overlay, {
+ "widgetIndex": index,
+ "widgetData": widgetData,
+ "widgetId": widgetData.id,
+ "sectionId": root.sectionId,
+ "screen": root.screen
+ });
+
+ if (dialog) {
+ root._activeDialog = dialog;
+ dialog.updateWidgetSettings.connect(root.updateWidgetSettings);
+ dialog.closed.connect(() => {
+ if (root._activeDialog === dialog) {
+ root._activeDialog = null;
+ dialog.destroy();
+ }
+ });
+ dialog.open();
+ } else {
+ Logger.e("NSectionEditor", "Failed to create settings dialog instance");
+ }
+ }
+
+ if (component.status === Component.Ready) {
+ instantiateAndOpen();
+ } else if (component.status === Component.Error) {
+ Logger.e("NSectionEditor", component.errorString());
+ } else {
+ component.statusChanged.connect(function () {
+ if (component.status === Component.Ready) {
+ instantiateAndOpen();
+ } else if (component.status === Component.Error) {
+ Logger.e("NSectionEditor", component.errorString());
+ }
+ });
+ }
+ }
+ }
+
+ ColumnLayout {
+ anchors.fill: parent
+ anchors.margins: Style.marginL
+ spacing: Style.marginM
+
+ RowLayout {
+ Layout.fillWidth: true
+ Layout.rightMargin: Style.marginS
+
+ ColumnLayout {
+ spacing: Style.marginXXS
+ Layout.alignment: Qt.AlignVCenter
+ Layout.fillWidth: false
+ Layout.leftMargin: Style.marginS
+ Layout.maximumWidth: {
+ // Reserve space for other elements: count indicator, combo box (~200), button (~50), and margins
+ // Use a reasonable maximum that leaves room for controls on the right
+ // On smaller screens, use a smaller percentage to ensure controls fit
+ var rowLayout = parent;
+ if (rowLayout && rowLayout.width > 0) {
+ // Use smaller percentage on smaller screens, but ensure minimum space for text
+ var minWidth = 150 * Style.uiScaleRatio;
+ var maxWidth = rowLayout.width < 600 ? rowLayout.width * 0.35 : rowLayout.width * 0.4;
+ return Math.max(minWidth, maxWidth);
+ }
+ return 250 * Style.uiScaleRatio;
+ }
+
+ NText {
+ text: sectionName
+ pointSize: Style.fontSizeL
+ font.weight: Style.fontWeightBold
+ color: Color.mOnSurface
+ elide: Text.ElideRight
+ Layout.fillWidth: true
+ }
+
+ NText {
+ visible: sectionSubtitle !== ""
+ text: sectionSubtitle
+ pointSize: Style.fontSizeS
+ color: Color.mOnSurfaceVariant
+ elide: Text.ElideRight
+ Layout.fillWidth: true
+ }
+ }
+
+ // Widget count indicator (when max is set)
+ NText {
+ visible: root.maxWidgets >= 0
+ text: root.maxWidgets === 0 ? "(LOCKED)" : "(" + widgetModel.length + "/" + root.maxWidgets + ")"
+ pointSize: Style.fontSizeS
+ color: root.isAtMaxCapacity ? Color.mError : Color.mOnSurfaceVariant
+ Layout.alignment: Qt.AlignVCenter
+ Layout.leftMargin: Style.marginXS
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ NSearchableComboBox {
+ id: comboBox
+ model: availableWidgets ?? null
+ label: ""
+ description: ""
+ placeholder: I18n.tr("bar.section-editor.placeholder")
+ searchPlaceholder: I18n.tr("bar.section-editor.search-placeholder")
+ onSelected: key => comboBox.currentKey = key
+ popupHeight: 300 * Style.uiScaleRatio
+ minimumWidth: 200 * Style.uiScaleRatio
+ enabled: !root.isAtMaxCapacity
+
+ Layout.alignment: Qt.AlignVCenter
+
+ // Re-filter when the model count changes (when widgets are loaded)
+ Connections {
+ target: availableWidgets ?? null
+ ignoreUnknownSignals: true
+ function onCountChanged() {
+ // Trigger a re-filter by clearing and re-setting the search text
+ var currentSearch = comboBox.searchText;
+ comboBox.searchText = "";
+ comboBox.searchText = currentSearch;
+ }
+ }
+ }
+
+ NIconButton {
+ icon: "add"
+ colorBg: Color.mPrimary
+ colorFg: Color.mOnPrimary
+ colorBgHover: Color.mSecondary
+ colorFgHover: Color.mOnSecondary
+ enabled: comboBox.currentKey !== "" && !root.isAtMaxCapacity
+ tooltipText: root.isAtMaxCapacity ? I18n.tr("tooltips.max-widgets-reached") : I18n.tr("tooltips.add-widget")
+ Layout.alignment: Qt.AlignVCenter
+ Layout.leftMargin: Style.marginS
+ onClicked: {
+ if (comboBox.currentKey !== "" && !root.isAtMaxCapacity) {
+ addWidget(comboBox.currentKey, sectionId);
+ comboBox.currentKey = "";
+ }
+ }
+ }
+ }
+
+ // Drag and Drop Widget Area
+ Item {
+ id: gridContainer
+ Layout.fillWidth: true
+ Layout.preferredHeight: {
+ if (widgetModel.length === 0) {
+ return 65 * Style.uiScaleRatio;
+ }
+ // Use actual width, fallback to a reasonable default if not yet available
+ var containerWidth = width > 0 ? width : (parent ? parent.width : 400);
+ var rows = Math.ceil(widgetModel.length / root.gridColumns);
+ // Calculate height: (rows * item height) + (row spacing between items) + grid margins
+ var gridTopMargin = Style.marginS;
+ var gridBottomMargin = Style.marginS;
+ var calculatedHeight = gridTopMargin + (rows * root.widgetItemHeight) + ((rows - 1) * Style.marginS) + gridBottomMargin;
+ return calculatedHeight;
+ }
+ Layout.minimumHeight: widgetModel.length === 0 ? (65 * Style.uiScaleRatio) : (Style.margin2S + root.widgetItemHeight)
+ clip: !flowDragArea.dragStarted
+
+ Rectangle {
+ anchors.fill: parent
+ radius: Style.iRadiusL
+ color: Qt.alpha(Color.mSecondary, 0.12)
+ border.color: Color.mSecondary
+ border.width: Style.borderM
+ visible: root.showCrossSectionDropHint
+ z: 1500
+ }
+
+ Grid {
+ id: widgetGrid
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.leftMargin: Style.marginS
+ anchors.rightMargin: Style.marginS
+ anchors.topMargin: Style.marginS
+ anchors.bottomMargin: Style.marginS
+ columns: root.gridColumns
+ rowSpacing: Style.marginS
+ columnSpacing: Style.marginM
+
+ Repeater {
+ id: widgetRepeater
+ model: widgetModel
+
+ delegate: Rectangle {
+ id: widgetItem
+ required property int index
+ required property var modelData
+
+ width: root.calculateWidgetWidth(parent.width)
+ height: root.widgetItemHeight
+ radius: Style.iRadiusL
+ color: root.getWidgetColor(modelData)[0]
+ border.color: Color.mOutline
+ border.width: Style.borderS
+
+ // Store the widget index for drag operations
+ property int widgetIndex: index
+ readonly property int buttonsWidth: Math.round(20)
+ readonly property int buttonsCount: root.widgetHasSettings(modelData.id) ? 1 : 0
+
+ // Visual feedback during drag
+ opacity: flowDragArea.draggedIndex === index ? 0.5 : 1.0
+ scale: flowDragArea.draggedIndex === index ? 0.95 : 1.0
+ z: flowDragArea.draggedIndex === index ? 1000 : 0
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Style.animationFast
+ }
+ }
+ Behavior on scale {
+ NumberAnimation {
+ duration: Style.animationFast
+ }
+ }
+
+ // Context menu for moving widget to other sections
+ NContextMenu {
+ id: contextMenu
+ parent: Overlay.overlay
+ width: 240 * Style.uiScaleRatio
+ model: {
+ var items = [];
+ // Add move options for each available section (except current)
+ for (var i = 0; i < root.availableSections.length; i++) {
+ var section = root.availableSections[i];
+ if (section !== root.sectionId) {
+ var label = root.getSectionLabel(section);
+ var displayLabel = '';
+ // Map section IDs to correct position keys based on bar orientation
+ var positionKey = section;
+ if (root.barIsVertical) {
+ if (section === "left")
+ positionKey = "top";
+ else if (section === "right")
+ positionKey = "bottom";
+ }
+ if (I18n.hasTranslation("positions." + positionKey)) {
+ displayLabel = I18n.tr("positions." + positionKey);
+ } else {
+ displayLabel = label.charAt(0).toUpperCase() + label.slice(1);
+ }
+
+ items.push({
+ "label": I18n.tr("tooltips.move-to-section", {
+ "section": displayLabel
+ }),
+ "action": section,
+ "icon": root.getSectionIcon(section),
+ "visible": true
+ });
+ }
+ }
+ // Add remove option
+ items.push({
+ "label": I18n.tr("tooltips.remove-widget"),
+ "action": "remove",
+ "icon": "trash",
+ "visible": true
+ });
+ return items;
+ }
+
+ onTriggered: action => {
+ if (action === "remove") {
+ root.removeWidget(root.sectionId, widgetItem.index);
+ } else {
+ root.moveWidget(root.sectionId, widgetItem.index, action);
+ }
+ }
+ }
+
+ // MouseArea for the context menu - only active when not dragging
+ MouseArea {
+ id: contextMouseArea
+ anchors.fill: parent
+ acceptedButtons: Qt.RightButton
+ cursorShape: Qt.PointingHandCursor
+ z: -1 // Below the buttons but above background
+ enabled: !flowDragArea.dragStarted && !flowDragArea.potentialDrag
+
+ onPressed: mouse => {
+ mouse.accepted = true;
+ // Check if click is not on the settings button area (if visible)
+ const localX = mouse.x;
+ const buttonsStartX = parent.width - (parent.buttonsCount * parent.buttonsWidth);
+ if (localX < buttonsStartX || parent.buttonsCount === 0) {
+ contextMenu.openAtItem(widgetItem, mouse.x, mouse.y);
+ }
+ }
+ }
+
+ RowLayout {
+ id: widgetContent
+ anchors.fill: parent
+ anchors.margins: Style.marginXS
+ anchors.rightMargin: Style.marginS
+ spacing: Style.marginXXS
+
+ NText {
+ text: {
+ // For plugin widgets, get the actual plugin name from manifest
+ if (root.widgetRegistry && root.widgetRegistry.isPluginWidget(modelData.id)) {
+ const pluginId = modelData.id.replace("plugin:", "");
+ const manifest = PluginRegistry.getPluginManifest(pluginId);
+ if (manifest && manifest.name) {
+ return manifest.name;
+ }
+ // Fallback: just strip the prefix
+ return pluginId;
+ }
+ return modelData.id;
+ }
+ pointSize: Style.fontSizeXS
+ color: root.getWidgetColor(modelData)[1]
+ horizontalAlignment: Text.AlignLeft
+ verticalAlignment: Text.AlignVCenter
+ elide: Text.ElideRight
+ leftPadding: Style.marginS
+ rightPadding: Style.marginS
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ }
+
+ // Plugin indicator icon
+ NIcon {
+ visible: root.widgetRegistry && root.widgetRegistry.isPluginWidget(modelData.id)
+ icon: "plugin"
+ pointSize: Style.fontSizeXXS
+ color: root.getWidgetColor(modelData)[1]
+ Layout.preferredWidth: visible ? Style.baseWidgetSize * 0.5 : 0
+ Layout.preferredHeight: Style.baseWidgetSize * 0.5
+ }
+
+ // CPU intensive indicator icon
+ NIcon {
+ visible: root.widgetRegistry && root.widgetRegistry.isCpuIntensive(modelData.id)
+ icon: "cpu-intensive"
+ pointSize: Style.fontSizeXXS
+ color: root.getWidgetColor(modelData)[1]
+ Layout.preferredWidth: visible ? Style.baseWidgetSize * 0.5 : 0
+ Layout.preferredHeight: Style.baseWidgetSize * 0.5
+ }
+
+ RowLayout {
+ spacing: 0
+ Layout.preferredWidth: buttonsCount * buttonsWidth * Style.uiScaleRatio
+ Layout.preferredHeight: parent.height
+
+ Loader {
+ active: root.widgetHasSettings(modelData.id) && root.enabled
+ sourceComponent: NIconButton {
+ icon: "settings"
+ tooltipText: I18n.tr("actions.widget-settings")
+ baseSize: miniButtonSize
+ colorBorder: Qt.alpha(Color.mOutline, Style.opacityLight)
+ colorBg: Color.mOnSurface
+ colorFg: Color.mOnPrimary
+ colorBgHover: Qt.alpha(Color.mOnPrimary, Style.opacityLight)
+ colorFgHover: Color.mOnPrimary
+ onClicked: {
+ root.openWidgetSettings(index, modelData);
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Ghost/Clone widget for dragging
+ Rectangle {
+ id: dragGhost
+ width: 0
+ height: Style.baseWidgetSize * 1.15
+ radius: Style.iRadiusL
+ color: "transparent"
+ border.color: Color.mOutline
+ border.width: Style.borderS
+ opacity: 0.7
+ visible: flowDragArea.dragStarted
+ z: 2000
+ clip: false // Ensure ghost isn't clipped
+
+ NText {
+ id: ghostText
+ anchors.centerIn: parent
+ pointSize: Style.fontSizeS
+ color: Color.mOnPrimary
+ }
+ }
+
+ // Drop indicator - visual feedback for where the widget will be inserted
+ Rectangle {
+ id: dropIndicator
+ width: 3
+ height: Style.baseWidgetSize * 1.15
+ radius: Style.iRadiusXXS
+ color: Color.mSecondary
+ opacity: 0
+ visible: opacity > 0
+ z: 1999
+
+ SequentialAnimation on opacity {
+ id: pulseAnimation
+ running: false
+ loops: Animation.Infinite
+ NumberAnimation {
+ to: 1
+ duration: 400
+ easing.type: Easing.InOutQuad
+ }
+ NumberAnimation {
+ to: 0.6
+ duration: 400
+ easing.type: Easing.InOutQuad
+ }
+ }
+
+ Behavior on x {
+ NumberAnimation {
+ duration: 100
+ easing.type: Easing.OutCubic
+ }
+ }
+ Behavior on y {
+ NumberAnimation {
+ duration: 100
+ easing.type: Easing.OutCubic
+ }
+ }
+ }
+
+ // MouseArea for drag and drop
+ MouseArea {
+ id: flowDragArea
+ anchors.fill: parent
+ z: 100 // Above widgets to ensure it captures events first
+ enabled: root.draggable || root.crossSectionDraggable
+
+ acceptedButtons: Qt.LeftButton
+ preventStealing: true // Always prevent stealing to ensure we get all events
+ propagateComposedEvents: true // Allow events to propagate when not handled
+ hoverEnabled: potentialDrag || dragStarted // Only track hover during drag operations
+ cursorShape: dragStarted ? Qt.ClosedHandCursor : Qt.ArrowCursor
+
+ property point startPos: Qt.point(0, 0)
+ property bool dragStarted: false
+ property bool potentialDrag: false // Track if we're in a potential drag interaction
+ property int draggedIndex: -1
+ property real dragThreshold: 8 // Reduced threshold for more responsive drag
+ property Item draggedWidget: null
+ property int dropTargetIndex: -1
+ property var draggedModelData: null
+ property bool isOverButtonArea: false
+
+ // Drop position calculation
+ // Map widget coordinates from grid-local to gridContainer coordinates
+ function mapWidgetCoords(widget) {
+ return {
+ x: widget.x + widgetGrid.x,
+ y: widget.y + widgetGrid.y,
+ width: widget.width,
+ height: widget.height
+ };
+ }
+
+ function updateDropIndicator(mouseX, mouseY) {
+ if (!dragStarted || draggedIndex === -1) {
+ dropIndicator.opacity = 0;
+ pulseAnimation.running = false;
+ return;
+ }
+
+ let bestIndex = -1;
+ let bestPosition = null;
+ let minDistance = Infinity;
+
+ // Check position relative to each widget
+ for (var i = 0; i < widgetModel.length; i++) {
+ if (i === draggedIndex)
+ continue;
+ const widget = widgetRepeater.itemAt(i);
+ if (!widget || widget.widgetIndex === undefined)
+ continue;
+
+ const mapped = mapWidgetCoords(widget);
+
+ // Check distance to left edge (insert before)
+ const leftDist = Math.sqrt(Math.pow(mouseX - mapped.x, 2) + Math.pow(mouseY - (mapped.y + mapped.height / 2), 2));
+
+ // Check distance to right edge (insert after)
+ const rightDist = Math.sqrt(Math.pow(mouseX - (mapped.x + mapped.width), 2) + Math.pow(mouseY - (mapped.y + mapped.height / 2), 2));
+
+ if (leftDist < minDistance) {
+ minDistance = leftDist;
+ bestIndex = i;
+ bestPosition = Qt.point(mapped.x - dropIndicator.width / 2 - Style.marginXS, mapped.y);
+ }
+
+ if (rightDist < minDistance) {
+ minDistance = rightDist;
+ bestIndex = i + 1;
+ bestPosition = Qt.point(mapped.x + mapped.width + Style.marginXS - dropIndicator.width / 2, mapped.y);
+ }
+ }
+
+ // Check if we should insert at position 0 (very beginning)
+ if (widgetModel.length > 0 && draggedIndex !== 0) {
+ const firstWidget = widgetRepeater.itemAt(0);
+ if (firstWidget) {
+ const mapped = mapWidgetCoords(firstWidget);
+ const dist = Math.sqrt(Math.pow(mouseX - mapped.x, 2) + Math.pow(mouseY - mapped.y, 2));
+ if (dist < minDistance && mouseX < mapped.x + mapped.width / 2) {
+ minDistance = dist;
+ bestIndex = 0;
+ // Position indicator to the left of the first widget
+ bestPosition = Qt.point(mapped.x - dropIndicator.width / 2 - Style.marginXS, mapped.y);
+ }
+ }
+ }
+
+ // Only show indicator if we're close enough and it's a different position
+ if (minDistance < 80 && bestIndex !== -1) {
+ // Adjust index if we're moving forward
+ let adjustedIndex = bestIndex;
+ if (bestIndex > draggedIndex) {
+ adjustedIndex = bestIndex - 1;
+ }
+
+ // Don't show if it's the same position
+ if (adjustedIndex === draggedIndex) {
+ dropIndicator.opacity = 0;
+ pulseAnimation.running = false;
+ dropTargetIndex = -1;
+ return;
+ }
+
+ dropTargetIndex = adjustedIndex;
+ if (bestPosition) {
+ dropIndicator.x = bestPosition.x;
+ dropIndicator.y = bestPosition.y;
+ dropIndicator.opacity = 1;
+ if (!pulseAnimation.running) {
+ pulseAnimation.running = true;
+ }
+ }
+ } else {
+ dropIndicator.opacity = 0;
+ pulseAnimation.running = false;
+ dropTargetIndex = -1;
+ }
+ }
+
+ function resetDragState() {
+ root.clearCrossSectionHover();
+ dragStarted = false;
+ potentialDrag = false;
+ draggedIndex = -1;
+ draggedWidget = null;
+ dropTargetIndex = -1;
+ draggedModelData = null;
+ isOverButtonArea = false;
+ dropIndicator.opacity = 0;
+ pulseAnimation.running = false;
+ dragGhost.width = 0;
+ }
+
+ onPressed: mouse => {
+ // Reset state
+ startPos = Qt.point(mouse.x, mouse.y);
+ dragStarted = false;
+ potentialDrag = false;
+ draggedIndex = -1;
+ draggedWidget = null;
+ dropTargetIndex = -1;
+ draggedModelData = null;
+ isOverButtonArea = false;
+
+ // Find which widget was clicked
+ for (var i = 0; i < widgetModel.length; i++) {
+ const widget = widgetRepeater.itemAt(i);
+ if (widget && widget.widgetIndex !== undefined) {
+ if (mouse.x >= widget.x && mouse.x <= widget.x + widget.width && mouse.y >= widget.y && mouse.y <= widget.y + widget.height) {
+ const localX = mouse.x - widget.x;
+ const buttonsStartX = widget.width - (widget.buttonsCount * widget.buttonsWidth * Style.uiScaleRatio);
+
+ if (localX >= buttonsStartX && widget.buttonsCount > 0) {
+ // Click is on button area - don't start drag, propagate event
+ isOverButtonArea = true;
+ mouse.accepted = false;
+ return;
+ } else {
+ // This is a draggable area
+ draggedIndex = widget.widgetIndex;
+ draggedWidget = widget;
+ draggedModelData = widget.modelData;
+ potentialDrag = true;
+ mouse.accepted = true;
+
+ // Signal that interaction started (prevents panel close)
+ root.dragPotentialStarted();
+ return;
+ }
+ }
+ }
+ }
+
+ // Click was not on any widget
+ mouse.accepted = false;
+ }
+
+ onPositionChanged: mouse => {
+ if (potentialDrag && draggedIndex !== -1) {
+ const deltaX = mouse.x - startPos.x;
+ const deltaY = mouse.y - startPos.y;
+ const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
+
+ // Start drag if threshold exceeded
+ if (!dragStarted && distance > dragThreshold) {
+ dragStarted = true;
+
+ // Setup ghost widget
+ if (draggedWidget) {
+ dragGhost.width = draggedWidget.width;
+ dragGhost.color = root.getWidgetColor(draggedModelData)[0];
+ ghostText.text = draggedModelData.id;
+ }
+
+ var startGlobal = flowDragArea.mapToGlobal(mouse.x, mouse.y);
+ root.updateCrossSectionHover(startGlobal.x, startGlobal.y);
+ }
+
+ if (dragStarted) {
+ // Move ghost widget
+ dragGhost.x = mouse.x - dragGhost.width / 2;
+ dragGhost.y = mouse.y - dragGhost.height / 2;
+
+ var moveGlobal = flowDragArea.mapToGlobal(mouse.x, mouse.y);
+ root.updateCrossSectionHover(moveGlobal.x, moveGlobal.y);
+
+ // Update drop indicator
+ updateDropIndicator(mouse.x, mouse.y);
+ }
+ }
+ }
+
+ onReleased: mouse => {
+ if (root.draggable && dragStarted && dropTargetIndex !== -1 && dropTargetIndex !== draggedIndex) {
+ // Perform the reorder
+ reorderWidget(sectionId, draggedIndex, dropTargetIndex);
+ } else if (dragStarted && draggedIndex !== -1) {
+ var globalPos = flowDragArea.mapToGlobal(mouse.x, mouse.y);
+ var targetSectionId = root.findSectionAtGlobalPosition(globalPos.x, globalPos.y);
+ if (targetSectionId !== "" && targetSectionId !== root.sectionId) {
+ root.moveWidget(root.sectionId, draggedIndex, targetSectionId);
+ }
+ }
+
+ // Always signal end of interaction if we started one
+ if (potentialDrag) {
+ root.dragPotentialEnded();
+ }
+
+ // Reset everything
+ resetDragState();
+ mouse.accepted = true;
+ }
+
+ onCanceled: {
+ // Handle cancel (e.g., ESC key pressed during drag)
+ if (potentialDrag) {
+ root.dragPotentialEnded();
+ }
+
+ resetDragState();
+ }
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NSettingsIndicator.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NSettingsIndicator.qml
new file mode 100644
index 0000000..b1aeb80
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NSettingsIndicator.qml
@@ -0,0 +1,47 @@
+import QtQuick
+import QtQuick.Layouts
+import qs.Commons
+import qs.Services.UI
+
+Rectangle {
+ id: root
+
+ property bool show: false
+ property var tooltipText
+
+ implicitWidth: root.show ? 6 * Style.uiScaleRatio : 0
+ implicitHeight: root.show ? 6 * Style.uiScaleRatio : 0
+ width: root.show ? 6 * Style.uiScaleRatio : 0
+ height: root.show ? 6 * Style.uiScaleRatio : 0
+ radius: width / 2
+ color: Color.mOnSurfaceVariant
+ opacity: 0.6
+
+ visible: root.show
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Style.animationFast
+ }
+ }
+
+ MouseArea {
+ enabled: root.show && root.tooltipText && (!Array.isArray(root.tooltipText) || root.tooltipText.length > 0)
+ anchors.fill: parent
+ hoverEnabled: true
+ acceptedButtons: Qt.NoButton
+ cursorShape: Qt.PointingHandCursor
+
+ onEntered: {
+ if (root.tooltipText && (!Array.isArray(root.tooltipText) || root.tooltipText.length > 0)) {
+ TooltipService.show(root, root.tooltipText);
+ }
+ }
+
+ onExited: {
+ if (root.tooltipText && (!Array.isArray(root.tooltipText) || root.tooltipText.length > 0)) {
+ TooltipService.hide();
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NSlideSwapView.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NSlideSwapView.qml
new file mode 100644
index 0000000..f37f14a
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NSlideSwapView.qml
@@ -0,0 +1,135 @@
+import QtQuick
+import qs.Commons
+
+Item {
+ id: root
+
+ property Component sourceComponent
+ property bool animationsEnabled: true
+ property int duration: Style.animationNormal
+ property real transitionGap: Style.marginXL
+ property real incomingStartOpacity: 0.0
+ property real outgoingTargetOpacity: 0.25
+
+ readonly property var item: contentLoader.item
+ readonly property bool running: _running
+
+ property bool _running: false
+ property var _pendingApplyChange: null
+ property real _contentOffset: 0
+ property real _contentOpacity: 1
+ property real _snapshotOffset: 0
+ property real _snapshotOpacity: 0
+ property real _snapshotTargetOffset: 0
+
+ clip: true
+
+ function resetVisuals() {
+ _running = false;
+ _pendingApplyChange = null;
+ _contentOffset = 0;
+ _contentOpacity = 1;
+ _snapshotOffset = 0;
+ _snapshotOpacity = 0;
+ snapshot.visible = false;
+ transition.stop();
+ }
+
+ function swap(direction, applyChange) {
+ if (!animationsEnabled || width <= 0 || height <= 0 || direction === 0) {
+ if (applyChange)
+ applyChange();
+ return;
+ }
+
+ if (_running)
+ resetVisuals();
+
+ const slideDistance = Math.max(1, width + transitionGap);
+ const movingForward = direction > 0;
+
+ snapshot.visible = true;
+ _snapshotOffset = 0;
+ _snapshotOpacity = 1;
+ _snapshotTargetOffset = movingForward ? -slideDistance : slideDistance;
+
+ _contentOffset = movingForward ? slideDistance : -slideDistance;
+ _contentOpacity = incomingStartOpacity;
+
+ _pendingApplyChange = applyChange || null;
+ _running = true;
+ snapshot.scheduleUpdate();
+
+ Qt.callLater(() => {
+ if (!_running) {
+ return;
+ }
+
+ const applyFn = _pendingApplyChange;
+ _pendingApplyChange = null;
+ const shouldAnimate = applyFn ? applyFn() !== false : true;
+ if (!shouldAnimate) {
+ resetVisuals();
+ return;
+ }
+ transition.restart();
+ });
+ }
+
+ ShaderEffectSource {
+ id: snapshot
+ visible: false
+ width: parent.width
+ height: parent.height
+ y: 0
+ sourceItem: contentLoader
+ hideSource: false
+ live: false
+ smooth: true
+ z: 2
+ x: root._snapshotOffset
+ opacity: root._snapshotOpacity
+ }
+
+ Loader {
+ id: contentLoader
+ width: parent.width
+ height: parent.height
+ x: root._contentOffset
+ opacity: root._contentOpacity
+ sourceComponent: root.sourceComponent
+ }
+
+ ParallelAnimation {
+ id: transition
+ NumberAnimation {
+ target: root
+ property: "_contentOffset"
+ to: 0
+ duration: root.duration
+ easing.type: Easing.OutCubic
+ }
+ NumberAnimation {
+ target: root
+ property: "_contentOpacity"
+ to: 1
+ duration: root.duration
+ easing.type: Easing.OutCubic
+ }
+ NumberAnimation {
+ target: root
+ property: "_snapshotOffset"
+ to: root._snapshotTargetOffset
+ duration: root.duration
+ easing.type: Easing.OutCubic
+ }
+ NumberAnimation {
+ target: root
+ property: "_snapshotOpacity"
+ to: root.outgoingTargetOpacity
+ duration: root.duration
+ easing.type: Easing.OutCubic
+ }
+ onFinished: root.resetVisuals()
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NSlider.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NSlider.qml
new file mode 100644
index 0000000..c30d6b6
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NSlider.qml
@@ -0,0 +1,253 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Shapes
+import qs.Commons
+import qs.Services.UI
+
+Slider {
+ id: root
+
+ readonly property bool sliderActive: activeFocus || pressed
+ property color fillColor: Color.mPrimary
+ property var cutoutColor: Color.mSurface
+ property bool snapAlways: true
+ property real heightRatio: 0.7
+ property var tooltipText
+ property string tooltipDirection: "auto"
+ property bool hovering: false
+
+ readonly property color effectiveFillColor: enabled ? fillColor : Color.mOutline
+
+ readonly property real knobDiameter: Math.round((Style.baseWidgetSize * heightRatio * Style.uiScaleRatio) / 2) * 2
+ readonly property real trackHeight: Math.round((knobDiameter * 0.4 * Style.uiScaleRatio) / 2) * 2
+ readonly property real trackRadius: Math.min(Style.iRadiusL, trackHeight / 2)
+ readonly property real cutoutExtra: Math.round((Style.baseWidgetSize * 0.1 * Style.uiScaleRatio) / 2) * 2
+
+ padding: cutoutExtra / 2
+
+ snapMode: snapAlways ? Slider.SnapAlways : Slider.SnapOnRelease
+ implicitHeight: Math.max(trackHeight, knobDiameter)
+
+ background: Item {
+ id: bgContainer
+ x: root.leftPadding
+ y: root.topPadding + Style.pixelAlignCenter(root.availableHeight, root.trackHeight)
+ implicitWidth: Style.sliderWidth
+ implicitHeight: root.trackHeight
+ width: root.availableWidth
+ height: root.trackHeight
+
+ readonly property real fillWidth: root.visualPosition * width
+
+ // Background track
+ Shape {
+ anchors.fill: parent
+ visible: bgContainer.width > 0 && bgContainer.height > 0
+ preferredRendererType: Shape.CurveRenderer
+ asynchronous: true
+
+ ShapePath {
+ id: bgPath
+ strokeColor: Qt.alpha(Color.mOutline, 0.5)
+ strokeWidth: Style.borderS
+ fillColor: Qt.alpha(Color.mSurface, 0.5)
+
+ readonly property real w: bgContainer.width
+ readonly property real h: bgContainer.height
+ readonly property real r: root.trackRadius
+
+ startX: r
+ startY: 0
+
+ PathLine {
+ x: bgPath.w - bgPath.r
+ y: 0
+ }
+ PathArc {
+ x: bgPath.w
+ y: bgPath.r
+ radiusX: bgPath.r
+ radiusY: bgPath.r
+ }
+ PathLine {
+ x: bgPath.w
+ y: bgPath.h - bgPath.r
+ }
+ PathArc {
+ x: bgPath.w - bgPath.r
+ y: bgPath.h
+ radiusX: bgPath.r
+ radiusY: bgPath.r
+ }
+ PathLine {
+ x: bgPath.r
+ y: bgPath.h
+ }
+ PathArc {
+ x: 0
+ y: bgPath.h - bgPath.r
+ radiusX: bgPath.r
+ radiusY: bgPath.r
+ }
+ PathLine {
+ x: 0
+ y: bgPath.r
+ }
+ PathArc {
+ x: bgPath.r
+ y: 0
+ radiusX: bgPath.r
+ radiusY: bgPath.r
+ }
+ }
+ }
+
+ LinearGradient {
+ id: fillGradient
+ x1: 0
+ y1: 0
+ x2: root.availableWidth
+ y2: 0
+ GradientStop {
+ position: 0.0
+ color: Qt.darker(effectiveFillColor, 1.2)
+ }
+ GradientStop {
+ position: 1.0
+ color: effectiveFillColor
+ }
+ }
+
+ // Active/filled track
+ Shape {
+ width: bgContainer.fillWidth
+ height: bgContainer.height
+ visible: bgContainer.fillWidth > 0 && bgContainer.height > 0
+ preferredRendererType: Shape.CurveRenderer
+ asynchronous: true
+ clip: true
+
+ ShapePath {
+ id: fillPath
+ strokeColor: "transparent"
+ fillGradient: fillGradient
+
+ readonly property real fullWidth: root.availableWidth
+ readonly property real h: root.trackHeight
+ readonly property real r: root.trackRadius
+
+ startX: r
+ startY: 0
+
+ PathLine {
+ x: fillPath.fullWidth - fillPath.r
+ y: 0
+ }
+ PathArc {
+ x: fillPath.fullWidth
+ y: fillPath.r
+ radiusX: fillPath.r
+ radiusY: fillPath.r
+ }
+ PathLine {
+ x: fillPath.fullWidth
+ y: fillPath.h - fillPath.r
+ }
+ PathArc {
+ x: fillPath.fullWidth - fillPath.r
+ y: fillPath.h
+ radiusX: fillPath.r
+ radiusY: fillPath.r
+ }
+ PathLine {
+ x: fillPath.r
+ y: fillPath.h
+ }
+ PathArc {
+ x: 0
+ y: fillPath.h - fillPath.r
+ radiusX: fillPath.r
+ radiusY: fillPath.r
+ }
+ PathLine {
+ x: 0
+ y: fillPath.r
+ }
+ PathArc {
+ x: fillPath.r
+ y: 0
+ radiusX: fillPath.r
+ radiusY: fillPath.r
+ }
+ }
+ }
+
+ // Circular cutout
+ Rectangle {
+ id: knobCutout
+ implicitWidth: root.knobDiameter + root.cutoutExtra
+ implicitHeight: root.knobDiameter + root.cutoutExtra
+ radius: Math.min(Style.iRadiusL, width / 2)
+ color: root.cutoutColor !== undefined ? root.cutoutColor : Color.mSurface
+ x: root.visualPosition * (root.availableWidth - root.knobDiameter) - root.cutoutExtra / 2
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ handle: Item {
+ implicitWidth: knobDiameter
+ implicitHeight: knobDiameter
+ x: root.leftPadding + root.visualPosition * (root.availableWidth - width)
+ anchors.verticalCenter: parent.verticalCenter
+
+ Rectangle {
+ id: knob
+ implicitWidth: knobDiameter
+ implicitHeight: knobDiameter
+ radius: Math.min(Style.iRadiusL, width / 2)
+ color: root.pressed ? Color.mHover : Color.mSurface
+ border.color: effectiveFillColor
+ border.width: Style.borderL
+ anchors.centerIn: parent
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+ }
+
+ MouseArea {
+ enabled: true
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ hoverEnabled: true
+ acceptedButtons: Qt.NoButton // Don't accept any mouse buttons - only hover
+ propagateComposedEvents: true
+
+ onEntered: {
+ root.hovering = true;
+ if (root.tooltipText && (!Array.isArray(root.tooltipText) || root.tooltipText.length > 0)) {
+ TooltipService.show(knob, root.tooltipText, root.tooltipDirection);
+ }
+ }
+
+ onExited: {
+ root.hovering = false;
+ if (root.tooltipText && (!Array.isArray(root.tooltipText) || root.tooltipText.length > 0)) {
+ TooltipService.hide();
+ }
+ }
+ }
+
+ // Hide tooltip when slider is pressed (anywhere on the slider)
+ Connections {
+ target: root
+ function onPressedChanged() {
+ if (root.pressed && root.tooltipText && (!Array.isArray(root.tooltipText) || root.tooltipText.length > 0)) {
+ TooltipService.hide();
+ }
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NSpinBox.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NSpinBox.qml
new file mode 100644
index 0000000..15c3e16
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NSpinBox.qml
@@ -0,0 +1,314 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import qs.Commons
+import qs.Widgets
+
+RowLayout {
+ id: root
+
+ // Public properties
+ property int value: 0
+ property int from: 0
+ property int to: 100
+ property int stepSize: 1
+ property string suffix: ""
+ property string prefix: ""
+ property string label: ""
+ property string description: ""
+ property bool hovering: false
+ property int baseSize: Style.baseWidgetSize
+ property var defaultValue: undefined
+ property string settingsPath: ""
+
+ // Convenience properties for common naming
+ property alias minimum: root.from
+ property alias maximum: root.to
+
+ // Properties for repeating
+ property int initialRepeatDelay: 400 // The "pause" after the first click (in ms)
+ property int repeatInterval: 80 // How often to step up after fist pause (ms)
+ property int rampFactor: 4 // How many ticks to wait before increasing the step multiplier
+ property int maxStepMultiplier: 10 // The max step (e.g., 10 * stepSize)
+ property int _holdTicks: 0 // Internal counter for hold duration
+ property int _repeatDirection: 0 // -1 for decrease, 1 for increase
+
+ signal entered
+ signal exited
+
+ Layout.fillWidth: true
+
+ readonly property bool isValueChanged: (defaultValue !== undefined) && (value !== defaultValue)
+ readonly property string indicatorTooltip: defaultValue !== undefined ? I18n.tr("panels.indicator.default-value", {
+ "value": String(defaultValue)
+ }) : ""
+
+ Timer {
+ id: repeatTimer
+ repeat: true
+ interval: root.initialRepeatDelay
+
+ onTriggered: {
+ if (repeatTimer.interval === root.initialRepeatDelay) {
+ repeatTimer.interval = root.repeatInterval;
+ root._holdTicks = 0;
+ }
+ root._holdTicks++;
+ var stepMultiplier = Math.min(root.maxStepMultiplier, 1 + Math.floor(root._holdTicks / root.rampFactor));
+ changeValue(root._repeatDirection, root.stepSize * stepMultiplier);
+ }
+ }
+
+ function changeValue(direction, step) {
+ var currentStep = step || root.stepSize;
+
+ if (direction === 1 && root.value < root.to) {
+ root.value = Math.min(root.to, root.value + currentStep);
+ } else if (direction === -1 && root.value > root.from) {
+ root.value = Math.max(root.from, root.value - currentStep);
+ } else {
+ return;
+ }
+
+ if (root.value === root.to || root.value === root.from) {
+ stopRepeat();
+ }
+ }
+
+ function stopRepeat() {
+ root._repeatDirection = 0;
+ repeatTimer.stop();
+ repeatTimer.interval = root.initialRepeatDelay;
+ }
+
+ NLabel {
+ label: root.label
+ description: root.description
+ showIndicator: root.isValueChanged
+ indicatorTooltip: root.indicatorTooltip
+ }
+
+ // Main spinbox container
+ Rectangle {
+ id: spinBoxContainer
+ Layout.margins: Style.borderS
+ implicitWidth: 120
+ implicitHeight: Math.round((root.baseSize - 4) / 2) * 2
+ radius: Style.iRadiusS
+ color: Color.mSurfaceVariant
+ border.color: (root.hovering || decreaseArea.containsMouse || increaseArea.containsMouse) ? Color.mHover : Color.mOutline
+ border.width: Style.borderS
+
+ Behavior on border.color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+
+ // Mouse area for hover and scroll
+ MouseArea {
+ anchors.fill: parent
+ acceptedButtons: Qt.NoButton
+ hoverEnabled: true
+ onEntered: {
+ if (!root.enabled)
+ return;
+ root.hovering = true;
+ root.entered();
+ }
+ onExited: {
+ root.hovering = false;
+ root.exited();
+ }
+ onWheel: wheel => {
+ if (wheel.angleDelta.y > 0 && root.value < root.to) {
+ let newValue = Math.min(root.to, root.value + root.stepSize);
+ root.value = newValue;
+ } else if (wheel.angleDelta.y < 0 && root.value > root.from) {
+ let newValue = Math.max(root.from, root.value - root.stepSize);
+ root.value = newValue;
+ }
+ }
+ }
+
+ // Decrease button (left)
+ Item {
+ id: decreaseButton
+ height: parent.height
+ width: height
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+ opacity: (root.enabled && root.value > root.from) || decreaseArea.containsMouse ? 1.0 : 0.3
+
+ Rectangle {
+ anchors.centerIn: parent
+ width: parent.height
+ height: width
+ radius: spinBoxContainer.radius
+ color: Color.mHover
+ opacity: decreaseArea.containsMouse ? 1.0 : 0.0
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Style.animationFast
+ }
+ }
+ }
+
+ NIcon {
+ anchors.centerIn: parent
+ icon: "chevron-left"
+ pointSize: Style.fontSizeS
+ color: decreaseArea.containsMouse ? Color.mOnHover : Color.mPrimary
+ }
+
+ MouseArea {
+ id: decreaseArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ enabled: root.enabled && root.value > root.from
+ onPressed: {
+ root._repeatDirection = -1;
+ changeValue(root._repeatDirection, root.stepSize);
+ repeatTimer.start();
+ }
+ onReleased: stopRepeat()
+ onExited: stopRepeat()
+ }
+ }
+
+ // Increase button (right)
+ Item {
+ id: increaseButton
+ height: parent.height
+ width: height
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.right: parent.right
+ opacity: (root.enabled && root.value < root.to) || increaseArea.containsMouse ? 1.0 : 0.3
+
+ Rectangle {
+ anchors.centerIn: parent
+ width: parent.height
+ height: width
+ radius: spinBoxContainer.radius
+ color: Color.mHover
+ opacity: increaseArea.containsMouse ? 1.0 : 0.0
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Style.animationFast
+ }
+ }
+ }
+
+ NIcon {
+ anchors.centerIn: parent
+ icon: "chevron-right"
+ pointSize: Style.fontSizeS
+ color: increaseArea.containsMouse ? Color.mOnHover : Color.mPrimary
+ }
+
+ MouseArea {
+ id: increaseArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ enabled: root.enabled && root.value < root.to
+ onPressed: {
+ root._repeatDirection = 1;
+ changeValue(root._repeatDirection, root.stepSize);
+ repeatTimer.start();
+ }
+ onReleased: stopRepeat()
+ onExited: stopRepeat()
+ }
+ }
+
+ // Center value display with separate prefix, value, and suffix
+ Item {
+ id: valueContainer
+ anchors.left: decreaseButton.right
+ anchors.right: increaseButton.left
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: 4
+ height: parent.height
+
+ RowLayout {
+ anchors.centerIn: parent
+ spacing: 0
+
+ // Prefix text (non-editable)
+ NText {
+ text: root.prefix
+ family: Settings.data.ui.fontFixed
+ pointSize: Style.fontSizeM
+ font.weight: Style.fontWeightMedium
+ color: Qt.alpha(Color.mOnSurface, root.enabled ? 1.0 : 0.6)
+ verticalAlignment: Text.AlignVCenter
+ Layout.alignment: Qt.AlignVCenter
+ visible: root.prefix !== ""
+ }
+
+ // Editable number input
+ TextInput {
+ id: valueInput
+ text: valueInput.focus ? valueInput.text : root.value.toString()
+ font.family: Settings.data.ui.fontFixed
+ font.pointSize: Style.fontSizeM
+ font.weight: Style.fontWeightMedium
+ color: Qt.alpha(Color.mOnSurface, root.enabled ? 1.0 : 0.6)
+ verticalAlignment: Text.AlignVCenter
+ Layout.alignment: Qt.AlignVCenter
+ selectByMouse: true
+ enabled: root.enabled
+
+ // Only allow numeric input within range
+ validator: IntValidator {
+ bottom: root.from
+ top: root.to
+ }
+
+ onAccepted: {
+ applyValue();
+ focus = false;
+ }
+
+ Keys.onEscapePressed: {
+ text = root.value.toString();
+ focus = false;
+ }
+
+ onFocusChanged: {
+ if (focus) {
+ selectAll();
+ } else {
+ applyValue();
+ }
+ }
+
+ function applyValue() {
+ let newValue = parseInt(text);
+ if (!isNaN(newValue)) {
+ // Don't manually set text here - let the binding handle it
+ newValue = Math.max(root.from, Math.min(root.to, newValue));
+ root.value = newValue;
+ }
+ }
+ }
+
+ // Suffix text (non-editable)
+ NText {
+ text: root.suffix
+ family: Settings.data.ui.fontFixed
+ pointSize: Style.fontSizeM
+ font.weight: Style.fontWeightMedium
+ color: Qt.alpha(Color.mOnSurface, root.enabled ? 1.0 : 0.6)
+ verticalAlignment: Text.AlignVCenter
+ Layout.alignment: Qt.AlignVCenter
+ visible: root.suffix !== ""
+ }
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NTabBar.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NTabBar.qml
new file mode 100644
index 0000000..c382ce1
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NTabBar.qml
@@ -0,0 +1,90 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import qs.Commons
+import qs.Widgets
+
+Rectangle {
+ id: root
+ objectName: "NTabBar"
+
+ // Public properties
+ property int currentIndex: 0
+ property real spacing: Style.marginXS
+ property real margins: 0
+ property real tabHeight: Style.baseWidgetSize
+ property bool distributeEvenly: false
+ default property alias content: tabRow.children
+
+ onDistributeEvenlyChanged: _applyDistribution()
+ Component.onCompleted: _applyDistribution()
+
+ function _updateFirstLast() {
+ // Defensive check for QML initialization timing
+ if (!tabRow || !tabRow.children) {
+ return;
+ }
+ var kids = tabRow.children;
+ var len = kids.length;
+ var firstVisible = -1;
+ var lastVisible = -1;
+ for (var i = 0; i < len; i++) {
+ var child = kids[i];
+ // Only consider items that have isFirst/isLast (actual tab buttons, not Repeaters)
+ if (child.visible && "isFirst" in child) {
+ if (firstVisible === -1)
+ firstVisible = i;
+ lastVisible = i;
+ }
+ }
+ for (var i = 0; i < len; i++) {
+ var child = kids[i];
+ if ("isFirst" in child)
+ child.isFirst = (i === firstVisible);
+ if ("isLast" in child)
+ child.isLast = (i === lastVisible);
+ }
+ }
+
+ function _applyDistribution() {
+ if (!tabRow || !tabRow.children) {
+ return;
+ }
+ if (!distributeEvenly) {
+ for (var i = 0; i < tabRow.children.length; i++) {
+ var child = tabRow.children[i];
+ child.Layout.fillWidth = true;
+ }
+ return;
+ }
+
+ for (var i = 0; i < tabRow.children.length; i++) {
+ var child = tabRow.children[i];
+ child.Layout.fillWidth = true;
+ child.Layout.preferredWidth = 1;
+ }
+ }
+
+ // Styling
+ Layout.margins: Style.borderS
+ implicitWidth: tabRow.implicitWidth + (margins * 2)
+ implicitHeight: tabHeight + (margins * 2)
+ color: Color.smartAlpha(Color.mSurfaceVariant)
+ radius: Style.iRadiusM
+
+ RowLayout {
+ id: tabRow
+ anchors.fill: parent
+ anchors.margins: margins
+ spacing: root.spacing
+
+ onChildrenChanged: {
+ for (var i = 0; i < children.length; i++) {
+ var child = children[i];
+ child.visibleChanged.connect(root._updateFirstLast);
+ }
+ root._updateFirstLast();
+ root._applyDistribution();
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NTabButton.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NTabButton.qml
new file mode 100644
index 0000000..ea132a6
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NTabButton.qml
@@ -0,0 +1,127 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import qs.Commons
+import qs.Services.UI
+import qs.Widgets
+
+Rectangle {
+ id: root
+
+ // Public properties
+ property string text: ""
+ property string icon: ""
+ property var tooltipText
+ property bool checked: false
+ property int tabIndex: 0
+ property real pointSize: Style.fontSizeM
+ property bool isFirst: false
+ property bool isLast: false
+
+ // Internal state
+ property bool isHovered: false
+
+ signal clicked
+
+ // Sizing
+ Layout.fillHeight: true
+ implicitWidth: contentLayout.implicitWidth + Style.margin2M
+
+ topLeftRadius: isFirst ? Style.iRadiusM : Style.iRadiusXXXS
+ bottomLeftRadius: isFirst ? Style.iRadiusM : Style.iRadiusXXXS
+ topRightRadius: isLast ? Style.iRadiusM : Style.iRadiusXXXS
+ bottomRightRadius: isLast ? Style.iRadiusM : Style.iRadiusXXXS
+
+ color: root.isHovered ? Color.mHover : (root.checked ? Color.mPrimary : Color.smartAlpha(Color.mSurface))
+ border.color: root.checked ? Color.mPrimary : Color.mOutline
+ border.width: Style.borderS
+
+ Behavior on color {
+ enabled: !Color.isTransitioning
+ ColorAnimation {
+ duration: Style.animationFast
+ easing.type: Easing.OutCubic
+ }
+ }
+
+ // Content
+ RowLayout {
+ id: contentLayout
+ anchors.centerIn: parent
+ width: Math.min(implicitWidth, parent.width - Style.margin2S)
+ spacing: (root.icon !== "" && root.text !== "") ? Style.marginXS : 0
+
+ NIcon {
+ visible: root.icon !== ""
+ Layout.alignment: Qt.AlignVCenter
+ icon: root.icon
+ pointSize: root.pointSize * 1.2
+ color: root.isHovered ? Color.mOnHover : (root.checked ? Color.mOnPrimary : Color.mOnSurface)
+
+ Behavior on color {
+ enabled: !Color.isTransitioning
+ ColorAnimation {
+ duration: Style.animationFast
+ easing.type: Easing.OutCubic
+ }
+ }
+ }
+
+ NText {
+ id: tabText
+ visible: root.text !== ""
+ Layout.alignment: Qt.AlignVCenter
+ text: root.text
+ pointSize: root.pointSize
+ font.weight: Style.fontWeightSemiBold
+ color: root.isHovered ? Color.mOnHover : (root.checked ? Color.mOnPrimary : Color.mOnSurface)
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+
+ Behavior on color {
+ enabled: !Color.isTransitioning
+ ColorAnimation {
+ duration: Style.animationFast
+ easing.type: Easing.OutCubic
+ }
+ }
+ }
+ }
+
+ // Tooltip
+ Timer {
+ id: tooltipTimer
+ interval: 500
+ onTriggered: {
+ if (root.isHovered && root.tooltipText && (!Array.isArray(root.tooltipText) || root.tooltipText.length > 0)) {
+ TooltipService.show(root, root.tooltipText);
+ }
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onEntered: {
+ root.isHovered = true;
+ if (root.tooltipText && (!Array.isArray(root.tooltipText) || root.tooltipText.length > 0)) {
+ tooltipTimer.start();
+ }
+ }
+ onExited: {
+ root.isHovered = false;
+ tooltipTimer.stop();
+ if (root.tooltipText && (!Array.isArray(root.tooltipText) || root.tooltipText.length > 0)) {
+ TooltipService.hide();
+ }
+ }
+ onClicked: {
+ root.clicked();
+ // Update parent NTabBar's currentIndex
+ if (root.parent && root.parent.parent && root.parent.parent.currentIndex !== undefined) {
+ root.parent.parent.currentIndex = root.tabIndex;
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NTabView.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NTabView.qml
new file mode 100644
index 0000000..309dadc
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NTabView.qml
@@ -0,0 +1,181 @@
+import QtQuick
+import QtQuick.Layouts
+import qs.Commons
+
+Item {
+ id: root
+ objectName: "NTabView"
+
+ property int currentIndex: 0
+
+ // Private
+ property int previousIndex: 0
+ property bool initialized: false
+ property bool animating: false
+ property real animatingHeight: 0
+ property real transitionGap: Style.marginXL
+ property real transitionTime: Style.animationNormal
+ property list- contentItems: []
+
+ default property alias content: container.data
+
+ clip: true
+ Layout.fillWidth: true
+
+ // During animation, use max height to prevent clipping. Otherwise use current item height.
+ implicitHeight: animating ? animatingHeight : (contentItems[currentIndex] ? contentItems[currentIndex].implicitHeight : 0)
+
+ Item {
+ id: container
+ anchors.fill: parent
+ }
+
+ // Set the visible tab to idx without triggering a slide animation.
+ // Call this BEFORE the bound currentIndex changes so that
+ // onCurrentIndexChanged sees previousIndex === currentIndex and skips.
+ function setIndexWithoutAnimation(idx) {
+ fromXAnim.stop();
+ fromOpacityAnim.stop();
+ toXAnim.stop();
+ toOpacityAnim.stop();
+ animating = false;
+ previousIndex = idx;
+ for (let i = 0; i < contentItems.length; i++) {
+ if (i === idx) {
+ contentItems[i].x = 0;
+ contentItems[i].visible = true;
+ contentItems[i].opacity = 1.0;
+ } else {
+ contentItems[i].x = root.width;
+ contentItems[i].visible = false;
+ contentItems[i].opacity = 1.0;
+ }
+ }
+ }
+
+ Component.onCompleted: {
+ _initializeItems();
+ }
+
+ function _initializeItems() {
+ contentItems = [];
+ for (let i = 0; i < container.children.length; i++) {
+ const child = container.children[i];
+ contentItems.push(child);
+ child.width = Qt.binding(() => root.width);
+
+ if (i === currentIndex) {
+ child.x = 0;
+ child.visible = true;
+ } else {
+ child.x = root.width;
+ child.visible = false;
+ }
+ }
+ initialized = true;
+ }
+
+ onCurrentIndexChanged: {
+ if (!initialized || contentItems.length === 0)
+ return;
+ if (previousIndex === currentIndex)
+ return;
+
+ _animateTransition(previousIndex, currentIndex);
+ previousIndex = currentIndex;
+ }
+
+ function _animateTransition(fromIdx, toIdx) {
+ // Stop any running animations
+ fromXAnim.stop();
+ fromOpacityAnim.stop();
+ toXAnim.stop();
+ toOpacityAnim.stop();
+
+ // Reset all items to clean state (except target)
+ for (let i = 0; i < contentItems.length; i++) {
+ if (i !== toIdx) {
+ contentItems[i].visible = false;
+ contentItems[i].opacity = 1.0;
+ }
+ }
+
+ const fromItem = contentItems[fromIdx];
+ const toItem = contentItems[toIdx];
+ const slideLeft = toIdx > fromIdx;
+
+ // Set height to max of both items during animation
+ const fromHeight = fromItem ? fromItem.implicitHeight : 0;
+ const toHeight = toItem ? toItem.implicitHeight : 0;
+ animatingHeight = Math.max(fromHeight, toHeight);
+ animating = true;
+
+ // Position outgoing item and make visible for animation
+ if (fromItem) {
+ fromItem.visible = true;
+ fromItem.x = 0;
+ fromItem.opacity = 1.0;
+ }
+
+ // Position incoming item off-screen (with gap) and set initial opacity
+ if (toItem) {
+ toItem.visible = true;
+ toItem.x = slideLeft ? root.width + transitionGap : -root.width - transitionGap;
+ toItem.opacity = 0.0;
+ }
+
+ // Animate both items together (with gap)
+ if (fromItem) {
+ fromXAnim.target = fromItem;
+ fromXAnim.to = slideLeft ? -root.width - transitionGap : root.width + transitionGap;
+ fromOpacityAnim.target = fromItem;
+ fromXAnim.start();
+ fromOpacityAnim.start();
+ }
+
+ if (toItem) {
+ toXAnim.target = toItem;
+ toOpacityAnim.target = toItem;
+ toXAnim.start();
+ toOpacityAnim.start();
+ }
+ }
+
+ NumberAnimation {
+ id: fromXAnim
+ property: "x"
+ duration: root.transitionTime
+ easing.type: Easing.OutCubic
+ onFinished: {
+ if (target && target !== contentItems[currentIndex]) {
+ target.visible = false;
+ target.opacity = 1.0;
+ }
+ animating = false;
+ }
+ }
+
+ NumberAnimation {
+ id: fromOpacityAnim
+ property: "opacity"
+ to: 0.25
+ duration: root.transitionTime
+ easing.type: Easing.OutCubic
+ }
+
+ NumberAnimation {
+ id: toXAnim
+ property: "x"
+ to: 0
+ duration: root.transitionTime
+ easing.type: Easing.OutCubic
+ }
+
+ NumberAnimation {
+ id: toOpacityAnim
+ property: "opacity"
+ to: 1.0
+ duration: root.transitionTime
+ easing.type: Easing.OutCubic
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NTagFilter.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NTagFilter.qml
new file mode 100644
index 0000000..d2515ac
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NTagFilter.qml
@@ -0,0 +1,48 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import qs.Commons
+import qs.Widgets
+
+NCollapsible {
+ id: root
+
+ // Public API
+ property var tags: [] // Array of tag strings
+ property string selectedTag: ""
+ property alias label: root.label
+ property alias description: root.description
+ property alias expanded: root.expanded
+
+ // Formatting function for tag display (optional override)
+ property var formatTag: function (tag) {
+ if (tag === "")
+ return I18n.tr("launcher.categories.all");
+ // Default: capitalize first letter
+ return tag.charAt(0).toUpperCase() + tag.slice(1);
+ }
+
+ Layout.fillWidth: true
+ contentSpacing: Style.marginXS
+
+ Flow {
+ Layout.fillWidth: true
+ spacing: Style.marginXS
+ flow: Flow.LeftToRight
+
+ Repeater {
+ model: [""].concat(root.tags)
+
+ delegate: NButton {
+ text: root.formatTag(modelData)
+ backgroundColor: root.selectedTag === modelData ? Color.mPrimary : Color.mSurfaceVariant
+ textColor: root.selectedTag === modelData ? Color.mOnPrimary : Color.mOnSurfaceVariant
+ onClicked: root.selectedTag = modelData
+ fontSize: Style.fontSizeS
+ iconSize: Style.fontSizeS
+ fontWeight: Style.fontWeightSemiBold
+ buttonRadius: Style.iRadiusM
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NText.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NText.qml
new file mode 100644
index 0000000..e7e2835
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NText.qml
@@ -0,0 +1,41 @@
+import QtQuick
+import QtQuick.Layouts
+import qs.Commons
+import qs.Widgets
+
+Text {
+ id: root
+
+ property bool richTextEnabled: false
+ property bool markdownTextEnabled: false
+ property string family: Settings.data.ui.fontDefault
+ property real pointSize: Style.fontSizeM
+ property bool applyUiScale: true
+ property real fontScale: {
+ const fontScale = (root.family === Settings.data.ui.fontDefault ? Settings.data.ui.fontDefaultScale : Settings.data.ui.fontFixedScale);
+ if (applyUiScale) {
+ return fontScale * Style.uiScaleRatio;
+ }
+ return fontScale;
+ }
+ property var features: ({})
+
+ opacity: enabled ? 1.0 : 0.6
+ font.family: root.family
+ font.weight: Style.fontWeightMedium
+ font.pointSize: Math.max(1, root.pointSize * fontScale)
+ font.features: root.features
+ color: Color.mOnSurface
+ elide: Text.ElideRight
+ wrapMode: Text.NoWrap
+ verticalAlignment: Text.AlignVCenter
+
+ textFormat: {
+ if (root.richTextEnabled) {
+ return Text.RichText;
+ } else if (root.markdownTextEnabled) {
+ return Text.MarkdownText;
+ }
+ return Text.PlainText;
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NTextInput.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NTextInput.qml
new file mode 100644
index 0000000..66fef69
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NTextInput.qml
@@ -0,0 +1,238 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import qs.Commons
+import qs.Widgets
+
+ColumnLayout {
+ id: root
+
+ property string label: ""
+ property string description: ""
+ property string inputIconName: ""
+ property bool readOnly: false
+ property color labelColor: Color.mOnSurface
+ property color descriptionColor: Color.mOnSurfaceVariant
+ property string fontFamily: Settings.data.ui.fontDefault
+ property real fontSize: Style.fontSizeS
+ property int fontWeight: Style.fontWeightRegular
+ property var defaultValue: undefined
+ property string settingsPath: ""
+ property real radius: Style.iRadiusM
+ property real minimumInputWidth: 80 * Style.uiScaleRatio
+ property bool showClearButton: true
+
+ property alias text: input.text
+ property alias placeholderText: input.placeholderText
+ property alias inputMethodHints: input.inputMethodHints
+ property alias horizontalAlignment: input.horizontalAlignment
+ property alias inputItem: input
+
+ signal editingFinished
+ signal accepted
+
+ opacity: enabled ? 1.0 : 0.3
+ spacing: Style.marginS
+
+ readonly property bool isValueChanged: (defaultValue !== undefined) && (text !== defaultValue)
+ readonly property string indicatorTooltip: defaultValue !== undefined ? I18n.tr("panels.indicator.default-value", {
+ "value": defaultValue === "" ? "(empty)" : String(defaultValue)
+ }) : ""
+
+ NLabel {
+ label: root.label
+ description: root.description
+ labelColor: root.labelColor
+ descriptionColor: root.descriptionColor
+ visible: root.label !== "" || root.description !== ""
+ Layout.fillWidth: true
+ showIndicator: root.isValueChanged
+ indicatorTooltip: root.indicatorTooltip
+ }
+
+ // An active control that blocks input, to avoid events leakage and dragging stuff in the background.
+ Control {
+ id: frameControl
+
+ Layout.fillWidth: true
+ Layout.minimumWidth: root.minimumInputWidth
+ Layout.margins: Style.borderS
+ implicitHeight: Style.baseWidgetSize * 1.1 * Style.uiScaleRatio
+
+ // This is important - makes the control accept focus
+ focusPolicy: Qt.StrongFocus
+ hoverEnabled: true
+
+ background: Rectangle {
+ id: frame
+
+ radius: root.radius
+ color: Color.mSurface
+ border.color: input.activeFocus ? Color.mSecondary : Color.mOutline
+ border.width: Style.borderS
+
+ Behavior on border.color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+ }
+
+ contentItem: Item {
+ // Invisible background that captures ALL mouse events
+ MouseArea {
+ id: backgroundCapture
+ anchors.fill: parent
+ z: 0
+ acceptedButtons: Qt.AllButtons
+ hoverEnabled: true
+ preventStealing: true
+ propagateComposedEvents: false
+
+ onPressed: mouse => {
+ mouse.accepted = true;
+ // Focus the input and position cursor
+ input.forceActiveFocus();
+ var inputPos = mapToItem(inputContainer, mouse.x, mouse.y);
+ if (inputPos.x >= 0 && inputPos.x <= inputContainer.width) {
+ var textPos = inputPos.x - Style.marginM;
+ if (textPos >= 0 && textPos <= input.width) {
+ input.cursorPosition = input.positionAt(textPos, input.height / 2);
+ }
+ }
+ }
+
+ onReleased: mouse => {
+ mouse.accepted = true;
+ }
+ onDoubleClicked: mouse => {
+ mouse.accepted = true;
+ input.selectAll();
+ }
+ onPositionChanged: mouse => {
+ mouse.accepted = true;
+ }
+ onWheel: wheel => {
+ wheel.accepted = false;
+ }
+ }
+
+ // Container for the actual text field
+ Item {
+ id: inputContainer
+ anchors.fill: parent
+ anchors.leftMargin: Style.marginM
+ anchors.rightMargin: 0
+ clip: true
+ z: 1
+
+ RowLayout {
+ anchors.fill: parent
+ spacing: 0
+
+ NIcon {
+ id: inputIcon
+ icon: root.inputIconName
+
+ visible: root.inputIconName !== ""
+ enabled: false
+
+ Layout.alignment: Qt.AlignVCenter
+ Layout.rightMargin: visible ? Style.marginS : 0
+ }
+
+ TextField {
+ id: input
+
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+
+ verticalAlignment: TextInput.AlignVCenter
+
+ echoMode: TextInput.Normal
+ readOnly: root.readOnly
+ placeholderTextColor: Qt.alpha(Color.mOnSurfaceVariant, 0.6)
+ color: enabled ? Color.mOnSurface : Qt.alpha(Color.mOnSurface, 0.4)
+
+ selectByMouse: true
+
+ topPadding: 0
+ bottomPadding: 0
+ leftPadding: 0
+ rightPadding: 0
+
+ background: null
+
+ font.family: root.fontFamily
+ font.pointSize: root.fontSize * Style.uiScaleRatio
+ font.weight: root.fontWeight
+
+ onEditingFinished: root.editingFinished()
+ onAccepted: root.accepted()
+
+ // Override mouse handling to prevent propagation
+ MouseArea {
+ id: textFieldMouse
+ anchors.fill: parent
+ acceptedButtons: Qt.AllButtons
+ preventStealing: true
+ propagateComposedEvents: false
+ cursorShape: Qt.IBeamCursor
+
+ property int selectionStart: 0
+
+ onPressed: mouse => {
+ mouse.accepted = true;
+ input.forceActiveFocus();
+ var pos = input.positionAt(mouse.x, mouse.y);
+ input.cursorPosition = pos;
+ selectionStart = pos;
+ }
+
+ onPositionChanged: mouse => {
+ if (mouse.buttons & Qt.LeftButton) {
+ mouse.accepted = true;
+ var pos = input.positionAt(mouse.x, mouse.y);
+ input.select(selectionStart, pos);
+ }
+ }
+
+ onDoubleClicked: mouse => {
+ mouse.accepted = true;
+ input.selectAll();
+ }
+
+ onReleased: mouse => {
+ mouse.accepted = true;
+ }
+ onWheel: wheel => {
+ wheel.accepted = false;
+ }
+ }
+ }
+ NIconButton {
+ id: clearButton
+ icon: "x"
+ tooltipText: (input.text.length > 0 && !root.readOnly && root.enabled) ? I18n.tr("common.clear") : ""
+
+ Layout.alignment: Qt.AlignVCenter
+ border.width: 0
+
+ colorBg: "transparent"
+ colorBgHover: "transparent"
+ colorFg: Color.mOnSurface
+ colorFgHover: Color.mError
+
+ visible: root.showClearButton && input.text.length > 0 && !root.readOnly
+ enabled: input.text.length > 0 && !root.readOnly && root.enabled
+
+ onClicked: {
+ input.clear();
+ input.forceActiveFocus();
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NTextInputButton.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NTextInputButton.qml
new file mode 100644
index 0000000..8971110
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NTextInputButton.qml
@@ -0,0 +1,57 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import qs.Commons
+import qs.Widgets
+
+ColumnLayout {
+ id: root
+ Layout.fillWidth: true
+
+ property alias text: input.text
+ property alias placeholderText: input.placeholderText
+ property string label: ""
+ property string description: ""
+ property string inputIconName: ""
+ property alias buttonIcon: button.icon
+ property alias buttonTooltip: button.tooltipText
+ property alias buttonEnabled: button.enabled
+ property real maximumWidth: 0
+
+ signal buttonClicked
+ signal inputTextChanged(string text)
+ signal inputEditingFinished
+
+ spacing: Style.marginS
+
+ // Label and description
+ NLabel {
+ label: root.label
+ description: root.description
+ visible: root.label !== "" || root.description !== ""
+ Layout.fillWidth: true
+ }
+
+ // Input field with button
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Style.marginM
+
+ NTextInput {
+ id: input
+ inputIconName: root.inputIconName
+ Layout.fillWidth: true
+ Layout.alignment: Qt.AlignVCenter
+ enabled: root.enabled
+ onTextChanged: root.inputTextChanged(text)
+ onEditingFinished: root.inputEditingFinished()
+ }
+
+ // Button
+ NIconButton {
+ id: button
+ baseSize: Style.baseWidgetSize
+ onClicked: root.buttonClicked()
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NToggle.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NToggle.qml
new file mode 100644
index 0000000..6c5158b
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NToggle.qml
@@ -0,0 +1,110 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import qs.Commons
+import qs.Widgets
+
+RowLayout {
+ id: root
+
+ property string label: ""
+ property string description: ""
+ property string icon: ""
+ property bool checked: false
+ property bool hovering: false
+ property int baseSize: Math.round(Style.baseWidgetSize * 0.8 * Style.uiScaleRatio)
+ property var defaultValue: undefined
+ property string settingsPath: ""
+
+ signal toggled(bool checked)
+ signal entered
+ signal exited
+
+ Layout.fillWidth: true
+ spacing: Style.marginM
+
+ readonly property bool isValueChanged: (defaultValue !== undefined) && (checked !== defaultValue)
+ readonly property string indicatorTooltip: defaultValue !== undefined ? I18n.tr("panels.indicator.default-value", {
+ "value": typeof defaultValue === "boolean" ? (defaultValue ? "true" : "false") : String(defaultValue)
+ }) : ""
+
+ NLabel {
+ Layout.fillWidth: true
+ label: root.label
+ description: root.description
+ icon: root.icon
+ iconColor: root.checked ? Color.mPrimary : Color.mOnSurface
+ visible: root.label !== "" || root.description !== ""
+ showIndicator: root.isValueChanged
+ indicatorTooltip: root.indicatorTooltip
+ }
+
+ Rectangle {
+ id: switcher
+
+ opacity: enabled ? 1.0 : 0.6
+ Layout.alignment: Qt.AlignVCenter
+ Layout.margins: Style.borderS
+ implicitWidth: Math.round(root.baseSize * .85) * 2
+ implicitHeight: Math.round(root.baseSize * .5) * 2
+ radius: Math.min(Style.iRadiusL, height / 2)
+ color: root.checked ? Color.mPrimary : Color.mSurface
+ border.color: Color.mOutline
+ border.width: Style.borderS
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+
+ Behavior on border.color {
+ ColorAnimation {
+ duration: Style.animationFast
+ }
+ }
+
+ Rectangle {
+ implicitWidth: Math.round(root.baseSize * 0.4) * 2
+ implicitHeight: Math.round(root.baseSize * 0.4) * 2
+ radius: Math.min(Style.iRadiusL, height / 2)
+ color: root.checked ? Color.mOnPrimary : Color.mPrimary
+ border.color: root.checked ? Color.mSurface : Color.mSurface
+ border.width: Style.borderM
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.verticalCenterOffset: 0
+ x: root.checked ? switcher.width - width - 3 : 3
+
+ Behavior on x {
+ NumberAnimation {
+ duration: Style.animationFast
+ easing.type: Easing.OutCubic
+ }
+ }
+ }
+
+ MouseArea {
+ enabled: root.enabled
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ hoverEnabled: true
+ onEntered: {
+ if (!enabled)
+ return;
+ hovering = true;
+ root.entered();
+ }
+ onExited: {
+ if (!enabled)
+ return;
+ hovering = false;
+ root.exited();
+ }
+ onClicked: {
+ if (!enabled)
+ return;
+ root.toggled(!root.checked);
+ }
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/Widgets/NValueSlider.qml b/arch/.config/quickshell/noctalia-shell/Widgets/NValueSlider.qml
new file mode 100644
index 0000000..08fc8ee
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/Widgets/NValueSlider.qml
@@ -0,0 +1,116 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import qs.Commons
+import qs.Widgets
+
+RowLayout {
+ id: root
+
+ property real from: 0
+ property real to: 1
+ property real value: 0
+ property real stepSize: 0.01
+ property var cutoutColor: Color.mSurface
+ property bool snapAlways: true
+ property real heightRatio: 0.7
+ property string text: ""
+ property real textSize: Style.fontSizeM
+ property real customHeight: -1
+ property real customHeightRatio: -1
+ property string label: ""
+ property string description: ""
+ property var defaultValue: undefined
+ property bool showReset: false
+
+ spacing: Style.marginL
+ Layout.fillWidth: true
+
+ // Signals
+ signal moved(real value)
+ signal pressedChanged(bool pressed, real value)
+
+ readonly property bool sliderActive: slider.activeFocus || slider.pressed
+ readonly property bool isValueChanged: defaultValue !== undefined && (value !== defaultValue)
+ readonly property string indicatorTooltip: {
+ if (defaultValue === undefined)
+ return "";
+ var defaultVal = defaultValue;
+ if (typeof defaultVal === "number") {
+ // If it's a decimal between 0 and 1, format as percentage
+ if (defaultVal > 0 && defaultVal <= 1 && from >= 0 && from < 1) {
+ return I18n.tr("panels.indicator.default-value", {
+ "value": Math.floor(defaultVal * 100) + "%"
+ });
+ }
+ return I18n.tr("panels.indicator.default-value", {
+ "value": String(defaultVal)
+ });
+ }
+ return I18n.tr("panels.indicator.default-value", {
+ "value": String(defaultVal)
+ });
+ }
+
+ ColumnLayout {
+ spacing: Style.marginS
+ Layout.fillWidth: true
+
+ NLabel {
+ label: root.label
+ description: root.description
+ visible: root.label !== "" || root.description !== ""
+ showIndicator: root.isValueChanged
+ indicatorTooltip: root.indicatorTooltip
+ opacity: root.enabled ? 1.0 : 0.6
+ Layout.fillWidth: true
+ }
+
+ RowLayout {
+ spacing: Style.marginL
+ Layout.fillWidth: true
+
+ NSlider {
+ id: slider
+ Layout.fillWidth: true
+ from: root.from
+ to: root.to
+ value: root.value
+ stepSize: root.stepSize
+ cutoutColor: root.cutoutColor
+ snapAlways: root.snapAlways
+ heightRatio: root.customHeightRatio > 0 ? root.customHeightRatio : root.heightRatio
+ onMoved: root.moved(value)
+ onPressedChanged: root.pressedChanged(pressed, value)
+ }
+
+ NText {
+ visible: root.text !== ""
+ text: root.text
+ pointSize: root.textSize
+ family: Settings.data.ui.fontFixed
+ opacity: root.enabled ? 1.0 : 0.6
+ Layout.alignment: Qt.AlignVCenter
+ Layout.preferredWidth: 45 * Style.uiScaleRatio
+ horizontalAlignment: Text.AlignRight
+ }
+ }
+ }
+
+ Item {
+ id: buttonItem
+ visible: root.showReset && root.defaultValue !== undefined
+ Layout.preferredWidth: 30 * Style.uiScaleRatio
+ Layout.preferredHeight: 30 * Style.uiScaleRatio
+
+ NIconButton {
+ icon: "restore"
+ enabled: root.enabled
+ baseSize: Style.baseWidgetSize * 0.8
+ tooltipText: I18n.tr("common.reset")
+ onClicked: root.moved(root.defaultValue)
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+}
diff --git a/arch/.config/quickshell/noctalia-shell/lefthook.yml b/arch/.config/quickshell/noctalia-shell/lefthook.yml
new file mode 100644
index 0000000..a6b3856
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/lefthook.yml
@@ -0,0 +1,6 @@
+pre-commit:
+ jobs:
+ - name: format qml
+ run: cd "$(git rev-parse --show-toplevel)" && ./Scripts/dev/qmlfmt.sh && git update-index --again
+ - name: build settings search index
+ run: cd "$(git rev-parse --show-toplevel)" && python3 Scripts/dev/build-settings-search-index.py && git add Assets/settings-search-index.json
diff --git a/arch/.config/quickshell/noctalia-shell/shell.nix b/arch/.config/quickshell/noctalia-shell/shell.nix
new file mode 100644
index 0000000..7700415
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/shell.nix
@@ -0,0 +1 @@
+(import
{ }).callPackage ./nix/shell.nix { }
diff --git a/arch/.config/quickshell/noctalia-shell/shell.qml b/arch/.config/quickshell/noctalia-shell/shell.qml
new file mode 100644
index 0000000..9a9b886
--- /dev/null
+++ b/arch/.config/quickshell/noctalia-shell/shell.qml
@@ -0,0 +1,259 @@
+/*
+* Noctalia โ made by https://github.com/noctalia-dev
+* Licensed under the MIT License.
+* Forks and modifications are allowed under the MIT License,
+* but proper credit must be given to the original author.
+*/
+
+//@ pragma Env QT_FFMPEG_DECODING_HW_DEVICE_TYPES=vaapi,vdpau
+//@ pragma Env QT_FFMPEG_ENCODING_HW_DEVICE_TYPES=vaapi,vdpau
+
+// Qt & Quickshell Core
+import QtQuick
+import Quickshell
+
+// Commons & Services
+import qs.Commons
+
+// Modules
+import qs.Modules.Background
+import qs.Modules.Bar
+import qs.Modules.DesktopWidgets
+import qs.Modules.Dock
+import qs.Modules.LockScreen
+import qs.Modules.MainScreen
+import qs.Modules.Notification
+import qs.Modules.OSD
+
+import qs.Modules.Panels.Launcher
+import qs.Modules.Panels.Settings
+import qs.Modules.Toast
+import qs.Services.Control
+import qs.Services.Hardware
+import qs.Services.Keyboard
+import qs.Services.Location
+import qs.Services.Networking
+import qs.Services.Noctalia
+import qs.Services.Power
+import qs.Services.System
+import qs.Services.Theming
+import qs.Services.UI
+
+ShellRoot {
+ id: shellRoot
+
+ property bool i18nLoaded: false
+ property bool settingsLoaded: false
+ property bool shellStateLoaded: false
+
+ Component.onCompleted: {
+ Logger.i("Shell", "---------------------------");
+ Logger.i("Shell", "Noctalia Hello!");
+
+ // Initialize plugin system early so Settings can validate plugin widgets
+ PluginRegistry.init();
+ }
+
+ Connections {
+ target: Quickshell
+ function onReloadCompleted() {
+ Quickshell.inhibitReloadPopup();
+ }
+ function onReloadFailed() {
+ if (!Settings?.isDebug) {
+ Quickshell.inhibitReloadPopup();
+ }
+ }
+ }
+
+ Connections {
+ target: I18n ? I18n : null
+ function onTranslationsLoaded() {
+ i18nLoaded = true;
+ }
+ }
+
+ Connections {
+ target: Settings ? Settings : null
+ function onSettingsLoaded() {
+ settingsLoaded = true;
+ }
+ }
+
+ Connections {
+ target: ShellState ? ShellState : null
+ function onIsLoadedChanged() {
+ if (ShellState.isLoaded) {
+ shellStateLoaded = true;
+ }
+ }
+ }
+
+ Loader {
+ active: i18nLoaded && settingsLoaded && shellStateLoaded
+
+ sourceComponent: Item {
+ Component.onCompleted: {
+ Logger.i("Shell", "---------------------------");
+
+ // Critical services needed for initial UI rendering
+ WallpaperService.init();
+ ImageCacheService.init();
+ AppThemeService.init();
+ ColorSchemeService.init();
+ DarkModeService.init();
+
+ // Defer non-critical services to unblock first frame
+ Qt.callLater(function () {
+ LocationService.init();
+ NightLightService.apply();
+ IdleInhibitorService.init();
+ IdleService.init();
+ PowerProfileService.init();
+ HostService.init();
+ NotificationRulesService.init();
+ GitHubService.init();
+ SupporterService.init();
+ CustomButtonIPCService.init();
+ IPCService.init(screenDetector);
+
+ // Force ClipboardService initialization so clipboard watchers
+ // start immediately instead of waiting for first launcher open
+ if (Settings.data.appLauncher.enableClipboardHistory) {
+ ClipboardService.checkCliphistAvailability();
+ }
+ });
+
+ delayedInitTimer.running = true;
+ }
+
+ Overview {}
+ Background {}
+ DesktopWidgets {}
+ AllScreens {}
+ Dock {}
+ Notification {}
+ ToastOverlay {}
+ OSD {}
+
+ // Launcher overlay window (for overlay layer mode)
+ Loader {
+ active: Settings.data.appLauncher.overviewLayer
+ sourceComponent: Component {
+ LauncherOverlayWindow {}
+ }
+ }
+
+ LockScreen {}
+ FadeOverlay {}
+
+ // Settings window mode (single window across all monitors)
+ SettingsPanelWindow {}
+
+ // Shared screen detector for IPC and plugins
+ CurrentScreenDetector {
+ id: screenDetector
+ }
+
+ // IPCService is a singleton, initialized via init() in deferred services block
+
+ // Container for plugins Main.qml instances (must be in graphics scene)
+ Item {
+ id: pluginContainer
+ visible: false
+
+ Component.onCompleted: {
+ PluginService.pluginContainer = pluginContainer;
+ PluginService.screenDetector = screenDetector;
+ }
+ }
+ }
+ }
+
+ // Delayed initialization and wizard/changelog
+ Timer {
+ id: delayedInitTimer
+ running: false
+ interval: 1500
+ onTriggered: {
+ HooksService.init();
+ FontService.init();
+ UpdateService.init();
+ showWizardOrChangelog();
+ }
+ }
+
+ // Retry timer for when panel isn't ready yet
+ Timer {
+ id: wizardRetryTimer
+ running: false
+ interval: 500
+ property string pendingWizardType: "" // "setup", "telemetry", or ""
+ onTriggered: showWizardOrChangelog()
+ }
+
+ // Connect to telemetry wizard signal from UpdateService (for async state loading)
+ Connections {
+ target: UpdateService
+ function onTelemetryWizardNeeded() {
+ wizardRetryTimer.pendingWizardType = "telemetry";
+ showWizardOrChangelog();
+ }
+ }
+
+ property var telemetryWizardConnection: null
+
+ function showWizardOrChangelog() {
+ // Determine what to show: setup wizard > telemetry wizard > changelog
+ var wizardType = wizardRetryTimer.pendingWizardType;
+
+ if (wizardType === "") {
+ // First call - determine wizard type
+ if (Settings.shouldOpenSetupWizard) {
+ wizardType = "setup";
+ } else if (UpdateService.shouldShowTelemetryWizard()) {
+ wizardType = "telemetry";
+ } else {
+ // No wizard needed - init telemetry and show changelog
+ TelemetryService.init();
+ UpdateService.checkTelemetryWizardOrChangelog();
+ return;
+ }
+ }
+
+ var targetScreen = PanelService.findScreenForPanels();
+ if (!targetScreen) {
+ Logger.w("Shell", "No screen available to show wizard");
+ wizardRetryTimer.pendingWizardType = "";
+ return;
+ }
+
+ var setupPanel = PanelService.getPanel("setupWizardPanel", targetScreen);
+ if (!setupPanel) {
+ // Panel not ready, retry
+ wizardRetryTimer.pendingWizardType = wizardType;
+ wizardRetryTimer.restart();
+ return;
+ }
+
+ // Panel is ready, show it
+ wizardRetryTimer.pendingWizardType = "";
+
+ if (wizardType === "telemetry") {
+ setupPanel.telemetryOnlyMode = true;
+
+ // Connect to completion signal to show changelog afterward
+ if (telemetryWizardConnection) {
+ setupPanel.telemetryWizardCompleted.disconnect(telemetryWizardConnection);
+ }
+ telemetryWizardConnection = function () {
+ UpdateService.showLatestChangelog();
+ };
+ setupPanel.telemetryWizardCompleted.connect(telemetryWizardConnection);
+ } else {
+ setupPanel.telemetryOnlyMode = false;
+ }
+
+ setupPanel.open();
+ }
+}