ColorSchemeTab: add download button

SchemeDownloader: fetch available color schemes from noctalia-colorscheme repo
ColorSchemes: move multiple schemes to the colorscheme repo
i18n: add translations to SchemeDownloader
ColorSchemeTab: move into own folder (+ SchemeDownloader)
This commit is contained in:
Ly-sec
2025-11-18 20:39:10 +01:00
parent 1d200e84a1
commit c3b73f02b3
79 changed files with 1328 additions and 1802 deletions
@@ -5,6 +5,7 @@ import Quickshell
import qs.Commons
import qs.Modules.MainScreen
import qs.Modules.Panels.Settings.Tabs
import qs.Modules.Panels.Settings.Tabs.ColorScheme
import qs.Services.System
import qs.Widgets
@@ -3,6 +3,7 @@ import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import "."
import qs.Commons
import qs.Services.System
import qs.Services.Theming
@@ -94,6 +95,11 @@ ColumnLayout {
cacheVersion++;
}
// Function to open download popup
function openDownloadPopup() {
downloadPopupLoader.open();
}
// When the list of available schemes changes, clear the cache
Connections {
target: ColorSchemeService
@@ -326,9 +332,49 @@ ColumnLayout {
Layout.fillWidth: true
visible: !Settings.data.colorSchemes.useWallpaperColors
NHeader {
label: I18n.tr("settings.color-scheme.predefined.section.label")
description: I18n.tr("settings.color-scheme.predefined.section.description")
RowLayout {
Layout.fillWidth: true
NHeader {
label: I18n.tr("settings.color-scheme.predefined.section.label")
description: I18n.tr("settings.color-scheme.predefined.section.description")
Layout.fillWidth: true
}
NButton {
text: I18n.tr("settings.color-scheme.download.button")
icon: "download"
onClicked: {
root.openDownloadPopup();
}
}
}
// Download popup
Loader {
id: downloadPopupLoader
active: false
sourceComponent: SchemeDownloader {
parent: Overlay.overlay
}
property bool pendingOpen: false
function open() {
pendingOpen = true;
active = true;
if (item) {
item.open();
pendingOpen = false;
}
}
onItemChanged: {
if (item && pendingOpen) {
item.open();
pendingOpen = false;
}
}
}
// Color Schemes Grid
@@ -0,0 +1,928 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services.Theming
import qs.Services.UI
import qs.Widgets
Popup {
id: root
property var availableSchemes: []
property bool fetching: false
property bool downloading: false
property bool hasInitialData: false // Track if we've loaded data at least once
property string downloadError: ""
property string downloadingScheme: ""
property string pendingApplyScheme: "" // Scheme name to apply after reload
property real lastApiFetchTime: 0 // Track when we last fetched from API to prevent rapid calls
property int minApiFetchInterval: 60 // Minimum seconds between API fetches (1 minute)
// Cache for remote scheme colors
property var schemeColorsCache: ({})
property int cacheVersion: 0
// Cache for available schemes list
property string schemesCacheFile: Settings.cacheDir + "color-schemes-list.json"
property int schemesCacheUpdateFrequency: 2 * 60 * 60 // 2 hours in seconds
// Cache for repo branch info (to reduce API calls during downloads)
property string cachedBranch: "main"
property string cachedBranchSha: ""
width: Math.max(500, contentColumn.implicitWidth + (Style.marginXL * 2))
height: Math.min(800, contentColumn.implicitHeight + (Style.marginXL * 2))
padding: Style.marginXL
modal: true
dim: false
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
anchors.centerIn: parent
// Helper function to get color from cached scheme data
function getSchemeColor(schemeName, colorKey) {
var _ = cacheVersion; // Create dependency
if (schemeColorsCache[schemeName]) {
var entry = schemeColorsCache[schemeName];
var variant = entry;
// Check if scheme has dark/light variants
if (entry.dark || entry.light) {
variant = Settings.data.colorSchemes.darkMode ? (entry.dark || entry.light) : (entry.light || entry.dark);
}
if (variant && variant[colorKey]) {
return variant[colorKey];
}
}
// Return visible defaults while loading
var defaults = {
"mSurface": Color.mSurfaceVariant,
"mPrimary": Color.mPrimary,
"mSecondary": Color.mSecondary,
"mTertiary": Color.mTertiary,
"mError": Color.mError,
"mOnSurface": Color.mOnSurfaceVariant
};
return defaults[colorKey] || Color.mOnSurfaceVariant;
}
// Fetch scheme JSON to get colors for swatches
function fetchSchemeColors(scheme) {
// Skip if already cached
if (schemeColorsCache[scheme.name]) {
return;
}
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function () {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
try {
var jsonData = JSON.parse(xhr.responseText);
schemeColorsCache[scheme.name] = jsonData;
cacheVersion++;
} catch (e) {
Logger.w("ColorSchemeDownload", "Failed to parse scheme JSON for", scheme.name, e);
}
}
}
};
// Try to get the JSON file from the scheme directory
xhr.open("GET", "https://raw.githubusercontent.com/noctalia-dev/noctalia-colorschemes/main/" + scheme.path + "/" + scheme.name + ".json");
xhr.send();
}
// Cache file for schemes list
FileView {
id: schemesCacheFileView
path: schemesCacheFile
printErrors: false
JsonAdapter {
id: schemesCacheAdapter
property var schemes: []
property real timestamp: 0
}
onLoaded: {
loadSchemesFromCache();
}
onLoadFailed: function (error) {
if (error.toString().includes("No such file") || error === 2) {
// Cache doesn't exist, fetch from API (only if popup is open)
if (root.visible) {
Qt.callLater(() => {
fetchAvailableSchemesFromAPI();
});
}
}
}
}
background: Rectangle {
color: Color.mSurface
radius: Style.radiusL
border.color: Color.mPrimary
border.width: Style.borderM
}
function loadSchemesFromCache() {
const now = Time.timestamp;
// Check if cache is expired or missing
if (!schemesCacheAdapter.timestamp || (now >= schemesCacheAdapter.timestamp + schemesCacheUpdateFrequency)) {
// Only fetch from API if we haven't fetched recently (prevent rapid repeated calls)
const timeSinceLastFetch = now - lastApiFetchTime;
if (timeSinceLastFetch >= minApiFetchInterval) {
Logger.d("ColorSchemeDownload", "Cache expired or missing, fetching new schemes");
fetchAvailableSchemesFromAPI();
return;
} else {
// Use cached data even if expired, to avoid rate limits
Logger.d("ColorSchemeDownload", "Cache expired but recent API call detected, using cached data");
if (schemesCacheAdapter.schemes && schemesCacheAdapter.schemes.length > 0) {
availableSchemes = schemesCacheAdapter.schemes;
hasInitialData = true;
fetching = false;
return;
}
}
}
const ageMinutes = Math.round((now - schemesCacheAdapter.timestamp) / 60);
Logger.d("ColorSchemeDownload", "Loading cached schemes (age:", ageMinutes, "minutes)");
if (schemesCacheAdapter.schemes && schemesCacheAdapter.schemes.length > 0) {
availableSchemes = schemesCacheAdapter.schemes;
hasInitialData = true;
fetching = false;
} else {
// Cache is empty, only fetch if we haven't fetched recently
const timeSinceLastFetch = now - lastApiFetchTime;
if (timeSinceLastFetch >= minApiFetchInterval) {
fetchAvailableSchemesFromAPI();
} else {
Logger.d("ColorSchemeDownload", "Cache empty but recent API call detected, skipping fetch");
fetching = false;
}
}
}
function saveSchemesToCache() {
schemesCacheAdapter.schemes = availableSchemes;
schemesCacheAdapter.timestamp = Time.timestamp;
// Ensure cache directory exists
Quickshell.execDetached(["mkdir", "-p", Settings.cacheDir]);
Qt.callLater(() => {
schemesCacheFileView.writeAdapter();
Logger.d("ColorSchemeDownload", "Schemes list saved to cache");
});
}
function fetchAvailableSchemes() {
if (fetching) {
return;
}
// Path is set when popup becomes visible, so FileView will start loading
// Try to load from cache first
if (schemesCacheFileView.loaded) {
loadSchemesFromCache();
} else if (schemesCacheFileView.path) {
// Cache file path is set but not loaded yet, wait for it to load
// The FileView will trigger loadSchemesFromCache() when loaded
// But if it fails, we should fetch from API
if (!schemesCacheFileView.loading) {
schemesCacheFileView.reload();
}
} else {
// No cache file path, fetch directly from API
fetchAvailableSchemesFromAPI();
}
}
function fetchAvailableSchemesFromAPI() {
if (fetching) {
return;
}
// Check if we've fetched recently to prevent rapid repeated calls
const now = Time.timestamp;
const timeSinceLastFetch = now - lastApiFetchTime;
if (timeSinceLastFetch < minApiFetchInterval) {
Logger.d("ColorSchemeDownload", "Skipping API fetch - too soon since last fetch (", Math.round(timeSinceLastFetch), "s ago)");
return;
}
fetching = true;
lastApiFetchTime = now;
// Don't clear availableSchemes immediately to prevent flicker - keep showing old list while fetching
// availableSchemes = [];
downloadError = "";
// Use GitHub API to list contents of the repo
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function () {
if (xhr.readyState === XMLHttpRequest.DONE) {
fetching = false;
if (xhr.status === 200) {
try {
var response = JSON.parse(xhr.responseText);
if (Array.isArray(response)) {
// Filter to only directories (type === "dir")
var schemes = [];
for (var i = 0; i < response.length; i++) {
if (response[i].type === "dir") {
schemes.push({
"name": response[i].name,
"path": response[i].path,
"url": response[i].url
});
}
}
availableSchemes = schemes;
hasInitialData = true;
Logger.i("ColorSchemeDownload", "Fetched", schemes.length, "available schemes from API");
// Save to cache
saveSchemesToCache();
} else {
downloadError = I18n.tr("settings.color-scheme.download.error.invalid-response");
Logger.e("ColorSchemeDownload", downloadError);
}
} catch (e) {
downloadError = I18n.tr("settings.color-scheme.download.error.parse-failed", {
"error": e.toString()
});
Logger.e("ColorSchemeDownload", downloadError);
}
} else if (xhr.status === 403) {
// Rate limit hit - try to use cache if available
downloadError = I18n.tr("settings.color-scheme.download.error.rate-limit");
Logger.w("ColorSchemeDownload", downloadError);
if (schemesCacheAdapter.schemes && schemesCacheAdapter.schemes.length > 0) {
availableSchemes = schemesCacheAdapter.schemes;
Logger.i("ColorSchemeDownload", "Using cached schemes due to rate limit");
}
} else {
downloadError = I18n.tr("settings.color-scheme.download.error.api-error", {
"status": xhr.status
});
Logger.e("ColorSchemeDownload", downloadError);
}
}
};
xhr.open("GET", "https://api.github.com/repos/noctalia-dev/noctalia-colorschemes/contents");
xhr.send();
}
function downloadScheme(scheme) {
if (downloading) {
return;
}
downloading = true;
downloadingScheme = scheme.name;
downloadError = "";
Logger.i("ColorSchemeDownload", "Downloading scheme:", scheme.name);
// Use cached branch/SHA if available, otherwise fetch
if (cachedBranchSha) {
// Use cached SHA directly
getSchemeTreeWithSha(scheme, cachedBranch, cachedBranchSha);
} else if (cachedBranch) {
// We have branch name, just need SHA
getSchemeTree(scheme, cachedBranch);
} else {
// Need to fetch branch info first
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function () {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
try {
var repoInfo = JSON.parse(xhr.responseText);
var defaultBranch = repoInfo.default_branch || "main";
cachedBranch = defaultBranch;
// Now get the tree for the scheme directory
getSchemeTree(scheme, defaultBranch);
} catch (e) {
// Fallback: try to get files directly
getSchemeFilesDirect(scheme);
}
} else {
// Fallback: try to get files directly
getSchemeFilesDirect(scheme);
}
}
};
xhr.open("GET", "https://api.github.com/repos/noctalia-dev/noctalia-colorschemes");
xhr.send();
}
}
function getSchemeTree(scheme, branch) {
// First get the SHA of the branch
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function () {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
try {
var refResponse = JSON.parse(xhr.responseText);
var sha = refResponse.object ? refResponse.object.sha : null;
if (sha) {
// Cache the SHA for future downloads
cachedBranchSha = sha;
// Now get the tree
getSchemeTreeWithSha(scheme, branch, sha);
} else {
// Fallback to direct method
getSchemeFilesDirect(scheme);
}
} catch (e) {
// Fallback to direct method
getSchemeFilesDirect(scheme);
}
} else {
// Fallback to direct method
getSchemeFilesDirect(scheme);
}
}
};
xhr.open("GET", "https://api.github.com/repos/noctalia-dev/noctalia-colorschemes/git/refs/heads/" + branch);
xhr.send();
}
function getSchemeTreeWithSha(scheme, branch, sha) {
// Use git trees API to get all files recursively
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function () {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
try {
var response = JSON.parse(xhr.responseText);
if (response.tree && Array.isArray(response.tree)) {
// Filter files that belong to this scheme
var files = [];
for (var i = 0; i < response.tree.length; i++) {
var item = response.tree[i];
if (item.type === "blob" && item.path.startsWith(scheme.path + "/")) {
files.push({
"path": item.path,
"url": "https://raw.githubusercontent.com/noctalia-dev/noctalia-colorschemes/" + branch + "/" + item.path,
"name": item.path.split("/").pop()
});
}
}
downloadSchemeFiles(scheme.name, files);
} else {
// Fallback to direct method
getSchemeFilesDirect(scheme);
}
} catch (e) {
downloadError = I18n.tr("settings.color-scheme.download.error.parse-failed", {
"error": e.toString()
});
downloading = false;
downloadingScheme = "";
Logger.e("ColorSchemeDownload", downloadError);
}
} else {
// Fallback to direct method
getSchemeFilesDirect(scheme);
}
}
};
xhr.open("GET", "https://api.github.com/repos/noctalia-dev/noctalia-colorschemes/git/trees/" + sha + "?recursive=1");
xhr.send();
}
function getSchemeFilesDirect(scheme) {
// Fallback: get files directly using contents API (non-recursive, but works)
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function () {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
try {
var response = JSON.parse(xhr.responseText);
if (Array.isArray(response)) {
// Recursively get all files
getAllFilesRecursive(scheme, response, []);
} else {
downloadError = I18n.tr("settings.color-scheme.download.error.invalid-response");
downloading = false;
downloadingScheme = "";
Logger.e("ColorSchemeDownload", downloadError);
}
} catch (e) {
downloadError = I18n.tr("settings.color-scheme.download.error.parse-failed", {
"error": e.toString()
});
downloading = false;
downloadingScheme = "";
Logger.e("ColorSchemeDownload", downloadError);
}
} else {
downloadError = I18n.tr("settings.color-scheme.download.error.api-error", {
"status": xhr.status
});
downloading = false;
downloadingScheme = "";
Logger.e("ColorSchemeDownload", downloadError);
}
}
};
xhr.open("GET", "https://api.github.com/repos/noctalia-dev/noctalia-colorschemes/contents/" + scheme.path);
xhr.send();
}
function getAllFilesRecursive(scheme, items, allFiles, callback) {
if (!callback) {
callback = function () {
downloadSchemeFiles(scheme.name, allFiles);
};
}
if (items.length === 0) {
callback();
return;
}
var pending = 0;
var completed = 0;
function checkComplete() {
completed++;
if (completed === items.length && pending === 0) {
callback();
}
}
for (var i = 0; i < items.length; i++) {
var item = items[i];
if (item.type === "file") {
allFiles.push({
"path": item.path,
"url": item.download_url,
"name": item.name
});
checkComplete();
} else if (item.type === "dir") {
pending++;
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function (dirItem) {
return function () {
if (xhr.readyState === XMLHttpRequest.DONE) {
pending--;
if (xhr.status === 200) {
try {
var dirResponse = JSON.parse(xhr.responseText);
if (Array.isArray(dirResponse)) {
getAllFilesRecursive(scheme, dirResponse, allFiles, function () {
checkComplete();
});
} else {
checkComplete();
}
} catch (e) {
Logger.e("ColorSchemeDownload", "Failed to parse directory response:", e);
checkComplete();
}
} else {
checkComplete();
}
}
};
}(item);
xhr.open("GET", item.url);
xhr.send();
} else {
checkComplete();
}
}
}
function downloadSchemeFiles(schemeName, files) {
if (files.length === 0) {
downloadError = I18n.tr("settings.color-scheme.download.error.no-files");
downloading = false;
downloadingScheme = "";
Logger.e("ColorSchemeDownload", downloadError);
return;
}
var targetDir = ColorSchemeService.schemesDirectory + "/" + schemeName;
var downloadScript = "mkdir -p '" + targetDir + "'\n";
// Build download script for all files
for (var i = 0; i < files.length; i++) {
var file = files[i];
var filePath = file.path;
// Remove scheme name and leading / from path
var relativePath = filePath;
if (filePath.startsWith(schemeName + "/")) {
relativePath = filePath.substring(schemeName.length + 1);
} else if (filePath.startsWith("/" + schemeName + "/")) {
relativePath = filePath.substring(schemeName.length + 2);
}
var localPath = targetDir + "/" + relativePath;
var localDir = localPath.substring(0, localPath.lastIndexOf('/'));
downloadScript += "mkdir -p '" + localDir + "'\n";
var downloadUrl = file.url || file.download_url;
if (downloadUrl) {
downloadScript += "curl -L -s -o '" + localPath + "' '" + downloadUrl + "' || wget -q -O '" + localPath + "' '" + downloadUrl + "'\n";
}
}
Logger.d("ColorSchemeDownload", "Downloading", files.length, "files for scheme", schemeName);
// Execute download script
var downloadProcess = Qt.createQmlObject(`
import QtQuick
import Quickshell.Io
Process {
id: downloadProcess
command: ["sh", "-c", ` + JSON.stringify(downloadScript) + `]
stderr: StdioCollector {
onStreamFinished: {
if (text && text.trim()) {
Logger.e("ColorSchemeDownload", "Download stderr:", text);
}
}
}
}
`, root, "DownloadProcess_" + schemeName);
downloadProcess.exited.connect(function (exitCode) {
if (exitCode === 0) {
Logger.i("ColorSchemeDownload", "Scheme downloaded successfully:", schemeName);
ToastService.showNotice(I18n.tr("settings.color-scheme.download.success.title"), I18n.tr("settings.color-scheme.download.success.description", {
"scheme": schemeName
}), "settings-color-scheme");
// Set pending scheme to apply after reload
pendingApplyScheme = schemeName;
// Reload color schemes
ColorSchemeService.loadColorSchemes();
downloading = false;
downloadingScheme = "";
} else {
downloadError = I18n.tr("settings.color-scheme.download.error.download-failed", {
"code": exitCode
});
Logger.e("ColorSchemeDownload", downloadError);
ToastService.showError(I18n.tr("settings.color-scheme.download.error.title"), I18n.tr("settings.color-scheme.download.error.description", {
"scheme": schemeName
}));
downloading = false;
downloadingScheme = "";
}
downloadProcess.destroy();
});
downloadProcess.running = true;
}
function isSchemeInstalled(schemeName) {
// Check if scheme already exists in ColorSchemeService
for (var i = 0; i < ColorSchemeService.schemes.length; i++) {
var path = ColorSchemeService.schemes[i];
if (path.indexOf("/" + schemeName + "/") !== -1 || path.indexOf("/" + schemeName + ".json") !== -1) {
return true;
}
}
return false;
}
function deleteScheme(schemeName) {
if (downloading) {
return;
}
Logger.i("ColorSchemeDownload", "Deleting scheme:", schemeName);
// Check if the deleted scheme is the currently active one
var currentScheme = Settings.data.colorSchemes.predefinedScheme || "";
var deletedSchemeDisplayName = ColorSchemeService.getBasename(schemeName);
var needsReset = (currentScheme === deletedSchemeDisplayName);
var targetDir = ColorSchemeService.schemesDirectory + "/" + schemeName;
var deleteScript = "rm -rf '" + targetDir + "'";
var deleteProcess = Qt.createQmlObject(`
import QtQuick
import Quickshell.Io
Process {
id: deleteProcess
command: ["sh", "-c", ` + JSON.stringify(deleteScript) + `]
}
`, root, "DeleteProcess_" + schemeName);
deleteProcess.exited.connect(function (exitCode) {
if (exitCode === 0) {
Logger.i("ColorSchemeDownload", "Scheme deleted successfully:", schemeName);
ToastService.showNotice(I18n.tr("settings.color-scheme.delete.success.title"), I18n.tr("settings.color-scheme.delete.success.description", {
"scheme": schemeName
}), "settings-color-scheme");
// If the deleted scheme was the active one, reset to default BEFORE reloading
if (needsReset) {
Logger.i("ColorSchemeDownload", "Deleted scheme was active, resetting to Noctalia (default)");
// Clear the setting immediately so ColorSchemeService won't try to apply the deleted scheme
Settings.data.colorSchemes.predefinedScheme = "Noctalia (default)";
// Apply the default scheme immediately
ColorSchemeService.setPredefinedScheme("Noctalia (default)");
}
// Reload color schemes
ColorSchemeService.loadColorSchemes();
} else {
Logger.e("ColorSchemeDownload", "Delete failed with exit code:", exitCode);
ToastService.showError(I18n.tr("settings.color-scheme.delete.error.title"), I18n.tr("settings.color-scheme.delete.error.description", {
"scheme": schemeName
}));
}
deleteProcess.destroy();
});
deleteProcess.running = true;
}
Connections {
target: ColorSchemeService
function onScanningChanged() {
// When scanning completes and we have a pending scheme, apply it
if (!ColorSchemeService.scanning && pendingApplyScheme !== "") {
var schemeToApply = pendingApplyScheme;
pendingApplyScheme = ""; // Clear pending before applying
// Wait a tiny bit to ensure schemes array is updated
applyTimer.schemeName = schemeToApply;
applyTimer.restart();
}
}
}
Timer {
id: applyTimer
property string schemeName: ""
interval: 100 // Small delay to ensure schemes array is populated
onTriggered: {
if (schemeName !== "") {
Logger.i("ColorSchemeDownload", "Auto-applying downloaded scheme:", schemeName);
// Use setPredefinedScheme which will apply and set it as the current scheme
ColorSchemeService.setPredefinedScheme(schemeName);
schemeName = "";
}
}
}
onOpened: {
fetchAvailableSchemes();
}
function preFetchSchemeColors() {
if (availableSchemes.length > 0 && visible) {
Qt.callLater(function () {
for (var i = 0; i < availableSchemes.length; i++) {
var scheme = availableSchemes[i];
if (!schemeColorsCache[scheme.name]) {
fetchSchemeColors(scheme);
}
}
});
}
}
onAvailableSchemesChanged: preFetchSchemeColors()
onVisibleChanged: preFetchSchemeColors()
contentItem: ColumnLayout {
id: contentColumn
width: parent.width
spacing: Style.marginL
// Header
RowLayout {
Layout.fillWidth: true
NText {
text: I18n.tr("settings.color-scheme.download.title")
pointSize: Style.fontSizeL
font.weight: Style.fontWeightBold
color: Color.mPrimary
Layout.fillWidth: true
}
NIconButton {
icon: "refresh"
tooltipText: I18n.tr("settings.color-scheme.download.refresh")
enabled: !fetching && !downloading
onClicked: {
// Force refresh by clearing cache timestamp and fetching directly from API
schemesCacheAdapter.timestamp = 0;
// Fetch directly from API to avoid cache check delay
fetchAvailableSchemesFromAPI();
}
}
NIconButton {
icon: "close"
tooltipText: I18n.tr("tooltips.close")
onClicked: root.close()
}
}
// Separator
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 1
color: Color.mOutline
}
// Error message
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: errorText.implicitHeight + Style.marginM
visible: downloadError !== ""
color: Color.mError
radius: Style.radiusS
NText {
id: errorText
anchors.fill: parent
anchors.margins: Style.marginM
text: downloadError
pointSize: Style.fontSizeS
color: Color.mOnError
wrapMode: Text.WordWrap
}
}
// Loading indicator - only show on initial load, not during refresh
RowLayout {
Layout.fillWidth: true
visible: fetching && !hasInitialData
spacing: Style.marginM
NBusyIndicator {
Layout.preferredWidth: 20
Layout.preferredHeight: 20
}
NText {
text: I18n.tr("settings.color-scheme.download.fetching")
pointSize: Style.fontSizeM
color: Color.mOnSurfaceVariant
}
}
// Schemes list - keep visible during refresh to prevent flicker
NScrollView {
id: schemesScrollView
Layout.fillWidth: true
Layout.preferredHeight: 400
visible: hasInitialData && availableSchemes.length > 0
verticalPolicy: ScrollBar.AsNeeded
horizontalPolicy: ScrollBar.AlwaysOff
// Only show scrollbar when content actually overflows (size < 1.0 means content is larger than viewport)
ScrollBar.vertical.visible: schemesScrollView.ScrollBar.vertical.size < 1.0
ColumnLayout {
width: {
// Always account for scrollbar width when it's visible (for testing with visible: true)
var scrollbarWidth = schemesScrollView.ScrollBar.vertical.visible ? (schemesScrollView.handleWidth + Style.marginS) : 0;
return parent.width - scrollbarWidth;
}
spacing: Style.marginS
Repeater {
model: availableSchemes
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 50 * Style.uiScaleRatio
radius: Style.radiusS
property string schemeName: modelData.name
color: root.getSchemeColor(schemeName, "mSurfaceVariant")
border.width: Style.borderS
border.color: Color.mOutline
Behavior on color {
ColorAnimation {
duration: Style.animationFast
easing.type: Easing.InOutCubic
}
}
Component.onCompleted: {
if (root.visible && !root.schemeColorsCache[schemeName]) {
root.fetchSchemeColors(modelData);
}
}
RowLayout {
id: schemeRow
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Style.marginL
anchors.rightMargin: Style.marginL
spacing: Style.marginS
property string schemeName: modelData.name
property int diameter: 16 * Style.uiScaleRatio
property var colorKeys: ["mPrimary", "mSecondary", "mTertiary", "mError"]
NText {
text: schemeRow.schemeName
pointSize: Style.fontSizeS
color: root.getSchemeColor(schemeRow.schemeName, "mOnSurface")
Layout.fillWidth: true
elide: Text.ElideRight
Layout.alignment: Qt.AlignVCenter
Behavior on color {
ColorAnimation {
duration: Style.animationFast
easing.type: Easing.InOutCubic
}
}
}
// Color swatches
Repeater {
model: schemeRow.colorKeys
Rectangle {
width: schemeRow.diameter
height: schemeRow.diameter
radius: schemeRow.diameter * 0.5
color: root.getSchemeColor(schemeRow.schemeName, modelData)
Layout.alignment: Qt.AlignVCenter
Behavior on color {
ColorAnimation {
duration: Style.animationFast
easing.type: Easing.InOutCubic
}
}
}
}
// Download/Delete button
NIconButton {
property bool isDownloading: downloading && downloadingScheme === schemeRow.schemeName
property bool isInstalled: root.isSchemeInstalled(schemeRow.schemeName)
icon: isDownloading ? "" : (isInstalled ? "trash" : "download")
tooltipText: isDownloading ? I18n.tr("settings.color-scheme.download.downloading") : (isInstalled ? I18n.tr("settings.color-scheme.download.delete") : I18n.tr("settings.color-scheme.download.download"))
enabled: !downloading
Layout.alignment: Qt.AlignVCenter
onClicked: isInstalled ? root.deleteScheme(schemeRow.schemeName) : root.downloadScheme(modelData)
NBusyIndicator {
anchors.centerIn: parent
width: 16 * Style.uiScaleRatio
height: 16 * Style.uiScaleRatio
visible: parent.isDownloading
}
}
}
}
}
}
}
// Empty state
ColumnLayout {
Layout.fillWidth: true
Layout.alignment: Qt.AlignHCenter
spacing: Style.marginM
visible: !fetching && availableSchemes.length === 0 && downloadError === ""
NIcon {
icon: "package"
pointSize: 48
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
NText {
text: I18n.tr("settings.color-scheme.download.empty")
pointSize: Style.fontSizeM
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
}
}
}