Merge remote-tracking branch 'upstream/main'

This commit is contained in:
shouya
2026-01-21 11:41:13 +09:00
63 changed files with 3187 additions and 1588 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# Fuzzel Colors
# Generated with Template Processor
# Generated by Noctalia's Template Processor
[colors]
background={{colors.background.default.hex_stripped}}CC
+36 -7
View File
@@ -1,23 +1,52 @@
/*
* GTK Colors
* Generated with Template Processor
* Generated by Noctalia's Template Processor
*/
@define-color accent_color {{colors.primary.default.hex}};
@define-color accent_bg_color {{colors.primary.default.hex}};
@define-color accent_fg_color {{colors.on_primary.default.hex}};
@define-color destructive_bg_color {{colors.error.default.hex}};
@define-color destructive_fg_color {{colors.on_error.default.hex}};
@define-color error_bg_color {{colors.error.default.hex}};
@define-color error_fg_color {{colors.on_error.default.hex}};
@define-color window_bg_color {{colors.surface.default.hex}};
@define-color window_fg_color {{colors.on_surface.default.hex}};
@define-color headerbar_bg_color {{colors.surface.default.hex}};
@define-color headerbar_fg_color {{colors.on_surface.default.hex}};
@define-color popover_bg_color {{colors.surface_variant.default.hex}};
@define-color popover_fg_color {{colors.on_surface_variant.default.hex}};
@define-color view_bg_color {{colors.surface.default.hex}};
@define-color view_fg_color {{colors.on_surface.default.hex}};
@define-color card_bg_color {{colors.surface.default.hex}};
@define-color headerbar_bg_color {{colors.surface.default.hex}};
@define-color headerbar_fg_color {{colors.on_surface.default.hex}};
@define-color headerbar_backdrop_color @window_bg_color;
@define-color popover_bg_color {{colors.surface_container.default.hex}};
@define-color popover_fg_color {{colors.on_surface.default.hex}};
@define-color card_bg_color {{colors.surface_container.default.hex}};
@define-color card_fg_color {{colors.on_surface.default.hex}};
@define-color dialog_bg_color {{colors.surface.default.hex}};
@define-color dialog_fg_color {{colors.on_surface.default.hex}};
@define-color overview_bg_color {{colors.surface_container.default.hex}};
@define-color overview_fg_color {{colors.on_surface.default.hex}};
@define-color sidebar_bg_color {{colors.surface_container.default.hex}};
@define-color sidebar_fg_color {{colors.on_surface.default.hex}};
@define-color sidebar_backdrop_color @sidebar_bg_color;
@define-color sidebar_border_color @window_bg_color;
@define-color sidebar_backdrop_color @window_bg_color;
@define-color secondary_sidebar_bg_color {{colors.surface.default.hex}};
@define-color secondary_sidebar_fg_color {{colors.on_surface.default.hex}};
/* Backdrop/unfocused states */
@define-color theme_unfocused_fg_color @window_fg_color;
@define-color theme_unfocused_text_color @view_fg_color;
@define-color theme_unfocused_bg_color @window_bg_color;
@define-color theme_unfocused_base_color @window_bg_color;
@define-color theme_unfocused_selected_bg_color @accent_bg_color;
@define-color theme_unfocused_selected_fg_color @accent_fg_color;
+1 -1
View File
@@ -1,5 +1,5 @@
// Material You theme for Telegram Desktop
// Generated by Template Processor
// Generated by Noctalia's Template Processor
COLOR_GRAY: {{colors.outline.default.hex}};
COLOR_DARK: {{colors.surface_variant.default.hex}};
+25 -25
View File
@@ -206,13 +206,13 @@
"memory-percentage-description": "Speicherverbrauch als Prozentsatz statt absolute Werte anzeigen.",
"memory-percentage-label": "Speicher als Prozentsatz",
"memory-usage-description": "Aktuelle RAM-Nutzungsinformationen anzeigen.",
"memory-usage-label": "Speicherverbrauch",
"memory-usage-label": "Speicherauslastung",
"network-traffic-description": "Upload- und Download-Geschwindigkeiten anzeigen.",
"network-traffic-label": "Netzwerkverkehr",
"storage-usage-description": "Festplattenspeicher-Nutzungsinformationen anzeigen.",
"storage-usage-label": "Speichernutzung",
"storage-usage-label": "Datenträgerauslastung",
"swap-usage-description": "Swap-Speichernutzung anzeigen.",
"swap-usage-label": "Swap-Nutzung",
"swap-usage-label": "Swap-Auslastung",
"use-monospace-font-description": "Verwende eine Monospace-Schriftart für eine konsistente Zeichenbreite.",
"use-monospace-font-label": "Schriftart mit fester Breite"
},
@@ -374,6 +374,7 @@
"enabled": "Aktiviert",
"events": "Ereignisse",
"execute": "Ausführen",
"faithful": "Originalgetreu",
"focus": "Fokus",
"frequency": "Frequenz",
"gateway": "Gateway",
@@ -453,6 +454,7 @@
"update": "Aktualisieren",
"upload": "Hochladen",
"version": "Version",
"vibrant": "Lebhaft",
"visualizer": "Visualisierer",
"volume": "Lautstärke",
"volumes": "Lautstärken",
@@ -740,15 +742,15 @@
"dark-mode-mode-manual": "Manuell",
"dark-mode-mode-off": "Aus",
"dark-mode-switch-description": "Wechselt zu einem dunkleren Theme für einfachere Betrachtung bei Nacht.",
"delete-error-description": "Fehler beim Löschen von {scheme}.",
"delete-error-description": "Fehler beim Löschen von {scheme}",
"delete-error-title": "Löschen fehlgeschlagen",
"delete-success-description": "{scheme} wurde erfolgreich gelöscht.",
"delete-success-description": "{scheme} wurde erfolgreich gelöscht",
"delete-success-title": "Farbschema gelöscht",
"download-button": "Mehr herunterladen",
"download-downloading": "Wird heruntergeladen...",
"download-empty": "Keine Farbschemata verfügbar",
"download-error-api-error": "API-Fehler: {status}",
"download-error-description": "Fehler beim Herunterladen von {scheme}.",
"download-error-description": "Fehler beim Herunterladen von {scheme}",
"download-error-download-failed": "Download fehlgeschlagen mit Exit-Code: {code}",
"download-error-invalid-response": "Ungültiges API-Antwortformat",
"download-error-no-files": "Keine Dateien für Schema gefunden",
@@ -756,7 +758,7 @@
"download-error-rate-limit": "GitHub API-Ratenlimit überschritten",
"download-error-title": "Download fehlgeschlagen",
"download-fetching": "Verfügbare Farbschemata werden abgerufen...",
"download-success-description": "{scheme} wurde erfolgreich heruntergeladen.",
"download-success-description": "{scheme} wurde erfolgreich heruntergeladen",
"download-success-title": "Farbschema heruntergeladen",
"download-title": "Farbschemata herunterladen",
"predefined-desc": "Wählen Sie aus einer Sammlung vordefinierter Farbschemata.",
@@ -772,8 +774,8 @@
"templates-none-detected": "Keine erkannt",
"templates-write-path": "Schreibt: {filepath}",
"title": "Farbschema",
"wallpaper-method-description": "Wähle deine bevorzugte Methode zur Palettengenerierung.",
"wallpaper-method-label": "Farbgewinnungsmethode"
"wallpaper-method-description": "Wählen Sie Ihre bevorzugte Methode zur Palettengenerierung.",
"wallpaper-method-label": "Methode zur Palettengenerierung"
},
"control-center": {
"cards-desc": "Passen Sie an, welche Steuerelemente im Kontrollzentrum angezeigt werden und in welcher Reihenfolge.",
@@ -1181,9 +1183,9 @@
"available-label": "Verfügbare Plugins",
"available-no-plugins-description": "Überprüfen Sie Ihre Plugin-Quellen oder aktualisieren Sie die Liste.",
"available-no-plugins-label": "Keine Plugins verfügbar",
"collision-already-installed": "Dieses Plugin ist bereits installiert.",
"collision-custom-version-exists": "Eine benutzerdefinierte Version von \"{source}\" ist bereits installiert.",
"collision-official-version-exists": "Die offizielle Version dieses Plugins ist bereits installiert.",
"collision-already-installed": "Dieses Plugin ist bereits installiert",
"collision-custom-version-exists": "Eine benutzerdefinierte Version von \"{source}\" ist bereits installiert",
"collision-official-version-exists": "Die offizielle Version dieses Plugins ist bereits installiert",
"filter-downloaded": "Heruntergeladen",
"filter-not-downloaded": "Nicht heruntergeladen",
"filter-tags-description": "Filter-Plugins nach Kategorie oder Download-Status filtern",
@@ -1191,9 +1193,9 @@
"hot-reload-description": "Plugins automatisch neu laden, wenn sich ihre Dateien ändern. Nützlich für die Plugin-Entwicklung.",
"hot-reload-label": "Hot Reload (Entwicklermodus)",
"hot-reloaded": "Plugin neu geladen: {name}",
"install-error": "Installation fehlgeschlagen: {error}.",
"install-error": "Installation fehlgeschlagen: {error}",
"install-incompatible": "{plugin} benötigt Noctalia v{version} oder höher",
"install-success": "{plugin} erfolgreich installiert.",
"install-success": "{plugin} erfolgreich installiert",
"installed-description": "Alle lokal installierten Plugins verwalten und konfigurieren.",
"installed-label": "Installierte Plugins",
"installed-no-plugins-description": "Installieren Sie Plugins aus dem Abschnitt \"Verfügbar\".",
@@ -1204,16 +1206,16 @@
"plugin-settings-title": "{plugin} Einstellungen",
"refresh-refreshing": "Aktualisiere Plugin-Liste...",
"refresh-tooltip": "Verfügbare Plugins aktualisieren",
"settings-error-not-loaded": "Plugin nicht geladen.",
"settings-saved": "Plugin-Einstellungen gespeichert.",
"settings-error-not-loaded": "Plugin nicht geladen",
"settings-saved": "Plugin-Einstellungen gespeichert",
"settings-tooltip": "Plugin-Einstellungen",
"source-custom": "Benutzerdefinierte Quelle",
"sources-add-custom": "Benutzerdefiniertes Repository hinzufügen",
"sources-add-dialog-description": "Ein GitHub-Repository als Plugin-Quelle hinzufügen.",
"sources-add-dialog-error": "Fehler beim Hinzufügen der Plugin-Quelle.",
"sources-add-dialog-error": "Fehler beim Hinzufügen der Plugin-Quelle",
"sources-add-dialog-name": "Repository-Name",
"sources-add-dialog-name-placeholder": "Meine benutzerdefinierten Plugins",
"sources-add-dialog-success": "Plugin-Quelle erfolgreich hinzugefügt.",
"sources-add-dialog-success": "Plugin-Quelle erfolgreich hinzugefügt",
"sources-add-dialog-title": "Plugin-Quelle hinzufügen",
"sources-add-dialog-url": "Repository-URL",
"sources-description": "Plugin-Repositories verwalten.",
@@ -1222,16 +1224,16 @@
"title": "Plugins",
"uninstall-dialog-description": "Sind Sie sicher, dass Sie {plugin} deinstallieren möchten? Dadurch werden alle Plugin-Daten entfernt.",
"uninstall-dialog-title": "Plugin deinstallieren",
"uninstall-error": "Deinstallation fehlgeschlagen: {error}.",
"uninstall-success": "{plugin} wurde erfolgreich deinstalliert.",
"uninstall-error": "Deinstallation fehlgeschlagen: {error}",
"uninstall-success": "{plugin} wurde erfolgreich deinstalliert",
"uninstalling": "{Plugin} wird deinstalliert...",
"update-all": "Alle aktualisieren ({count})",
"update-all-success": "Alle Plugins wurden erfolgreich aktualisiert.",
"update-all-success": "Alle Plugins wurden erfolgreich aktualisiert",
"update-available": "Neues Plugin-Update verfügbar",
"update-available-plural": "Neue Plugin-Updates verfügbar ({count})",
"update-error": "Fehler beim Aktualisieren des Plugins: {plugin}: {error}.",
"update-error": "Fehler beim Aktualisieren des Plugins: {plugin}: {error}",
"update-pending": "v{current} → v{new} (benötigt Noctalia v{required})",
"update-success": "{plugin} wurde auf v{version} aktualisiert.",
"update-success": "{plugin} wurde auf v{version} aktualisiert",
"update-version": "v{current} → v{new}",
"updating": "Aktualisierung läuft..."
},
@@ -1536,9 +1538,7 @@
"connected": "Verbunden mit '{ssid}'",
"connection-failed": "Verbindung fehlgeschlagen",
"connection-timeout": "Zeitüberschreitung bei der Verbindung",
"disabled": "Deaktiviert",
"disconnected": "Getrennt von '{ssid}'",
"enabled": "Aktiviert",
"incorrect-password": "Falsches Passwort",
"network-not-found": "Netzwerk nicht gefunden"
}
+21 -21
View File
@@ -374,6 +374,7 @@
"enabled": "Enabled",
"events": "Events",
"execute": "Execute",
"faithful": "Faithful",
"focus": "Focus",
"frequency": "Frequency",
"gateway": "Gateway",
@@ -453,6 +454,7 @@
"update": "Update",
"upload": "Upload",
"version": "Version",
"vibrant": "Vibrant",
"visualizer": "Visualizer",
"volume": "Volume",
"volumes": "Volumes",
@@ -740,15 +742,15 @@
"dark-mode-mode-manual": "Manual",
"dark-mode-mode-off": "Off",
"dark-mode-switch-description": "Switches to a darker theme for easier viewing at night.",
"delete-error-description": "Failed to delete {scheme}.",
"delete-error-description": "Failed to delete {scheme}",
"delete-error-title": "Delete failed",
"delete-success-description": "Successfully deleted {scheme}.",
"delete-success-description": "Successfully deleted {scheme}",
"delete-success-title": "Color scheme deleted",
"download-button": "Download more",
"download-downloading": "Downloading...",
"download-empty": "No color schemes available",
"download-error-api-error": "API error: {status}",
"download-error-description": "Failed to download {scheme}.",
"download-error-description": "Failed to download {scheme}",
"download-error-download-failed": "Download failed with exit code: {code}",
"download-error-invalid-response": "Invalid API response format",
"download-error-no-files": "No files found for scheme",
@@ -756,7 +758,7 @@
"download-error-rate-limit": "GitHub API rate limit exceeded",
"download-error-title": "Download failed",
"download-fetching": "Fetching available color schemes...",
"download-success-description": "Successfully downloaded {scheme}.",
"download-success-description": "Successfully downloaded {scheme}",
"download-success-title": "Color scheme downloaded",
"download-title": "Download Color Schemes",
"predefined-desc": "Choose from a collection of predefined color schemes.",
@@ -773,7 +775,7 @@
"templates-write-path": "Writes: {filepath}",
"title": "Color Scheme",
"wallpaper-method-description": "Choose your favorite palette generation method.",
"wallpaper-method-label": "Color extraction method"
"wallpaper-method-label": "Palette generation method"
},
"control-center": {
"cards-desc": "Customize which controls appear in the control center and in what order.",
@@ -1181,9 +1183,9 @@
"available-label": "Available plugins",
"available-no-plugins-description": "Check your plugin sources or refresh the list.",
"available-no-plugins-label": "No plugins available",
"collision-already-installed": "This plugin is already installed.",
"collision-custom-version-exists": "A custom version from \"{source}\" is already installed.",
"collision-official-version-exists": "The official version of this plugin is already installed.",
"collision-already-installed": "This plugin is already installed",
"collision-custom-version-exists": "A custom version from \"{source}\" is already installed",
"collision-official-version-exists": "The official version of this plugin is already installed",
"filter-downloaded": "Downloaded",
"filter-not-downloaded": "Not Downloaded",
"filter-tags-description": "Filter plugins by category or download status",
@@ -1191,9 +1193,9 @@
"hot-reload-description": "Automatically reload plugins when their files change. Useful for plugin development.",
"hot-reload-label": "Hot reload (dev mode)",
"hot-reloaded": "Reloaded plugin: {name}",
"install-error": "Failed to install: {error}.",
"install-error": "Failed to install: {error}",
"install-incompatible": "{plugin} requires Noctalia v{version} or higher",
"install-success": "Successfully installed {plugin}.",
"install-success": "Successfully installed {plugin}",
"installed-description": "Manage and configure all locally installed plugins.",
"installed-label": "Installed plugins",
"installed-no-plugins-description": "Install plugins from the \"Available\" section.",
@@ -1204,16 +1206,16 @@
"plugin-settings-title": "{plugin} Settings",
"refresh-refreshing": "Refreshing plugins list...",
"refresh-tooltip": "Refresh available plugins",
"settings-error-not-loaded": "Plugin not loaded.",
"settings-saved": "Plugin settings saved.",
"settings-error-not-loaded": "Plugin not loaded",
"settings-saved": "Plugin settings saved",
"settings-tooltip": "Plugin settings",
"source-custom": "Custom source",
"sources-add-custom": "Add custom repository",
"sources-add-dialog-description": "Add a GitHub repository as a plugin source.",
"sources-add-dialog-error": "Failed to add plugin source.",
"sources-add-dialog-error": "Failed to add plugin source",
"sources-add-dialog-name": "Repository name",
"sources-add-dialog-name-placeholder": "My Custom Plugins",
"sources-add-dialog-success": "Plugin source added successfully.",
"sources-add-dialog-success": "Plugin source added successfully",
"sources-add-dialog-title": "Add plugin source",
"sources-add-dialog-url": "Repository URL",
"sources-description": "Manage plugin repositories.",
@@ -1222,16 +1224,16 @@
"title": "Plugins",
"uninstall-dialog-description": "Are you sure you want to uninstall {plugin}? This will remove all plugin data.",
"uninstall-dialog-title": "Uninstall plugin",
"uninstall-error": "Failed to uninstall: {error}.",
"uninstall-success": "Successfully uninstalled {plugin}.",
"uninstall-error": "Failed to uninstall: {error}",
"uninstall-success": "Successfully uninstalled {plugin}",
"uninstalling": "Uninstalling {plugin}...",
"update-all": "Update All ({count})",
"update-all-success": "All plugins updated successfully.",
"update-all-success": "All plugins updated successfully",
"update-available": "New plugin update available",
"update-available-plural": "Plugin updates available ({count})",
"update-error": "Failed to update plugin: {plugin}: {error}.",
"update-error": "Failed to update plugin: {plugin}: {error}",
"update-pending": "v{current} → v{new} (requires Noctalia v{required})",
"update-success": "Updated {plugin} to v{version}.",
"update-success": "Updated {plugin} to v{version}",
"update-version": "v{current} → v{new}",
"updating": "Updating..."
},
@@ -1536,9 +1538,7 @@
"connected": "Connected to '{ssid}'",
"connection-failed": "Connection failed",
"connection-timeout": "Connection timeout",
"disabled": "Disabled",
"disconnected": "Disconnected from '{ssid}'",
"enabled": "Enabled",
"incorrect-password": "Incorrect password",
"network-not-found": "Network not found"
}
+21 -21
View File
@@ -374,6 +374,7 @@
"enabled": "Activado",
"events": "Eventos",
"execute": "Ejecutar",
"faithful": "Fiel",
"focus": "Enfoque",
"frequency": "Frecuencia",
"gateway": "Puerta de enlace",
@@ -453,6 +454,7 @@
"update": "Actualizar",
"upload": "Subir",
"version": "Versión",
"vibrant": "Vibrante",
"visualizer": "Visualizador",
"volume": "Volumen",
"volumes": "Volúmenes",
@@ -740,15 +742,15 @@
"dark-mode-mode-manual": "Manual",
"dark-mode-mode-off": "Apagado",
"dark-mode-switch-description": "Cambia a un tema más oscuro para una visualización más fácil por la noche.",
"delete-error-description": "Error al eliminar {scheme}.",
"delete-error-description": "Error al eliminar {scheme}",
"delete-error-title": "Error al eliminar",
"delete-success-description": "{scheme} eliminado correctamente.",
"delete-success-description": "{scheme} eliminado correctamente",
"delete-success-title": "Esquema de colores eliminado",
"download-button": "Descargar más",
"download-downloading": "Descargando...",
"download-empty": "No hay esquemas de colores disponibles",
"download-error-api-error": "Error de API: {status}",
"download-error-description": "Error al descargar {scheme}.",
"download-error-description": "Error al descargar {scheme}",
"download-error-download-failed": "Error al descargar con código de salida: {code}",
"download-error-invalid-response": "Formato de respuesta de API inválido",
"download-error-no-files": "No se encontraron archivos para el esquema",
@@ -756,7 +758,7 @@
"download-error-rate-limit": "Límite de velocidad de la API de GitHub excedido",
"download-error-title": "Error al descargar",
"download-fetching": "Obteniendo esquemas de colores disponibles...",
"download-success-description": "{scheme} descargado correctamente.",
"download-success-description": "{scheme} descargado correctamente",
"download-success-title": "Esquema de colores descargado",
"download-title": "Descargar esquemas de colores",
"predefined-desc": "Elige entre una colección de esquemas de colores predefinidos.",
@@ -773,7 +775,7 @@
"templates-write-path": "Escribe: {filepath}",
"title": "Esquema de colores",
"wallpaper-method-description": "Elige tu método de generación de paleta favorito.",
"wallpaper-method-label": "Método de extracción de color"
"wallpaper-method-label": "Método de generación de paleta"
},
"control-center": {
"cards-desc": "Personaliza qué controles aparecen en el centro de control y en qué orden.",
@@ -1181,9 +1183,9 @@
"available-label": "Plugins disponibles",
"available-no-plugins-description": "Comprueba las fuentes de tu plugin o actualiza la lista.",
"available-no-plugins-label": "No hay plugins disponibles",
"collision-already-installed": "Este plugin ya está instalado.",
"collision-custom-version-exists": "Ya hay instalada una versión personalizada de \"{source}\".",
"collision-official-version-exists": "La versión oficial de este plugin ya está instalada.",
"collision-already-installed": "Este plugin ya está instalado",
"collision-custom-version-exists": "Ya hay instalada una versión personalizada de \"{source}\"",
"collision-official-version-exists": "La versión oficial de este plugin ya está instalada",
"filter-downloaded": "Descargado",
"filter-not-downloaded": "No descargado",
"filter-tags-description": "Filtrar plugins por categoría o estado de descarga",
@@ -1191,9 +1193,9 @@
"hot-reload-description": "Recarga automáticamente los plugins cuando sus archivos cambian. Útil para el desarrollo de plugins.",
"hot-reload-label": "Recarga en caliente (modo desarrollo)",
"hot-reloaded": "Plugin recargado: {name}",
"install-error": "Error al instalar: {error}.",
"install-error": "Error al instalar: {error}",
"install-incompatible": "{plugin} requiere Noctalia v{version} o superior",
"install-success": "Se instaló {plugin} correctamente.",
"install-success": "Se instaló {plugin} correctamente",
"installed-description": "Gestiona y configura todos los plugins instalados localmente.",
"installed-label": "Plugins instalados",
"installed-no-plugins-description": "Instale los plugins de la sección \"Disponible\".",
@@ -1204,16 +1206,16 @@
"plugin-settings-title": "Ajustes de {plugin}",
"refresh-refreshing": "Actualizando la lista de plugins...",
"refresh-tooltip": "Actualizar plugins disponibles",
"settings-error-not-loaded": "Plugin no cargado.",
"settings-saved": "Ajustes del plugin guardados.",
"settings-error-not-loaded": "Plugin no cargado",
"settings-saved": "Ajustes del plugin guardados",
"settings-tooltip": "Ajustes del plugin",
"source-custom": "Fuente personalizada",
"sources-add-custom": "Añadir repositorio personalizado",
"sources-add-dialog-description": "Añadir un repositorio de GitHub como fuente de plugin.",
"sources-add-dialog-error": "Error al agregar la fuente del plugin.",
"sources-add-dialog-error": "Error al agregar la fuente del plugin",
"sources-add-dialog-name": "Nombre del repositorio",
"sources-add-dialog-name-placeholder": "Mis plugins personalizados",
"sources-add-dialog-success": "Fuente del plugin añadida con éxito.",
"sources-add-dialog-success": "Fuente del plugin añadida con éxito",
"sources-add-dialog-title": "Añadir fuente del plugin",
"sources-add-dialog-url": "URL del repositorio",
"sources-description": "Gestionar repositorios de plugins.",
@@ -1222,16 +1224,16 @@
"title": "Plugins",
"uninstall-dialog-description": "¿Está seguro de que quiere desinstalar {plugin}? Esto eliminará todos los datos del plugin.",
"uninstall-dialog-title": "Desinstalar plugin",
"uninstall-error": "Error al desinstalar: {error}.",
"uninstall-success": "{plugin} desinstalado correctamente.",
"uninstall-error": "Error al desinstalar: {error}",
"uninstall-success": "{plugin} desinstalado correctamente",
"uninstalling": "Desinstalando {plugin}...",
"update-all": "Actualizar todo ({count})",
"update-all-success": "Todos los plugins se actualizaron correctamente.",
"update-all-success": "Todos los plugins se actualizaron correctamente",
"update-available": "Nueva actualización de plugin disponible",
"update-available-plural": "Nuevas actualizaciones de plugins disponibles ({count})",
"update-error": "Error al actualizar el plugin: {plugin}: {error}.",
"update-error": "Error al actualizar el plugin: {plugin}: {error}",
"update-pending": "v{current} → v{new} (requiere Noctalia v{required})",
"update-success": "{plugin} actualizado a la versión {version}.",
"update-success": "{plugin} actualizado a la versión {version}",
"update-version": "v{current} → v{new}",
"updating": "Actualizando..."
},
@@ -1536,9 +1538,7 @@
"connected": "Conectado a '{ssid}'",
"connection-failed": "Error de conexión",
"connection-timeout": "Tiempo de conexión agotado",
"disabled": "Desactivado",
"disconnected": "Desconectado de '{ssid}'",
"enabled": "Activado",
"incorrect-password": "Contraseña incorrecta",
"network-not-found": "Red no encontrada"
}
+21 -21
View File
@@ -374,6 +374,7 @@
"enabled": "Activé",
"events": "Événements",
"execute": "Exécuter",
"faithful": "Fidèle",
"focus": "Concentration",
"frequency": "Fréquence",
"gateway": "Passerelle",
@@ -453,6 +454,7 @@
"update": "Mise à jour",
"upload": "Téléverser",
"version": "Version",
"vibrant": "Vibrant",
"visualizer": "Visualiseur",
"volume": "Volume",
"volumes": "Volumes",
@@ -740,15 +742,15 @@
"dark-mode-mode-manual": "Manuel",
"dark-mode-mode-off": "Éteint",
"dark-mode-switch-description": "Passe à un thème plus sombre pour une visualisation plus facile la nuit.",
"delete-error-description": "Échec de la suppression de {scheme}.",
"delete-error-description": "Échec de la suppression de {scheme}",
"delete-error-title": "Échec de la suppression",
"delete-success-description": "{scheme} supprimé avec succès.",
"delete-success-description": "{scheme} supprimé avec succès",
"delete-success-title": "Jeu de couleurs supprimé",
"download-button": "Télécharger plus",
"download-downloading": "Téléchargement...",
"download-empty": "Aucun jeu de couleurs disponible",
"download-error-api-error": "Erreur API: {status}",
"download-error-description": "Échec du téléchargement de {scheme}.",
"download-error-description": "Échec du téléchargement de {scheme}",
"download-error-download-failed": "Échec du téléchargement avec le code de sortie: {code}",
"download-error-invalid-response": "Format de réponse API invalide",
"download-error-no-files": "Aucun fichier trouvé pour le jeu de couleurs",
@@ -756,7 +758,7 @@
"download-error-rate-limit": "Limite de débit de l'API GitHub dépassée",
"download-error-title": "Échec du téléchargement",
"download-fetching": "Récupération des jeux de couleurs disponibles...",
"download-success-description": "{scheme} téléchargé avec succès.",
"download-success-description": "{scheme} téléchargé avec succès",
"download-success-title": "Jeu de couleurs téléchargé",
"download-title": "Télécharger des jeux de couleurs",
"predefined-desc": "Choisissez parmi une collection de jeux de couleurs prédéfinis.",
@@ -773,7 +775,7 @@
"templates-write-path": "Écrit : {filepath}",
"title": "Jeu de couleurs",
"wallpaper-method-description": "Choisissez votre méthode de génération de palette préférée.",
"wallpaper-method-label": "Méthode d'extraction des couleurs"
"wallpaper-method-label": "Méthode de génération de palette"
},
"control-center": {
"cards-desc": "Personnalisez les commandes qui apparaissent dans le centre de contrôle et leur ordre d'affichage.",
@@ -1181,9 +1183,9 @@
"available-label": "Plugins disponibles",
"available-no-plugins-description": "Vérifiez les sources de vos plugins ou actualisez la liste.",
"available-no-plugins-label": "Aucun plugin disponible",
"collision-already-installed": "Ce plugin est déjà installé.",
"collision-custom-version-exists": "Une version personnalisée de \"{source}\" est déjà installée.",
"collision-official-version-exists": "La version officielle de cette extension est déjà installée.",
"collision-already-installed": "Ce plugin est déjà installé",
"collision-custom-version-exists": "Une version personnalisée de \"{source}\" est déjà installée",
"collision-official-version-exists": "La version officielle de cette extension est déjà installée",
"filter-downloaded": "Téléchargé",
"filter-not-downloaded": "Non téléchargé",
"filter-tags-description": "Filtrer les extensions par catégorie ou état de téléchargement",
@@ -1191,9 +1193,9 @@
"hot-reload-description": "Recharger automatiquement les plugins lorsque leurs fichiers sont modifiés. Utile pour le développement de plugins.",
"hot-reload-label": "Rechargement à chaud (mode développement)",
"hot-reloaded": "Plugin rechargé : {name}",
"install-error": "Échec de l'installation : {error}.",
"install-error": "Échec de l'installation : {error}",
"install-incompatible": "{plugin} nécessite Noctalia v{version} ou une version ultérieure",
"install-success": "Installation de {plugin} réussie.",
"install-success": "Installation de {plugin} réussie",
"installed-description": "Gérer et configurer tous les plugins installés localement.",
"installed-label": "Plugins installés",
"installed-no-plugins-description": "Installez les extensions depuis la section \"Disponible\".",
@@ -1204,16 +1206,16 @@
"plugin-settings-title": "Paramètres de {plugin}",
"refresh-refreshing": "Actualisation de la liste des plugins...",
"refresh-tooltip": "Actualiser les extensions disponibles",
"settings-error-not-loaded": "Plugin non chargé.",
"settings-saved": "Paramètres du plugin enregistrés.",
"settings-error-not-loaded": "Plugin non chargé",
"settings-saved": "Paramètres du plugin enregistrés",
"settings-tooltip": "Paramètres du plugin",
"source-custom": "Source personnalisée",
"sources-add-custom": "Ajouter un dépôt personnalisé",
"sources-add-dialog-description": "Ajouter un dépôt GitHub comme source de plugin.",
"sources-add-dialog-error": "Échec de l'ajout de la source du plugin.",
"sources-add-dialog-error": "Échec de l'ajout de la source du plugin",
"sources-add-dialog-name": "Nom du dépôt",
"sources-add-dialog-name-placeholder": "Mes extensions personnalisées",
"sources-add-dialog-success": "Source du plugin ajoutée avec succès.",
"sources-add-dialog-success": "Source du plugin ajoutée avec succès",
"sources-add-dialog-title": "Ajouter la source du plugin",
"sources-add-dialog-url": "URL du dépôt",
"sources-description": "Gérer les dépôts d'extensions.",
@@ -1222,16 +1224,16 @@
"title": "Modules d'extension",
"uninstall-dialog-description": "Êtes-vous sûr de vouloir désinstaller {plugin} ? Cette action supprimera toutes les données du plugin.",
"uninstall-dialog-title": "Désinstaller le plugin",
"uninstall-error": "Échec de la désinstallation : {error}.",
"uninstall-success": "{plugin} a été désinstallé avec succès.",
"uninstall-error": "Échec de la désinstallation : {error}",
"uninstall-success": "{plugin} a été désinstallé avec succès",
"uninstalling": "Désinstallation de {plugin}...",
"update-all": "Mettre à jour tout ({count})",
"update-all-success": "Tous les plugins ont été mis à jour avec succès.",
"update-all-success": "Tous les plugins ont été mis à jour avec succès",
"update-available": "Nouvelle mise à jour de plugin disponible",
"update-available-plural": "Nouvelles mises à jour d'extensions disponibles ({count})",
"update-error": "Échec de la mise à jour du plugin : {plugin} : {error}.",
"update-error": "Échec de la mise à jour du plugin : {plugin} : {error}",
"update-pending": "v{current} → v{new} (nécessite Noctalia v{required})",
"update-success": "Mise à jour du plugin {plugin} vers la version {version}.",
"update-success": "Mise à jour du plugin {plugin} vers la version {version}",
"update-version": "v{current} → v{new}",
"updating": "Mise à jour..."
},
@@ -1536,9 +1538,7 @@
"connected": "Connecté à '{ssid}'",
"connection-failed": "Échec de la connexion",
"connection-timeout": "Délai de connexion dépassé",
"disabled": "Désactivé",
"disconnected": "Déconnecté de '{ssid}'",
"enabled": "Activé",
"incorrect-password": "Mot de passe incorrect",
"network-not-found": "Réseau introuvable"
}
+21 -21
View File
@@ -374,6 +374,7 @@
"enabled": "Engedélyezve",
"events": "Események",
"execute": "Végrehajt",
"faithful": "Hű",
"focus": "Fókusz",
"frequency": "Frekvencia",
"gateway": "Átjáró",
@@ -453,6 +454,7 @@
"update": "Frissítés",
"upload": "Feltöltés",
"version": "Verzió",
"vibrant": "Élénk",
"visualizer": "Vizualizáló",
"volume": "Térfogat",
"volumes": "Kötetek",
@@ -740,15 +742,15 @@
"dark-mode-mode-manual": "Kézi",
"dark-mode-mode-off": "Ki",
"dark-mode-switch-description": "Sötétebb témára vált a könnyebb éjszakai megtekintés érdekében.",
"delete-error-description": "Nem sikerült törölni a(z) {scheme} sémát.",
"delete-error-description": "Nem sikerült törölni a(z) {scheme} sémát",
"delete-error-title": "A törlés sikertelen",
"delete-success-description": "{scheme} sikeresen törölve.",
"delete-success-description": "{scheme} sikeresen törölve",
"delete-success-title": "Színséma törölve",
"download-button": "Több letöltése",
"download-downloading": "Letöltés...",
"download-empty": "Nincsenek elérhető színsémák",
"download-error-api-error": "API hiba: {status}",
"download-error-description": "Nem sikerült letölteni a(z) {scheme} sémát.",
"download-error-description": "Nem sikerült letölteni a(z) {scheme} sémát",
"download-error-download-failed": "A letöltés sikertelen, kilépési kóddal: {code}",
"download-error-invalid-response": "Érvénytelen API válaszformátum",
"download-error-no-files": "Nem található fájl a sémához",
@@ -756,7 +758,7 @@
"download-error-rate-limit": "A GitHub API sebességkorlátja túl lett lépve",
"download-error-title": "A letöltés sikertelen",
"download-fetching": "Elérhető színsémák lekérése...",
"download-success-description": "Sikeresen letöltve: {scheme}.",
"download-success-description": "Sikeresen letöltve: {scheme}",
"download-success-title": "Színséma letöltve",
"download-title": "Színsémák letöltése",
"predefined-desc": "Válasszon az előre definiált színsémák gyűjteményéből.",
@@ -773,7 +775,7 @@
"templates-write-path": "Írja: {filepath}",
"title": "Színséma",
"wallpaper-method-description": "Válassza ki a kedvenc palettagenerálási módszereit.",
"wallpaper-method-label": "Színkivonási módszer"
"wallpaper-method-label": "Paletta generálási módszer"
},
"control-center": {
"cards-desc": "Személyre szabhatja, hogy mely vezérlők jelenjenek meg a vezérlőközpontban és milyen sorrendben.",
@@ -1181,9 +1183,9 @@
"available-label": "Elérhető bővítmények",
"available-no-plugins-description": "Ellenőrizze a bővítményforrásokat, vagy frissítse a listát.",
"available-no-plugins-label": "Nincsenek elérhető bővítmények",
"collision-already-installed": "Ez a bővítmény már telepítve van.",
"collision-custom-version-exists": "Egyéni verzió a következő helyről: \"{source}\" már telepítve van.",
"collision-official-version-exists": "A plugin hivatalos verziója már telepítve van.",
"collision-already-installed": "Ez a bővítmény már telepítve van",
"collision-custom-version-exists": "Egyéni verzió a következő helyről: \"{source}\" már telepítve van",
"collision-official-version-exists": "A plugin hivatalos verziója már telepítve van",
"filter-downloaded": "Letöltött",
"filter-not-downloaded": "Nincs letöltve",
"filter-tags-description": "Bővítmények szűrése kategória vagy letöltési állapot szerint",
@@ -1191,9 +1193,9 @@
"hot-reload-description": "Automatikusan újratölti a bővítményeket, amikor a fájljaik megváltoznak. Hasznos a bővítményfejlesztéshez.",
"hot-reload-label": "Gyors újratöltés (fejlesztői mód)",
"hot-reloaded": "Újratöltött bővítmény: {name}",
"install-error": "Sikertelen telepítés: {error}.",
"install-error": "Sikertelen telepítés: {error}",
"install-incompatible": "A {plugin} bővítményhez Noctalia v{version} vagy újabb verzió szükséges",
"install-success": "Sikeresen telepítve: {plugin}.",
"install-success": "Sikeresen telepítve: {plugin}",
"installed-description": "Az összes helyileg telepített bővítmény kezelése és beállítása.",
"installed-label": "Telepített bővítmények",
"installed-no-plugins-description": "Telepítsen bővítményeket az \"Elérhető\" szakaszból.",
@@ -1204,16 +1206,16 @@
"plugin-settings-title": "{plugin} beállításai",
"refresh-refreshing": "Bővítmények listájának frissítése...",
"refresh-tooltip": "Elérhető bővítmények frissítése",
"settings-error-not-loaded": "Bővítmény nincs betöltve.",
"settings-saved": "Bővítmény beállításai mentve.",
"settings-error-not-loaded": "Bővítmény nincs betöltve",
"settings-saved": "Bővítmény beállításai mentve",
"settings-tooltip": "Bővítmény beállításai",
"source-custom": "Egyéni forrás",
"sources-add-custom": "Egyéni tároló hozzáadása",
"sources-add-dialog-description": "GitHub tároló hozzáadása bővítményforrásként.",
"sources-add-dialog-error": "Sikertelen bővítményforrás hozzáadás.",
"sources-add-dialog-error": "Sikertelen bővítményforrás hozzáadás",
"sources-add-dialog-name": "Tároló neve",
"sources-add-dialog-name-placeholder": "Saját egyéni bővítményeim",
"sources-add-dialog-success": "Bővítményforrás sikeresen hozzáadva.",
"sources-add-dialog-success": "Bővítményforrás sikeresen hozzáadva",
"sources-add-dialog-title": "Bővítményforrás hozzáadása",
"sources-add-dialog-url": "Tároló URL-címe",
"sources-description": "Bővítménytárolók kezelése.",
@@ -1222,16 +1224,16 @@
"title": "Bővítmények",
"uninstall-dialog-description": "Biztosan el szeretné távolítani a(z) {plugin} bővítményt? Ez eltávolítja az összes bővítményadatot.",
"uninstall-dialog-title": "Bővítmény eltávolítása",
"uninstall-error": "Sikertelen eltávolítás: {error}.",
"uninstall-success": "A(z) {plugin} sikeresen eltávolítva.",
"uninstall-error": "Sikertelen eltávolítás: {error}",
"uninstall-success": "{plugin} sikeresen eltávolítva",
"uninstalling": "{plugin} eltávolítása...",
"update-all": "Összes frissítése ({count})",
"update-all-success": "Minden bővítmény sikeresen frissítve.",
"update-all-success": "Minden bővítmény sikeresen frissítve",
"update-available": "Új bővítményfrissítés elérhető",
"update-available-plural": "Új bővítményfrissítések elérhetők ({count})",
"update-error": "Nem sikerült frissíteni a bővítményt: {plugin}: {error}.",
"update-error": "Nem sikerült frissíteni a bővítményt: {plugin}: {error}",
"update-pending": "v{current} → v{new} (szükséges Noctalia v{required})",
"update-success": "A(z) {plugin} frissítve lett a v{version} verzióra.",
"update-success": "A(z) {plugin} frissítve lett a v{version} verzióra",
"update-version": "v{current} → v{new}",
"updating": "Frissítés..."
},
@@ -1536,9 +1538,7 @@
"connected": "Csatlakozva a(z) „{ssid}” hálózathoz",
"connection-failed": "Csatlakozás sikertelen",
"connection-timeout": "Csatlakozási időtúllépés",
"disabled": "Kikapcsolva",
"disconnected": "Kapcsolat megszakadt a(z) „{ssid}” hálózattal",
"enabled": "Bekapcsolva",
"incorrect-password": "Helytelen jelszó.",
"network-not-found": "Hálózat nem található"
}
+21 -21
View File
@@ -374,6 +374,7 @@
"enabled": "有効",
"events": "イベント",
"execute": "実行",
"faithful": "忠実",
"focus": "集中",
"frequency": "頻度",
"gateway": "ゲートウェイ",
@@ -453,6 +454,7 @@
"update": "アップデート",
"upload": "アップロード",
"version": "バージョン",
"vibrant": "鮮やか",
"visualizer": "ビジュアライザー",
"volume": "ボリューム",
"volumes": "ボリューム",
@@ -740,15 +742,15 @@
"dark-mode-mode-manual": "手動",
"dark-mode-mode-off": "オフ",
"dark-mode-switch-description": "夜間でも見やすいように、暗いテーマに切り替えます。",
"delete-error-description": "{scheme} の削除に失敗しました",
"delete-error-description": "{scheme} の削除に失敗しました",
"delete-error-title": "削除失敗",
"delete-success-description": "{scheme} を削除しました",
"delete-success-description": "{scheme} を削除しました",
"delete-success-title": "配色を削除しました",
"download-button": "さらにダウンロード",
"download-downloading": "ダウンロード中...",
"download-empty": "利用可能な配色がありません",
"download-error-api-error": "API エラー: {status}",
"download-error-description": "{scheme} のダウンロードに失敗しました",
"download-error-description": "{scheme} のダウンロードに失敗しました",
"download-error-download-failed": "ダウンロードに失敗しました(終了コード: {code})",
"download-error-invalid-response": "無効なAPIレスポンス形式",
"download-error-no-files": "スキームのファイルが見つかりません",
@@ -756,7 +758,7 @@
"download-error-rate-limit": "GitHub APIのレート制限を超えました",
"download-error-title": "ダウンロード失敗",
"download-fetching": "利用可能な配色を取得中...",
"download-success-description": "{scheme} をダウンロードしました",
"download-success-description": "{scheme} をダウンロードしました",
"download-success-title": "配色をダウンロードしました",
"download-title": "配色のダウンロード",
"predefined-desc": "あらかじめ用意された配色から選択します。",
@@ -773,7 +775,7 @@
"templates-write-path": "書き込み先: {filepath}",
"title": "配色",
"wallpaper-method-description": "お好みのパレット生成方法を選択してください。",
"wallpaper-method-label": "色の抽出方法"
"wallpaper-method-label": "パレット生成方法"
},
"control-center": {
"cards-desc": "コントロールセンターに表示する項目と、その順序をカスタマイズします。",
@@ -1181,9 +1183,9 @@
"available-label": "利用可能なプラグイン",
"available-no-plugins-description": "プラグインのソースを確認するか、リストを更新してください。",
"available-no-plugins-label": "利用可能なプラグインはありません",
"collision-already-installed": "このプラグインはすでにインストールされています",
"collision-custom-version-exists": "「{source}」 発 の カスタム版 は 既に インストール されています",
"collision-official-version-exists": "このプラグインの公式バージョンはすでにインストールされています",
"collision-already-installed": "このプラグインはすでにインストールされています",
"collision-custom-version-exists": "「{source}」 発 の カスタム版 は 既に インストール されています",
"collision-official-version-exists": "このプラグインの公式バージョンはすでにインストールされています",
"filter-downloaded": "ダウンロード済み",
"filter-not-downloaded": "未ダウンロード",
"filter-tags-description": "カテゴリーまたはダウンロード状況でプラグインを絞り込む",
@@ -1191,9 +1193,9 @@
"hot-reload-description": "ファイルの変更時にプラグインを自動的にリロードします。プラグイン開発に便利です。",
"hot-reload-label": "ホットリロード(開発モード)",
"hot-reloaded": "プラグインをリロードしました: {name}",
"install-error": "インストールに失敗しました: {error}",
"install-error": "インストールに失敗しました: {error}",
"install-incompatible": "{plugin} には Noctalia v{version} 以降が必要です",
"install-success": "{plugin} をインストールしました",
"install-success": "{plugin} をインストールしました",
"installed-description": "ローカルにインストールされたすべてのプラグインを管理・設定します。",
"installed-label": "インストール済みプラグイン",
"installed-no-plugins-description": "「利用可能」セクションからプラグインをインストールしてください。",
@@ -1204,16 +1206,16 @@
"plugin-settings-title": "{plugin} の設定",
"refresh-refreshing": "プラグインリストを更新中...",
"refresh-tooltip": "利用可能なプラグインを更新",
"settings-error-not-loaded": "プラグインは読み込まれていません",
"settings-saved": "プラグイン設定を保存しました",
"settings-error-not-loaded": "プラグインは読み込まれていません",
"settings-saved": "プラグイン設定を保存しました",
"settings-tooltip": "プラグイン設定",
"source-custom": "カスタムソース",
"sources-add-custom": "カスタムリポジトリを追加",
"sources-add-dialog-description": "プラグインソースとして GitHub リポジトリを追加します。",
"sources-add-dialog-error": "プラグインソースの追加に失敗しました",
"sources-add-dialog-error": "プラグインソースの追加に失敗しました",
"sources-add-dialog-name": "リポジトリ名",
"sources-add-dialog-name-placeholder": "マイカスタムプラグイン",
"sources-add-dialog-success": "プラグインソースを追加しました",
"sources-add-dialog-success": "プラグインソースを追加しました",
"sources-add-dialog-title": "プラグインソースを追加",
"sources-add-dialog-url": "リポジトリの URL",
"sources-description": "プラグインリポジトリを管理します。",
@@ -1222,16 +1224,16 @@
"title": "プラグイン",
"uninstall-dialog-description": "本当に {plugin} をアンインストールしますか?すべてのプラグインデータが削除されます。",
"uninstall-dialog-title": "プラグインのアンインストール",
"uninstall-error": "アンインストールに失敗しました: {error}",
"uninstall-success": "{plugin} をアンインストールしました",
"uninstall-error": "アンインストールに失敗しました: {error}",
"uninstall-success": "{plugin} をアンインストールしました",
"uninstalling": "{plugin} をアンインストール中...",
"update-all": "すべてアップデート ({count})",
"update-all-success": "すべてのプラグインをアップデートしました",
"update-all-success": "すべてのプラグインをアップデートしました",
"update-available": "新しいプラグインアップデートがあります",
"update-available-plural": "新しいプラグインアップデートがあります({count}個)",
"update-error": "プラグインのアップデートに失敗しました: {plugin}: {error}",
"update-error": "プラグインのアップデートに失敗しました: {plugin}: {error}",
"update-pending": "v{current} → v{new} (Noctalia v{required}が必要です)",
"update-success": "{plugin} を v{version} にアップデートしました",
"update-success": "{plugin} を v{version} にアップデートしました",
"update-version": "v{current} → v{new}",
"updating": "更新中..."
},
@@ -1536,9 +1538,7 @@
"connected": "{ssid} に接続しました",
"connection-failed": "接続に失敗しました",
"connection-timeout": "接続タイムアウト",
"disabled": "無効",
"disconnected": "{ssid} から切断しました",
"enabled": "有効",
"incorrect-password": "パスワードが違います",
"network-not-found": "ネットワークが見つかりません"
}
+21 -21
View File
@@ -374,6 +374,7 @@
"enabled": "Çalak",
"events": "Bûyer",
"execute": "Bicîh bîne",
"faithful": "Dilsoz",
"focus": "Bala serincê",
"frequency": "Frekans",
"gateway": "Dergeh",
@@ -453,6 +454,7 @@
"update": "Nûkirin",
"upload": "Barkirin",
"version": "Guherto",
"vibrant": "Geş",
"visualizer": "Dîmender",
"volume": "Hêjmar",
"volumes": "Cild",
@@ -740,15 +742,15 @@
"dark-mode-mode-manual": "Bi destan",
"dark-mode-mode-off": "Vemirî",
"dark-mode-switch-description": "Veguherîne bo temayek tarîtir ji bo dîtina hêsantir bi şev.",
"delete-error-description": "Xwe ji birina {scheme} bi ser neket.",
"delete-error-description": "Xwe ji birina {scheme} bi ser neket",
"delete-error-title": "Serbirin nemû ye",
"delete-success-description": "Bi serkeftî {scheme} hate jêbirin.",
"delete-success-description": "Bi serkeftî {scheme} hate jêbirin",
"delete-success-title": "Şêweya rengan hate jêbirin",
"download-button": "Bêtir daxîne",
"download-downloading": "Tê daxistin...",
"download-empty": "Rengên rengan tune ne",
"download-error-api-error": "Çewtiya API: {status}",
"download-error-description": "Daxistina {scheme} bi ser neket.",
"download-error-description": "Daxistina {scheme} bi ser neket",
"download-error-download-failed": "Daxistin bi koda derketinê ya: {code} têk çû",
"download-error-invalid-response": "Formata bersiva API ne derbasdar e",
"download-error-no-files": "Tu dosye ji bo şemayê nehatin dîtin",
@@ -756,7 +758,7 @@
"download-error-rate-limit": "Rêjeya daxwazên API ya GitHubê derbas bû",
"download-error-title": "Daxistin bi ser neket",
"download-fetching": "Digerandina şemayên rengan ên berdest...",
"download-success-description": "{scheme} bi serkeftî hate daxistin.",
"download-success-description": "{scheme} bi serkeftî hate daxistin",
"download-success-title": "Şêweya rengê hate daxistin",
"download-title": "Dakêşkirina rengên rengîn",
"predefined-desc": "Ji berhevokek şemayên rengan ên pêşdiyarkirî hilbijêrin.",
@@ -773,7 +775,7 @@
"templates-write-path": "Dinivîse: {filepath}",
"title": "Şêweya rengan",
"wallpaper-method-description": "Rêbaza xweya bijarte ya hilberîna paletê hilbijêre.",
"wallpaper-method-label": "Rêbaza derxistina rengan"
"wallpaper-method-label": "Rêbaza çêkirina paletê"
},
"control-center": {
"cards-desc": "Xwerû bike ka kîjan kontrol di navenda kontrolê de û bi çi rêzê de xuya dibin.",
@@ -1181,9 +1183,9 @@
"available-label": "Pêvekên berdest",
"available-no-plugins-description": "Çavkaniyên pêvekê xwe kontrol bikin an lîsteyê nû bikin.",
"available-no-plugins-label": "Tiştên pêvek tune ne",
"collision-already-installed": "Ev pêvek berê hatiye sazkirin.",
"collision-custom-version-exists": "Guhertoyeke taybet a ji \"{source}\" berê hatiye sazkirin.",
"collision-official-version-exists": "Guhertoya fermî ya vê pêvekê berê hatiye sazkirin.",
"collision-already-installed": "Ev pêvek berê hatiye sazkirin",
"collision-custom-version-exists": "Guhertoyeke taybet a ji \"{source}\" berê hatiye sazkirin",
"collision-official-version-exists": "Guhertoya fermî ya vê pêvekê berê hatiye sazkirin",
"filter-downloaded": "Daxistî",
"filter-not-downloaded": "Nehatiye Dakêşandin",
"filter-tags-description": "Parzûna pêvekên bi kategoriya an rewşa daxistinê",
@@ -1191,9 +1193,9 @@
"hot-reload-description": "Bi guhertina pelên wan, bixweber pêvekên xwe ji nû ve bar bike. Ji bo pêşxistina pêvekan bikêr e.",
"hot-reload-label": "Nûbarkirina germ (moda pêşvebirinê)",
"hot-reloaded": "Plugin ji nû ve hate barkirin: {name}",
"install-error": "Sazkirin bi ser neket: {error}.",
"install-error": "Sazkirin bi ser neket: {error}",
"install-incompatible": "{plugin} pêdivî bi Noctalia v{version} an bilindtir heye",
"install-success": "Bi serkeftî {plugin} hate sazkirin.",
"install-success": "Bi serkeftî {plugin} hate sazkirin",
"installed-description": "Birêvebir û hemû pêvekên ku herêmî hatine sazkirin mîheng bike.",
"installed-label": "Pêvekên sazkirî",
"installed-no-plugins-description": "Pêvekên ji beşa \"Berdest\" a saz bike.",
@@ -1204,16 +1206,16 @@
"plugin-settings-title": "Sazkairyên {plugin}",
"refresh-refreshing": "Lîsteya pêvekên nûvekirinê...",
"refresh-tooltip": "Nûkirina pêvekên berdest",
"settings-error-not-loaded": "Pêvek nehatiye barkirin.",
"settings-saved": "Ayarlarê pluginê hatin tomarkirin.",
"settings-error-not-loaded": "Pêvek nehatiye barkirin",
"settings-saved": "Ayarlarê pluginê hatin tomarkirin",
"settings-tooltip": "Mîhengên pêvekê",
"source-custom": "Çavkaniya xwerû",
"sources-add-custom": "Lê zêde bike embarê xwerû",
"sources-add-dialog-description": "Depoyek GitHub wekî çavkaniyek plugin zêde bike.",
"sources-add-dialog-error": "Çavkaniya pêvekê nehat zêdekirin.",
"sources-add-dialog-error": "Çavkaniya pêvekê nehat zêdekirin",
"sources-add-dialog-name": "Navê embarê",
"sources-add-dialog-name-placeholder": "Pêvekên min ên xweser",
"sources-add-dialog-success": "Çavkaniya pêvekê bi serkeftî hate zêdekirin.",
"sources-add-dialog-success": "Çavkaniya pêvekê bi serkeftî hate zêdekirin",
"sources-add-dialog-title": "Çavkaniya pêvekê zêde bike",
"sources-add-dialog-url": "Girêdanê depoyê",
"sources-description": "Depoyên pêvekan bi rê ve bibe.",
@@ -1222,16 +1224,16 @@
"title": "Pêvek",
"uninstall-dialog-description": "Ma hûn bawer in ku hûn dixwazin {plugin} jêbirin? Ev ê hemî daneyên pêvekê jê bibe.",
"uninstall-dialog-title": "Rakêşana pêvekê",
"uninstall-error": "Nekarî were rakirin: {error}.",
"uninstall-success": "Bi serkeftî {plugin} hate rakirin.",
"uninstall-error": "Nekarî were rakirin: {error}",
"uninstall-success": "Bi serkeftî {plugin} hate rakirin",
"uninstalling": "Rakirina {plugin}...",
"update-all": "Hemûyan nû bike ({count})",
"update-all-success": "Hemû pêvek bi serkeftî hatin nûkirin.",
"update-all-success": "Hemû pêvek bi serkeftî hatin nûkirin",
"update-available": "Rojanekirina pêveka nû heye",
"update-available-plural": "Rojanekirinên pêvekên nû hene ({count})",
"update-error": "Nekarî plugîn nûve bikin: {plugin}: {error}.",
"update-error": "Nekarî plugîn nûve bikin: {plugin}: {error}",
"update-pending": "v{current} → v{new} (hewce dike Noctalia v{required})",
"update-success": "{plugin} hate nûkirin bo v{version}.",
"update-success": "{plugin} hate nûkirin bo v{version}",
"update-version": "v{current} → v{new}",
"updating": "Tê rojanekirin..."
},
@@ -1536,9 +1538,7 @@
"connected": "Bi '{ssid}' ve hat girêdan",
"connection-failed": "Girêdan têk çû",
"connection-timeout": "Demja girêdanê bi dawî bû",
"disabled": "Neçalakkirî",
"disconnected": "Ji '{ssid}' qut bû",
"enabled": "Çalakkirî",
"incorrect-password": "Borînpeyva şaş",
"network-not-found": "Tor nehat dîtin"
}
+35 -35
View File
@@ -46,17 +46,17 @@
},
"battery": {
"device-default": "Standaard (weergaveapparaat)",
"device-description": "Selecteer welk batterijapparaat u wilt weergeven.",
"device-label": "Batterijapparaat",
"hide-if-idle-description": "Verberg de widget wanneer de batterij niet wordt opgeladen of ontladen.",
"device-description": "Selecteer welk accuapparaat u wilt weergeven.",
"device-label": "Accuapparaat",
"hide-if-idle-description": "Verberg de widget wanneer de accu niet wordt opgeladen of ontladen.",
"hide-if-idle-label": "Verbergen indien inactief",
"hide-if-not-detected-description": "Verberg de widget als er geen batterij op het systeem wordt gedetecteerd.",
"hide-if-not-detected-description": "Verberg de widget als er geen accu op het systeem wordt gedetecteerd.",
"hide-if-not-detected-label": "Verbergen indien niet gedetecteerd",
"low-battery-threshold-description": "Toon een waarschuwing wanneer de batterij onder dit percentage komt.",
"low-battery-threshold-label": "Waarschuwingsdrempel voor lage batterij",
"show-noctalia-performance-description": "Toon de Noctalia-prestatiemodus-schakelaar in het batterijpaneel.<br>Schakelt schaduwen en animaties in Noctalia uit om het resourcegebruik te verminderen.",
"low-battery-threshold-description": "Toon een waarschuwing wanneer de accu onder dit percentage komt.",
"low-battery-threshold-label": "Waarschuwingsdrempel voor lage accu",
"show-noctalia-performance-description": "Toon de Noctalia-prestatiemodus-schakelaar in het accupaneel.<br>Schakelt schaduwen en animaties in Noctalia uit om het resourcegebruik te verminderen.",
"show-noctalia-performance-label": "Noctalia-Performance-schakelaar tonen",
"show-power-profile-description": "Toon de keuze van het stroomprofiel in het batterijpaneel.",
"show-power-profile-description": "Toon de keuze van het stroomprofiel in het accupaneel.",
"show-power-profile-label": "Stroomprofielbediening tonen"
},
"clock": {
@@ -273,12 +273,12 @@
}
},
"battery": {
"battery-level": "Batterijniveau",
"battery-level": "Accuniveau",
"charging-rate": "Laadsnelheid: {rate} W",
"discharging-rate": "Ontlaadsnelheid: {rate} W",
"health": "Gezondheid: {percent}%",
"inhibit-idle-description": "Houdt het systeem wakker.",
"no-battery-detected": "Geen batterij gedetecteerd",
"no-battery-detected": "Geen accu gedetecteerd",
"plugged-in": "Op netstroom",
"power-profile": "Energieprofiel",
"time-left": "Resterende tijd: {time}",
@@ -340,7 +340,7 @@
"automation": "Automatisering",
"available": "Beschikbaar",
"back": "Terug",
"battery": "Batterij",
"battery": "Accu",
"bluetooth": "Bluetooth",
"brightness": "Helderheid",
"browse": "Bladeren",
@@ -374,6 +374,7 @@
"enabled": "Ingeschakeld",
"events": "Evenementen",
"execute": "Uitvoeren",
"faithful": "Getrouw",
"focus": "Focus",
"frequency": "Frequentie",
"gateway": "Poort",
@@ -453,6 +454,7 @@
"update": "Update",
"upload": "Uploaden",
"version": "Versie",
"vibrant": "Levendig",
"visualizer": "Visualiseerder",
"volume": "Volume",
"volumes": "Volumes",
@@ -740,15 +742,15 @@
"dark-mode-mode-manual": "Handmatig",
"dark-mode-mode-off": "Uit",
"dark-mode-switch-description": "Schakelt over naar een donkerder thema voor comfortabeler kijken 's nachts.",
"delete-error-description": "Verwijderen van {scheme} mislukt.",
"delete-error-description": "Verwijderen van {scheme} mislukt",
"delete-error-title": "Verwijderen mislukt",
"delete-success-description": "{scheme} succesvol verwijderd.",
"delete-success-description": "{scheme} succesvol verwijderd",
"delete-success-title": "Kleurenschema verwijderd",
"download-button": "Meer downloaden",
"download-downloading": "Downloaden...",
"download-empty": "Geen kleurenschema's beschikbaar",
"download-error-api-error": "API-fout: {status}",
"download-error-description": "Download van {scheme} mislukt.",
"download-error-description": "Download van {scheme} mislukt",
"download-error-download-failed": "Download mislukt met afsluitcode: {code}",
"download-error-invalid-response": "Ongeldig API-antwoordformaat",
"download-error-no-files": "Geen bestanden gevonden voor schema",
@@ -756,7 +758,7 @@
"download-error-rate-limit": "GitHub API-snelheidslimiet overschreden",
"download-error-title": "Download mislukt",
"download-fetching": "Beschikbare kleurenschema's ophalen...",
"download-success-description": "{scheme} succesvol gedownload.",
"download-success-description": "{scheme} succesvol gedownload",
"download-success-title": "Kleurenschema gedownload",
"download-title": "Kleurenschema's downloaden",
"predefined-desc": "Kies uit een verzameling vooraf gedefinieerde kleurenschema's.",
@@ -773,7 +775,7 @@
"templates-write-path": "Schrijft: {filepath}",
"title": "Kleurschema",
"wallpaper-method-description": "Kies je favoriete methode voor het genereren van paletten.",
"wallpaper-method-label": "Kleurextractiemethode"
"wallpaper-method-label": "Paletgeneratiemethode"
},
"control-center": {
"cards-desc": "Pas aan welke bedieningselementen in het bedieningscentrum verschijnen en in welke volgorde.",
@@ -1181,9 +1183,9 @@
"available-label": "Beschikbare plug-ins",
"available-no-plugins-description": "Controleer je plugin-bronnen of vernieuw de lijst.",
"available-no-plugins-label": "Geen plug-ins beschikbaar",
"collision-already-installed": "Deze plugin is al geïnstalleerd.",
"collision-custom-version-exists": "Er is al een aangepaste versie van \"{source}\" geïnstalleerd.",
"collision-official-version-exists": "De officiële versie van deze plugin is al geïnstalleerd.",
"collision-already-installed": "Deze plugin is al geïnstalleerd",
"collision-custom-version-exists": "Er is al een aangepaste versie van \"{source}\" geïnstalleerd",
"collision-official-version-exists": "De officiële versie van deze plugin is al geïnstalleerd",
"filter-downloaded": "Gedownload",
"filter-not-downloaded": "Niet gedownload",
"filter-tags-description": "Filter plug-ins op categorie of downloadstatus",
@@ -1191,9 +1193,9 @@
"hot-reload-description": "Laad plugins automatisch opnieuw wanneer hun bestanden veranderen. Handig voor plugin-ontwikkeling.",
"hot-reload-label": "Hot reload (ontwikkelmodus)",
"hot-reloaded": "Plugin opnieuw geladen: {name}",
"install-error": "Installatie mislukt: {error}.",
"install-error": "Installatie mislukt: {error}",
"install-incompatible": "{plugin} vereist Noctalia v{version} of hoger",
"install-success": "{plugin} succesvol geïnstalleerd.",
"install-success": "{plugin} succesvol geïnstalleerd",
"installed-description": "Beheer en configureer alle lokaal geïnstalleerde plugins.",
"installed-label": "Geïnstalleerde plug-ins",
"installed-no-plugins-description": "Installeer plugins uit de sectie \"Beschikbaar\".",
@@ -1204,16 +1206,16 @@
"plugin-settings-title": "{plugin} Instellingen",
"refresh-refreshing": "Pluginlijst vernieuwen...",
"refresh-tooltip": "Beschikbare plugins vernieuwen",
"settings-error-not-loaded": "Plugin niet geladen.",
"settings-saved": "Plugininstellingen opgeslagen.",
"settings-error-not-loaded": "Plugin niet geladen",
"settings-saved": "Plugininstellingen opgeslagen",
"settings-tooltip": "Plugin instellingen",
"source-custom": "Aangepaste bron",
"sources-add-custom": "Aangepaste repository toevoegen",
"sources-add-dialog-description": "Voeg een GitHub repository toe als een pluginbron.",
"sources-add-dialog-error": "Pluginbron toevoegen mislukt.",
"sources-add-dialog-error": "Pluginbron toevoegen mislukt",
"sources-add-dialog-name": "Repositorynaam",
"sources-add-dialog-name-placeholder": "Mijn aangepaste plugins",
"sources-add-dialog-success": "Pluginbron succesvol toegevoegd.",
"sources-add-dialog-success": "Pluginbron succesvol toegevoegd",
"sources-add-dialog-title": "Pluginbron toevoegen",
"sources-add-dialog-url": "Repository URL",
"sources-description": "Beheer plugin-repositories.",
@@ -1222,16 +1224,16 @@
"title": "Plugins",
"uninstall-dialog-description": "Weet je zeker dat je {plugin} wilt verwijderen? Hiermee worden alle plugin-gegevens verwijderd.",
"uninstall-dialog-title": "Plugin verwijderen",
"uninstall-error": "Verwijderen mislukt: {error}.",
"uninstall-success": "{plugin} is succesvol verwijderd.",
"uninstall-error": "Verwijderen mislukt: {error}",
"uninstall-success": "{plugin} is succesvol verwijderd",
"uninstalling": "{plugin} verwijderen...",
"update-all": "Alles bijwerken ({count})",
"update-all-success": "Alle plugins succesvol bijgewerkt.",
"update-all-success": "Alle plugins succesvol bijgewerkt",
"update-available": "Nieuwe plugin update beschikbaar",
"update-available-plural": "Nieuwe plugin updates beschikbaar ({count})",
"update-error": "Plugin bijwerken mislukt: {plugin}: {error}.",
"update-error": "Plugin bijwerken mislukt: {plugin}: {error}",
"update-pending": "v{current} → v{new} (vereist Noctalia v{required})",
"update-success": "{plugin} is bijgewerkt naar v{version}.",
"update-success": "{plugin} is bijgewerkt naar v{version}",
"update-version": "v{current} → v{new}",
"updating": "Bezig met updaten..."
},
@@ -1268,7 +1270,7 @@
"critical-color-label": "Kritische kleur",
"custom-highlight-colors-title-label": "Aangepaste markeerkleuren",
"disk-section-label": "Schijfgebruik",
"enable-dgpu-monitoring-description": "Waarschuwing: Dit zal uw dedicated GPU (NVIDIA/AMD) activeren, wat een aanzienlijke impact kan hebben op de batterijduur van laptops met hybride grafische kaarten.",
"enable-dgpu-monitoring-description": "Waarschuwing: Dit zal uw dedicated GPU (NVIDIA/AMD) activeren, wat een aanzienlijke impact kan hebben op de accuduur van laptops met hybride grafische kaarten.",
"enable-dgpu-monitoring-label": "Dedicated GPU-monitoring inschakelen",
"external-monitor-description": "Voer de opdracht of applicatiepad in om te starten bij het activeren van de externe systeemmonitor applicatie.",
"external-monitor-label": "Externe systeemmonitor opdracht",
@@ -1466,8 +1468,8 @@
"title": "Vliegtuigmodus"
},
"battery": {
"low": "Batterij bijna leeg",
"low-desc": "De batterij is op {percent}%. Sluit de oplader aan"
"low": "Accu bijna leeg",
"low-desc": "De accu is op {percent}%. Sluit de oplader aan"
},
"bluetooth": {
"address-copied": "Adres gekopieerd naar klembord",
@@ -1536,9 +1538,7 @@
"connected": "Verbonden met '{ssid}'",
"connection-failed": "Verbinding mislukt",
"connection-timeout": "Time-out bij verbinding",
"disabled": "Wi-Fi uitgeschakeld",
"disconnected": "Verbinding met '{ssid}' verbroken",
"enabled": "Wi-Fi ingeschakeld",
"incorrect-password": "Onjuist wachtwoord",
"network-not-found": "Netwerk niet gevonden"
}
+21 -21
View File
@@ -374,6 +374,7 @@
"enabled": "Włączone",
"events": "Wydarzenia",
"execute": "Wykonaj",
"faithful": "Wierny",
"focus": "Skupienie",
"frequency": "Częstotliwość",
"gateway": "Brama",
@@ -453,6 +454,7 @@
"update": "Aktualizacja",
"upload": "Wyślij",
"version": "Wersja",
"vibrant": "Żywy",
"visualizer": "Wizualizator",
"volume": "Objętość",
"volumes": "Tomy",
@@ -740,15 +742,15 @@
"dark-mode-mode-manual": "Ręcznie",
"dark-mode-mode-off": "Wyłączony",
"dark-mode-switch-description": "Przełącza na ciemniejszy motyw dla łatwiejszego oglądania w nocy.",
"delete-error-description": "Nie udało się usunąć {scheme}.",
"delete-error-description": "Nie udało się usunąć {scheme}",
"delete-error-title": "Usuwanie nieudane",
"delete-success-description": "Pomyślnie usunięto {scheme}.",
"delete-success-description": "Pomyślnie usunięto {scheme}",
"delete-success-title": "Schemat kolorów usunięty",
"download-button": "Pobierz więcej",
"download-downloading": "Pobieranie...",
"download-empty": "Brak dostępnych schematów kolorów",
"download-error-api-error": "Błąd API: {status}",
"download-error-description": "Nie udało się pobrać {scheme}.",
"download-error-description": "Nie udało się pobrać {scheme}",
"download-error-download-failed": "Pobieranie nieudane z kodem: {code}",
"download-error-invalid-response": "Nieprawidłowy format odpowiedzi API",
"download-error-no-files": "Nie znaleziono plików dla schematu",
@@ -756,7 +758,7 @@
"download-error-rate-limit": "Przekroczono limit zapytań GitHub API",
"download-error-title": "Pobieranie nieudane",
"download-fetching": "Pobieranie dostępnych schematów kolorów...",
"download-success-description": "Pomyślnie pobrano {scheme}.",
"download-success-description": "Pomyślnie pobrano {scheme}",
"download-success-title": "Schemat kolorów pobrany",
"download-title": "Pobierz schematy kolorów",
"predefined-desc": "Wybierz z kolekcji predefiniowanych schematów kolorów.",
@@ -773,7 +775,7 @@
"templates-write-path": "Zapisuje: {filepath}",
"title": "Schemat kolorów",
"wallpaper-method-description": "Wybierz swoją ulubioną metodę generowania palety.",
"wallpaper-method-label": "Metoda ekstrakcji kolorów"
"wallpaper-method-label": "Metoda generowania palety"
},
"control-center": {
"cards-desc": "Dostosuj, które kontrolki pojawiają się w centrum sterowania i w jakiej kolejności.",
@@ -1181,9 +1183,9 @@
"available-label": "Dostępne wtyczki",
"available-no-plugins-description": "Sprawdź źródła wtyczek lub odśwież listę.",
"available-no-plugins-label": "Brak dostępnych wtyczek",
"collision-already-installed": "Ta wtyczka jest już zainstalowana.",
"collision-custom-version-exists": "Niestandardowa wersja z \"{source}\" jest już zainstalowana.",
"collision-official-version-exists": "Oficjalna wersja tej wtyczki jest już zainstalowana.",
"collision-already-installed": "Ta wtyczka jest już zainstalowana",
"collision-custom-version-exists": "Niestandardowa wersja z \"{source}\" jest już zainstalowana",
"collision-official-version-exists": "Oficjalna wersja tej wtyczki jest już zainstalowana",
"filter-downloaded": "Pobrane",
"filter-not-downloaded": "Niepobrane",
"filter-tags-description": "Filtruj wtyczki według kategorii lub statusu pobierania",
@@ -1191,9 +1193,9 @@
"hot-reload-description": "Automatycznie przeładuj wtyczki przy zmianie ich plików. Przydatne przy tworzeniu wtyczek.",
"hot-reload-label": "Gorące przeładowanie (tryb deweloperski)",
"hot-reloaded": "Przeładowano wtyczkę: {name}",
"install-error": "Błąd instalacji: {error}.",
"install-error": "Błąd instalacji: {error}",
"install-incompatible": "{plugin} wymaga Noctalii w wersji v{version} lub wyższej",
"install-success": "Pomyślnie zainstalowano {plugin}.",
"install-success": "Pomyślnie zainstalowano {plugin}",
"installed-description": "Zarządzaj i konfiguruj lokalnie zainstalowane wtyczki.",
"installed-label": "Zainstalowane wtyczki",
"installed-no-plugins-description": "Zainstaluj wtyczki z sekcji \"Dostępne\".",
@@ -1204,16 +1206,16 @@
"plugin-settings-title": "Ustawienia {plugin}",
"refresh-refreshing": "Odświeżanie listy wtyczek...",
"refresh-tooltip": "Odśwież dostępne wtyczki",
"settings-error-not-loaded": "Wtyczka nie została załadowana.",
"settings-saved": "Ustawienia wtyczki zapisane.",
"settings-error-not-loaded": "Wtyczka nie została załadowana",
"settings-saved": "Ustawienia wtyczki zapisane",
"settings-tooltip": "Ustawienia wtyczki",
"source-custom": "Źródło niestandardowe",
"sources-add-custom": "Dodaj własne repozytorium",
"sources-add-dialog-description": "Dodaj repozytorium GitHub jako źródło wtyczek.",
"sources-add-dialog-error": "Nie udało się dodać źródła wtyczek.",
"sources-add-dialog-error": "Nie udało się dodać źródła wtyczek",
"sources-add-dialog-name": "Nazwa repozytorium",
"sources-add-dialog-name-placeholder": "Moje własne wtyczki",
"sources-add-dialog-success": "Źródło wtyczek dodane pomyślnie.",
"sources-add-dialog-success": "Źródło wtyczek dodane pomyślnie",
"sources-add-dialog-title": "Dodaj źródło wtyczek",
"sources-add-dialog-url": "Adres URL repozytorium",
"sources-description": "Zarządzaj repozytoriami wtyczek.",
@@ -1222,16 +1224,16 @@
"title": "Wtyczki",
"uninstall-dialog-description": "Czy na pewno chcesz odinstalować {plugin}? Spowoduje to usunięcie wszystkich danych wtyczki.",
"uninstall-dialog-title": "Odinstaluj wtyczkę",
"uninstall-error": "Błąd odinstalowywania: {error}.",
"uninstall-success": "Pomyślnie odinstalowano {plugin}.",
"uninstall-error": "Błąd odinstalowywania: {error}",
"uninstall-success": "Pomyślnie odinstalowano {plugin}",
"uninstalling": "Odinstalowywanie {plugin}...",
"update-all": "Aktualizuj wszystkie ({count})",
"update-all-success": "Wszystkie wtyczki zaktualizowane pomyślnie.",
"update-all-success": "Wszystkie wtyczki zaktualizowane pomyślnie",
"update-available": "Dostępna nowa aktualizacja wtyczki",
"update-available-plural": "Dostępne nowe aktualizacje wtyczek ({count})",
"update-error": "Błąd aktualizacji wtyczki {plugin}: {error}.",
"update-error": "Błąd aktualizacji wtyczki {plugin}: {error}",
"update-pending": "v{current} → v{new} (wymaga Noctalii v{required})",
"update-success": "Zaktualizowano {plugin} do v{version}.",
"update-success": "Zaktualizowano {plugin} do v{version}",
"update-version": "v{current} → v{new}",
"updating": "Aktualizowanie {plugin}..."
},
@@ -1536,9 +1538,7 @@
"connected": "Połączono z '{ssid}'",
"connection-failed": "Połączenie nieudane",
"connection-timeout": "Przekroczenie czasu połączenia",
"disabled": "Wyłączone",
"disconnected": "Rozłączono z '{ssid}'",
"enabled": "Włączone",
"incorrect-password": "Nieprawidłowe hasło",
"network-not-found": "Nie znaleziono sieci"
}
+21 -21
View File
@@ -374,6 +374,7 @@
"enabled": "Ativado",
"events": "Eventos",
"execute": "Executar",
"faithful": "Fiel",
"focus": "Foco",
"frequency": "Frequência",
"gateway": "Porta de entrada",
@@ -453,6 +454,7 @@
"update": "Atualização",
"upload": "Enviar",
"version": "Versão",
"vibrant": "Vibrante",
"visualizer": "Visualizador",
"volume": "Volume",
"volumes": "Volumes",
@@ -740,15 +742,15 @@
"dark-mode-mode-manual": "Manual",
"dark-mode-mode-off": "Desligado",
"dark-mode-switch-description": "Muda para um tema mais escuro para facilitar a visualização à noite.",
"delete-error-description": "Falha ao excluir {scheme}.",
"delete-error-description": "Falha ao excluir {scheme}",
"delete-error-title": "Falha ao excluir",
"delete-success-description": "{scheme} excluído com sucesso.",
"delete-success-description": "{scheme} excluído com sucesso",
"delete-success-title": "Esquema de cores excluído",
"download-button": "Baixar mais",
"download-downloading": "Baixando...",
"download-empty": "Nenhum esquema de cores disponível",
"download-error-api-error": "Erro da API: {status}",
"download-error-description": "Falha ao baixar {scheme}.",
"download-error-description": "Falha ao baixar {scheme}",
"download-error-download-failed": "Falha no download com código de saída: {code}",
"download-error-invalid-response": "Formato de resposta da API inválido",
"download-error-no-files": "Nenhum arquivo encontrado para o esquema",
@@ -756,7 +758,7 @@
"download-error-rate-limit": "Limite de taxa da API do GitHub excedido",
"download-error-title": "Falha no download",
"download-fetching": "Buscando esquemas de cores disponíveis...",
"download-success-description": "{scheme} baixado com sucesso.",
"download-success-description": "{scheme} baixado com sucesso",
"download-success-title": "Esquema de cores baixado",
"download-title": "Baixar esquemas de cores",
"predefined-desc": "Escolha entre uma coleção de esquemas de cores predefinidos.",
@@ -773,7 +775,7 @@
"templates-write-path": "Escreve: {filepath}",
"title": "Esquema de cores",
"wallpaper-method-description": "Escolha o seu método de geração de paleta favorito.",
"wallpaper-method-label": "Método de extração de cor"
"wallpaper-method-label": "Método de geração de paleta"
},
"control-center": {
"cards-desc": "Personalize quais controles aparecem na central de controle e em que ordem.",
@@ -1181,9 +1183,9 @@
"available-label": "Plugins disponíveis",
"available-no-plugins-description": "Verifique as fontes do seu plugin ou atualize a lista.",
"available-no-plugins-label": "Nenhum plugin disponível",
"collision-already-installed": "Este plugin já está instalado.",
"collision-custom-version-exists": "Uma versão personalizada de \"{source}\" já está instalada.",
"collision-official-version-exists": "A versão oficial deste plugin já está instalada.",
"collision-already-installed": "Este plugin já está instalado",
"collision-custom-version-exists": "Uma versão personalizada de \"{source}\" já está instalada",
"collision-official-version-exists": "A versão oficial deste plugin já está instalada",
"filter-downloaded": "Baixado",
"filter-not-downloaded": "Não baixado",
"filter-tags-description": "Filtrar plugins por categoria ou estado de download",
@@ -1191,9 +1193,9 @@
"hot-reload-description": "Recarrega automaticamente os plugins quando seus arquivos são alterados. Útil para o desenvolvimento de plugins.",
"hot-reload-label": "Recarga a quente (modo de desenvolvimento)",
"hot-reloaded": "Plugin recarregado: {name}",
"install-error": "Falha ao instalar: {error}.",
"install-error": "Falha ao instalar: {error}",
"install-incompatible": "{plugin} requer Noctalia v{version} ou superior",
"install-success": "{plugin} instalado com sucesso.",
"install-success": "{plugin} instalado com sucesso",
"installed-description": "Gerenciar e configurar todos os plugins instalados localmente.",
"installed-label": "Plugins instalados",
"installed-no-plugins-description": "Instale os plugins da seção \"Disponível\".",
@@ -1204,16 +1206,16 @@
"plugin-settings-title": "Configurações do {plugin}",
"refresh-refreshing": "Atualizando a lista de plugins...",
"refresh-tooltip": "Atualizar plugins disponíveis",
"settings-error-not-loaded": "Plugin não carregado.",
"settings-saved": "Configurações do plugin salvas.",
"settings-error-not-loaded": "Plugin não carregado",
"settings-saved": "Configurações do plugin salvas",
"settings-tooltip": "Configurações do plugin",
"source-custom": "Fonte personalizada",
"sources-add-custom": "Adicionar repositório personalizado",
"sources-add-dialog-description": "Adicionar um repositório GitHub como fonte de plugin.",
"sources-add-dialog-error": "Falha ao adicionar a fonte do plugin.",
"sources-add-dialog-error": "Falha ao adicionar a fonte do plugin",
"sources-add-dialog-name": "Nome do repositório",
"sources-add-dialog-name-placeholder": "Meus plugins personalizados",
"sources-add-dialog-success": "Fonte do plugin adicionada com sucesso.",
"sources-add-dialog-success": "Fonte do plugin adicionada com sucesso",
"sources-add-dialog-title": "Adicionar fonte do plugin",
"sources-add-dialog-url": "URL do repositório",
"sources-description": "Gerenciar repositórios de plugins.",
@@ -1222,16 +1224,16 @@
"title": "Plugins",
"uninstall-dialog-description": "Tem certeza de que deseja desinstalar {plugin}? Isso removerá todos os dados do plugin.",
"uninstall-dialog-title": "Desinstalar plugin",
"uninstall-error": "Falha ao desinstalar: {error}.",
"uninstall-success": "{plugin} desinstalado com sucesso.",
"uninstall-error": "Falha ao desinstalar: {error}",
"uninstall-success": "{plugin} desinstalado com sucesso",
"uninstalling": "Desinstalando {plugin}...",
"update-all": "Atualizar tudo ({count})",
"update-all-success": "Todos os plugins foram atualizados com sucesso.",
"update-all-success": "Todos os plugins foram atualizados com sucesso",
"update-available": "Nova atualização de plugin disponível",
"update-available-plural": "Novas atualizações de plugin disponíveis ({count})",
"update-error": "Falha ao atualizar o plugin: {plugin}: {error}.",
"update-error": "Falha ao atualizar o plugin: {plugin}: {error}",
"update-pending": "v{current} → v{new} (requer Noctalia v{required})",
"update-success": "{plugin} atualizado para a versão {version}.",
"update-success": "{plugin} atualizado para a versão {version}",
"update-version": "v{current} → v{new}",
"updating": "Atualizando..."
},
@@ -1536,9 +1538,7 @@
"connected": "Conectado a '{ssid}'",
"connection-failed": "Falha na conexão",
"connection-timeout": "Tempo de conexão esgotado",
"disabled": "Desativado",
"disconnected": "Desconectado de '{ssid}'",
"enabled": "Ativado",
"incorrect-password": "Senha incorreta",
"network-not-found": "Rede não encontrada"
}
+21 -21
View File
@@ -374,6 +374,7 @@
"enabled": "Включено",
"events": "События",
"execute": "Выполнить",
"faithful": "Верный",
"focus": "Фокус",
"frequency": "Частота",
"gateway": "Шлюз",
@@ -453,6 +454,7 @@
"update": "Обновление",
"upload": "Загрузить",
"version": "Версия",
"vibrant": "Яркий",
"visualizer": "Визуализатор",
"volume": "Объём",
"volumes": "Тома",
@@ -740,15 +742,15 @@
"dark-mode-mode-manual": "Вручную",
"dark-mode-mode-off": "Выкл",
"dark-mode-switch-description": "Переключается на более темную тему для удобства просмотра ночью.",
"delete-error-description": "Не удалось удалить {scheme}.",
"delete-error-description": "Не удалось удалить {scheme}",
"delete-error-title": "Ошибка удаления",
"delete-success-description": "{scheme} успешно удалена.",
"delete-success-description": "{scheme} успешно удалена",
"delete-success-title": "Цветовая схема удалена",
"download-button": "Загрузить ещё",
"download-downloading": "Загрузка...",
"download-empty": "Нет доступных цветовых схем",
"download-error-api-error": "Ошибка API: {status}",
"download-error-description": "Не удалось загрузить {scheme}.",
"download-error-description": "Не удалось загрузить {scheme}",
"download-error-download-failed": "Ошибка загрузки с кодом выхода: {code}",
"download-error-invalid-response": "Неверный формат ответа API",
"download-error-no-files": "Файлы для схемы не найдены",
@@ -756,7 +758,7 @@
"download-error-rate-limit": "Превышен лимит запросов GitHub API",
"download-error-title": "Ошибка загрузки",
"download-fetching": "Получение доступных цветовых схем...",
"download-success-description": "{scheme} успешно загружена.",
"download-success-description": "{scheme} успешно загружена",
"download-success-title": "Цветовая схема загружена",
"download-title": "Загрузить цветовые схемы",
"predefined-desc": "Выберите из коллекции предопределенных цветовых схем.",
@@ -773,7 +775,7 @@
"templates-write-path": "Записывает: {filepath}",
"title": "Цветовая схема",
"wallpaper-method-description": "Выберите ваш любимый метод генерации палитры.",
"wallpaper-method-label": "Метод извлечения цвета"
"wallpaper-method-label": "Метод генерации палитры"
},
"control-center": {
"cards-desc": "Настройте, какие элементы управления появляются в центре управления и в каком порядке.",
@@ -1181,9 +1183,9 @@
"available-label": "Доступные плагины",
"available-no-plugins-description": "Проверьте исходники вашего плагина или обновите список.",
"available-no-plugins-label": "Нет доступных плагинов",
"collision-already-installed": "Этот плагин уже установлен.",
"collision-custom-version-exists": "Пользовательская версия из \"{source}\" уже установлена.",
"collision-official-version-exists": "Официальная версия этого плагина уже установлена.",
"collision-already-installed": "Этот плагин уже установлен",
"collision-custom-version-exists": "Пользовательская версия из \"{source}\" уже установлена",
"collision-official-version-exists": "Официальная версия этого плагина уже установлена",
"filter-downloaded": "Скачано",
"filter-not-downloaded": "Не загружено",
"filter-tags-description": "Фильтровать плагины по категории или статусу загрузки",
@@ -1191,9 +1193,9 @@
"hot-reload-description": "Автоматически перезагружать плагины при изменении их файлов. Полезно для разработки плагинов.",
"hot-reload-label": "Горячая перезагрузка (режим разработки)",
"hot-reloaded": "Перезагружен плагин: {name}",
"install-error": "Не удалось установить: {error}.",
"install-error": "Не удалось установить: {error}",
"install-incompatible": "{plugin} требует Noctalia версии {version} или выше",
"install-success": "Успешно установлен {plugin}.",
"install-success": "Успешно установлен {plugin}",
"installed-description": "Управляйте и настраивайте все локально установленные плагины.",
"installed-label": "Установленные плагины",
"installed-no-plugins-description": "Установите плагины из раздела \"Доступно\".",
@@ -1204,16 +1206,16 @@
"plugin-settings-title": "Настройки {plugin}",
"refresh-refreshing": "Обновление списка плагинов...",
"refresh-tooltip": "Обновить доступные плагины",
"settings-error-not-loaded": "Плагин не загружен.",
"settings-saved": "Настройки плагина сохранены.",
"settings-error-not-loaded": "Плагин не загружен",
"settings-saved": "Настройки плагина сохранены",
"settings-tooltip": "Настройки плагина",
"source-custom": "Пользовательский источник",
"sources-add-custom": "Добавить пользовательский репозиторий",
"sources-add-dialog-description": "Добавить репозиторий GitHub в качестве источника плагинов.",
"sources-add-dialog-error": "Не удалось добавить источник плагина.",
"sources-add-dialog-error": "Не удалось добавить источник плагина",
"sources-add-dialog-name": "Имя репозитория",
"sources-add-dialog-name-placeholder": "Мои пользовательские плагины",
"sources-add-dialog-success": "Источник плагина успешно добавлен.",
"sources-add-dialog-success": "Источник плагина успешно добавлен",
"sources-add-dialog-title": "Добавить исходный код плагина",
"sources-add-dialog-url": "URL репозитория",
"sources-description": "Управление репозиториями плагинов.",
@@ -1222,16 +1224,16 @@
"title": "Плагины",
"uninstall-dialog-description": "Вы уверены, что хотите удалить {plugin}? Это удалит все данные плагина.",
"uninstall-dialog-title": "Удалить плагин",
"uninstall-error": "Не удалось удалить: {error}.",
"uninstall-success": "{plugin} успешно удален.",
"uninstall-error": "Не удалось удалить: {error}",
"uninstall-success": "{plugin} успешно удален",
"uninstalling": "Удаление {plugin}...",
"update-all": "Обновить все ({count})",
"update-all-success": "Все плагины успешно обновлены.",
"update-all-success": "Все плагины успешно обновлены",
"update-available": "Доступно новое обновление плагина",
"update-available-plural": "Доступны новые обновления плагинов ({count})",
"update-error": "Не удалось обновить плагин: {plugin}: {error}.",
"update-error": "Не удалось обновить плагин: {plugin}: {error}",
"update-pending": "v{current} → v{new} (требуется Noctalia v{required})",
"update-success": "Плагин {plugin} обновлён до версии {version}.",
"update-success": "Плагин {plugin} обновлён до версии {version}",
"update-version": "v{current} → v{new}",
"updating": "Обновление..."
},
@@ -1536,9 +1538,7 @@
"connected": "Подключено к '{ssid}'",
"connection-failed": "Соединение не установлено",
"connection-timeout": "Превышено время ожидания соединения",
"disabled": "Отключен",
"disconnected": "Отключено от '{ssid}'",
"enabled": "Включен",
"incorrect-password": "Неверный пароль",
"network-not-found": "Сеть не найдена"
}
+21 -21
View File
@@ -374,6 +374,7 @@
"enabled": "Etkinleştirildi",
"events": "Etkinlikler",
"execute": "Yürüt",
"faithful": "Sadık",
"focus": "Odaklanma",
"frequency": "Sıklık",
"gateway": "Geçit",
@@ -453,6 +454,7 @@
"update": "Güncelleme",
"upload": "Yükle",
"version": "Sürüm",
"vibrant": "Canlı",
"visualizer": "Görselleştirici",
"volume": "Hacim",
"volumes": "Hacimler",
@@ -740,15 +742,15 @@
"dark-mode-mode-manual": "El ile",
"dark-mode-mode-off": "Kapalı",
"dark-mode-switch-description": "Gece daha kolay görüntülemek için daha koyu bir tema geçiş yapar.",
"delete-error-description": "{scheme} silinemedi.",
"delete-error-description": "{scheme} silinemedi",
"delete-error-title": "Silme başarısız",
"delete-success-description": "{scheme} başarıyla silindi.",
"delete-success-description": "{scheme} başarıyla silindi",
"delete-success-title": "Renk şeması silindi",
"download-button": "Daha fazla indir",
"download-downloading": "İndiriliyor...",
"download-empty": "Mevcut renk şeması yok",
"download-error-api-error": "API hatası: {status}",
"download-error-description": "{scheme} indirilemedi.",
"download-error-description": "{scheme} indirilemedi",
"download-error-download-failed": "İndirme başarısız, çıkış kodu: {code}",
"download-error-invalid-response": "Geçersiz API yanıt formatı",
"download-error-no-files": "Şema için dosya bulunamadı",
@@ -756,7 +758,7 @@
"download-error-rate-limit": "GitHub API hız sınırı aşıldı",
"download-error-title": "İndirme başarısız",
"download-fetching": "Mevcut renk şemaları alınıyor...",
"download-success-description": "{scheme} başarıyla indirildi.",
"download-success-description": "{scheme} başarıyla indirildi",
"download-success-title": "Renk şeması indirildi",
"download-title": "Renk şemalarını indir",
"predefined-desc": "Önceden tanımlanmış renk şemaları koleksiyonundan seçim yapın.",
@@ -773,7 +775,7 @@
"templates-write-path": "Yazıyor: {filepath}",
"title": "Renk şeması",
"wallpaper-method-description": "Favori palet oluşturma yönteminizi seçin.",
"wallpaper-method-label": "Renk çıkarma yöntemi"
"wallpaper-method-label": "Palet oluşturma yöntemi"
},
"control-center": {
"cards-desc": "Kontrol merkezinde hangi kontrollerin ve hangi sırada görüneceğini özelleştirin.",
@@ -1181,9 +1183,9 @@
"available-label": "Mevcut eklentiler",
"available-no-plugins-description": "Eklenti kaynaklarınızı kontrol edin veya listeyi yenileyin.",
"available-no-plugins-label": "Kullanılabilir eklenti yok",
"collision-already-installed": "Bu eklenti zaten yüklü.",
"collision-custom-version-exists": "\"{source}\" kaynağından özel bir sürüm zaten yüklü.",
"collision-official-version-exists": "Bu eklentinin resmi sürümü zaten yüklü.",
"collision-already-installed": "Bu eklenti zaten yüklü",
"collision-custom-version-exists": "\"{source}\" kaynağından özel bir sürüm zaten yüklü",
"collision-official-version-exists": "Bu eklentinin resmi sürümü zaten yüklü",
"filter-downloaded": "İndirildi",
"filter-not-downloaded": "İndirilmedi",
"filter-tags-description": "Eklentileri kategoriye veya indirme durumuna göre filtrele",
@@ -1191,9 +1193,9 @@
"hot-reload-description": "Eklenti dosyaları değiştiğinde eklentileri otomatik olarak yeniden yükle. Eklenti geliştirme için kullanışlıdır.",
"hot-reload-label": "Hızlı yeniden yükleme (geliştirme modu)",
"hot-reloaded": "Yeniden yüklenen eklenti: {name}",
"install-error": "Yükleme başarısız oldu: {error}.",
"install-error": "Yükleme başarısız oldu: {error}",
"install-incompatible": "{plugin} Noctalia v{version} veya daha üstünü gerektiriyor",
"install-success": "{plugin} başarıyla kuruldu.",
"install-success": "{plugin} başarıyla kuruldu",
"installed-description": "Yerel olarak yüklenmiş tüm eklentileri yönetin ve yapılandırın.",
"installed-label": "Yüklü eklentiler",
"installed-no-plugins-description": "\"Mevcut\" bölümünden eklentileri kurun.",
@@ -1204,16 +1206,16 @@
"plugin-settings-title": "{plugin} ayarları",
"refresh-refreshing": "Eklenti listesi yenileniyor...",
"refresh-tooltip": "Mevcut eklentileri yenile",
"settings-error-not-loaded": "Eklenti yüklenmedi.",
"settings-saved": "Eklenti ayarları kaydedildi.",
"settings-error-not-loaded": "Eklenti yüklenmedi",
"settings-saved": "Eklenti ayarları kaydedildi",
"settings-tooltip": "Eklenti ayarları",
"source-custom": "Özel kaynak",
"sources-add-custom": "Özel depo ekle",
"sources-add-dialog-description": "Bir GitHub deposunu eklenti kaynağı olarak ekle.",
"sources-add-dialog-error": "Eklenti kaynağı eklenemedi.",
"sources-add-dialog-error": "Eklenti kaynağı eklenemedi",
"sources-add-dialog-name": "Depo adı",
"sources-add-dialog-name-placeholder": "Özel eklentilerim",
"sources-add-dialog-success": "Eklenti kaynağı başarıyla eklendi.",
"sources-add-dialog-success": "Eklenti kaynağı başarıyla eklendi",
"sources-add-dialog-title": "Eklenti kaynağı ekle",
"sources-add-dialog-url": "Depo URL'si",
"sources-description": "Eklenti depolarını yönetin.",
@@ -1222,16 +1224,16 @@
"title": "Eklentiler",
"uninstall-dialog-description": "{plugin} eklentisini kaldırmak istediğinizden emin misiniz? Bu, tüm eklenti verilerini kaldıracaktır.",
"uninstall-dialog-title": "Eklentiyi kaldır",
"uninstall-error": "Kaldırma başarısız oldu: {error}.",
"uninstall-success": "{plugin} başarıyla kaldırıldı.",
"uninstall-error": "Kaldırma başarısız oldu: {error}",
"uninstall-success": "{plugin} başarıyla kaldırıldı",
"uninstalling": "{plugin} kaldırılıyor...",
"update-all": "Tümünü güncelle ({count})",
"update-all-success": "Tüm eklentiler başarıyla güncellendi.",
"update-all-success": "Tüm eklentiler başarıyla güncellendi",
"update-available": "Yeni eklenti güncellemesi mevcut",
"update-available-plural": "Yeni eklenti güncellemeleri mevcut ({count})",
"update-error": "Eklenti güncellenemedi: {plugin}: {error}.",
"update-error": "Eklenti güncellenemedi: {plugin}: {error}",
"update-pending": "v{current} → v{new} (Noctalia v{required} gerektirir)",
"update-success": "{plugin} v{version} sürümüne güncellendi.",
"update-success": "{plugin} v{version} sürümüne güncellendi",
"update-version": "v{current} → v{new}",
"updating": "Güncelleniyor..."
},
@@ -1536,9 +1538,7 @@
"connected": "'{ssid}' ile bağlandı",
"connection-failed": "Bağlantı başarısız",
"connection-timeout": "Bağlantı zaman aşımı",
"disabled": "Devre dışı",
"disconnected": "'{ssid}' bağlantısı kesildi",
"enabled": "Etkin",
"incorrect-password": "Yanlış şifre",
"network-not-found": "Ağ bulunamadı"
}
+21 -21
View File
@@ -374,6 +374,7 @@
"enabled": "Увімкнено",
"events": "Події",
"execute": "Виконати",
"faithful": "Вірний",
"focus": "Зосередженість",
"frequency": "Частота",
"gateway": "Шлюз",
@@ -453,6 +454,7 @@
"update": "Оновлення",
"upload": "Вивантажити",
"version": "Версія",
"vibrant": "Яскравий",
"visualizer": "Візуалізатор",
"volume": "Об'єм",
"volumes": "Обсяги",
@@ -740,15 +742,15 @@
"dark-mode-mode-manual": "Вручну",
"dark-mode-mode-off": "Вимкнено",
"dark-mode-switch-description": "Перемикає на темнішу тему для зручного перегляду вночі.",
"delete-error-description": "Не вдалося видалити {scheme}.",
"delete-error-description": "Не вдалося видалити {scheme}",
"delete-error-title": "Помилка видалення",
"delete-success-description": "{scheme} успішно видалено.",
"delete-success-description": "{scheme} успішно видалено",
"delete-success-title": "Кольорову схему видалено",
"download-button": "Завантажити ще",
"download-downloading": "Завантаження...",
"download-empty": "Немає доступних кольорових схем",
"download-error-api-error": "Помилка API: {status}",
"download-error-description": "Не вдалося завантажити {scheme}.",
"download-error-description": "Не вдалося завантажити {scheme}",
"download-error-download-failed": "Помилка завантаження з кодом виходу: {code}",
"download-error-invalid-response": "Невірний формат відповіді API",
"download-error-no-files": "Файли для схеми не знайдено",
@@ -756,7 +758,7 @@
"download-error-rate-limit": "Перевищено ліміт запитів GitHub API",
"download-error-title": "Помилка завантаження",
"download-fetching": "Отримання доступних кольорових схем...",
"download-success-description": "{scheme} успішно завантажено.",
"download-success-description": "{scheme} успішно завантажено",
"download-success-title": "Кольорову схему завантажено",
"download-title": "Завантажити кольорові схеми",
"predefined-desc": "Виберіть із колекції попередньо визначених колірних схем.",
@@ -773,7 +775,7 @@
"templates-write-path": "Записує: {filepath}",
"title": "Колірна схема",
"wallpaper-method-description": "Оберіть свій улюблений метод створення палітри.",
"wallpaper-method-label": "Метод вилучення кольору"
"wallpaper-method-label": "Метод створення палітри"
},
"control-center": {
"cards-desc": "Налаштуйте, які елементи керування з'являються в центрі керування та в якому порядку.",
@@ -1181,9 +1183,9 @@
"available-label": "Доступні плагіни",
"available-no-plugins-description": "Перевірте вихідні коди вашого плагіна або оновіть список.",
"available-no-plugins-label": "Немає доступних плагінів",
"collision-already-installed": "Цей плагін вже встановлено.",
"collision-custom-version-exists": "Вже встановлено власну версію з \"{source}\".",
"collision-official-version-exists": "Офіційна версія цього плагіна вже встановлена.",
"collision-already-installed": "Цей плагін вже встановлено",
"collision-custom-version-exists": "Вже встановлено власну версію з \"{source}\"",
"collision-official-version-exists": "Офіційна версія цього плагіна вже встановлена",
"filter-downloaded": "Завантажено",
"filter-not-downloaded": "Не завантажено",
"filter-tags-description": "Фільтрувати плагіни за категорією або статусом завантаження",
@@ -1191,9 +1193,9 @@
"hot-reload-description": "Автоматично перезавантажувати плагіни при зміні їхніх файлів. Корисно для розробки плагінів.",
"hot-reload-label": "Гаряче перезавантаження (режим розробки)",
"hot-reloaded": "Перезавантажено плагін: {name}",
"install-error": "Не вдалося встановити: {error}.",
"install-error": "Не вдалося встановити: {error}",
"install-incompatible": "{plugin} вимагає Noctalia v{version} або вище",
"install-success": "Успішно встановлено {plugin}.",
"install-success": "Успішно встановлено {plugin}",
"installed-description": "Керуйте та налаштовуйте всі локально встановлені плагіни.",
"installed-label": "Встановлені плагіни",
"installed-no-plugins-description": "Встановіть плагіни з розділу \"Доступний\".",
@@ -1204,16 +1206,16 @@
"plugin-settings-title": "Налаштування {plugin}",
"refresh-refreshing": "Оновлення списку плагінів...",
"refresh-tooltip": "Оновити доступні плагіни",
"settings-error-not-loaded": "Плагін не завантажено.",
"settings-saved": "Налаштування плагіна збережено.",
"settings-error-not-loaded": "Плагін не завантажено",
"settings-saved": "Налаштування плагіна збережено",
"settings-tooltip": "Налаштування плагіна",
"source-custom": "Власне джерело",
"sources-add-custom": "Додати власний репозиторій",
"sources-add-dialog-description": "Додати репозиторій GitHub як джерело плагінів.",
"sources-add-dialog-error": "Не вдалося додати джерело плагінів.",
"sources-add-dialog-error": "Не вдалося додати джерело плагінів",
"sources-add-dialog-name": "Назва репозиторію",
"sources-add-dialog-name-placeholder": "Мої власні плагіни",
"sources-add-dialog-success": "Джерело плагіна успішно додано.",
"sources-add-dialog-success": "Джерело плагіна успішно додано",
"sources-add-dialog-title": "Додати джерело плагіна",
"sources-add-dialog-url": "URL репозиторію",
"sources-description": "Керування репозиторіями плагінів.",
@@ -1222,16 +1224,16 @@
"title": "Плагіни",
"uninstall-dialog-description": "Ви впевнені, що хочете видалити {plugin}? Це призведе до видалення всіх даних плагіна.",
"uninstall-dialog-title": "Видалити плагін",
"uninstall-error": "Не вдалося видалити: {error}.",
"uninstall-success": "Успішно видалено {plugin}.",
"uninstall-error": "Не вдалося видалити: {error}",
"uninstall-success": "Успішно видалено {plugin}",
"uninstalling": "Видалення {plugin}...",
"update-all": "Оновити все ({count})",
"update-all-success": "Усі плагіни успішно оновлено.",
"update-all-success": "Усі плагіни успішно оновлено",
"update-available": "Доступне нове оновлення плагіна",
"update-available-plural": "Доступні нові оновлення плагінів ({count})",
"update-error": "Не вдалося оновити плагін: {plugin}: {error}.",
"update-error": "Не вдалося оновити плагін: {plugin}: {error}",
"update-pending": "v{current} → v{new} (потрібно Noctalia v{required})",
"update-success": "Оновлено {plugin} до версії {version}.",
"update-success": "Оновлено {plugin} до версії {version}",
"update-version": "v{current} → v{new}",
"updating": "Оновлення..."
},
@@ -1536,9 +1538,7 @@
"connected": "Підключено до '{ssid}'",
"connection-failed": "З'єднання не вдалося",
"connection-timeout": "Час очікування з'єднання вичерпано",
"disabled": "Вимкнено",
"disconnected": "Відключено від '{ssid}'",
"enabled": "Увімкнено",
"incorrect-password": "Неправильний пароль",
"network-not-found": "Мережу не знайдено"
}
+21 -21
View File
@@ -374,6 +374,7 @@
"enabled": "已启用",
"events": "事件",
"execute": "执行",
"faithful": "忠实",
"focus": "专注",
"frequency": "频率",
"gateway": "网关",
@@ -453,6 +454,7 @@
"update": "更新",
"upload": "上传",
"version": "版本",
"vibrant": "鲜艳",
"visualizer": "可视化工具",
"volume": "音量",
"volumes": "音量",
@@ -740,15 +742,15 @@
"dark-mode-mode-manual": "手动",
"dark-mode-mode-off": "关",
"dark-mode-switch-description": "切换到更暗的主题,便于夜间观看。",
"delete-error-description": "删除 {scheme} 失败",
"delete-error-description": "删除 {scheme} 失败",
"delete-error-title": "删除失败",
"delete-success-description": "成功删除 {scheme}",
"delete-success-description": "成功删除 {scheme}",
"delete-success-title": "配色方案已删除",
"download-button": "下载更多",
"download-downloading": "正在下载...",
"download-empty": "没有可用的配色方案",
"download-error-api-error": "API 错误: {status}",
"download-error-description": "下载 {scheme} 失败",
"download-error-description": "下载 {scheme} 失败",
"download-error-download-failed": "下载失败,退出代码: {code}",
"download-error-invalid-response": "无效的 API 响应格式",
"download-error-no-files": "未找到方案的文件",
@@ -756,7 +758,7 @@
"download-error-rate-limit": "GitHub API 速率限制已超出",
"download-error-title": "下载失败",
"download-fetching": "正在获取可用的配色方案...",
"download-success-description": "成功下载 {scheme}",
"download-success-description": "成功下载 {scheme}",
"download-success-title": "配色方案已下载",
"download-title": "下载配色方案",
"predefined-desc": "从预定义配色方案集合中选择。",
@@ -773,7 +775,7 @@
"templates-write-path": "写入:{filepath}",
"title": "配色方案",
"wallpaper-method-description": "选择您喜欢的调色板生成方法。",
"wallpaper-method-label": "颜色提取方法"
"wallpaper-method-label": "调色板生成方法"
},
"control-center": {
"cards-desc": "自定义在控制中心显示的控制项及其顺序。",
@@ -1181,9 +1183,9 @@
"available-label": "可用插件",
"available-no-plugins-description": "检查你的插件源或刷新列表。",
"available-no-plugins-label": "没有可用的插件",
"collision-already-installed": "此插件已安装",
"collision-custom-version-exists": "来自“{source}”的自定义版本已安装",
"collision-official-version-exists": "此插件的官方版本已安装",
"collision-already-installed": "此插件已安装",
"collision-custom-version-exists": "来自“{source}”的自定义版本已安装",
"collision-official-version-exists": "此插件的官方版本已安装",
"filter-downloaded": "已下载",
"filter-not-downloaded": "未下载",
"filter-tags-description": "按类别或下载状态筛选插件",
@@ -1191,9 +1193,9 @@
"hot-reload-description": "当插件文件发生更改时自动重新加载插件。对插件开发很有用。",
"hot-reload-label": "热重载(开发模式)",
"hot-reloaded": "已重新加载插件:{name}",
"install-error": "安装失败:{error}",
"install-error": "安装失败:{error}",
"install-incompatible": "{plugin} 需要 Noctalia v{version} 或更高版本",
"install-success": "成功安装 {plugin}",
"install-success": "成功安装 {plugin}",
"installed-description": "管理和配置所有本地安装的插件。",
"installed-label": "已安装插件",
"installed-no-plugins-description": "从“可用”部分安装插件。",
@@ -1204,16 +1206,16 @@
"plugin-settings-title": "{plugin} 设置",
"refresh-refreshing": "正在刷新插件列表...",
"refresh-tooltip": "刷新可用插件",
"settings-error-not-loaded": "插件未加载",
"settings-saved": "插件设置已保存",
"settings-error-not-loaded": "插件未加载",
"settings-saved": "插件设置已保存",
"settings-tooltip": "插件设置",
"source-custom": "自定义源",
"sources-add-custom": "添加自定义存储库",
"sources-add-dialog-description": "添加一个 GitHub 仓库作为插件源。",
"sources-add-dialog-error": "无法添加插件源",
"sources-add-dialog-error": "无法添加插件源",
"sources-add-dialog-name": "仓库名称",
"sources-add-dialog-name-placeholder": "我的自定义插件",
"sources-add-dialog-success": "插件源添加成功",
"sources-add-dialog-success": "插件源添加成功",
"sources-add-dialog-title": "添加插件源",
"sources-add-dialog-url": "仓库 URL",
"sources-description": "管理插件仓库。",
@@ -1222,16 +1224,16 @@
"title": "插件",
"uninstall-dialog-description": "你确定要卸载 {plugin} 吗? 这将移除所有插件数据。",
"uninstall-dialog-title": "卸载插件",
"uninstall-error": "卸载失败:{error}",
"uninstall-success": "成功卸载 {plugin}",
"uninstall-error": "卸载失败:{error}",
"uninstall-success": "成功卸载 {plugin}",
"uninstalling": "正在卸载 {plugin}...",
"update-all": "全部更新({count}",
"update-all-success": "所有插件已成功更新",
"update-all-success": "所有插件已成功更新",
"update-available": "新插件更新可用",
"update-available-plural": "新插件更新可用({count}个)",
"update-error": "更新插件失败:{plugin}{error}",
"update-error": "更新插件失败:{plugin}{error}",
"update-pending": "v{current} → v{new} (需要 Noctalia v{required})",
"update-success": "已将 {plugin} 更新至 v{version}",
"update-success": "已将 {plugin} 更新至 v{version}",
"update-version": "v{current} → v{new}",
"updating": "正在更新..."
},
@@ -1536,9 +1538,7 @@
"connected": "已连接到 '{ssid}'",
"connection-failed": "连接失败",
"connection-timeout": "连接超时",
"disabled": "已禁用",
"disconnected": "已断开与 '{ssid}' 的连接",
"enabled": "已启用",
"incorrect-password": "密码错误",
"network-not-found": "未找到网络"
}
+4 -4
View File
@@ -369,6 +369,7 @@
"enabled": "已啟用",
"events": "事件",
"execute": "執行",
"faithful": "忠實",
"focus": "關注",
"frequency": "頻率",
"gateway": "網路閘道",
@@ -446,6 +447,7 @@
"update": "更新",
"upload": "上傳",
"version": "版本",
"vibrant": "鮮明",
"visualizer": "視覺效果",
"volume": "音量",
"volumes": "音量",
@@ -765,7 +767,7 @@
"templates-write-path": "寫入: {filepath}",
"title": "主題配色",
"wallpaper-method-description": "選擇你喜歡的配色生成方法",
"wallpaper-method-label": "色彩提取方式"
"wallpaper-method-label": "調色盤產生方法"
},
"control-center": {
"cards-desc": "自訂控制中心出現的控制項目及排序",
@@ -1189,7 +1191,7 @@
"uninstall-dialog-description": "你確定想要移除 {plugin}? 這樣會移除所有模組的資料",
"uninstall-dialog-title": "移除外掛模組",
"uninstall-error": "移除失敗: {error}",
"uninstall-success": "成功移除 {plugin}...",
"uninstall-success": "成功移除 {plugin}",
"uninstalling": "正在移除 {plugin}...",
"update-all": "將全部 {count} 個都更新",
"update-all-success": "已成功更新所有的外掛模組",
@@ -1498,9 +1500,7 @@
"connected": "已連上 '{ssid}'",
"connection-failed": "連線失敗",
"connection-timeout": "連線逾時",
"disabled": "已停用",
"disconnected": "已自 '{ssid}' 斷開連線",
"enabled": "已啟用",
"incorrect-password": "密碼錯誤",
"network-not-found": "找不到網路"
}
+1 -1
View File
@@ -390,7 +390,7 @@
"schedulingMode": "off",
"manualSunrise": "06:30",
"manualSunset": "18:30",
"extractionMethod": "default"
"generationMethod": "tonal-spot"
},
"templates": {
"activeTemplates": [],
+1
View File
@@ -79,6 +79,7 @@ Singleton {
"star-off": "star-off",
"battery-exclamation": "battery-exclamation",
"common.charging": "common.charging",
"battery-charging-2": "battery-charging-2",
"battery-4": "battery-4",
"battery-3": "battery-3",
"battery-2": "battery-2",
+1 -1
View File
@@ -616,7 +616,7 @@ Singleton {
property string schedulingMode: "off"
property string manualSunrise: "06:30"
property string manualSunset: "18:30"
property string extractionMethod: "default"
property string generationMethod: "tonal-spot"
}
// templates toggles
+39 -30
View File
@@ -39,10 +39,10 @@ Item {
readonly property bool hideIfNotDetected: widgetSettings.hideIfNotDetected !== undefined ? widgetSettings.hideIfNotDetected : widgetMetadata.hideIfNotDetected
readonly property bool hideIfIdle: widgetSettings.hideIfIdle !== undefined ? widgetSettings.hideIfIdle : widgetMetadata.hideIfIdle
// Only show low battery warning if device is ready (prevents false positive during initialization)
readonly property bool isLowBattery: isReady && !charging && percent <= warningThreshold
readonly property bool isLowBattery: isReady && (!charging && !isPluggedIn) && percent <= warningThreshold
// Visibility: show if hideIfNotDetected is false, or if battery is ready (after initialization)
readonly property bool shouldShow: !hideIfNotDetected || (isReady && (hideIfIdle ? (charging || battery.state === UPowerDeviceState.Discharging) : true))
readonly property bool shouldShow: !hideIfNotDetected || (isReady && (hideIfIdle ? (!charging && !isPluggedIn) : true))
visible: shouldShow
opacity: shouldShow ? 1.0 : 0.0
@@ -50,7 +50,7 @@ Item {
readonly property bool testMode: false
readonly property int testPercent: 35
readonly property bool testCharging: false
readonly property bool testPluggedIn: false
readonly property string deviceNativePath: widgetSettings.deviceNativePath || ""
function findBatteryDevice(nativePath) {
@@ -127,6 +127,8 @@ Item {
readonly property bool isReady: testMode ? true : (initializationComplete && battery && battery.ready && isDevicePresent && (battery.percentage !== undefined || hasBluetoothBattery))
readonly property real percent: testMode ? testPercent : (isReady ? (hasBluetoothBattery ? (bluetoothDevice.battery * 100) : (battery.percentage * 100)) : 0)
readonly property bool charging: testMode ? testCharging : (isReady ? chargingStatus(battery.state) : false) // Assuming not charging if battery is not ready
readonly property bool isPluggedIn: testMode ? testPluggedIn : (isReady ? getPluggedInStatus(battery.state) : false) // We can be plugged in or charging but can't both.
property bool hasNotifiedLowBattery: false
implicitWidth: pill.width
@@ -135,26 +137,38 @@ Item {
function chargingStatus(state) {
switch (state) {
case UPowerDeviceState.Charging: // 1
case UPowerDeviceState.FullyCharged: // 4
case UPowerDeviceState.PendingCharge: // 5
// Logger.e("Battery", "Battery is charging (Battery is charging with " + (Math.floor(battery.changeRate * 10) / 10).toFixed(1) + "W)"); // debug
return true;
case UPowerDeviceState.Discharging: // 2
case UPowerDeviceState.Empty: // 3
case UPowerDeviceState.PendingDischarge: // 6
return false;
default:
return true; // unknown state 0 Fix #1417
return false; // unknown state 0 Fix #1417
}
}
function maybeNotify(currentPercent, isCharging) {
if (!isCharging && !hasNotifiedLowBattery && currentPercent <= warningThreshold) {
function getPluggedInStatus(state) {
// Treat low charge rate (< 5W) as plugged in but not actively charging (grace period)
if (state === UPowerDeviceState.Charging && battery.changeRate !== undefined && Math.abs(battery.changeRate) < 5) {
return true;
}
switch (state) {
case UPowerDeviceState.FullyCharged: // 4
case UPowerDeviceState.PendingCharge: // 5
// Logger.e("Battery", "Battery is NOT charging (Power rate: " + (Math.floor(battery.changeRate * 10) / 10).toFixed(1) + "W)"); // debug
return true;
default:
return false;
}
}
function maybeNotify(currentPercent, isCharging, isPluggedIn, isReady) {
if (isReady && (!isCharging && !isPluggedIn) && !hasNotifiedLowBattery && currentPercent <= warningThreshold) {
hasNotifiedLowBattery = true;
ToastService.showWarning(I18n.tr("toast.battery.low"), I18n.tr("toast.battery.low-desc", {
"percent": Math.round(currentPercent)
}));
// Logger.e("Battery", "Low battery at " + currentPercent + "%", "isCharging: " + isCharging); // debug
} else if (hasNotifiedLowBattery && (isCharging || currentPercent > warningThreshold + 5)) {
// Logger.e("Battery", "Low battery at " + (Math.floor(currentPercent).toFixed(1)) + "%", "isCharging: " + isCharging, "isPluggedIn: " + isPluggedIn, "isReady: " + isReady); // debug
} else if (hasNotifiedLowBattery && (isCharging || isPluggedIn || currentPercent > warningThreshold + 5)) {
hasNotifiedLowBattery = false;
}
}
@@ -167,15 +181,15 @@ Item {
target: battery
function onPercentageChanged() {
if (battery) {
maybeNotify(getCurrentPercent(), chargingStatus(battery.state));
maybeNotify(getCurrentPercent(), chargingStatus(battery.state), getPluggedInStatus(battery.state), isReady);
}
}
function onStateChanged() {
if (battery) {
if (chargingStatus(battery.state)) {
if (chargingStatus(battery.state) || getPluggedInStatus(battery.state)) {
hasNotifiedLowBattery = false;
}
maybeNotify(getCurrentPercent(), chargingStatus(battery.state));
maybeNotify(getCurrentPercent(), chargingStatus(battery.state), getPluggedInStatus(battery.state), isReady);
}
}
}
@@ -184,7 +198,7 @@ Item {
target: bluetoothDevice
function onBatteryChanged() {
if (bluetoothDevice && hasBluetoothBattery) {
maybeNotify(bluetoothDevice.battery * 100, battery ? chargingStatus(battery.state) : false);
maybeNotify(bluetoothDevice.battery * 100, battery ? chargingStatus(battery.state) : false, battery ? getPluggedInStatus(battery.state) : false, true);
}
}
}
@@ -217,7 +231,7 @@ Item {
screen: root.screen
oppositeDirection: BarService.getPillDirection(root)
icon: testMode ? BatteryService.getIcon(testPercent, testCharging, true) : BatteryService.getIcon(percent, charging, isReady)
icon: testMode ? BatteryService.getIcon(testPercent, testCharging, testPluggedIn, true) : BatteryService.getIcon(percent, charging, isPluggedIn, isReady)
text: (isReady || testMode) ? Math.round(percent) : "-"
suffix: "%"
autoHide: false
@@ -246,23 +260,18 @@ Item {
}));
}
if (battery.changeRate !== undefined) {
const rate = battery.changeRate;
if (rate > 0) {
lines.push(charging ? I18n.tr("battery.charging-rate", {
"rate": rate.toFixed(2)
}) : I18n.tr("battery.discharging-rate", {
"rate": rate.toFixed(2)
}));
} else if (rate < 0) {
lines.push(I18n.tr("battery.discharging-rate", {
"rate": Math.abs(rate).toFixed(2)
const rate = Math.abs(battery.changeRate);
if (charging) {
lines.push(I18n.tr("battery.charging-rate", {
"rate": rate.toFixed(2)
}));
} else if (isPluggedIn) {
lines.push(I18n.tr("battery.plugged-in"));
} else {
// Rate is 0 - check if plugged in (charging state) or idle
lines.push(charging ? I18n.tr("battery.plugged-in") : I18n.tr("common.idle"));
lines.push(I18n.tr("battery.discharging-rate", {
"rate": rate.toFixed(2)
}));
}
} else {
lines.push(charging ? I18n.tr("common.charging") : I18n.tr("common.discharging"));
}
if (battery.healthPercentage !== undefined && battery.healthPercentage > 0) {
lines.push(I18n.tr("battery.health", {
+171 -46
View File
@@ -102,6 +102,9 @@ Loader {
// Combined model of running apps and pinned apps
property var dockApps: []
// Track the session order of apps (transient reordering)
property var sessionAppOrder: []
// Revision counter to force icon re-evaluation
property int iconRevision: 0
@@ -112,6 +115,83 @@ Loader {
}
}
function getAppKey(appData) {
if (!appData)
return null;
// prefer toplevel object identity for running apps to distinguish instances
if (appData.toplevel)
return appData.toplevel;
// fallback to appId for pinned-only apps
return appData.appId;
}
function sortDockApps(apps) {
if (!sessionAppOrder || sessionAppOrder.length === 0) {
return apps;
}
const sorted = [];
const remaining = [...apps];
// 1. Pick apps that are in the session order
for (let i = 0; i < sessionAppOrder.length; i++) {
const key = sessionAppOrder[i];
const idx = remaining.findIndex(app => getAppKey(app) === key);
if (idx !== -1) {
sorted.push(remaining[idx]);
remaining.splice(idx, 1);
}
}
// 2. Append any new/remaining apps
remaining.forEach(app => sorted.push(app));
return sorted;
}
function reorderApps(fromIndex, toIndex) {
if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0 || fromIndex >= dockApps.length || toIndex >= dockApps.length)
return;
const list = [...dockApps];
const item = list.splice(fromIndex, 1)[0];
list.splice(toIndex, 0, item);
dockApps = list;
sessionAppOrder = dockApps.map(getAppKey);
savePinnedOrder();
}
function savePinnedOrder() {
const currentPinned = Settings.data.dock.pinnedApps || [];
const newPinned = [];
const seen = new Set();
// Extract pinned apps in their current visual order
dockApps.forEach(app => {
if (app.appId && !seen.has(app.appId)) {
const isPinned = currentPinned.some(p => normalizeAppId(p) === normalizeAppId(app.appId));
if (isPinned) {
newPinned.push(app.appId);
seen.add(app.appId);
}
}
});
// Check if any pinned apps were missed (unlikely if dockApps is correct)
currentPinned.forEach(p => {
if (!seen.has(p)) {
newPinned.push(p);
seen.add(p);
}
});
if (JSON.stringify(currentPinned) !== JSON.stringify(newPinned)) {
Settings.data.dock.pinnedApps = newPinned;
}
}
// Helper function to normalize app IDs for case-insensitive matching
function normalizeAppId(appId) {
if (!appId || typeof appId !== 'string')
@@ -235,7 +315,11 @@ Loader {
pushPinned();
}
dockApps = combined;
dockApps = sortDockApps(combined);
// Sync session order if needed (e.g. first run or new apps added)
if (!sessionAppOrder || sessionAppOrder.length === 0 || sessionAppOrder.length !== dockApps.length) {
sessionAppOrder = dockApps.map(getAppKey);
}
}
// Timer to unload dock after hide animation completes
@@ -543,6 +627,20 @@ Loader {
}
property bool isRunning: modelData && (modelData.type === "running" || modelData.type === "pinned-running")
// Store index for drag-and-drop
property int modelIndex: index
objectName: "dockAppButton"
DropArea {
anchors.fill: parent
keys: ["dock-app"]
onDropped: function (drop) {
if (drop.source && drop.source.objectName === "dockAppButton" && drop.source !== appButton) {
root.reorderApps(drop.source.modelIndex, appButton.modelIndex);
}
}
}
// Listen for the toplevel being closed
Connections {
target: modelData?.toplevel
@@ -551,33 +649,25 @@ Loader {
}
}
IconImage {
id: appIcon
// Draggable container for the icon
Item {
id: iconContainer
width: iconSize
height: iconSize
anchors.centerIn: parent
source: {
root.iconRevision; // Force re-evaluation when revision changes
return dock.getAppIcon(modelData);
}
visible: source.toString() !== ""
smooth: true
asynchronous: true
// Dim pinned apps that aren't running
opacity: appButton.isRunning ? 1.0 : Settings.data.dock.deadOpacity
// When dragging, remove anchors so MouseArea can position it
anchors.centerIn: dragging ? undefined : parent
scale: appButton.hovered ? 1.15 : 1.0
property bool dragging: appMouseArea.drag.active
// Apply dock-specific colorization shader only to non-focused apps
layer.enabled: !appButton.isActive && Settings.data.dock.colorizeIcons
layer.effect: ShaderEffect {
property color targetColor: Settings.data.colorSchemes.darkMode ? Color.mOnSurface : Color.mSurfaceVariant
property real colorizeMode: 0.0 // Dock mode (grayscale)
fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/appicon_colorize.frag.qsb")
}
Drag.active: dragging
Drag.source: appButton
Drag.hotSpot.x: width / 2
Drag.hotSpot.y: height / 2
Drag.keys: ["dock-app"]
z: dragging ? 1000 : 0
scale: dragging ? 1.1 : (appButton.hovered ? 1.15 : 1.0)
Behavior on scale {
NumberAnimation {
duration: Style.animationNormal
@@ -586,36 +676,51 @@ Loader {
}
}
Behavior on opacity {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.OutQuad
IconImage {
id: appIcon
anchors.fill: parent
source: {
root.iconRevision; // Force re-evaluation when revision changes
return dock.getAppIcon(modelData);
}
}
}
visible: source.toString() !== ""
smooth: true
asynchronous: true
// Fall back if no icon
NIcon {
anchors.centerIn: parent
visible: !appIcon.visible
icon: "question-mark"
pointSize: iconSize * 0.7
color: appButton.isActive ? Color.mPrimary : Color.mOnSurfaceVariant
opacity: appButton.isRunning ? 1.0 : 0.6
scale: appButton.hovered ? 1.15 : 1.0
// Dim pinned apps that aren't running
opacity: appButton.isRunning ? 1.0 : Settings.data.dock.deadOpacity
Behavior on scale {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.OutBack
easing.overshoot: 1.2
// Apply dock-specific colorization shader only to non-focused apps
layer.enabled: !appButton.isActive && Settings.data.dock.colorizeIcons
layer.effect: ShaderEffect {
property color targetColor: Settings.data.colorSchemes.darkMode ? Color.mOnSurface : Color.mSurfaceVariant
property real colorizeMode: 0.0 // Dock mode (grayscale)
fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/appicon_colorize.frag.qsb")
}
Behavior on opacity {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.OutQuad
}
}
}
Behavior on opacity {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.OutQuad
// Fall back if no icon
NIcon {
anchors.centerIn: parent
visible: !appIcon.visible
icon: "question-mark"
pointSize: iconSize * 0.7
color: appButton.isActive ? Color.mPrimary : Color.mOnSurfaceVariant
opacity: appButton.isRunning ? 1.0 : 0.6
Behavior on opacity {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.OutQuad
}
}
}
}
@@ -670,6 +775,26 @@ Loader {
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
// Only allow left-click dragging via axis control
drag.target: iconContainer
drag.axis: (pressedButtons & Qt.LeftButton) ? Drag.XAndYAxis : Drag.None
preventStealing: true
onPressed: {
var p1 = appButton.mapFromItem(dockContainer, 0, 0);
var p2 = appButton.mapFromItem(dockContainer, dockContainer.width, dockContainer.height);
drag.minimumX = p1.x;
drag.maximumX = p2.x - iconContainer.width;
drag.minimumY = p1.y;
drag.maximumY = p2.y - iconContainer.height;
}
onReleased: {
if (iconContainer.Drag.active) {
iconContainer.Drag.drop();
}
}
onEntered: {
anyAppHovered = true;
const appName = appButton.appTitle || appButton.appId || "Unknown";
+6 -2
View File
@@ -110,6 +110,7 @@ SmartPanel {
readonly property bool isReady: battery && battery.ready && isDevicePresent && (battery.percentage !== undefined || hasBluetoothBattery)
readonly property int percent: isReady ? Math.round(hasBluetoothBattery ? (bluetoothDevice.battery * 100) : (battery.percentage * 100)) : -1
readonly property bool charging: isReady ? battery.state === UPowerDeviceState.Charging : false
readonly property bool isPluggedIn: isReady ? (battery.state === UPowerDeviceState.FullyCharged || battery.state === UPowerDeviceState.PendingCharge) : false
readonly property bool healthAvailable: isReady && battery.healthSupported
readonly property int healthPercent: healthAvailable ? Math.round(battery.healthPercentage) : -1
@@ -146,9 +147,12 @@ SmartPanel {
"time": Time.formatVagueHumanReadableDuration(battery.timeToEmpty)
});
}
if (!charging && isPluggedIn) {
return I18n.tr("battery.plugged-in"); // i18n: Could be Plugged in, not charging? Ask maintainers if i not forgot
}
return I18n.tr("common.idle");
}
readonly property string iconName: BatteryService.getIcon(percent, charging, isReady)
readonly property string iconName: BatteryService.getIcon(percent, charging, isPluggedIn, isReady)
property var batteryWidgetInstance: BarService.lookupWidget("Battery", screen ? screen.name : null)
readonly property var batteryWidgetSettings: batteryWidgetInstance ? batteryWidgetInstance.widgetSettings : null
@@ -215,7 +219,7 @@ SmartPanel {
NIcon {
pointSize: Style.fontSizeXXL
color: charging ? Color.mPrimary : Color.mOnSurface
color: (charging || isPluggedIn) ? Color.mPrimary : Color.mOnSurface
icon: iconName
}
@@ -191,6 +191,10 @@ ColumnLayout {
}
}
NDivider {
Layout.fillWidth: true
}
NToggle {
label: I18n.tr("panels.color-scheme.color-source-use-wallpaper-colors-label")
description: I18n.tr("panels.color-scheme.color-source-use-wallpaper-colors-description")
@@ -213,20 +217,31 @@ ColumnLayout {
label: I18n.tr("panels.color-scheme.wallpaper-method-label")
description: I18n.tr("panels.color-scheme.wallpaper-method-description")
enabled: Settings.data.colorSchemes.useWallpaperColors
visible: Settings.data.colorSchemes.useWallpaperColors
model: [
{
"key": "default",
"name": I18n.tr("common.default")
"key": "tonal-spot",
"name": "M3-Tonal Spot"
},
{
"key": "material",
"name": "Material Design" // Do not translate
}
"key": "fruit-salad",
"name": "M3-Fruit Salad"
},
{
"key": "rainbow",
"name": "M3-Rainbow"
},
{
"key": "vibrant",
"name": I18n.tr("common.vibrant")
},
{
"key": "faithful",
"name": I18n.tr("common.faithful")
},
]
currentKey: Settings.data.colorSchemes.extractionMethod
currentKey: Settings.data.colorSchemes.generationMethod
onSelected: key => {
Settings.data.colorSchemes.extractionMethod = key;
Settings.data.colorSchemes.generationMethod = key;
AppThemeService.generate();
}
}
@@ -40,7 +40,7 @@ ColumnLayout {
if (exitCode === 0) {
Settings.data.nightLight.enabled = true;
NightLightService.apply();
ToastService.showNotice(I18n.tr("common.night-light"), I18n.tr("toast.wifi.enabled"), "nightlight-on");
ToastService.showNotice(I18n.tr("common.night-light"), I18n.tr("common.enabled"), "nightlight-on");
} else {
Settings.data.nightLight.enabled = false;
ToastService.showWarning(I18n.tr("common.night-light"), I18n.tr("toast.night-light.not-installed"));
@@ -26,7 +26,7 @@ ColumnLayout {
Settings.data.nightLight.enabled = false;
Settings.data.nightLight.forced = false;
NightLightService.apply();
ToastService.showNotice(I18n.tr("common.night-light"), I18n.tr("toast.wifi.disabled"), "nightlight-off");
ToastService.showNotice(I18n.tr("common.night-light"), I18n.tr("common.disabled"), "nightlight-off");
}
}
}
@@ -26,16 +26,21 @@ ColumnLayout {
}
NToggle {
visible: Settings.data.wallpaper.enabled && CompositorService.isNiri
enabled: Settings.data.wallpaper.enabled
visible: CompositorService.isNiri
label: I18n.tr("panels.wallpaper.settings-enable-overview-label")
description: I18n.tr("panels.wallpaper.settings-enable-overview-description")
checked: Settings.data.wallpaper.overviewEnabled
checked: Settings.data.wallpaper.enabled && Settings.data.wallpaper.overviewEnabled
onToggled: checked => Settings.data.wallpaper.overviewEnabled = checked
defaultValue: Settings.getDefaultValue("wallpaper.overviewEnabled")
}
NDivider {
Layout.fillWidth: true
}
ColumnLayout {
visible: Settings.data.wallpaper.enabled
enabled: Settings.data.wallpaper.enabled
spacing: Style.marginL
Layout.fillWidth: true
@@ -51,20 +56,6 @@ ColumnLayout {
onButtonClicked: root.openMainFolderPicker()
}
RowLayout {
NLabel {
label: I18n.tr("tooltips.wallpaper-selector")
description: I18n.tr("panels.wallpaper.settings-selector-description")
Layout.alignment: Qt.AlignTop
}
NIconButton {
icon: "wallpaper-selector"
tooltipText: I18n.tr("tooltips.wallpaper-selector")
onClicked: PanelService.getPanel("wallpaperPanel", root.screen)?.toggle()
}
}
NToggle {
label: I18n.tr("panels.wallpaper.settings-recursive-search-label")
description: I18n.tr("panels.wallpaper.settings-recursive-search-description")
@@ -121,6 +112,31 @@ ColumnLayout {
}
}
}
}
NDivider {
Layout.fillWidth: true
}
ColumnLayout {
enabled: Settings.data.wallpaper.enabled
spacing: Style.marginL
Layout.fillWidth: true
RowLayout {
NLabel {
label: I18n.tr("tooltips.wallpaper-selector")
description: I18n.tr("panels.wallpaper.settings-selector-description")
Layout.alignment: Qt.AlignTop
}
NIconButton {
icon: "wallpaper-selector"
tooltipText: I18n.tr("tooltips.wallpaper-selector")
onClicked: PanelService.getPanel("wallpaperPanel", root.screen)?.toggle()
}
}
NComboBox {
label: I18n.tr("common.position")
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Default to ~/.config if not provided
CONFIG_DIR="${1:-$HOME/.config}"
if [ ! -d "$CONFIG_DIR" ]; then
echo "Error: Config directory not found: $CONFIG_DIR" >&2
exit 1
fi
apply_gtk3_colors() {
local config_dir="$1"
local gtk3_dir="$config_dir/gtk-3.0"
local colors_file="$gtk3_dir/noctalia.css"
local gtk_css="$gtk3_dir/gtk.css"
if [ ! -f "$colors_file" ]; then
echo "Error: noctalia.css not found at $colors_file" >&2
echo "Run template processor first to generate theme files" >&2
exit 1
fi
if [ -L "$gtk_css" ]; then
rm "$gtk_css"
elif [ -f "$gtk_css" ]; then
mv "$gtk_css" "$gtk_css.backup.$(date +%s)"
echo "Backed up existing gtk.css"
fi
ln -s "noctalia.css" "$gtk_css"
echo "Created symlink: $gtk_css -> noctalia.css"
}
apply_gtk4_colors() {
local config_dir="$1"
local gtk4_dir="$config_dir/gtk-4.0"
local colors_file="$gtk4_dir/noctalia.css"
local gtk_css="$gtk4_dir/gtk.css"
local gtk4_import="@import url(\"noctalia.css\");"
if [ ! -f "$colors_file" ]; then
echo "Error: GTK4 noctalia.css not found at $colors_file" >&2
echo "Run template processor first to generate theme files" >&2
exit 1
fi
echo "$gtk4_import" > "$gtk_css"
echo "Updated GTK4 CSS import"
}
refresh_theme() {
# 1. Get current values
raw_theme=$(gsettings get org.gnome.desktop.interface gtk-theme)
current_theme=$(echo "$raw_theme" | tr -d "'")
raw_scheme=$(gsettings get org.gnome.desktop.interface color-scheme)
current_scheme=$(echo "$raw_scheme" | tr -d "'")
# Fallback defaults if unset
if [ -z "$current_theme" ]; then current_theme="adw-gtk3-dark"; fi
if [ -z "$current_scheme" ]; then current_scheme="prefer-dark"; fi
# 2. Toggle Scheme
if [ "$current_scheme" == "prefer-dark" ]; then
temp_scheme="default"
else
temp_scheme="prefer-dark"
fi
gsettings set org.gnome.desktop.interface color-scheme "$temp_scheme"
dconf write /org/gnome/desktop/interface/color-scheme "'$temp_scheme'"
# 3. Toggle Theme
gsettings set org.gnome.desktop.interface gtk-theme ""
dconf write /org/gnome/desktop/interface/gtk-theme "''"
sleep 0.5
# 4. Restore Original Values
gsettings set org.gnome.desktop.interface color-scheme "$current_scheme"
dconf write /org/gnome/desktop/interface/color-scheme "'$current_scheme'"
gsettings set org.gnome.desktop.interface gtk-theme "$current_theme"
dconf write /org/gnome/desktop/interface/gtk-theme "'$current_theme'"
}
mkdir -p "$CONFIG_DIR/gtk-3.0" "$CONFIG_DIR/gtk-4.0"
apply_gtk3_colors "$CONFIG_DIR"
apply_gtk4_colors "$CONFIG_DIR"
refresh_theme
echo "GTK colors applied successfully"
+205
View File
@@ -0,0 +1,205 @@
#!/usr/bin/env python3
"""
Compare Noctalia's template-processor color extraction with matugen.
Usage:
./compare-matugen.py <wallpaper_path>
./compare-matugen.py ~/Pictures/Wallpapers/example.png
Compares all M3 schemes (tonal-spot, fruit-salad, rainbow) and shows
a table with hue differences.
"""
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 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 compare_schemes(image_path: Path) -> None:
"""Compare all M3 schemes between our processor and matugen."""
schemes = ["tonal-spot", "fruit-salad", "rainbow"]
color_keys = ["primary", "secondary", "tertiary", "surface", "on_surface"]
print(f"\nComparing: {image_path.name}\n")
print("=" * 78)
# Header
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
# (hue is meaningless for near-grayscale colors)
if avg_chroma < 15:
rgb_dist = rgb_distance(our_hex, mat_hex)
# RGB distance: 0-10 excellent, 10-25 good, 25-50 fair
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)
# Also show source color comparison
print("\nSource Color Extraction:")
print("-" * 40)
ours = run_our_processor(image_path, "tonal-spot")
matugen = run_matugen(image_path, "tonal-spot")
if ours and matugen:
# Get source from primary at tone 40 (approximation)
our_primary = ours.get("primary", "")
mat_source = matugen.get("source_color", "")
if our_primary and mat_source:
our_hct = get_hct(our_primary)
mat_hct = get_hct(mat_source)
print(f"Our primary hue: {our_hct.hue:.1f}°")
print(f"Matugen source hue: {mat_hct.hue:.1f}°")
print(f"Difference: {hue_diff(our_hct.hue, mat_hct.hue):.1f}°")
def main() -> int:
parser = argparse.ArgumentParser(
description="Compare Noctalia template-processor with matugen"
)
parser.add_argument(
"wallpaper",
type=Path,
help="Path to wallpaper image"
)
args = parser.parse_args()
if not args.wallpaper.exists():
print(f"Error: File not found: {args.wallpaper}", file=sys.stderr)
return 1
# Check if matugen is available
try:
subprocess.run(["matugen", "--version"], capture_output=True, check=True)
except (subprocess.CalledProcessError, FileNotFoundError):
print("Error: matugen not found. Please install matugen first.", file=sys.stderr)
return 1
compare_schemes(args.wallpaper)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -2,15 +2,17 @@
Color representation and conversion utilities.
This module provides the Color class and functions for converting between
RGB and HSL color spaces.
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
@@ -182,3 +184,170 @@ def saturate(color: Color, amount: float) -> Color:
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
+826
View File
@@ -0,0 +1,826 @@
"""
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 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}"
+390
View File
@@ -0,0 +1,390 @@
"""
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 (legacy "material" mode)
"""
from .hct import Hct, TonalPalette
# =============================================================================
# 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,
}
# =============================================================================
# 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']),
# 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 legacy "material" mode that preserves the extracted
color's characteristics:
- Primary: source hue and chroma (unchanged)
- Secondary: same hue, reduced chroma
- Tertiary: hue +60°, reduced chroma
- Neutrals: low chroma (tinted with source hue)
"""
def __init__(self, source_color: Hct):
super().__init__(source_color)
# Primary: preserve source color's hue and chroma
self.primary_palette = TonalPalette(source_color.hue, source_color.chroma)
# Secondary: same hue, reduced chroma
secondary_chroma = max(source_color.chroma - 24.0, source_color.chroma * 0.6)
self.secondary_palette = TonalPalette(source_color.hue, secondary_chroma)
# Tertiary: 60° hue rotation with reduced chroma
tertiary_hue = (source_color.hue + 60.0) % 360.0
tertiary_chroma = max(source_color.chroma - 24.0, source_color.chroma * 0.6)
self.tertiary_palette = TonalPalette(tertiary_hue, tertiary_chroma)
# Neutral: source hue, low chroma (chroma / 6)
neutral_chroma = source_color.chroma / 6.0
self.neutral_palette = TonalPalette(source_color.hue, neutral_chroma)
# Neutral variant: slightly more chroma
neutral_variant_chroma = (source_color.chroma / 6.0) + 4.0
self.neutral_variant_palette = TonalPalette(source_color.hue, neutral_variant_chroma)
# 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
+440
View File
@@ -0,0 +1,440 @@
"""
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 _hue_distance(h1: float, h2: float) -> float:
"""Calculate circular distance between two hues (0-360)."""
diff = abs(h1 - h2)
return min(diff, 360.0 - diff)
def _score_colors_chroma(
colors_with_counts: list[tuple[RGB, int]],
) -> list[tuple[Color, float]]:
"""
Score colors prioritizing chroma (vibrancy).
This is the original scoring algorithm that picks the most colorful colors.
Used for "vibrant" mode.
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, weighted heavily by count
# Using count directly to strongly favor more prominent colors (area coverage)
score = (chroma_score - tone_penalty - hue_penalty) * count
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 _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
- "chroma-representative": chroma-prioritized with actual pixels (faithful)
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 == "chroma-representative":
# Faithful mode: more clusters, no pre-filtering
# This picks actual dominant colors from the image without averaging
cluster_count = 48
filtered = sampled # No colorfulness filter - let scoring handle it
else:
# Vibrant mode: fewer clusters with colorfulness pre-filter
cluster_count = k
# 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)
# - chroma-representative: representative pixels with chroma scoring (faithful mode)
# - 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 == "chroma-representative":
# Use representative colors with chroma scoring (faithful mode)
colors_for_scoring = [(c[1], c[2]) for c in clusters]
scored = _score_colors_chroma(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")
def derive_harmonious_colors(primary: Color) -> tuple[Color, Color, Color]:
"""
Derive secondary and tertiary colors as harmonious complements to primary.
Uses hue shifts for visual distinction (matugen-compatible):
- Secondary: 30° hue shift (analogous, slightly cooler/warmer)
- Tertiary: 60° hue shift (distinct accent color)
- Quaternary: 180° hue shift (complementary)
Returns:
Tuple of (secondary, tertiary, quaternary) colors
"""
h, s, l = primary.to_hsl()
# Secondary: 30° analogous hue shift with slightly lower saturation
secondary = Color.from_hsl((h + 30) % 360, s * 0.8, l)
# Tertiary: complementary (180° shift) for strong contrast
tertiary = Color.from_hsl((h + 180) % 360, s * 0.9, l)
# Quaternary: complementary - opposite on color wheel
quaternary = Color.from_hsl((h + 180) % 360, s, l)
return secondary, tertiary, quaternary
@@ -17,7 +17,7 @@ try:
except ImportError:
tomllib = None
from .color import Color
from .color import Color, find_closest_color
class TemplateRenderer:
@@ -285,6 +285,10 @@ class TemplateRenderer:
result = re.sub(pattern, replace, template_text)
# Process escape sequences (matugen-compatible)
# \\ in template becomes \ in output
result = result.replace('\\\\', '\\')
if self._error_count > 0:
print(f"Template rendering completed with {self._error_count} error(s)", file=sys.stderr)
@@ -318,6 +322,10 @@ class TemplateRenderer:
self._current_file = None
return success
def _substitute_closest_color(self, text: str, closest_color: str) -> str:
"""Substitute {{closest_color}} in text."""
return re.sub(r"\{\{\s*closest_color\s*\}\}", closest_color, text)
def process_config_file(self, config_path: Path):
"""Process Matugen TOML configuration file."""
if not tomllib:
@@ -339,10 +347,38 @@ class TemplateRenderer:
self.render_file(Path(input_path).expanduser(), Path(output_path).expanduser())
# Handle closest_color if configured (matugen-compatible)
closest_color_value = ""
colors_to_compare = template.get("colors_to_compare")
compare_to = template.get("compare_to")
if colors_to_compare and compare_to:
# Render compare_to to get the actual hex color
rendered_compare_to = self.render(compare_to)
# Find the closest color name
closest_color_value = find_closest_color(rendered_compare_to, colors_to_compare)
# Execute pre_hook if specified
pre_hook = template.get("pre_hook")
if pre_hook:
import subprocess
# Substitute closest_color first, then render color variables
if closest_color_value:
pre_hook = self._substitute_closest_color(pre_hook, closest_color_value)
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
# Substitute closest_color first, then render color variables
if closest_color_value:
post_hook = self._substitute_closest_color(post_hook, closest_color_value)
post_hook = self.render(post_hook)
try:
subprocess.run(post_hook, shell=True, check=False)
except Exception as e:
@@ -4,44 +4,69 @@ 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
- vibrant: Preserves wallpaper colors directly (legacy)
"""
from typing import Literal
from .color import Color, shift_hue, hue_distance, adjust_surface
from .contrast import ensure_contrast
from .material import MaterialScheme
from .material import SchemeTonalSpot, SchemeFruitSalad, SchemeRainbow, SchemeContent
from .palette import find_error_color
# Type alias
# Type aliases
ThemeMode = Literal["dark", "light"]
SchemeType = Literal["tonal-spot", "fruit-salad", "rainbow", "vibrant", "faithful"]
# Map scheme type strings to classes
SCHEME_CLASSES = {
"tonal-spot": SchemeTonalSpot,
"fruit-salad": SchemeFruitSalad,
"rainbow": SchemeRainbow,
# "vibrant" uses generate_normal_* functions, not a scheme class
}
def generate_material_dark(palette: list[Color]) -> dict[str, str]:
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.
Uses proper Material Design 3 tonal palettes and tone values for
perceptually accurate and consistent theming.
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)
# Create Material scheme from primary color
scheme = MaterialScheme.from_rgb(primary.r, primary.g, primary.b)
# 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]) -> dict[str, str]:
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.
Uses proper Material Design 3 tonal palettes and tone values for
perceptually accurate and consistent theming.
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)
# Create Material scheme from primary color
scheme = MaterialScheme.from_rgb(primary.r, primary.g, primary.b)
# 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()
@@ -111,11 +136,20 @@ def generate_normal_dark(palette: list[Color]) -> dict[str, str]:
if 160 <= surface_hue <= 200:
surface_hue = (surface_hue + 10) % 360
base_surface = Color.from_hsl(surface_hue, s, 0.5) # l doesn't matter for next step
# 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
# Preserving saturation (up to 0.9) to be true to primary color
surface = adjust_surface(base_surface, 0.90, 0.12)
surface_variant = adjust_surface(base_surface, 0.80, 0.16)
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)
@@ -143,13 +177,14 @@ def generate_normal_dark(palette: list[Color]) -> dict[str, str]:
on_error = ensure_contrast(dark_fg, error, 7.0)
# "On" colors for containers - light text on dark containers, tinted with respective color
on_primary_container = ensure_contrast(Color.from_hsl(primary_h, primary_s, 0.90), primary_container, 4.5)
# 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)
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)
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)
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
@@ -335,14 +370,15 @@ def generate_normal_light(palette: list[Color]) -> dict[str, str]:
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)
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)
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)
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)
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
@@ -449,14 +485,27 @@ def generate_normal_light(palette: list[Color]) -> dict[str, str]:
def generate_theme(
palette: list[Color],
mode: ThemeMode,
material: bool = True
scheme_type: str = "tonal-spot"
) -> dict[str, str]:
"""Generate theme for specified mode."""
if material:
if mode == "dark":
return generate_material_dark(palette)
return generate_material_light(palette)
else:
"""
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"
Returns:
Dictionary of color token names to hex values
"""
# Handle vibrant/faithful modes (use generate_normal_* functions)
# Both use same theme generation, but different color extraction (handled in palette.py)
if scheme_type in ("vibrant", "faithful"):
if mode == "dark":
return generate_normal_dark(palette)
return generate_normal_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)
@@ -1,17 +1,21 @@
#!/usr/bin/env python3
"""
Template processor - Wallpaper-based color extraction and theme generation.
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:
- Material Design 3 using HCT (Hue, Chroma, Tone) color space.
- Vibrant accent-based using HSL (Hue, Saturation, Lightness) color space.
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)
- fruit-salad: Bold/playful with -50° hue rotation
- rainbow: Chromatic accents with grayscale neutrals
- vibrant: Colorful with smooth blended colors
- faithful: Colorful with actual wallpaper pixels
Usage:
python3 template-processor.py IMAGE_OR_JSON [OPTIONS]
Options:
--default Generate vibrant accent-based colors (default)
--material Generate Material Design 3 colors
--scheme-type Scheme type: tonal-spot (default), fruit-salad, rainbow, vibrant
--dark Generate dark theme only
--light Generate light theme only
--both Generate both themes (default)
@@ -24,7 +28,8 @@ Input:
Can be an image file (PNG/JPG) or a JSON color palette file.
Example:
python3 template-processor.py ~/wallpaper.png --material --both
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
@@ -53,7 +58,7 @@ def parse_args() -> argparse.Namespace:
epilog="""
Examples:
python3 template-processor.py wallpaper.png # default mode, both themes
python3 template-processor.py wallpaper.png --material --dark # material mode, dark only
python3 template-processor.py wallpaper.png --vibrant --dark # vibrant mode, dark only
python3 template-processor.py wallpaper.jpg --dark -o theme.json # output to file
python3 template-processor.py wallpaper.png -r tpl.txt:out.txt # render template
"""
@@ -66,18 +71,24 @@ Examples:
help='Path to wallpaper image (PNG/JPG) or JSON color palette (not required if --scheme is used)'
)
# Theme style (mutually exclusive)
style_group = parser.add_mutually_exclusive_group()
style_group.add_argument(
# Scheme type selection
parser.add_argument(
'--scheme-type',
choices=['tonal-spot', 'fruit-salad', 'rainbow', 'vibrant', 'faithful'],
default='tonal-spot',
help='Color scheme type (default: tonal-spot)'
)
# Legacy flags for backward compatibility
parser.add_argument(
'--material',
action='store_true',
help='Generate Material Design 3 colors'
help='(deprecated) Alias for --scheme-type tonal-spot'
)
style_group.add_argument(
'--default',
parser.add_argument(
'--vibrant',
action='store_true',
default=True,
help='Generate vibrant accent-based palette (default)'
help='(deprecated) Alias for --scheme-type vibrant'
)
# Theme mode (mutually exclusive)
@@ -238,18 +249,33 @@ def main() -> int:
print(f"Unexpected error reading image: {e}", file=sys.stderr)
return 1
# Extract palette
# Determine scheme type (handle legacy flags)
scheme_type = args.scheme_type
if args.vibrant:
scheme_type = "vibrant"
elif args.material:
scheme_type = "tonal-spot"
# Extract palette with appropriate scoring method
# - vibrant: chroma scoring with centroid averaging (smooth blended colors)
# - faithful: chroma scoring with representative pixels (actual wallpaper colors)
# - M3 schemes: population scoring (most representative colors)
k = 5
palette = extract_palette(pixels, k=k)
if scheme_type == "vibrant":
scoring = "chroma"
elif scheme_type == "faithful":
scoring = "chroma-representative"
else:
scoring = "population"
palette = extract_palette(pixels, k=k, scoring=scoring)
if not palette:
print("Error: Could not extract colors from image", file=sys.stderr)
return 1
# Generate theme for each mode
use_material = args.material
for mode in modes:
result[mode] = generate_theme(palette, mode, use_material)
result[mode] = generate_theme(palette, mode, scheme_type)
# Output JSON
json_output = json.dumps(result, indent=2)
-454
View File
@@ -1,454 +0,0 @@
"""
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 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."""
if tone <= 0.0:
return (0, 0, 0)
if tone >= 100.0:
return (255, 255, 255)
if chroma < 0.5:
y = lstar_to_y(tone)
return xyz_to_rgb(y, y, y)
low_chroma = 0.0
high_chroma = chroma
best_rgb = None
best_chroma = 0.0
for iteration in range(20):
mid_chroma = (low_chroma + high_chroma) / 2.0
rgb = Hct._find_rgb_for_hct(hue, mid_chroma, tone)
if rgb is not None:
r, g, b = rgb
if 0 <= r <= 255 and 0 <= g <= 255 and 0 <= b <= 255:
best_rgb = rgb
best_chroma = mid_chroma
low_chroma = mid_chroma
else:
high_chroma = mid_chroma
else:
high_chroma = mid_chroma
if best_rgb is not None:
return best_rgb
y = lstar_to_y(tone)
return xyz_to_rgb(y, y, y)
@staticmethod
def _find_rgb_for_hct(hue: float, chroma: float, tone: float) -> tuple[int, int, int] | None:
"""Find an RGB color for the given HCT values."""
j = tone
for _ in range(5):
cam = Cam16.from_jch(j, chroma, hue)
rgb = cam.to_rgb()
r, g, b = rgb
r_clamped = max(0, min(255, r))
g_clamped = max(0, min(255, g))
b_clamped = max(0, min(255, b))
if r != r_clamped or g != g_clamped or b != b_clamped:
return None
_, y, _ = rgb_to_xyz(r, g, b)
actual_tone = y_to_lstar(y)
tone_diff = tone - actual_tone
if abs(tone_diff) < 0.5:
return (r, g, b)
j += tone_diff * 0.5
if j <= 0 or j > 100:
return None
cam = Cam16.from_jch(j, chroma, hue)
rgb = cam.to_rgb()
r, g, b = rgb
if 0 <= r <= 255 and 0 <= g <= 255 and 0 <= b <= 255:
return (r, g, b)
return None
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 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}"
-266
View File
@@ -1,266 +0,0 @@
"""
Material Design 3 color scheme implementation.
This module provides the MaterialScheme class for generating MD3 color schemes
from a source color using the HCT color space.
"""
from .hct import Hct, TonalPalette
class MaterialScheme:
"""
Material Design 3 color scheme generator.
Implements the official Material Design 3 color system using HCT color space.
Based on SchemeContent variant which preserves the source color's character.
"""
# 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,
}
def __init__(self, source_color: Hct):
"""
Create a Material Design 3 scheme from a source color.
Args:
source_color: The source color in HCT space
"""
self.source = source_color
# Create tonal palettes for each color role
# SchemeContent-style: preserves source color characteristics
# Primary: source color's hue and chroma (unchanged)
self.primary_palette = TonalPalette(source_color.hue, source_color.chroma)
# Secondary: same hue, reduced chroma
# Formula: 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: analogous color (simplified as 60° rotation)
# In full implementation this uses TemperatureCache for analogous colors
tertiary_hue = (source_color.hue + 60.0) % 360.0
tertiary_chroma = max(source_color.chroma - 32.0, source_color.chroma * 0.5)
self.tertiary_palette = TonalPalette(tertiary_hue, tertiary_chroma)
# Error: red hue with high chroma
self.error_palette = TonalPalette(25.0, 84.0) # Material red
# 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: source hue, slightly more chroma than neutral
neutral_variant_chroma = (source_color.chroma / 8.0) + 4.0
self.neutral_variant_palette = TonalPalette(source_color.hue, neutral_variant_chroma)
@classmethod
def from_rgb(cls, r: int, g: int, b: int) -> 'MaterialScheme':
"""Create scheme from RGB color."""
return cls(Hct.from_rgb(r, g, b))
@classmethod
def from_hex(cls, hex_color: str) -> 'MaterialScheme':
"""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 = self.DARK_TONES if is_dark else self.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']),
# 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
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
-279
View File
@@ -1,279 +0,0 @@
"""
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
from .hct import Cam16, Hct
# Type aliases
RGB = tuple[int, int, int]
HSL = 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 color_distance_hsl(c1: HSL, c2: HSL) -> float:
"""
Calculate perceptual distance between two colors in HSL space.
Hue is weighted less for low-saturation colors (grays).
"""
h1, s1, l1 = c1
h2, s2, l2 = c2
# Hue distance (circular)
dh = min(abs(h1 - h2), 360 - abs(h1 - h2)) / 180.0
# Weight hue by average saturation (grays have similar hues but shouldn't match)
avg_sat = (s1 + s2) / 2
dh_weighted = dh * avg_sat
ds = abs(s1 - s2)
dl = abs(l1 - l2)
return (dh_weighted ** 2 + ds ** 2 + dl ** 2) ** 0.5
def kmeans_cluster(
colors: list[RGB],
k: int = 5,
iterations: int = 15
) -> list[tuple[RGB, int]]:
"""
Perform K-means clustering on colors.
Returns list of (centroid_rgb, cluster_size) tuples, sorted by cluster size.
Uses deterministic initialization for reproducible results.
"""
if len(colors) < k:
# Not enough colors, return what we have
unique = list(set(colors))
return [(c, colors.count(c)) for c in unique[:k]]
# Convert to HSL for perceptual clustering
colors_hsl = [rgb_to_hsl(*c) for c in colors]
# Deterministic initialization: pick evenly spaced colors from sorted list
sorted_indices = sorted(range(len(colors_hsl)), key=lambda i: colors_hsl[i])
step = len(sorted_indices) // k
centroids = [colors_hsl[sorted_indices[i * step]] for i in range(k)]
# K-means iterations
for _ in range(iterations):
# Assign colors to nearest centroid
clusters: list[list[HSL]] = [[] for _ in range(k)]
for color in colors_hsl:
min_dist = float('inf')
min_idx = 0
for i, centroid in enumerate(centroids):
dist = color_distance_hsl(color, centroid)
if dist < min_dist:
min_dist = dist
min_idx = i
clusters[min_idx].append(color)
# Update centroids
new_centroids = []
for i, cluster in enumerate(clusters):
if cluster:
# Circular mean for hue (hue is 0-360, wraps around)
sin_sum = sum(math.sin(math.radians(c[0])) for c in cluster)
cos_sum = sum(math.cos(math.radians(c[0])) for c in cluster)
avg_h = math.degrees(math.atan2(sin_sum, cos_sum)) % 360
avg_s = sum(c[1] for c in cluster) / len(cluster)
avg_l = sum(c[2] for c in cluster) / len(cluster)
new_centroids.append((avg_h, avg_s, avg_l))
else:
new_centroids.append(centroids[i])
centroids = new_centroids
# Final assignment and counting
cluster_counts = [0] * k
for color in colors_hsl:
min_dist = float('inf')
min_idx = 0
for i, centroid in enumerate(centroids):
dist = color_distance_hsl(color, centroid)
if dist < min_dist:
min_dist = dist
min_idx = i
cluster_counts[min_idx] += 1
# Convert centroids back to RGB and pair with counts
results = []
for i, centroid in enumerate(centroids):
rgb = hsl_to_rgb(*centroid)
results.append((rgb, cluster_counts[i]))
# Sort by cluster size (most common first)
results.sort(key=lambda x: -x[1])
return results
def extract_palette(pixels: list[RGB], k: int = 5) -> list[Color]:
"""
Extract K dominant colors from pixel data using CAM16 chroma filtering.
Uses the same approach as matugen: filter by CAM16 chroma >= 5.0 to
ensure we get colorful, usable theme colors.
Args:
pixels: List of RGB tuples
k: Number of colors to extract
Returns:
List of Color objects, sorted by dominance
"""
# Downsample for performance
sampled = downsample_pixels(pixels, factor=4)
# Filter using CAM16 chroma (like matugen does with chroma >= 5.0)
# This is more perceptually accurate than HSL saturation filtering
filtered = []
for p in sampled:
try:
cam = Cam16.from_rgb(p[0], p[1], p[2])
# Keep colors with sufficient chroma (colorfulness)
# matugen uses chroma >= 5.0
if cam.chroma >= 5.0:
filtered.append(p)
except (ValueError, ZeroDivisionError):
# Skip invalid colors
continue
# Fall back to tone-based filter if CAM16 filtering removed too many
if len(filtered) < k * 10:
filtered = []
for p in sampled:
try:
hct = Hct.from_rgb(p[0], p[1], p[2])
# Keep colors with reasonable tone (not too dark or bright)
if 15.0 < hct.tone < 85.0:
filtered.append(p)
except (ValueError, ZeroDivisionError):
continue
if len(filtered) < k * 10:
filtered = sampled
# Cluster
clusters = kmeans_cluster(filtered, k=k)
# Score colors like Material's Score algorithm
# Prioritizes colors that will work well as theme source colors
result_colors = []
for rgb, count in clusters:
color = Color.from_rgb(rgb)
try:
hct = color.to_hct()
# Calculate score based on Material Design principles:
# 1. Chroma contribution - prefer colorful colors
chroma_score = hct.chroma
# 2. Tone penalty - prefer mid-tones (40-60 is ideal)
# Penalize very dark (<20) or very bright (>80) colors
if hct.tone < 20:
tone_penalty = (20 - hct.tone) * 2 # Heavy penalty for dark
elif hct.tone > 80:
tone_penalty = (hct.tone - 80) * 1.5 # Moderate penalty for bright
elif hct.tone < 40:
tone_penalty = (40 - hct.tone) * 0.5 # Light penalty for somewhat dark
elif hct.tone > 60:
tone_penalty = (hct.tone - 60) * 0.3 # Very light penalty
else:
tone_penalty = 0 # Ideal tone range
# 3. Hue penalty - slight penalty for yellow-green hues (less popular)
hue = hct.hue
if 80 < hue < 110: # Yellow-green range
hue_penalty = 5
else:
hue_penalty = 0
# Combined score: chroma contribution minus penalties, weighted by count
score = (chroma_score - tone_penalty - hue_penalty) * math.sqrt(count)
result_colors.append((color, score, hct.chroma, hct.tone))
except (ValueError, ZeroDivisionError):
result_colors.append((color, 0.0, 0.0, 50.0))
# Sort by score (highest first)
result_colors.sort(key=lambda x: -x[1])
# Extract just the colors
final_colors = [c[0] for c in result_colors]
# Ensure we have enough colors by deriving from primary using HCT
while len(final_colors) < k:
primary = final_colors[0]
primary_hct = primary.to_hct()
offset = len(final_colors) * 60.0 # 60° hue rotation in HCT
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")
def derive_harmonious_colors(primary: Color) -> tuple[Color, Color, Color]:
"""
Derive secondary and tertiary colors as harmonious complements to primary.
Uses hue shifts for visual distinction (matugen-compatible):
- Secondary: 30° hue shift (analogous, slightly cooler/warmer)
- Tertiary: 60° hue shift (distinct accent color)
- Quaternary: 180° hue shift (complementary)
Returns:
Tuple of (secondary, tertiary, quaternary) colors
"""
h, s, l = primary.to_hsl()
# Secondary: 30° analogous hue shift with slightly lower saturation
secondary = Color.from_hsl((h + 30) % 360, s * 0.8, l)
# Tertiary: complementary (180° shift) for strong contrast
tertiary = Color.from_hsl((h + 180) % 360, s * 0.9, l)
# Quaternary: complementary - opposite on color wheel
quaternary = Color.from_hsl((h + 180) % 360, s, l)
return secondary, tertiary, quaternary
+4 -1
View File
@@ -80,13 +80,16 @@ Singleton {
return primaryDevice.connected === true;
}
function getIcon(percent, charging, isReady) {
function getIcon(percent, charging, pluggedIn, isReady) {
if (!isReady) {
return "battery-exclamation";
}
if (charging) {
return "common.charging";
}
if (pluggedIn) {
return "battery-charging-2";
}
if (percent >= 90) {
return "battery-4";
}
@@ -10,9 +10,9 @@ Singleton {
id: root
// Python scripts
readonly property string checkCalendarAvailableScript: Quickshell.shellDir + '/Scripts/calendar/check-calendar.py'
readonly property string listCalendarsScript: Quickshell.shellDir + '/Scripts/calendar/list-calendars.py'
readonly property string calendarEventsScript: Quickshell.shellDir + '/Scripts/calendar/calendar-events.py'
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(service) {
CalendarService = service;
+146 -35
View File
@@ -4,8 +4,6 @@ import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services.Location.Calendar
import qs.Services.System
Singleton {
id: root
@@ -17,8 +15,6 @@ Singleton {
property string lastError: ""
property var calendars: ([])
property var dataProvider: null
// Persistent cache
property string cacheFile: Settings.cacheDir + "calendar.json"
@@ -59,25 +55,6 @@ Singleton {
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();
}
@@ -110,28 +87,37 @@ Singleton {
// Core functions
function checkAvailability() {
if (!Settings.data.location.showCalendarEvents) {
if (Settings.data.location.showCalendarEvents) {
availabilityCheckProcess.running = true;
} else {
root.available = false;
return;
}
Khal.init();
EvolutionDataServer.init();
}
function loadCalendars() {
if (!root.available || !dataProvider) {
return;
}
dataProvider.loadCalendars();
listCalendarsProcess.running = true;
}
function loadEvents(daysAhead = 31, daysBehind = 14) {
if (!root.available || !dataProvider) {
if (!Settings.data.location.showCalendarEvents) {
root.loading = false;
root.events = [];
return;
}
if (loading)
return;
loading = true;
lastError = "";
dataProvider.loadEvents(daysAhead, daysBehind);
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()}`);
}
// Helper to format date/time
@@ -139,4 +125,129 @@ Singleton {
const date = new Date(timestamp * 1000);
return Qt.formatDateTime(date, "yyyy-MM-dd hh:mm");
}
// 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();
root.available = result === "available";
if (root.available) {
Logger.i("Calendar", "EDS libraries available");
loadCalendars();
} else {
Logger.w("Calendar", "EDS libraries not available: " + result);
root.lastError = "Evolution Data Server libraries not installed";
}
}
}
stderr: StdioCollector {
onStreamFinished: {
if (text.trim()) {
Logger.d("Calendar", "Availability check error: " + text);
root.available = false;
root.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());
root.calendars = result;
cacheAdapter.cachedCalendars = result;
saveCache();
Logger.d("Calendar", `Found ${result.length} calendar(s)`);
// Auto-load events after discovering calendars
// Only load if we have calendars and no cached events
if (result.length > 0 && root.events.length === 0) {
loadEvents();
} else if (result.length > 0) {
// If we already have cached events, load in background
loadEvents();
}
} catch (e) {
Logger.d("Calendar", "Failed to parse calendars: " + e);
root.lastError = "Failed to parse calendar list";
}
}
}
stderr: StdioCollector {
onStreamFinished: {
if (text.trim()) {
Logger.d("Calendar", "List calendars error: " + text);
root.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: {
root.loading = false;
try {
const result = JSON.parse(text.trim());
root.events = result;
cacheAdapter.cachedEvents = result;
cacheAdapter.lastUpdate = new Date().toISOString();
saveCache();
Logger.d("Calendar", `Loaded ${result.length} event(s)`);
} catch (e) {
Logger.d("Calendar", "Failed to parse events: " + e);
root.lastError = "Failed to parse events";
// Fall back to cached events if available
if (cacheAdapter.cachedEvents.length > 0) {
root.events = cacheAdapter.cachedEvents;
Logger.d("Calendar", "Using cached events");
}
}
}
}
stderr: StdioCollector {
onStreamFinished: {
root.loading = false;
if (text.trim()) {
Logger.d("Calendar", "Load events error: " + text);
root.lastError = text.trim();
// Fall back to cached events if available
if (cacheAdapter.cachedEvents.length > 0) {
root.events = cacheAdapter.cachedEvents;
Logger.d("Calendar", "Using cached events due to error");
}
}
}
}
}
}
+1 -1
View File
@@ -65,7 +65,7 @@ Singleton {
apply();
// Toast: night light toggled
const enabled = !!Settings.data.nightLight.enabled;
ToastService.showNotice(I18n.tr("common.night-light"), enabled ? I18n.tr("toast.wifi.enabled") : I18n.tr("toast.wifi.disabled"), enabled ? "nightlight-on" : "nightlight-off");
ToastService.showNotice(I18n.tr("common.night-light"), enabled ? I18n.tr("common.enabled") : I18n.tr("common.disabled"), enabled ? "nightlight-on" : "nightlight-off");
}
function onForcedChanged() {
apply();
+1
View File
@@ -26,6 +26,7 @@ Singleton {
}
property var currentPlayer: null
property string playerIdentity: currentPlayer ? (currentPlayer.identity || "") : ""
property real currentPosition: 0
property bool isSeeking: false
property int selectedPlayerIndex: 0
+7 -7
View File
@@ -233,10 +233,10 @@ Singleton {
if (bluetoothBlockedToggled) {
checkWifiBlocked.running = true;
} else if (adapter.state === BluetoothAdapter.Enabled) {
ToastService.showNotice(I18n.tr("common.bluetooth"), I18n.tr("toast.wifi.enabled"), "bluetooth");
ToastService.showNotice(I18n.tr("common.bluetooth"), I18n.tr("common.enabled"), "bluetooth");
Logger.d("Bluetooth", "Adapter enabled");
} else if (adapter.state === BluetoothAdapter.Disabled) {
ToastService.showNotice(I18n.tr("common.bluetooth"), I18n.tr("toast.wifi.disabled"), "bluetooth-off");
ToastService.showNotice(I18n.tr("common.bluetooth"), I18n.tr("common.disabled"), "bluetooth-off");
Logger.d("Bluetooth", "Adapter disabled");
}
}
@@ -255,17 +255,17 @@ Singleton {
root.airplaneModeToggled = true;
root.lastWifiBlocked = true;
NetworkService.setWifiEnabled(false);
ToastService.showNotice(I18n.tr("toast.airplane-mode.title"), I18n.tr("toast.wifi.enabled"), "plane");
ToastService.showNotice(I18n.tr("toast.airplane-mode.title"), I18n.tr("common.enabled"), "plane");
} else if (!wifiBlocked && lastWifiBlocked) {
root.airplaneModeToggled = true;
root.lastWifiBlocked = false;
NetworkService.setWifiEnabled(true);
ToastService.showNotice(I18n.tr("toast.airplane-mode.title"), I18n.tr("toast.wifi.disabled"), "plane-off");
ToastService.showNotice(I18n.tr("toast.airplane-mode.title"), I18n.tr("common.disabled"), "plane-off");
} else if (adapter.enabled) {
ToastService.showNotice(I18n.tr("common.bluetooth"), I18n.tr("toast.wifi.enabled"), "bluetooth");
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("toast.wifi.disabled"), "bluetooth-off");
ToastService.showNotice(I18n.tr("common.bluetooth"), I18n.tr("common.disabled"), "bluetooth-off");
Logger.d("Bluetooth", "Adapter disabled");
}
root.airplaneModeToggled = false;
@@ -587,7 +587,7 @@ Singleton {
const totalPauseMs = (pairWait * 1000) + (attempts * intervalSec * 1000) + 2000;
_pauseDiscoveryFor(totalPauseMs);
const scriptPath = Quickshell.shellDir + "/Scripts/network/bluetooth-connect.py";
const scriptPath = Quickshell.shellDir + "/Scripts/python/src/network/bluetooth-connect.py";
pairingProcess.command = ["python3", scriptPath, String(addr), String(pairWait), String(attempts), String(intervalSec)];
pairingProcess.running = true;
+2 -2
View File
@@ -69,14 +69,14 @@ Singleton {
function onWifiEnabledChanged() {
if (Settings.data.network.wifiEnabled) {
if (!BluetoothService.airplaneModeToggled) {
ToastService.showNotice(I18n.tr("wifi.panel.title"), I18n.tr("toast.wifi.enabled"), "wifi");
ToastService.showNotice(I18n.tr("wifi.panel.title"), I18n.tr("common.enabled"), "wifi");
}
// Perform a scan to update the UI
delayedScanTimer.interval = 3000;
delayedScanTimer.restart();
} else {
if (!BluetoothService.airplaneModeToggled) {
ToastService.showNotice(I18n.tr("wifi.panel.title"), I18n.tr("toast.wifi.disabled"), "wifi-off");
ToastService.showNotice(I18n.tr("wifi.panel.title"), I18n.tr("common.disabled"), "wifi-off");
}
// Clear networks so the widget icon changes
root.networks = ({});
+2 -2
View File
@@ -218,7 +218,7 @@ Singleton {
if (activeInhibitors.includes("manual")) {
removeInhibitor("manual");
ToastService.showNotice(I18n.tr("tooltips.keep-awake"), I18n.tr("toast.wifi.disabled"), "keep-awake-off");
ToastService.showNotice(I18n.tr("tooltips.keep-awake"), I18n.tr("common.disabled"), "keep-awake-off");
Logger.i("IdleInhibitor", "Manual inhibition disabled");
}
}
@@ -226,7 +226,7 @@ Singleton {
function addManualInhibitor(timeoutSec) {
if (!activeInhibitors.includes("manual")) {
addInhibitor("manual", "Manually activated by user");
ToastService.showNotice(I18n.tr("tooltips.keep-awake"), I18n.tr("toast.wifi.enabled"), "keep-awake-on");
ToastService.showNotice(I18n.tr("tooltips.keep-awake"), I18n.tr("common.enabled"), "keep-awake-on");
}
if (timeoutSec === null && timeout === null) {
+71 -29
View File
@@ -955,60 +955,102 @@ Singleton {
Timer {
id: mediaToastDebounce
interval: 300 // Debounce rapid changes
interval: 250 // Dynamic interval based on player
onTriggered: {
if (!Settings.data.notifications.enableMediaToast || !mediaToastInitialized)
checkMediaToast();
}
}
function checkMediaToast() {
if (!Settings.data.notifications.enableMediaToast || !mediaToastInitialized)
return;
if (doNotDisturb || PowerProfileService.noctaliaPerformanceMode)
if (doNotDisturb || PowerProfileService.noctaliaPerformanceMode)
return;
const title = MediaService.trackTitle || "";
const artist = MediaService.trackArtist || "";
const isPlaying = MediaService.isPlaying;
// 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));
// Only show toast if something meaningful changed
const titleChanged = title !== previousMediaTitle && title !== "";
const playStateChanged = isPlaying !== previousMediaIsPlaying;
const hasMedia = title !== "" || artist !== "";
// 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;
}
if (hasMedia && (titleChanged || playStateChanged)) {
const icon = isPlaying ? "media-play" : "media-pause";
let message = "";
const title = MediaService.trackTitle || "";
const artist = MediaService.trackArtist || "";
const isPlaying = MediaService.isPlaying;
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);
}
}
// 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() {
mediaToastDebounce.restart();
restartDebounce();
}
function onTrackArtistChanged() {
mediaToastDebounce.restart();
restartDebounce();
}
function onIsPlayingChanged() {
mediaToastDebounce.restart();
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();
}
}
-2
View File
@@ -8,8 +8,6 @@ import qs.Services.UI
Singleton {
id: root
readonly property string colorsApplyScript: Quickshell.shellDir + '/Scripts/theming/template-apply.sh'
Connections {
target: WallpaperService
+1 -1
View File
@@ -27,7 +27,7 @@ Singleton {
// 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("toast.wifi.enabled");
const description = I18n.tr("common.enabled");
ToastService.showNotice(label, description, "dark-mode");
}
}
+14 -8
View File
@@ -13,7 +13,7 @@ Singleton {
id: root
readonly property string dynamicConfigPath: Settings.cacheDir + "theming.dynamic.toml"
readonly property string templateProcessorScript: Quickshell.shellDir + "/Scripts/theming/template-processor.py"
readonly property string templateProcessorScript: Quickshell.shellDir + "/Scripts/python/src/theming/template-processor.py"
readonly property var schemeNameMap: ({
"Noctalia (default)": "Noctalia-default",
@@ -159,7 +159,7 @@ Singleton {
lines.push(`input_path = "${Quickshell.shellDir}/Assets/Templates/${terminal.templatePath}"`);
const outputPath = terminal.outputPath.replace("~", homeDir);
lines.push(`output_path = "${outputPath}"`);
const postHook = terminal.postHook || `${TemplateRegistry.colorsApplyScript} ${terminal.id}`;
const postHook = terminal.postHook || `${TemplateRegistry.templateApplyScript} ${terminal.id}`;
const postHookEsc = escapeTomlString(postHook);
lines.push(`post_hook = "${postHookEsc}"`);
}
@@ -252,6 +252,13 @@ Singleton {
return false;
}
// Get scheme type, defaulting to tonal-spot if not a recognized value
function getSchemeType() {
const method = Settings.data.colorSchemes.generationMethod;
const validTypes = ["tonal-spot", "fruit-salad", "rainbow", "vibrant", "faithful"];
return validTypes.includes(method) ? method : "tonal-spot";
}
function buildGenerationScript(content, wallpaper, mode) {
const delimiter = "THEME_CONFIG_EOF_" + Math.random().toString(36).substr(2, 9);
const pathEsc = dynamicConfigPath.replace(/'/g, "'\\''");
@@ -262,9 +269,8 @@ Singleton {
script += `NOCTALIA_WP_PATH=$(cat << '${wpDelimiter}'\n${wallpaper}\n${wpDelimiter}\n)\n`;
// Use template-processor.py (Python implementation)
const styleFlag = (Settings.data.colorSchemes.extractionMethod === "default") ? "--default" : "--material";
// We pass --type for compatibility but it is ignored by internal logic unless needed
script += `python3 "${templateProcessorScript}" "$NOCTALIA_WP_PATH" ${styleFlag} --config '${pathEsc}' --mode ${mode} `;
const schemeType = getSchemeType();
script += `python3 "${templateProcessorScript}" "$NOCTALIA_WP_PATH" --scheme-type ${schemeType} --config '${pathEsc}' --mode ${mode} `;
script += buildUserTemplateCommand("$NOCTALIA_WP_PATH", mode);
@@ -294,7 +300,7 @@ Singleton {
const hyphenPath = escapeShellPath(templatePaths.hyphen);
const spacePath = escapeShellPath(templatePaths.space);
commands.push(`if [ -f ${hyphenPath} ]; then cp -f ${hyphenPath} ${escapeShellPath(outputPath)}; elif [ -f ${spacePath} ]; then cp -f ${spacePath} ${escapeShellPath(outputPath)}; else echo "ERROR: Template file not found for ${terminal} (tried both hyphen and space patterns)"; fi`);
commands.push(`${TemplateRegistry.colorsApplyScript} ${terminal}`);
commands.push(`${TemplateRegistry.templateApplyScript} ${terminal}`);
}
});
@@ -363,8 +369,8 @@ Singleton {
// Otherwise, use single quotes for safety with file paths
const inputQuoted = input.startsWith("$") ? `"${input}"` : `'${input.replace(/'/g, "'\\''")}'`;
const styleFlag = (Settings.data.colorSchemes.extractionMethod === "default") ? "--default" : "--material";
script += ` python3 "${templateProcessorScript}" ${inputQuoted} ${styleFlag} --config '${userConfigPath}' --mode ${mode}\n`;
const schemeType = getSchemeType();
script += ` python3 "${templateProcessorScript}" ${inputQuoted} --scheme-type ${schemeType} --config '${userConfigPath}' --mode ${mode}\n`;
script += "fi";
return script;
+14 -13
View File
@@ -8,7 +8,8 @@ import qs.Commons
Singleton {
id: root
readonly property string colorsApplyScript: Quickshell.shellDir + '/Scripts/theming/template-apply.sh'
readonly property string templateApplyScript: Quickshell.shellDir + '/Scripts/bash/template-apply.sh'
readonly property string gtkRefreshScript: Quickshell.shellDir + '/Scripts/bash/gtk-refresh.sh'
// Terminal configurations (for wallpaper-based templates)
readonly property var terminals: [
@@ -55,13 +56,13 @@ Singleton {
"input": "gtk.css",
"outputs": [
{
"path": "~/.config/gtk-3.0/gtk.css"
"path": "~/.config/gtk-3.0/noctalia.css"
},
{
"path": "~/.config/gtk-4.0/gtk.css"
"path": "~/.config/gtk-4.0/noctalia.css"
}
],
"postProcess": mode => `gsettings set org.gnome.desktop.interface color-scheme prefer-${mode}`
"postProcess": mode => `gsettings set org.gnome.desktop.interface color-scheme prefer-${mode} && ${gtkRefreshScript}`
},
{
"id": "qt",
@@ -98,7 +99,7 @@ Singleton {
"path": "~/.config/fuzzel/themes/noctalia"
}
],
"postProcess": () => `${colorsApplyScript} fuzzel`
"postProcess": () => `${templateApplyScript} fuzzel`
},
{
"id": "vicinae",
@@ -110,7 +111,7 @@ Singleton {
"path": "~/.local/share/vicinae/themes/noctalia.toml"
}
],
"postProcess": () => `cp --update=none ${Quickshell.shellDir}/Assets/noctalia.svg ~/.local/share/vicinae/themes/noctalia.svg && ${colorsApplyScript} vicinae`
"postProcess": () => `cp --update=none ${Quickshell.shellDir}/Assets/noctalia.svg ~/.local/share/vicinae/themes/noctalia.svg && ${templateApplyScript} vicinae`
},
{
"id": "walker",
@@ -122,7 +123,7 @@ Singleton {
"path": "~/.config/walker/themes/noctalia/style.css"
}
],
"postProcess": () => `${colorsApplyScript} walker`,
"postProcess": () => `${templateApplyScript} walker`,
"strict": true // Use strict mode for palette generation (preserves custom surface/outline values)
},
{
@@ -135,7 +136,7 @@ Singleton {
"path": "~/.cache/wal/colors.json"
}
],
"postProcess": mode => `${colorsApplyScript} pywalfox ${mode}`
"postProcess": mode => `${templateApplyScript} pywalfox ${mode}`
} // CONSOLIDATED DISCORD CLIENTS
,
{
@@ -280,7 +281,7 @@ Singleton {
"path": "~/.config/cava/themes/noctalia"
}
],
"postProcess": () => `${colorsApplyScript} cava`
"postProcess": () => `${templateApplyScript} cava`
},
{
"id": "yazi",
@@ -318,7 +319,7 @@ Singleton {
"path": "~/.config/niri/noctalia.kdl"
}
],
"postProcess": () => `${colorsApplyScript} niri`
"postProcess": () => `${templateApplyScript} niri`
},
{
"id": "hyprland",
@@ -330,7 +331,7 @@ Singleton {
"path": "~/.config/hypr/noctalia/noctalia-colors.conf"
}
],
"postProcess": () => `${colorsApplyScript} hyprland`
"postProcess": () => `${templateApplyScript} hyprland`
},
{
"id": "hyprtoolkit",
@@ -353,7 +354,7 @@ Singleton {
"path": "~/.config/mango/noctalia.conf"
}
],
"postProcess": () => `${colorsApplyScript} mango`
"postProcess": () => `${templateApplyScript} mango`
},
{
"id": "btop",
@@ -365,7 +366,7 @@ Singleton {
"path": "~/.config/btop/themes/noctalia.theme"
}
],
"postProcess": () => `${colorsApplyScript} btop`
"postProcess": () => `${templateApplyScript} btop`
}
]