add noctalia and fuzzel
This commit is contained in:
+436
@@ -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
|
||||
+23
@@ -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."
|
||||
+331
@@ -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())
|
||||
Reference in New Issue
Block a user