add noctalia and fuzzel

This commit is contained in:
2026-05-23 21:20:58 +02:00
parent 364801f1a3
commit 9a3eceb3ab
599 changed files with 204318 additions and 0 deletions
@@ -0,0 +1,436 @@
#!/usr/bin/env python3
"""
Build settings search index from QML source files.
Parses settings tab QML files to extract searchable metadata
(i18n keys, widget types, tab/sub-tab locations, visibility conditions).
Output: Assets/settings-search-index.json
Usage:
python Scripts/dev/build-settings-search-index.py
"""
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent.parent
SETTINGS_DIR = ROOT / "Modules" / "Panels" / "Settings"
TABS_DIR = SETTINGS_DIR / "Tabs"
OUTPUT = ROOT / "Assets" / "settings-search-index.json"
# Widget types that have searchable label/description
WIDGET_TYPES = (
"NToggle",
"NComboBox",
"NValueSlider",
"NSpinBox",
"NSearchableComboBox",
"NTextInputButton",
"NTextInput",
"NCheckbox",
"NLabel",
"NColorChoice",
"HookRow",
)
# Regex patterns
RE_WIDGET_OPEN = re.compile(
r"^\s*(" + "|".join(WIDGET_TYPES) + r")\s*\{", re.MULTILINE
)
RE_LABEL = re.compile(r'label:\s*I18n\.tr\("([^"]+)"')
RE_DESCRIPTION = re.compile(r'description:\s*I18n\.tr\("([^"]+)"')
RE_VISIBLE = re.compile(r'^\s*visible:\s*(.+?)(?:\s*;)?\s*$')
# Prefixes that indicate externally-resolvable conditions (singleton services or globals).
# Conditions referencing local variables (root., parent., model, index, etc.) are skipped.
RESOLVABLE_PREFIXES = (
"CompositorService.",
"Settings.data.",
"Quickshell.",
"IdleService.",
"SystemStatService.",
"SoundService.",
"BluetoothService.",
"LocationService.",
"false",
)
def parse_component_declarations(content: str) -> dict[str, str]:
"""
Parse Component declarations from SettingsContent.qml.
Returns: component_id -> QML type name (e.g. "generalTab" -> "GeneralTab")
"""
components = {}
# Match patterns like:
# Component {
# id: generalTab
# GeneralTab {}
# }
pattern = re.compile(
r"Component\s*\{\s*\n\s*id:\s*(\w+)\s*\n\s*(\w+)\s*\{",
re.MULTILINE,
)
for m in pattern.finditer(content):
comp_id = m.group(1)
type_name = m.group(2)
components[comp_id] = type_name
return components
def parse_tabs_model_order(content: str) -> list[tuple[str, str]]:
"""
Parse updateTabsModel() to get the ordered list of (source_id, label_key) pairs.
Returns: list of (component_id, i18n_label_key) in display order.
"""
# Find the updateTabsModel function body
match = re.search(r"function updateTabsModel\(\)\s*\{", content)
if not match:
return []
func_body = content[match.end():]
# Extract tab entries: each has "label" and "source" fields
entries = []
for m in re.finditer(
r'"label":\s*"([^"]+)"[^}]*?"source":\s*(\w+)',
func_body,
re.DOTALL,
):
entries.append((m.group(2), m.group(1)))
return entries
def build_tab_mappings(content: str) -> tuple[dict[str, int], dict[str, str]]:
"""
Build mappings from QML type name to tabsModel index and label key.
Parses Component declarations and updateTabsModel() order.
Returns: (type_to_index, type_to_label)
- type_to_index: e.g. {"GeneralTab": 0, ...}
- type_to_label: e.g. {"GeneralTab": "common.general", ...}
"""
components = parse_component_declarations(content)
entries = parse_tabs_model_order(content)
type_to_index = {}
type_to_label = {}
for idx, (source_id, label_key) in enumerate(entries):
type_name = components.get(source_id)
if type_name:
type_to_index[type_name] = idx
type_to_label[type_name] = label_key
return type_to_index, type_to_label
def get_subtab_info(parent_tab_file: Path) -> tuple[list[str], list[str | None]]:
"""
Parse a parent tab file to get subtab order and labels.
Returns: (subtab_type_names, subtab_label_keys)
- subtab_type_names: list of component names like ["VolumesSubTab", ...]
- subtab_label_keys: list of i18n keys like ["common.volumes", ...] (same order)
"""
content = parent_tab_file.read_text()
# Extract NTabButton labels in order from NTabBar
labels = []
in_tabbar = False
tabbar_depth = 0
for line in content.splitlines():
stripped = line.strip()
if not in_tabbar:
if re.match(r"NTabBar\s*\{", stripped):
in_tabbar = True
tabbar_depth = 1
continue
continue
tabbar_depth += stripped.count("{") - stripped.count("}")
if tabbar_depth <= 0:
break
# Match text: I18n.tr("...") inside NTabButton
m = re.search(r'text:\s*I18n\.tr\("([^"]+)"', stripped)
if m:
labels.append(m.group(1))
# Extract subtab component names from NTabView
subtabs = []
in_tabview = False
tabview_depth = 0
for line in content.splitlines():
stripped = line.strip()
if not in_tabview:
if re.match(r"NTabView\s*\{", stripped):
in_tabview = True
tabview_depth = 1
continue
continue
tabview_depth += stripped.count("{") - stripped.count("}")
if tabview_depth <= 0:
break
# Match component instantiations like "VolumesSubTab {}" or "VolumesSubTab {"
m = re.match(r"(\w+SubTab)\s*\{", stripped)
if m:
subtabs.append(m.group(1))
# Pad labels list if shorter than subtabs (shouldn't happen, but safety)
while len(labels) < len(subtabs):
labels.append(None)
return subtabs, labels[:len(subtabs)]
def resolve_tab_info(
qml_file: Path,
type_to_index: dict[str, int],
type_to_label: dict[str, str],
) -> tuple[int | None, str | None, int | None, str | None]:
"""
Determine the tab index, tab label, sub-tab index, and sub-tab label for a QML file.
Returns (tab_index, tab_label_key, sub_tab_index, sub_tab_label_key)
"""
parent = qml_file.parent
stem = qml_file.stem
# Top-level tab files (directly in Tabs/)
if parent == TABS_DIR:
tab_index = type_to_index.get(stem)
tab_label = type_to_label.get(stem)
return tab_index, tab_label, None, None
# Sub-directory files
dir_name = parent.name # e.g. "Audio", "Bar"
parent_type = f"{dir_name}Tab" # e.g. "AudioTab"
tab_index = type_to_index.get(parent_type)
tab_label = type_to_label.get(parent_type)
if tab_index is None:
return None, None, None, None
# Skip the parent tab file itself (e.g. AudioTab.qml) — still scan for widgets
if stem.endswith("Tab") and not stem.endswith("SubTab"):
return tab_index, tab_label, None, None
# Determine sub-tab index and label from parent tab's NTabBar/NTabView
parent_tab_file = parent / f"{dir_name}Tab.qml"
if not parent_tab_file.exists():
return tab_index, tab_label, None, None
subtab_names, subtab_labels = get_subtab_info(parent_tab_file)
try:
idx = subtab_names.index(stem)
sub_label = subtab_labels[idx] if idx < len(subtab_labels) else None
return tab_index, tab_label, idx, sub_label
except ValueError:
# File doesn't map to any subtab (e.g. a dialog). If the parent tab
# has subtabs, the focus ring can't reach widgets inside dialogs, so
# exclude them from the index.
if subtab_names:
return None, None, None, None
return tab_index, tab_label, None, None
def is_resolvable_condition(cond: str) -> bool:
"""Check if a visibility condition can be resolved at runtime by the shell."""
# Strip negation for prefix checking
check = cond.lstrip("!").lstrip(" ").lstrip("(").lstrip(" ")
return any(check.startswith(p) for p in RESOLVABLE_PREFIXES)
def build_scope_visibility(content: str) -> dict[int, list[str]]:
"""
For each line number, compute the list of inherited visibility conditions
from all enclosing QML scopes.
Tracks brace-depth to maintain a scope stack. When a ``visible:`` property
is found, it is associated with the innermost scope. The conditions are
inherited by all lines within that scope.
Returns: line_number -> list of condition strings
"""
lines = content.splitlines()
# Each entry: visibility condition string or None
scope_stack: list[str | None] = []
result: dict[int, list[str]] = {}
for line_num, raw_line in enumerate(lines):
stripped = raw_line.strip()
# Record inherited conditions BEFORE processing this line's braces.
# This means a widget opening on this line inherits from parent scopes,
# not from its own scope (which hasn't been populated yet).
result[line_num] = [c for c in scope_stack if c is not None]
# Process opening braces — push new scopes
n_opens = stripped.count("{")
for _ in range(n_opens):
scope_stack.append(None)
# Check for visible: property — assign to the innermost scope
vis_match = RE_VISIBLE.match(stripped)
if vis_match and scope_stack:
cond = vis_match.group(1).strip()
if cond != "true":
scope_stack[-1] = cond
# Process closing braces — pop scopes
n_closes = stripped.count("}")
for _ in range(n_closes):
if scope_stack:
scope_stack.pop()
return result
def extract_widget_blocks(content: str) -> list[tuple[str, str, int]]:
"""
Extract (widget_type, block_text, start_line) tuples from QML content.
Uses brace-depth tracking to capture the full widget block.
"""
results = []
lines = content.splitlines()
i = 0
while i < len(lines):
m = RE_WIDGET_OPEN.match(lines[i])
if m:
widget_type = m.group(1)
depth = 0
block_lines = []
start_line = i
j = i
while j < len(lines):
line = lines[j]
block_lines.append(line)
depth += line.count("{") - line.count("}")
if depth <= 0:
break
j += 1
block_text = "\n".join(block_lines)
results.append((widget_type, block_text, start_line))
i = j + 1
else:
i += 1
return results
def extract_entries(
qml_file: Path,
type_to_index: dict[str, int],
type_to_label: dict[str, str],
) -> list[dict]:
"""Extract all searchable settings entries from a QML file."""
tab_index, tab_label, sub_tab, sub_tab_label = resolve_tab_info(
qml_file, type_to_index, type_to_label
)
if tab_index is None:
return []
content = qml_file.read_text()
scope_vis = build_scope_visibility(content)
entries = []
for widget_type, block, start_line in extract_widget_blocks(content):
label_match = RE_LABEL.search(block)
if not label_match:
continue
label_key = label_match.group(1)
desc_match = RE_DESCRIPTION.search(block)
desc_key = desc_match.group(1) if desc_match else None
# Collect visibility conditions: ancestor scopes + widget's own visible:
conditions = list(scope_vis.get(start_line, []))
widget_vis = re.search(
r'^\s*visible:\s*(.+?)(?:\s*;)?\s*$', block, re.MULTILINE
)
if widget_vis:
cond = widget_vis.group(1).strip()
if cond != "true" and cond not in conditions:
conditions.append(cond)
# Keep only externally-resolvable conditions
conditions = [c for c in conditions if is_resolvable_condition(c)]
entry = {
"labelKey": label_key,
"descriptionKey": desc_key,
"widget": widget_type,
"tab": tab_index,
"tabLabel": tab_label,
"subTab": sub_tab,
}
if sub_tab_label is not None:
entry["subTabLabel"] = sub_tab_label
if conditions:
entry["visibleWhen"] = conditions
entries.append(entry)
return entries
def main():
if not TABS_DIR.exists():
print(f"Error: Tabs directory not found: {TABS_DIR}", file=sys.stderr)
sys.exit(1)
settings_content = SETTINGS_DIR / "SettingsContent.qml"
if not settings_content.exists():
print(f"Error: SettingsContent.qml not found: {settings_content}", file=sys.stderr)
sys.exit(1)
# Build type -> tabsModel index/label mappings from SettingsContent.qml
content = settings_content.read_text()
type_to_index, type_to_label = build_tab_mappings(content)
if not type_to_index:
print("Error: Could not parse tab model from SettingsContent.qml", file=sys.stderr)
sys.exit(1)
print(f"Parsed {len(type_to_index)} tab types from SettingsContent.qml")
all_entries = []
seen_labels = set()
# Scan all QML files in Tabs/ (recursive)
for qml_file in sorted(TABS_DIR.rglob("*.qml")):
entries = extract_entries(qml_file, type_to_index, type_to_label)
for entry in entries:
if entry["labelKey"] not in seen_labels:
seen_labels.add(entry["labelKey"])
all_entries.append(entry)
# Write output
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
with open(OUTPUT, "w") as f:
json.dump(all_entries, f, indent=2)
print(f"Generated {len(all_entries)} entries -> {OUTPUT.relative_to(ROOT)}")
if __name__ == "__main__":
main()
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Generate a registry.json from all color schemes in Assets/ColorScheme
# Output format matches ~/Development/misc/noctalia/noctalia-colorschemes/registry.json
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
COLORSCHEME_DIR="$PROJECT_ROOT/Assets/ColorScheme"
# Start JSON output
echo '{'
echo ' "version": 1,'
echo ' "themes": ['
first=true
for dir in "$COLORSCHEME_DIR"/*/; do
[ -d "$dir" ] || continue
name=$(basename "$dir")
json_file="$dir/$name.json"
[ -f "$json_file" ] || continue
# Read the JSON file content
content=$(cat "$json_file")
# Extract dark and light objects using jq
dark=$(echo "$content" | jq -c '.dark')
light=$(echo "$content" | jq -c '.light')
# Skip if missing dark or light
[ "$dark" = "null" ] || [ "$light" = "null" ] && continue
# Add comma before all but first entry
if [ "$first" = true ]; then
first=false
else
echo ','
fi
# Output theme entry
printf ' {\n'
printf ' "name": "%s",\n' "$name"
printf ' "path": "%s",\n' "$name"
printf ' "dark": %s,\n' "$dark"
printf ' "light": %s\n' "$light"
printf ' }'
done
echo ''
echo ' ]'
echo '}'
@@ -0,0 +1,73 @@
#!/bin/bash
# Pull translations from Noctalia Translate API
# Usage: ./i18n-pull.sh <project-slug> <output-dir>
# Example: ./i18n-pull.sh noctalia-shell ./Assets/Translations
set -e
# Configuration
API_BASE="${I18N_API_BASE:-https://i18n.noctalia.dev}"
PROJECT_SLUG="${1:-noctalia-shell}"
OUTPUT_DIR="${2:-./Assets/Translations}"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${YELLOW}Pulling translations for project: ${PROJECT_SLUG}${NC}"
echo "API: ${API_BASE}"
echo "Output: ${OUTPUT_DIR}"
echo ""
# Confirmation
read -p "Pull translations and overwrite local files? [y/N] " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Aborted."
exit 0
fi
echo ""
# Create output directory if it doesn't exist
mkdir -p "$OUTPUT_DIR"
# Pull all translations
RESPONSE=$(curl -s -w "\n%{http_code}" "${API_BASE}/api/projects/${PROJECT_SLUG}/pull")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed '$d')
if [ "$HTTP_CODE" != "200" ]; then
echo -e "${RED}Error: HTTP ${HTTP_CODE}${NC}"
echo "$BODY"
exit 1
fi
# Check if jq is available
if ! command -v jq &> /dev/null; then
echo -e "${RED}Error: jq is required but not installed${NC}"
echo "Install with: apt install jq (Linux) or brew install jq (macOS)"
exit 1
fi
# Get list of locales
LOCALES=$(echo "$BODY" | jq -r 'keys[]')
if [ -z "$LOCALES" ]; then
echo -e "${RED}Error: No translations found${NC}"
exit 1
fi
# Save each locale to a separate file
COUNT=0
for LOCALE in $LOCALES; do
OUTPUT_FILE="${OUTPUT_DIR}/${LOCALE}.json"
echo "$BODY" | jq ".[\"${LOCALE}\"]" > "$OUTPUT_FILE"
echo -e "${GREEN}Saved: ${OUTPUT_FILE}${NC}"
COUNT=$((COUNT + 1))
done
echo ""
echo -e "${GREEN}Successfully pulled ${COUNT} language(s)${NC}"
@@ -0,0 +1,143 @@
#!/bin/bash
# Push translations to Noctalia Translate API
# Usage: TRANSLATION_PUSH_SECRET=your_secret ./push-translations.sh [--overwrite] [--lang <code>] [/path/to/Assets/Translations]
# Or set the secret in environment and pass the path as argument
#
# Options:
# --overwrite Overwrite existing translations
# --lang <code> 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
@@ -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."
@@ -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
@@ -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; }
@@ -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."
@@ -0,0 +1,331 @@
#!/usr/bin/env python3
"""
Analyze Noctalia's template-processor color extraction.
Usage:
./template-processor-analysis.py <wallpaper_path>
./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())