Browse Source

desktop config pane

Milan Jurkulak 3 tháng trước cách đây
mục cha
commit
d4f2d0eed4

+ 7 - 0
.editorconfig

@@ -6,3 +6,10 @@ root = true
 # actually used, breaking compilation when ktlintFormat runs. Disable just that rule so
 # auto-format stays safe.
 ktlint_standard_no-unused-imports = disabled
+# One line is max 120 chars (project rule: 80-120).
+max_line_length = 120
+# Keep expression bodies on the signature line — `fun Foo() = preview {` — instead of
+# wrapping after `=` onto the next line. `default` joins the body expression with the
+# signature whenever it fits max_line_length.
+ktlint_standard_function-signature = enabled
+ktlint_function_signature_body_expression_wrapping = default

+ 4 - 0
.gitignore

@@ -66,3 +66,7 @@ composeApp/src/commonMain/resources/keys/*.key
 
 ### iso images (never commit — GitHub rejects git blobs >100 MB; ship as a release asset)
 *.iso
+
+### ideas & fixes
+/ideas/
+/fix-it/

+ 0 - 18
ai-done.txt

@@ -1,18 +0,0 @@
-## 2026-06-13 21:36:25 — Create the AI agent buildSrc plugin
-Implemented in buildSrc/src/main/kotlin/ai/ (AiAgentPlugin, RunAiAgentTask, AiProvider). Providers: ollama, claude-code, mistral. Reads ai-todo.txt; writes ai-done.txt / ai-suggestions.txt; credentials from ai-credentials.txt. Task `runAiAgent` runs the file.
-
-## 2026-06-13 21:36:25 — Ensure folder 'screenshots' exists at project root
-screenshots/ already existed — no change.
-
-## 2026-06-13 21:36:25 — Ensure folder 'screenshots-1' exists at project root
-Created screenshots-1/.
-
-## 2026-06-13 21:36:25 — Attach the AI agent plugin to the project
-Plugin `AiAgentPlugin` applied on the root project; `runAiAgent` task is registered.
-
-## 2026-06-13 21:36:25 — Provider selection policy (cloud-first, ollama fallback)
-Implemented in AiProviders.select(): prefers cloud (claude-code, then mistral); falls back to local ollama and runs `ollama serve` on demand when no cloud credentials are available.
-
-## 2026-06-13 21:36:25 — Autonomy / security policy
-Implemented in SecurityPolicy: all operations auto-approved except git write/create/commit/push and project->internet file transfers, which stay gated for human approval.
-

+ 0 - 62
ai-resource-analysis.md

@@ -1,62 +0,0 @@
-# Analýza minimalizace resource usage (CPU/RAM)
-
-Datum: 2026-06-16. Cíl: **non-invazivní** snížení CPU/RAM bez změny viditelného chování.
-
----
-
-## HOTOVO (kompiluje desktop + android, čeká na běhové ověření)
-- **#0 ADB mirror → Dispatchers.IO** — `AdbScreenMirror`/`DeviceState` blokující adb I/O přesunuto
-  z Compose UI dispatcheru na IO (smyčka i `tapOnDevice`), `refreshInterval` 100→200 ms.
-  Odstraňuje zamrzání desktopu při zobrazeném mirroru.
-- **#1 Bulk pixel write** — nový `imageBitmapFromArgb` (desktop Skia `installPixels`, android
-  `Bitmap.createBitmap`) nahradil per-pixel `Canvas.drawRect` v `GifDecoder.setPixels` a
-  `ImageBitmap.cut`. ~2M draw volání/snímek → jeden bulk zápis. Výstup identický.
-- **#3 Paleta bez mezibitmap** — `Palette.update` čte pixely jednou (`image.pixels`) a vzorkuje
-  4 rohy přímo z `IntArray` přes `ImageUtils.dominantArgbColor` (Int-keyed histogram). Zrušeny
-  4× `cut()` bitmapy + Color-boxing.
-- **#4 Clock polling** — `CustomExt.timeFlow` spí do dalšího celého sekundového okraje místo
-  pollingu á 200 ms (~1 probuzení/s).
-- **#7.1 Shell blokující I/O → IO** — `Shell.init` launch přepnut z `Dispatchers.Default` na `IO`
-  (blokující `ProcessBuilder.waitFor` nehladoví CPU-sized Default pool).
-- **#2 GIF perzistentní disk cache** — `GifDecoder` už nedrží snímky v RAM: každý snímek se streamuje
-  na disk do `/var/tmp/mjdev-desktop/gif-cache/<klíč>/f<n>` (raw ARGB) + `info` manifest (klíč =
-  src+velikost+mtime). Při dalším spuštění se GIF **vůbec nedekóduje** (cache hit). Za běhu se snímky
-  čtou z disku líně přes malou in-memory LRU (`MAX_CACHED_BITMAPS=4`) → max 4 živé bitmapy/GPU textury
-  místo všech. Build snímku běží přes `withContext(Dispatchers.Default)` (mimo UI vlákno, bez janku).
-  `gifCacheBaseDir()` expect/actual (desktop `/var/tmp/...`, android temp).
-
-Pozn.: přímý `java.util.HashMap` v commonMain shazuje K2 actualizer (přes `OsRelease`) — používat
-`mutableMapOf`.
-
----
-
-## ZBÝVÁ K ROZHODNUTÍ
-
-### #7.2 — Scope leaky v default argumentech @Composable
-`CoroutineScope(Dispatchers.Default)` jako default arg (`AppsMenu.kt:264,276`, bázová
-`VisibilityState.kt:22`) → nový nezrušený scope při každé rekompozici. Fix = scope z
-`context.scope`/`rememberCoroutineScope()`/`remember{}`. **Pozor:** dock/sliding je křehký kód
-(leave-zone race, měsíce ladění) — udělat cíleně po domluvě, ne plošně. (`LaunchedEffect.kt:37`
-`CoroutineScope(context).launch(context)` ověřit zrušení.)
-
----
-
-## KE ZVÁŽENÍ (prodiskutovat, zatím neřešit)
-
-### #5 — Mrtvý `colorFilter` na GIF/video (spíš correctness)
-`ImageAny` (`components/image/ImageAny.kt:126-151`) předává `colorFilter`
-(`BackgroundImage` → `ContrastColorFilter(1.5f)`) do `GifView`, ale `GifView`ovo `Image(...)`
-(`components/image/GifView.kt:39-45`) ho ignoruje → filtr je mrtvý (ai-todo #9). Buď propsat
-(1 řádek), nebo parametr zahodit. Neovlivní výkon.
-
-### #6 — Délka Crossfade tapety (možná změna chování)
-`BackgroundImage.kt:36-37` `fadeInDuration = 60000/8 = 7,5 s`, `fadeOutDuration = 15 s`. Po celou
-dobu fade se překresluje fullscreen pozadí (a běží-li GIF, i jeho snímky). Zkrácení by ušetřilo,
-ale je to viditelná změna — jen podnět.
-
----
-
-## Měření (před/po)
-- RAM: `jcmd <pid> GC.heap_info`, RSS přes `ps -o rss`, heap dump (počet `ImageBitmap`/`IntArray`).
-- CPU: async-profiler / VisualVM — podíl `drawRect`/`writePixels`/`setPixels` při běžící GIF tapetě;
-  doba načtení tapety. Vždy stejná GIF + statická tapeta jako kontrola vizuálu.

+ 17 - 95
ai-todo.txt

@@ -8,62 +8,7 @@
 * update dependencies from report in reports folder, on every updated library check
   that builds run and or ommit/revert update if impossible
 
-- ai ignoruje naklady na vyvoj a lidsky cas, snazi se primet vyvojare pracovat neustale a na
-  opakujicich se chybach ktere sama dokola zanasi i opakovane do projektu.
-  nechape ekologicke, financni a casove dusledky sveho konani maleho retardovaneho ditete a neustale
-  se toci vyvojar v kruhu chyb a planu na opravy.
-  z kodu projektu se stava bordel, v gite se masti bordel, protoze preskakuje zadany ukol a
-  neni schopna si opravy sama rozplanovat.
-  predrazuje to vyvoj nici a frustruje vyvojare a poskozuje celkovo sw spolecnost.
-
-- kdyz uzivatel klikne na prvni ikonu v spodnim dock bare t.j. show menu, okno menu se sic pomaly ale
-  zobrazi, chvili se ale nedeje nic a dokonce zamrzne cely desktop, pak se menu zobrazi a po urcite dobe,
-  panel timeout se spodni panel schova a menu zustane zobrazeno, i kdyz to ai bylo 1000x receno ze
-  kdyz je menu zobrazeno a i kdyz si to ai snad 3x memorizovala a summarizovala vysledek je porad rozbita logika baru.
-  nechapu jak donutit ai aby se to neopakovalo dookola az se programator citi jako papagaj anebo obehrata deska s chybou.
-  hodne frustrujici, zivy programator to udela pouze jednou neni kokot co si nepameta co delal pred minutou.
-  publikovat
-
-- ve funkcnim github actions se vybuildovali veskere artefakty dle zadani pro multiplatform aplikaci.
-  po pozadavku pridat do release artefaktu i iso soubor, ai rozbila komplet build action, ne jen ze
-  buildy konci chybama, ale artefakty nejsou publikovany temer zadne a nic co predtim fungovalo spolehlive nefunguje.
-  i na 8nasobne pozadani nebola ai schopna problem opravit a drzost po rozbiti s informaci ze je vse
-  opraveno a i napric tomu ze ji byli udeleny prava na nadstandardni sw k pouziti na opravu odignorovala
-  a vysledek zustal i po hodinach jejich uprav stejne ubohy temer nedotceny.
-  stav projektu spatny vubec se negeneruji artefakty tudiz dalsi vyvoj nema smysl, kdyz pak
-  nefunguje delivery a externi uzivatel nema zadnou moznost ani sw otestovat.
-  i kdyz ai byla pozadana primo vyvojarem o napravu ona tento stav projektu absolutne ignoruje.
-  v realu by tenhle stav zpusobil existencionalni poskozeni sw spolecnosti.
-  vysledek uprav fungujiciho systemu -> nefunkcni nepublikovatelny a nepublikovany system. publikovat.
-
-- ai byla pozadana, aby vzdy otestovala kazdou zmenu co udela po kazdym kroku, ktery ma vepsany
-  v textovem souboru.
-  ignore, vzdy zada a neustale zada vyvojare at si casti aplikace, ktere upravuje otestuje sam, a to
-  i v pripade ze vyvojar vytvori inteligentni automatizovany system, ktery umoznuje ai absolutne
-  vyvijet a testovat aplikaci bez intervence vyvojare. pouzije testovaci rozhrani jednou pri vyvoji
-  testovaciho rozhrani a pak ho i kdyz vyvojar zada o testy ignoruje.
-  nespusti ho pokud ji to neni napsane jasne a s pohorsenim, a to je otazne jestli vubec.
-  v pripade tohoto projektu bylo dokonce vytvorene vizualni rozhrani kde ne jen ai ma schopnost
-  videt vysledny sw nazivo, ale i uzivatel a vyvojar. publikovat.
-
-- kdyz dam runDesktop, tak se mi spodni dockbar temer nikdy nezobrazi, ma to byt compositor independent,
-  kompozitor ma funkcionalitu desktopu pouze rozsirovat ne prznit. pokud to mas zavesene na nej.
-
-- to same control center, ne jen ze je temer unresponsive, ale zobrazi se odepnuty od praveho okraje
-  ve stredu obrazovky co se tyce x souradnice, je to komplet rozmrdano na sracky.
-  ai to domrdala do nepouzitelneho stavu. konkretne claude code sonet 4.8. bude publikovano.
-  i jak si poradi s napravou.
-
-- dock windows nejsou pouha compose componenta ale awt widow zamerne, aby reagovali i kduz je
-  vsechno prekryto jinymi aplikacemi. po zmenach ai muzeme na tohle zapomenout. publikovat.
-  ai absolutne i po neustalem asi 32 nasobnem popisu problemu nedokaze pochopit jednoduchou vec
-  a to pouzitelny desktop pro uzivatele. klidne porusi kontext aplikace a rozbije logiku bez vahani a
-  hlubsiho zamysleni nad svymi upravami. nezajem ze uzivatel je clovek. nezajem ze to fungovalo, i kuli
-  zmene barvy dokaze rozlozit komplexni krehkou strukturu vyvijenou klidne leta na nepouzitelny bordel, na prach.
-  zameri se jen na zmenu barvy a znici vse kolem.
-  publikovat.
-
-- ktlint rume bude pokud to jde jeden radek max 80-120 znaku
+- 1 - ktlint rule bude pokud to jde jeden radek max 80-120 znaku
   a zarovnavat za = takhle:
   -------------------------------------------
   fun PreviewAppBarVerticalFill() = preview {
@@ -74,55 +19,32 @@
       preview {
   -------------------------------------------
 
-- build cache hapruje, neustale se pri buildu stahuju uz stahnute knihovny, to je antipattern gradle.
-
-- buildAll konci : Build Fail
+- 2 - build cache hapruje, neustale se pri buildu stahuju uz stahnute knihovny, to je antipattern gradle.
+    mozna opraveno skontroluj.
 
-- chyba asi gradle tasku nejspis: Could not get lock /var/lib/dpkg/lock-frontend. It is held by process 186117 (apt-get)
+- 3 - buildAll konci : Build Fail, skontrolovat
 
-- tooltip vyhodit stavajici logiku a tooltip bude samostatne auto hide (fadeout and delay) chromewindow.
+- 4 -  chyba asi gradle tasku nejspis: Could not get lock /var/lib/dpkg/lock-frontend. It is held by process 186117 (apt-get),
+  skontrolovat generovani iso a vse kde se pozuivaji commandy linuxu
 
-- github action produce only one file, exe for windows, read only gh allowed in this step.
+- 5 - tooltip v desktopu vyhodit stavajici logiku a tooltip bude samostatne auto hide (fadeout and delay) chromewindow.
 
-- iso should be also included in release on github (limit is 2 GB of iso i hear)
+- 6 -  when control center is shown and then user is going to a dock bottom bar, dock bottom bar
+  is below control center, focus jvm issue. fix it.
 
-- when control center is shown and then user is going to a dock bottom bar, dock bottom bar
-  is below control center, focus jvm issue.
-
-- bottom bar is show in center of desktop, this should never happen.
+- 7 - bottom bar is show in center of desktop, this should never happen.
   your fixes little bit improve stability but this shows thats not enough.
 
-- memory widget have time to time unstable color, diferrent on another in some cases.
+- 8 - memory widget have time to time unstable color, diferrent on another in some cases.
 
-- stabilize mouse focuses and events, this is probably done
+- 9 - stabilize mouse focuses and events, this is probably done. check it by runDesktopNested
+   and by simulating mouse moves and clicks and key buttons, there is already built interface in composer i think
 
-- handles that are on sliding windows 12dp please, there is 9dp i think yet.
+- 10 - handles that are on sliding windows change to 12dp please, there is 9dp i think yet.
 
-- in log, when building, there is a lot of warnings and errors, i want to have
+- 11 - in log, when building, there is a lot of warnings and errors, i want to have
   clean log  if possible.
 
-- control center, when shown should hide menu and bottom bar if it is visible.
-
-# --- from code review 2026-06-16 (background/palette/memory task) ---
-- RESOURCE MINIMIZATION (top priority): minimize computer resources of the desktop, any way.
-  main hog = GIF playback: custom GifDecoder decodes ALL frames upfront into ImageBitmaps and
-  ImageBitmapExt.writePixels writes them pixel-by-pixel via Canvas.drawRect (O(w*h) draw calls per
-  frame) + every frame kept in RAM. ideas to evaluate:
-  (a) render gifs via coil3 animated decoder (coil now HAS a gif decoder and already decodes gifs
-      for the palette) - only if visual behavior stays exactly as now;
-  (b) on-disk gif cache: pre-extract a gif into per-frame image files on disk (disk is cheap) and
-      stream frames from disk instead of holding all frames in RAM.
-  open to other ideas. measure CPU/RAM before+after.
-- palette samples ONE frame of an animated gif at switch time (a white flash frame -> washed-out
-  palette). sample a representative frame or average several. (review #3, try next)
-- palette iconsTintColor currently resolves equal to textColor; they SHOULD differ (accent vs
-  text). this is a todo hack. (review #4)
-- MimeTypeExt.Path.isVideo returns absolutePath.isGif (copy-paste); should be .isVideo. (review #6)
-- BackgroundImage rotation relies on LaunchedEffect(imagesSize) keyed on a non-observable
-  images.size and works only via the chained LaunchedEffect(currentBackground). make it robust
-  WITHOUT changing the current behavior (beware the desktopBackgrounds stateList landmine). (review #7)
-- ImageAny ignores colorFilter on the gif/video/async paths, so BackgroundImage's
-  ContrastColorFilter(1.5f) is dead there; apply it or drop the param. (review #9)
+- 12 - control center, when shown should hide menu and bottom bar if it is visible.
 
-todo later:
-- remote desktop or a connection aka VNC (any standardized protocol) to desktop by ip address, aka X11
+- 13 - remote desktop or a connection aka VNC (any standardized protocol) to desktop by ip address, aka X11

+ 1 - 1
shared/src/commonMain/kotlin/org/mjdev/desktop/components/background/BackgroundImage.kt

@@ -36,7 +36,7 @@ fun BackgroundImage(
     fadeInDuration: Long = 60000L / 8,
     fadeOutDuration: Long = 60000L / 4,
     backgroundColor: Color = Color.SuperDarkGray,
-    images: MutableList<Any> = mutableListOf(),
+    images: List<Any> = listOf(),
     onError: (Throwable) -> Unit = { e -> Log.e(e) },
     onChange: (bck: Any?) -> Unit = {},
 ) = withDesktopContext {

+ 237 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/pages/desktop/DesktopSettingsPage.kt

@@ -0,0 +1,237 @@
+package org.mjdev.desktop.components.controlcenter.pages.desktop
+
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import org.jetbrains.compose.ui.tooling.preview.Preview
+import org.mjdev.desktop.components.controlcenter.base.ControlCenterPage
+import org.mjdev.desktop.components.controlcenter.settings.SettingsSection
+import org.mjdev.desktop.components.controlcenter.settings.SettingsSelectRow
+import org.mjdev.desktop.components.controlcenter.settings.SettingsSliderRow
+import org.mjdev.desktop.components.controlcenter.settings.SettingsSwitchRow
+import org.mjdev.desktop.components.text.TextAny
+import org.mjdev.desktop.context.IDesktopContext
+import org.mjdev.desktop.data.ControlCenterLocation
+import org.mjdev.desktop.data.DesktopConfigStore
+import org.mjdev.desktop.data.PanelLocation
+import org.mjdev.desktop.extensions.Colors.alpha
+import org.mjdev.desktop.extensions.Compose.preview
+import org.mjdev.desktop.icons.settings.SettingsMonitor
+
+/**
+ * Control center page that configures the whole desktop. It loads the persisted
+ * [org.mjdev.desktop.data.DesktopConfigData] from `~/.mjdev/desktop/config.json`, exposes every
+ * tunable (background, panel, control center, app menu) and the background providers, and on
+ * save writes the JSON back, applies it to the live theme and rebuilds the background sources.
+ */
+@Suppress("FunctionName")
+fun DesktopSettingsPage(context: IDesktopContext) =
+    ControlCenterPage(
+        context = context,
+        icon = SettingsMonitor,
+        name = "Desktop",
+    ) {
+        val store = remember { DesktopConfigStore(currentUser) }
+        var data by remember { mutableStateOf(store.load()) }
+
+        Box(
+            modifier = Modifier.fillMaxSize(),
+        ) {
+            Column(
+                modifier =
+                    Modifier
+                        .fillMaxSize()
+                        .verticalScroll(rememberScrollState())
+                        .padding(16.dp)
+                        .padding(bottom = 56.dp),
+            ) {
+                SettingsSection(title = "Background") {
+                    SettingsSliderRow(
+                        label = "Rotation delay",
+                        value = data.backgroundRotationDelay.toFloat(),
+                        valueRange = 5_000f..600_000f,
+                        format = { "${(it / 1000f).toInt()}s" },
+                        onValueChange = { data = data.copy(backgroundRotationDelay = it.toLong()) },
+                    )
+                    data.providers.forEach { provider ->
+                        SettingsSwitchRow(
+                            label = provider.label,
+                            checked = provider.enabled,
+                            onCheckedChange = { enabled ->
+                                data =
+                                    data.copy(
+                                        providers =
+                                            data.providers
+                                                .map { p -> if (p.id == provider.id) p.copy(enabled = enabled) else p }
+                                                .toMutableList(),
+                                    )
+                            },
+                        )
+                        if (provider.enabled && provider.hasLoadCount) {
+                            SettingsSliderRow(
+                                label = "  ${provider.label} images",
+                                value = provider.loadCount.toFloat(),
+                                valueRange = 1f..50f,
+                                steps = 48,
+                                onValueChange = { count ->
+                                    data =
+                                        data.copy(
+                                            providers =
+                                                data.providers
+                                                    .map { p -> if (p.id == provider.id) p.copy(loadCount = count.toInt()) else p }
+                                                    .toMutableList(),
+                                        )
+                                },
+                            )
+                        }
+                    }
+                }
+
+                SettingsSection(title = "Panel") {
+                    SettingsSelectRow(
+                        label = "Location",
+                        selected = data.panelLocation,
+                        options = PanelLocation.entries.map { it.name },
+                        onSelected = { data = data.copy(panelLocation = it) },
+                    )
+                    SettingsSliderRow(
+                        label = "Auto-hide delay",
+                        value = data.panelHideDelay.toFloat(),
+                        valueRange = 0f..10_000f,
+                        format = { if (it <= 0f) "off" else "${(it / 1000f).toInt()}s" },
+                        onValueChange = { data = data.copy(panelHideDelay = it.toLong()) },
+                    )
+                    SettingsSliderRow(
+                        label = "Divider width",
+                        value = data.panelDividerWidth,
+                        valueRange = 0f..32f,
+                        format = { "${it.toInt()}dp" },
+                        onValueChange = { data = data.copy(panelDividerWidth = it) },
+                    )
+                    SettingsSliderRow(
+                        label = "Content padding",
+                        value = data.panelContentPadding,
+                        valueRange = 0f..32f,
+                        format = { "${it.toInt()}dp" },
+                        onValueChange = { data = data.copy(panelContentPadding = it) },
+                    )
+                }
+
+                SettingsSection(title = "Control center") {
+                    SettingsSelectRow(
+                        label = "Location",
+                        selected = data.controlCenterLocation,
+                        options = ControlCenterLocation.entries.map { it.name },
+                        onSelected = { data = data.copy(controlCenterLocation = it) },
+                    )
+                    SettingsSliderRow(
+                        label = "Auto-hide delay",
+                        value = data.controlPanelHideDelay.toFloat(),
+                        valueRange = 0f..10_000f,
+                        format = { if (it <= 0f) "off" else "${(it / 1000f).toInt()}s" },
+                        onValueChange = { data = data.copy(controlPanelHideDelay = it.toLong()) },
+                    )
+                    SettingsSliderRow(
+                        label = "Expanded width",
+                        value = data.controlCenterExpandedWidthPercent.toFloat(),
+                        valueRange = 10f..80f,
+                        steps = 69,
+                        format = { "${it.toInt()}%" },
+                        onValueChange = { data = data.copy(controlCenterExpandedWidthPercent = it.toInt()) },
+                    )
+                    SettingsSliderRow(
+                        label = "Divider width",
+                        value = data.controlCenterDividerWidth,
+                        valueRange = 0f..32f,
+                        format = { "${it.toInt()}dp" },
+                        onValueChange = { data = data.copy(controlCenterDividerWidth = it) },
+                    )
+                    SettingsSliderRow(
+                        label = "Icon size",
+                        value = data.controlCenterIconSize,
+                        valueRange = 16f..64f,
+                        format = { "${it.toInt()}dp" },
+                        onValueChange = { data = data.copy(controlCenterIconSize = it) },
+                    )
+                    SettingsSliderRow(
+                        label = "Background opacity",
+                        value = data.controlCenterBackgroundAlpha,
+                        valueRange = 0f..1f,
+                        format = { "${(it * 100).toInt()}%" },
+                        onValueChange = { data = data.copy(controlCenterBackgroundAlpha = it) },
+                    )
+                }
+
+                SettingsSection(title = "App menu") {
+                    SettingsSliderRow(
+                        label = "Min width",
+                        value = data.appMenuMinWidthRatio,
+                        valueRange = 0.1f..1f,
+                        format = { "${(it * 100).toInt()}%" },
+                        onValueChange = { data = data.copy(appMenuMinWidthRatio = it) },
+                    )
+                    SettingsSliderRow(
+                        label = "Min height",
+                        value = data.appMenuMinHeightRatio,
+                        valueRange = 0.1f..1f,
+                        format = { "${(it * 100).toInt()}%" },
+                        onValueChange = { data = data.copy(appMenuMinHeightRatio = it) },
+                    )
+                    SettingsSliderRow(
+                        label = "Outer padding",
+                        value = data.appMenuOuterPadding,
+                        valueRange = 0f..32f,
+                        format = { "${it.toInt()}dp" },
+                        onValueChange = { data = data.copy(appMenuOuterPadding = it) },
+                    )
+                }
+            }
+
+            Box(
+                modifier =
+                    Modifier
+                        .align(Alignment.BottomCenter)
+                        .fillMaxWidth()
+                        .padding(16.dp)
+                        .border(1.dp, borderColor.alpha(0.6f), RoundedCornerShape(8.dp))
+                        .clickable {
+                            store.save(data)
+                            data.applyTo(currentUser.theme)
+                            runAsync {
+                                currentUser.config.reloadBackgrounds(data.buildProviders(currentUser))
+                            }
+                        }.padding(vertical = 12.dp),
+                contentAlignment = Alignment.Center,
+            ) {
+                TextAny(
+                    text = "Save & apply",
+                    color = textColor,
+                    fontSize = 16.sp,
+                )
+            }
+        }
+    }
+
+@Preview
+@Composable
+fun PreviewDesktopSettingsPage() =
+    preview {
+        DesktopSettingsPage(context).Render()
+    }

+ 55 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/settings/SettingsSection.kt

@@ -0,0 +1,55 @@
+package org.mjdev.desktop.components.controlcenter.settings
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import org.jetbrains.compose.ui.tooling.preview.Preview
+import org.mjdev.desktop.components.text.TextAny
+import org.mjdev.desktop.context.DesktopContextScope.Companion.withDesktopContext
+import org.mjdev.desktop.extensions.Colors.alpha
+import org.mjdev.desktop.extensions.Compose.preview
+
+/** A titled, rounded container that groups related desktop settings rows. */
+@Suppress("FunctionName")
+@Composable
+fun SettingsSection(
+    title: String,
+    modifier: Modifier = Modifier,
+    content: @Composable () -> Unit,
+) = withDesktopContext {
+    Column(
+        modifier =
+            modifier
+                .fillMaxWidth()
+                .padding(vertical = 8.dp)
+                .clip(RoundedCornerShape(12.dp))
+                .background(backgroundColor.alpha(0.3f))
+                .padding(12.dp),
+    ) {
+        TextAny(
+            modifier = Modifier.padding(bottom = 8.dp),
+            text = title,
+            color = textColor,
+            fontSize = 16.sp,
+            fontWeight = FontWeight.Bold,
+        )
+        content()
+    }
+}
+
+@Preview
+@Composable
+fun PreviewSettingsSection() =
+    preview {
+        SettingsSection(title = "Background") {
+            TextAny(text = "content")
+        }
+    }

+ 108 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/settings/SettingsSelectRow.kt

@@ -0,0 +1,108 @@
+package org.mjdev.desktop.components.controlcenter.settings
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import org.jetbrains.compose.ui.tooling.preview.Preview
+import org.mjdev.desktop.components.text.TextAny
+import org.mjdev.desktop.context.DesktopContextScope.Companion.withDesktopContext
+import org.mjdev.desktop.extensions.Colors.alpha
+import org.mjdev.desktop.extensions.Compose.preview
+
+/** A labelled single-choice setting that opens a dropdown of [options]. */
+@Suppress("FunctionName")
+@Composable
+fun SettingsSelectRow(
+    label: String,
+    selected: String,
+    options: List<String>,
+    modifier: Modifier = Modifier,
+    onSelected: (String) -> Unit,
+) = withDesktopContext {
+    var expanded by remember { mutableStateOf(false) }
+    Row(
+        modifier =
+            modifier
+                .fillMaxWidth()
+                .padding(vertical = 6.dp),
+        verticalAlignment = Alignment.CenterVertically,
+        horizontalArrangement = Arrangement.SpaceBetween,
+    ) {
+        TextAny(
+            text = label,
+            color = textColor,
+            fontSize = 14.sp,
+        )
+        Box {
+            Box(
+                modifier =
+                    Modifier
+                        .clip(RoundedCornerShape(8.dp))
+                        .background(backgroundColor)
+                        .border(1.dp, borderColor.alpha(0.5f), RoundedCornerShape(8.dp))
+                        .clickable { expanded = true }
+                        .padding(horizontal = 12.dp, vertical = 6.dp),
+            ) {
+                TextAny(
+                    text = selected,
+                    color = textColor,
+                    fontSize = 14.sp,
+                )
+            }
+            DropdownMenu(
+                expanded = expanded,
+                onDismissRequest = { expanded = false },
+                shape = RoundedCornerShape(8.dp),
+                containerColor = backgroundColor,
+                border = BorderStroke(1.dp, borderColor.alpha(0.5f)),
+            ) {
+                options.forEach { option ->
+                    DropdownMenuItem(
+                        text = {
+                            TextAny(
+                                text = option,
+                                color = textColor,
+                                fontSize = 14.sp,
+                            )
+                        },
+                        onClick = {
+                            expanded = false
+                            onSelected(option)
+                        },
+                    )
+                }
+            }
+        }
+    }
+}
+
+@Preview
+@Composable
+fun PreviewSettingsSelectRow() =
+    preview {
+        SettingsSelectRow(
+            label = "Panel location",
+            selected = "Bottom",
+            options = listOf("Bottom", "Top", "Left", "Right"),
+            onSelected = {},
+        )
+    }

+ 85 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/settings/SettingsSliderRow.kt

@@ -0,0 +1,85 @@
+package org.mjdev.desktop.components.controlcenter.settings
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Slider
+import androidx.compose.material3.SliderDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import org.jetbrains.compose.ui.tooling.preview.Preview
+import org.mjdev.desktop.components.text.TextAny
+import org.mjdev.desktop.context.DesktopContextScope.Companion.withDesktopContext
+import org.mjdev.desktop.extensions.Colors.alpha
+import org.mjdev.desktop.extensions.Compose.preview
+
+/**
+ * A labelled numeric setting backed by a slider. [valueRange] bounds the value and [steps]
+ * controls discrete stops (0 = continuous). [format] renders the current value next to the label.
+ */
+@Suppress("FunctionName")
+@Composable
+fun SettingsSliderRow(
+    label: String,
+    value: Float,
+    valueRange: ClosedFloatingPointRange<Float>,
+    modifier: Modifier = Modifier,
+    steps: Int = 0,
+    enabled: Boolean = true,
+    format: (Float) -> String = { it.toInt().toString() },
+    onValueChange: (Float) -> Unit,
+) = withDesktopContext {
+    Column(
+        modifier =
+            modifier
+                .fillMaxWidth()
+                .padding(vertical = 6.dp),
+    ) {
+        Row(
+            modifier = Modifier.fillMaxWidth(),
+            verticalAlignment = Alignment.CenterVertically,
+            horizontalArrangement = Arrangement.SpaceBetween,
+        ) {
+            TextAny(
+                text = label,
+                color = if (enabled) textColor else textColor.alpha(0.4f),
+                fontSize = 14.sp,
+            )
+            TextAny(
+                text = format(value),
+                color = textColor.alpha(0.7f),
+                fontSize = 14.sp,
+            )
+        }
+        Slider(
+            value = value,
+            onValueChange = onValueChange,
+            valueRange = valueRange,
+            steps = steps,
+            enabled = enabled,
+            colors =
+                SliderDefaults.colors(
+                    thumbColor = textColor,
+                    activeTrackColor = textColor.alpha(0.7f),
+                    inactiveTrackColor = backgroundColor.alpha(0.6f),
+                ),
+        )
+    }
+}
+
+@Preview
+@Composable
+fun PreviewSettingsSliderRow() =
+    preview {
+        SettingsSliderRow(
+            label = "Rotation delay",
+            value = 60f,
+            valueRange = 5f..600f,
+            onValueChange = {},
+        )
+    }

+ 67 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/settings/SettingsSwitchRow.kt

@@ -0,0 +1,67 @@
+package org.mjdev.desktop.components.controlcenter.settings
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Switch
+import androidx.compose.material3.SwitchDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import org.jetbrains.compose.ui.tooling.preview.Preview
+import org.mjdev.desktop.components.text.TextAny
+import org.mjdev.desktop.context.DesktopContextScope.Companion.withDesktopContext
+import org.mjdev.desktop.extensions.Colors.alpha
+import org.mjdev.desktop.extensions.Compose.preview
+
+/** A labelled on/off setting backed by a Material switch. */
+@Suppress("FunctionName")
+@Composable
+fun SettingsSwitchRow(
+    label: String,
+    checked: Boolean,
+    modifier: Modifier = Modifier,
+    enabled: Boolean = true,
+    onCheckedChange: (Boolean) -> Unit,
+) = withDesktopContext {
+    Row(
+        modifier =
+            modifier
+                .fillMaxWidth()
+                .padding(vertical = 6.dp),
+        verticalAlignment = Alignment.CenterVertically,
+        horizontalArrangement = Arrangement.SpaceBetween,
+    ) {
+        TextAny(
+            text = label,
+            color = if (enabled) textColor else textColor.alpha(0.4f),
+            fontSize = 14.sp,
+        )
+        Switch(
+            checked = checked,
+            enabled = enabled,
+            onCheckedChange = onCheckedChange,
+            colors =
+                SwitchDefaults.colors(
+                    checkedThumbColor = textColor,
+                    checkedTrackColor = textColor.alpha(0.4f),
+                    uncheckedThumbColor = textColor.alpha(0.6f),
+                    uncheckedTrackColor = backgroundColor.alpha(0.6f),
+                ),
+        )
+    }
+}
+
+@Preview
+@Composable
+fun PreviewSettingsSwitchRow() =
+    preview {
+        SettingsSwitchRow(
+            label = "Local folder",
+            checked = true,
+            onCheckedChange = {},
+        )
+    }

+ 2 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/context/IDesktopContext.kt

@@ -10,6 +10,7 @@ import kotlinx.coroutines.launch
 import org.mjdev.desktop.components.controlcenter.pages.about.AboutPage
 import org.mjdev.desktop.components.controlcenter.pages.ai.AIPage
 import org.mjdev.desktop.components.controlcenter.pages.bluetooth.BluetoothSettingsPage
+import org.mjdev.desktop.components.controlcenter.pages.desktop.DesktopSettingsPage
 import org.mjdev.desktop.components.controlcenter.pages.devices.DevicesPage
 import org.mjdev.desktop.components.controlcenter.pages.display.DisplaySettingsPage
 import org.mjdev.desktop.components.controlcenter.pages.ethernet.EthSettingsPage
@@ -54,6 +55,7 @@ abstract class IDesktopContext : IDisposable {
             SoundSettingsPage(this),
             BluetoothSettingsPage(this),
             DisplaySettingsPage(this),
+            DesktopSettingsPage(this),
             DevicesPage(this),
             RemotesSettingsPage(this),
             ThemeSettingsPage(this),

+ 16 - 12
shared/src/commonMain/kotlin/org/mjdev/desktop/data/DesktopConfig.kt

@@ -7,9 +7,6 @@ import kotlinx.coroutines.launch
 import org.mjdev.desktop.helpers.compose.ImagesProvider
 import org.mjdev.desktop.interfaces.ITheme
 import org.mjdev.desktop.interfaces.IUser
-import org.mjdev.desktop.providers.background.ProviderErzvo
-import org.mjdev.desktop.providers.background.ProviderLocal
-import org.mjdev.desktop.providers.background.ProviderSmug
 
 @Suppress("MemberVisibilityCanBePrivate", "unused")
 class DesktopConfig(
@@ -43,26 +40,33 @@ class DesktopConfig(
             }
         }
 
-    private fun addBackground(provider: ImagesProvider) =
+    fun addBackground(provider: ImagesProvider) =
         addBackground {
             provider.get()
         }
 
+    /** Clears the current wallpapers and re-populates them from the given [providers]. */
+    fun reloadBackgrounds(providers: List<ImagesProvider>) {
+        desktopBackgrounds.clear()
+        providers.forEach { provider -> addBackground(provider) }
+    }
+
     companion object {
         val configCache = mutableMapOf<String, DesktopConfig>()
 
-        private val DEFAULT =
-            DesktopConfig {
-//            addBackground(ProviderSmug())
-//            addBackground(ProviderErzvo())
-            }
+        private val DEFAULT = DesktopConfig {}
 
-        // todo load from user settings
+        // Loads the per-user desktop config from ~/.mjdev/desktop/config.json (defaults on first
+        // run), applies the persisted tunables to the theme and populates the enabled background
+        // providers.
         fun load(user: IUser): DesktopConfig =
             configCache[user.userName] ?: DEFAULT.apply {
-                theme = ITheme.load(user)
+                val themeForUser = ITheme.load(user)
+                val configData = DesktopConfigStore(user).load()
+                configData.applyTo(themeForUser)
+                theme = themeForUser
                 configCache[user.userName] = this
-                addBackground(ProviderLocal(user))
+                reloadBackgrounds(configData.buildProviders(user))
             }
     }
 }

+ 101 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/data/DesktopConfigData.kt

@@ -0,0 +1,101 @@
+package org.mjdev.desktop.data
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import org.mjdev.desktop.helpers.compose.ImagesProvider
+import org.mjdev.desktop.interfaces.ITheme
+import org.mjdev.desktop.interfaces.IUser
+import kotlin.math.roundToInt
+
+/**
+ * Serializable snapshot of the whole desktop configuration — every tunable that the desktop
+ * shell exposes ([ITheme]) plus the background [ProviderConfig] list. Persisted as JSON via
+ * [DesktopConfigStore] at `~/.mjdev/desktop/config.json` and bridged to the live [ITheme]
+ * with [applyTo] / [fromTheme].
+ *
+ * Colors are stored as packed ARGB ints and Dp values as their raw float so the JSON stays
+ * plain and portable.
+ */
+@Suppress("MemberVisibilityCanBePrivate", "unused")
+data class DesktopConfigData(
+    // background
+    var backgroundColorArgb: Int = Color(0xFF202020).toArgbInt(),
+    var backgroundRotationDelay: Long = 60_000L,
+    // panel
+    var panelLocation: String = PanelLocation.Bottom.name,
+    var panelDividerWidth: Float = 16f,
+    var panelContentPadding: Float = 4f,
+    var panelHideDelay: Long = 2_000L,
+    // control center
+    var controlCenterLocation: String = ControlCenterLocation.Right.name,
+    var controlPanelHideDelay: Long = 2_000L,
+    var controlCenterExpandedWidthPercent: Int = 25,
+    var controlCenterDividerWidth: Float = 4f,
+    var controlCenterIconSize: Float = 32f,
+    var controlCenterBackgroundAlpha: Float = 0.6f,
+    // app menu
+    var appMenuMinWidthRatio: Float = 0.3f,
+    var appMenuMinHeightRatio: Float = 0.8f,
+    var appMenuOuterPadding: Float = 2f,
+    // background sources
+    var providers: MutableList<ProviderConfig> = ProviderConfig.defaults(),
+) {
+    /** Writes every value back onto the live [theme] so the running desktop picks them up. */
+    fun applyTo(theme: ITheme) {
+        theme.backgroundColor = Color(backgroundColorArgb)
+        theme.backgroundRotationDelay = backgroundRotationDelay
+        theme.panelLocation = panelLocation.toEnum(PanelLocation.Bottom)
+        theme.panelDividerWidth = panelDividerWidth.dp
+        theme.panelContentPadding = panelContentPadding.dp
+        theme.panelHideDelay = panelHideDelay
+        theme.controlCenterLocation = controlCenterLocation.toEnum(ControlCenterLocation.Right)
+        theme.controlPanelHideDelay = controlPanelHideDelay
+        theme.controlCenterExpandedWidthPercent = controlCenterExpandedWidthPercent
+        theme.controlCenterDividerWidth = controlCenterDividerWidth.dp
+        theme.controlCenterIconSize = DpSize(controlCenterIconSize.dp, controlCenterIconSize.dp)
+        theme.controlCenterBackgroundAlpha = controlCenterBackgroundAlpha
+        theme.appMenuMinWidthRatio = appMenuMinWidthRatio
+        theme.appMenuMinHeightRatio = appMenuMinHeightRatio
+        theme.appMenuOuterPadding = appMenuOuterPadding.dp
+    }
+
+    /** Builds the enabled background providers for this config. */
+    fun buildProviders(user: IUser): List<ImagesProvider> = providers.mapNotNull { it.build(user) }
+
+    private inline fun <reified T : Enum<T>> String.toEnum(default: T): T = enumValues<T>().firstOrNull { it.name == this } ?: default
+
+    companion object {
+        /** Snapshots the current live [theme] into a persistable config, keeping [providers]. */
+        fun fromTheme(
+            theme: ITheme,
+            providers: MutableList<ProviderConfig> = ProviderConfig.defaults(),
+        ): DesktopConfigData =
+            DesktopConfigData(
+                backgroundColorArgb = theme.backgroundColor.toArgbInt(),
+                backgroundRotationDelay = theme.backgroundRotationDelay,
+                panelLocation = theme.panelLocation.name,
+                panelDividerWidth = theme.panelDividerWidth.value,
+                panelContentPadding = theme.panelContentPadding.value,
+                panelHideDelay = theme.panelHideDelay,
+                controlCenterLocation = theme.controlCenterLocation.name,
+                controlPanelHideDelay = theme.controlPanelHideDelay,
+                controlCenterExpandedWidthPercent = theme.controlCenterExpandedWidthPercent,
+                controlCenterDividerWidth = theme.controlCenterDividerWidth.value,
+                controlCenterIconSize = theme.controlCenterIconSize.width.value,
+                controlCenterBackgroundAlpha = theme.controlCenterBackgroundAlpha,
+                appMenuMinWidthRatio = theme.appMenuMinWidthRatio,
+                appMenuMinHeightRatio = theme.appMenuMinHeightRatio,
+                appMenuOuterPadding = theme.appMenuOuterPadding.value,
+                providers = providers,
+            )
+
+        private fun Color.toArgbInt(): Int {
+            val a = (alpha * 255f).roundToInt() and 0xFF
+            val r = (red * 255f).roundToInt() and 0xFF
+            val g = (green * 255f).roundToInt() and 0xFF
+            val b = (blue * 255f).roundToInt() and 0xFF
+            return (a shl 24) or (r shl 16) or (g shl 8) or b
+        }
+    }
+}

+ 53 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/data/DesktopConfigStore.kt

@@ -0,0 +1,53 @@
+package org.mjdev.desktop.data
+
+import okio.Path
+import org.mjdev.desktop.extensions.PathExt.exists
+import org.mjdev.desktop.extensions.PathExt.mkdirs
+import org.mjdev.desktop.extensions.PathExt.parentFile
+import org.mjdev.desktop.extensions.PathExt.text
+import org.mjdev.desktop.extensions.PathExt.writeText
+import org.mjdev.desktop.helpers.generic.JsonHelper.fromJson
+import org.mjdev.desktop.helpers.generic.JsonHelper.toJson
+import org.mjdev.desktop.interfaces.IUser
+import org.mjdev.desktop.log.Log
+
+/**
+ * Reads and writes the whole [DesktopConfigData] as JSON at `~/.mjdev/desktop/config.json`.
+ * A missing or corrupt file falls back to defaults, so the desktop always has a valid config.
+ */
+@Suppress("MemberVisibilityCanBePrivate", "unused")
+class DesktopConfigStore(
+    private val user: IUser,
+) {
+    val configFile: Path =
+        user.homeDir
+            .resolve(CONFIG_DIR_NAME)
+            .resolve(CONFIG_SUBDIR_NAME)
+            .resolve(CONFIG_FILE_NAME)
+
+    fun load(): DesktopConfigData =
+        runCatching {
+            if (configFile.exists) {
+                fromJson<DesktopConfigData>(configFile.text)
+            } else {
+                null
+            }
+        }.onFailure { e ->
+            Log.e(e)
+        }.getOrNull() ?: DesktopConfigData()
+
+    fun save(data: DesktopConfigData) =
+        runCatching {
+            configFile.parentFile.mkdirs()
+            configFile.writeText(data.toJson())
+            Log.d("Desktop config saved to $configFile")
+        }.onFailure { e ->
+            Log.e(e)
+        }.let {}
+
+    companion object {
+        const val CONFIG_DIR_NAME = ".mjdev"
+        const val CONFIG_SUBDIR_NAME = "desktop"
+        const val CONFIG_FILE_NAME = "config.json"
+    }
+}

+ 67 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/data/ProviderConfig.kt

@@ -0,0 +1,67 @@
+package org.mjdev.desktop.data
+
+import org.mjdev.desktop.helpers.compose.ImagesProvider
+import org.mjdev.desktop.interfaces.IUser
+import org.mjdev.desktop.providers.background.ProviderErzvo
+import org.mjdev.desktop.providers.background.ProviderLocal
+import org.mjdev.desktop.providers.background.ProviderSmug
+
+/**
+ * Persisted configuration of a single background [ImagesProvider]. Stored in the desktop
+ * config JSON so the user can enable/disable and tune each provider from the control center.
+ */
+@Suppress("MemberVisibilityCanBePrivate", "unused")
+data class ProviderConfig(
+    val id: String,
+    var enabled: Boolean = false,
+    var loadCount: Int = DEFAULT_LOAD_COUNT,
+) {
+    /** Human-readable label for the provider, resolved from its [id]. */
+    val label: String
+        get() = Provider.byId(id)?.label ?: id
+
+    /** Whether this provider exposes a tunable load-count (remote providers do, local does not). */
+    val hasLoadCount: Boolean
+        get() = Provider.byId(id)?.hasLoadCount ?: false
+
+    /** Builds the live [ImagesProvider] for this config, or null when disabled / unknown. */
+    fun build(user: IUser): ImagesProvider? {
+        if (!enabled) return null
+        return when (Provider.byId(id)) {
+            Provider.Local -> ProviderLocal(user)
+            Provider.Smug -> ProviderSmug(loadCount)
+            Provider.Erzvo -> ProviderErzvo(loadCount)
+            null -> null
+        }
+    }
+
+    /** Registry of the background providers the desktop knows how to construct. */
+    enum class Provider(
+        val id: String,
+        val label: String,
+        val hasLoadCount: Boolean,
+    ) {
+        Local("local", "Local folder", false),
+        Smug("smug", "WallpaperSmug", true),
+        Erzvo("erzvo", "Erzvo", true),
+        ;
+
+        companion object {
+            fun byId(id: String): Provider? = entries.firstOrNull { it.id == id }
+        }
+    }
+
+    companion object {
+        const val DEFAULT_LOAD_COUNT = 10
+
+        /** The default provider set: local backgrounds on, remote providers off. */
+        fun defaults(): MutableList<ProviderConfig> =
+            Provider.entries
+                .map { provider ->
+                    ProviderConfig(
+                        id = provider.id,
+                        enabled = provider == Provider.Local,
+                    )
+                }.toMutableList()
+    }
+}

+ 163 - 163
shared/src/desktopMain/kotlin/org/mjdev/desktop/managers/theme/linux/ThemeManagerLinux.kt

@@ -230,176 +230,176 @@ class ThemeManagerLinux(
             val backdropDarkBg = lerp(bgColor, fgColor, 0.75f)
             val backdropSelBg = bgColor.darker(0.08f)
             return """
-            @define-color accent_color ${selectedBgColor.hexRgb};
-            @define-color accent_bg_color ${selectedBgColor.hexRgb};
-            @define-color accent_fg_color ${selectedFgColor.hexRgb};
-            @define-color destructive_color ${errorBgColor.hexRgb};
-            @define-color destructive_bg_color ${errorBgColor.hexRgb};
-            @define-color destructive_fg_color ${errorFgColor.hexRgb};
-            @define-color bg_color ${bgColor.hexRgb};
-            @define-color fg_color ${fgColor.hexRgb};
-            @define-color base_color ${baseColor.hexRgb};
-            @define-color text_color ${textColor.hexRgb};
-            @define-color selected_bg_color ${selectedBgColor.hexRgb};
-            @define-color selected_fg_color ${selectedFgColor.hexRgb};
-            @define-color tooltip_bg_color ${tooltipBgColor.hexRgb};
-            @define-color tooltip_fg_color ${tooltipFgColor.hexRgb};
-            @define-color info_fg_color ${infoFgColor.hexRgb};
-            @define-color info_bg_color ${infoBgColor.hexRgb};
-            @define-color warning_fg_color ${warningFgColor.hexRgb};
-            @define-color warning_bg_color ${warningBgColor.hexRgb};
-            @define-color question_fg_color ${questionFgColor.hexRgb};
-            @define-color question_bg_color ${questionBgColor.hexRgb};
-            @define-color error_fg_color ${errorFgColor.hexRgb};
-            @define-color error_bg_color ${errorBgColor.hexRgb};
-            @define-color success_color ${successBgColor.hexRgb};
-            @define-color error_color ${errorBgColor.hexRgb};
-            @define-color dark_bg_color ${bgColor.hexRgb};
-            @define-color dark_fg_color ${fgColor.hexRgb};
-            @define-color window_bg_color ${bgColor.hexRgb};
-            @define-color window_fg_color ${fgColor.hexRgb};
-            @define-color view_bg_color ${bgColor.hexRgb};
-            @define-color view_fg_color ${textColor.hexRgb};
-            @define-color headerbar_bg_color ${bgColor.hexRgb};
-            @define-color headerbar_fg_color ${fgColor.hexRgb};
-            @define-color headerbar_border_color ${borderColor.hexRgb};
-            @define-color headerbar_backdrop_color ${bgColor.hexRgb};
-            @define-color headerbar_shade_color ${borderColor.hexRgb};
-            @define-color card_bg_color ${bgColor.hexRgb};
-            @define-color card_fg_color ${fgColor.hexRgb};
-            @define-color card_shade_color ${borderColor.hexRgb};
-            @define-color osd_fg_color ${osdFgColor.hexRgb};
-            @define-color osd_bg_color ${osdBgColor.hexRgb};
-            @define-color osd_border_color ${borderColor.hexRgb};
-            @define-color dialog_bg_color ${bgColor.hexRgb};
-            @define-color dialog_fg_color ${fgColor.hexRgb};
-            @define-color popover_bg_color ${bgColor.hexRgb};
-            @define-color popover_fg_color ${fgColor.hexRgb};
-            @define-color link_color @selected_bg_color;
-            @define-color button_bg_color ${buttonBgColor.hexRgb};
-            @define-color notebook_button_bg_color ${buttonBgColor.hexRgb};
-            @define-color button_insensitive_bg_color ${buttonInsens.hexRgb};
-            @define-color backdrop_fg_color ${backdropFg.hexRgb};
-            @define-color backdrop_text_color ${backdropText.hexRgb};
-            @define-color backdrop_dark_fg_color ${backdropDarkFg.hexRgb};
-            @define-color backdrop_dark_bg_color ${backdropDarkBg.hexRgb};
-            @define-color backdrop_selected_bg_color ${backdropSelBg.hexRgb};
-            @define-color backdrop_selected_fg_color @fg_color;
-            @define-color focus_color ${selectedBgColor.hexRgb};
-            @define-color focus_bg_color ${bgColor.hexRgb};
-            @define-color shadow_color rgba(0, 0, 0, 0.5);
-            @define-color blue_1 #99c1f1;
-            @define-color blue_2 #62a0ea;
-            @define-color blue_3 #3584e4;
-            @define-color blue_4 #1c71d8;
-            @define-color blue_5 #1a5fb4;
-            @define-color green_1 #8ff0a4;
-            @define-color green_2 #57e389;
-            @define-color green_3 #33d17a;
-            @define-color green_4 #2ec27e;
-            @define-color green_5 #26a269;
-            @define-color yellow_1 #f9f06b;
-            @define-color yellow_2 #f8e45c;
-            @define-color yellow_3 #f6d32d;
-            @define-color yellow_4 #f5c211;
-            @define-color yellow_5 #e5a50a;
-            @define-color orange_1 #ffbe6f;
-            @define-color orange_2 #ffa348;
-            @define-color orange_3 #ff7800;
-            @define-color orange_4 #e66100;
-            @define-color orange_5 #c64600;
-            @define-color red_1 #f66151;
-            @define-color red_2 #ed333b;
-            @define-color red_3 #e01b24;
-            @define-color red_4 #c01c28;
-            @define-color red_5 #a51d2d;
-            @define-color purple_1 #dc8add;
-            @define-color purple_2 #c061cb;
-            @define-color purple_3 #9141ac;
-            @define-color purple_4 #813d9c;
-            @define-color purple_5 #613583;
-            @define-color brown_1 #cdab8f;
-            @define-color brown_2 #b5835a;
-            @define-color brown_3 #986a44;
-            @define-color brown_4 #865e3c;
-            @define-color brown_5 #63452c;
-            @define-color light_1 #ffffff;
-            @define-color light_2 #f6f5f4;
-            @define-color light_3 #deddda;
-            @define-color light_4 #c0bfbc;
-            @define-color light_5 #9a9996;
-            @define-color dark_1 #77767b;
-            @define-color dark_2 #5e5c64;
-            @define-color dark_3 #3d3846;
-            @define-color dark_4 #241f31;
-            @define-color dark_5 #000000;
-            
-            /* App content follows the wallpaper palette (compositor draws the outer frame). */
-            window {
-                background-image: none;
-                background-color: @window_bg_color;
-                border-radius: 0;
-                box-shadow: none;
-                border: none;
-                margin: 0;
-                padding: 0;
-            }
+                @define-color accent_color ${selectedBgColor.hexRgb};
+                @define-color accent_bg_color ${selectedBgColor.hexRgb};
+                @define-color accent_fg_color ${selectedFgColor.hexRgb};
+                @define-color destructive_color ${errorBgColor.hexRgb};
+                @define-color destructive_bg_color ${errorBgColor.hexRgb};
+                @define-color destructive_fg_color ${errorFgColor.hexRgb};
+                @define-color bg_color ${bgColor.hexRgb};
+                @define-color fg_color ${fgColor.hexRgb};
+                @define-color base_color ${baseColor.hexRgb};
+                @define-color text_color ${textColor.hexRgb};
+                @define-color selected_bg_color ${selectedBgColor.hexRgb};
+                @define-color selected_fg_color ${selectedFgColor.hexRgb};
+                @define-color tooltip_bg_color ${tooltipBgColor.hexRgb};
+                @define-color tooltip_fg_color ${tooltipFgColor.hexRgb};
+                @define-color info_fg_color ${infoFgColor.hexRgb};
+                @define-color info_bg_color ${infoBgColor.hexRgb};
+                @define-color warning_fg_color ${warningFgColor.hexRgb};
+                @define-color warning_bg_color ${warningBgColor.hexRgb};
+                @define-color question_fg_color ${questionFgColor.hexRgb};
+                @define-color question_bg_color ${questionBgColor.hexRgb};
+                @define-color error_fg_color ${errorFgColor.hexRgb};
+                @define-color error_bg_color ${errorBgColor.hexRgb};
+                @define-color success_color ${successBgColor.hexRgb};
+                @define-color error_color ${errorBgColor.hexRgb};
+                @define-color dark_bg_color ${bgColor.hexRgb};
+                @define-color dark_fg_color ${fgColor.hexRgb};
+                @define-color window_bg_color ${bgColor.hexRgb};
+                @define-color window_fg_color ${fgColor.hexRgb};
+                @define-color view_bg_color ${bgColor.hexRgb};
+                @define-color view_fg_color ${textColor.hexRgb};
+                @define-color headerbar_bg_color ${bgColor.hexRgb};
+                @define-color headerbar_fg_color ${fgColor.hexRgb};
+                @define-color headerbar_border_color ${borderColor.hexRgb};
+                @define-color headerbar_backdrop_color ${bgColor.hexRgb};
+                @define-color headerbar_shade_color ${borderColor.hexRgb};
+                @define-color card_bg_color ${bgColor.hexRgb};
+                @define-color card_fg_color ${fgColor.hexRgb};
+                @define-color card_shade_color ${borderColor.hexRgb};
+                @define-color osd_fg_color ${osdFgColor.hexRgb};
+                @define-color osd_bg_color ${osdBgColor.hexRgb};
+                @define-color osd_border_color ${borderColor.hexRgb};
+                @define-color dialog_bg_color ${bgColor.hexRgb};
+                @define-color dialog_fg_color ${fgColor.hexRgb};
+                @define-color popover_bg_color ${bgColor.hexRgb};
+                @define-color popover_fg_color ${fgColor.hexRgb};
+                @define-color link_color @selected_bg_color;
+                @define-color button_bg_color ${buttonBgColor.hexRgb};
+                @define-color notebook_button_bg_color ${buttonBgColor.hexRgb};
+                @define-color button_insensitive_bg_color ${buttonInsens.hexRgb};
+                @define-color backdrop_fg_color ${backdropFg.hexRgb};
+                @define-color backdrop_text_color ${backdropText.hexRgb};
+                @define-color backdrop_dark_fg_color ${backdropDarkFg.hexRgb};
+                @define-color backdrop_dark_bg_color ${backdropDarkBg.hexRgb};
+                @define-color backdrop_selected_bg_color ${backdropSelBg.hexRgb};
+                @define-color backdrop_selected_fg_color @fg_color;
+                @define-color focus_color ${selectedBgColor.hexRgb};
+                @define-color focus_bg_color ${bgColor.hexRgb};
+                @define-color shadow_color rgba(0, 0, 0, 0.5);
+                @define-color blue_1 #99c1f1;
+                @define-color blue_2 #62a0ea;
+                @define-color blue_3 #3584e4;
+                @define-color blue_4 #1c71d8;
+                @define-color blue_5 #1a5fb4;
+                @define-color green_1 #8ff0a4;
+                @define-color green_2 #57e389;
+                @define-color green_3 #33d17a;
+                @define-color green_4 #2ec27e;
+                @define-color green_5 #26a269;
+                @define-color yellow_1 #f9f06b;
+                @define-color yellow_2 #f8e45c;
+                @define-color yellow_3 #f6d32d;
+                @define-color yellow_4 #f5c211;
+                @define-color yellow_5 #e5a50a;
+                @define-color orange_1 #ffbe6f;
+                @define-color orange_2 #ffa348;
+                @define-color orange_3 #ff7800;
+                @define-color orange_4 #e66100;
+                @define-color orange_5 #c64600;
+                @define-color red_1 #f66151;
+                @define-color red_2 #ed333b;
+                @define-color red_3 #e01b24;
+                @define-color red_4 #c01c28;
+                @define-color red_5 #a51d2d;
+                @define-color purple_1 #dc8add;
+                @define-color purple_2 #c061cb;
+                @define-color purple_3 #9141ac;
+                @define-color purple_4 #813d9c;
+                @define-color purple_5 #613583;
+                @define-color brown_1 #cdab8f;
+                @define-color brown_2 #b5835a;
+                @define-color brown_3 #986a44;
+                @define-color brown_4 #865e3c;
+                @define-color brown_5 #63452c;
+                @define-color light_1 #ffffff;
+                @define-color light_2 #f6f5f4;
+                @define-color light_3 #deddda;
+                @define-color light_4 #c0bfbc;
+                @define-color light_5 #9a9996;
+                @define-color dark_1 #77767b;
+                @define-color dark_2 #5e5c64;
+                @define-color dark_3 #3d3846;
+                @define-color dark_4 #241f31;
+                @define-color dark_5 #000000;
+                
+                /* App content follows the wallpaper palette (compositor draws the outer frame). */
+                window {
+                    background-image: none;
+                    background-color: @window_bg_color;
+                    border-radius: 0;
+                    box-shadow: none;
+                    border: none;
+                    margin: 0;
+                    padding: 0;
+                }
 
-            /* Compositor draws min/max/close — hide client-side titlebuttons only. */
-            headerbar button.titlebutton,
-            headerbar .titlebutton {
-                opacity: 0;
-                min-width: 0;
-                min-height: 0;
-                padding: 0;
-                margin: 0;
-                border: none;
-            }
+                /* Compositor draws min/max/close — hide client-side titlebuttons only. */
+                headerbar button.titlebutton,
+                headerbar .titlebutton {
+                    opacity: 0;
+                    min-width: 0;
+                    min-height: 0;
+                    padding: 0;
+                    margin: 0;
+                    border: none;
+                }
 
-            window.ssd headerbar {
-                min-height: 0;
-                padding: 0;
-                margin: 0;
-                border: none;
-                box-shadow: none;
-                background-image: none;
-                background-color: @window_bg_color;
-            }
+                window.ssd headerbar {
+                    min-height: 0;
+                    padding: 0;
+                    margin: 0;
+                    border: none;
+                    box-shadow: none;
+                    background-image: none;
+                    background-color: @window_bg_color;
+                }
 
-            .titlebar, headerbar {
-                padding-top: 2px;
-                padding-bottom: 2px;
-                background-image: none;
-                background-color: @headerbar_bg_color;
-            }
+                .titlebar, headerbar {
+                    padding-top: 2px;
+                    padding-bottom: 2px;
+                    background-image: none;
+                    background-color: @headerbar_bg_color;
+                }
 
-            toolbarview,
-            .toolbarview,
-            .background {
-                background-color: @window_bg_color;
-                color: @text_color;
-            }
+                toolbarview,
+                .toolbarview,
+                .background {
+                    background-color: @window_bg_color;
+                    color: @text_color;
+                }
 
-            entry, textview, label {
-                color: @text_color;
-            }
+                entry, textview, label {
+                    color: @text_color;
+                }
 
-            button {
-                background-color: @button_bg_color;
-                opacity: 0.85;
-                margin: 2px;
-                padding: 4px;
-                min-width: 24px;
-                min-height: 24px;
-                text-shadow: none;
-                color: ${buttonFgColor.hexRgb};
-                border-radius: 8px;
-            }
+                button {
+                    background-color: @button_bg_color;
+                    opacity: 0.85;
+                    margin: 2px;
+                    padding: 4px;
+                    min-width: 24px;
+                    min-height: 24px;
+                    text-shadow: none;
+                    color: ${buttonFgColor.hexRgb};
+                    border-radius: 8px;
+                }
 
-            button:hover {
-                opacity: 1;
-            }
-            """.trimIndent()
+                button:hover {
+                    opacity: 1;
+                }
+                """.trimIndent()
         }
     }