mirror of
https://github.com/noctalia-dev/noctalia-shell.git
synced 2026-05-11 17:08:27 +08:00
Merge remote-tracking branch 'upstream/main'
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
# Fuzzel Colors
|
||||
# Generated with Template Processor
|
||||
# Generated by Noctalia's Template Processor
|
||||
|
||||
[colors]
|
||||
background={{colors.background.default.hex_stripped}}CC
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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ı"
|
||||
}
|
||||
|
||||
@@ -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": "Мережу не знайдено"
|
||||
}
|
||||
|
||||
@@ -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": "未找到网络"
|
||||
}
|
||||
|
||||
@@ -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": "找不到網路"
|
||||
}
|
||||
|
||||
@@ -390,7 +390,7 @@
|
||||
"schedulingMode": "off",
|
||||
"manualSunrise": "06:30",
|
||||
"manualSunset": "18:30",
|
||||
"extractionMethod": "default"
|
||||
"generationMethod": "tonal-spot"
|
||||
},
|
||||
"templates": {
|
||||
"activeTemplates": [],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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
@@ -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";
|
||||
|
||||
@@ -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")
|
||||
|
||||
Executable
+96
@@ -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"
|
||||
Executable
+205
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
+46
-20
@@ -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)
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = ({});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user