Milan Jurkulak 3 месяцев назад
Родитель
Сommit
d4f2d0eed4

+ 7 - 0
.editorconfig

@@ -6,3 +6,10 @@ root = true
 # actually used, breaking compilation when ktlintFormat runs. Disable just that rule so
 # actually used, breaking compilation when ktlintFormat runs. Disable just that rule so
 # auto-format stays safe.
 # auto-format stays safe.
 ktlint_standard_no-unused-imports = disabled
 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 images (never commit — GitHub rejects git blobs >100 MB; ship as a release asset)
 *.iso
 *.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
 * update dependencies from report in reports folder, on every updated library check
   that builds run and or ommit/revert update if impossible
   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:
   a zarovnavat za = takhle:
   -------------------------------------------
   -------------------------------------------
   fun PreviewAppBarVerticalFill() = preview {
   fun PreviewAppBarVerticalFill() = preview {
@@ -74,55 +19,32 @@
       preview {
       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.
   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.
   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,
     fadeInDuration: Long = 60000L / 8,
     fadeOutDuration: Long = 60000L / 4,
     fadeOutDuration: Long = 60000L / 4,
     backgroundColor: Color = Color.SuperDarkGray,
     backgroundColor: Color = Color.SuperDarkGray,
-    images: MutableList<Any> = mutableListOf(),
+    images: List<Any> = listOf(),
     onError: (Throwable) -> Unit = { e -> Log.e(e) },
     onError: (Throwable) -> Unit = { e -> Log.e(e) },
     onChange: (bck: Any?) -> Unit = {},
     onChange: (bck: Any?) -> Unit = {},
 ) = withDesktopContext {
 ) = 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.about.AboutPage
 import org.mjdev.desktop.components.controlcenter.pages.ai.AIPage
 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.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.devices.DevicesPage
 import org.mjdev.desktop.components.controlcenter.pages.display.DisplaySettingsPage
 import org.mjdev.desktop.components.controlcenter.pages.display.DisplaySettingsPage
 import org.mjdev.desktop.components.controlcenter.pages.ethernet.EthSettingsPage
 import org.mjdev.desktop.components.controlcenter.pages.ethernet.EthSettingsPage
@@ -54,6 +55,7 @@ abstract class IDesktopContext : IDisposable {
             SoundSettingsPage(this),
             SoundSettingsPage(this),
             BluetoothSettingsPage(this),
             BluetoothSettingsPage(this),
             DisplaySettingsPage(this),
             DisplaySettingsPage(this),
+            DesktopSettingsPage(this),
             DevicesPage(this),
             DevicesPage(this),
             RemotesSettingsPage(this),
             RemotesSettingsPage(this),
             ThemeSettingsPage(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.helpers.compose.ImagesProvider
 import org.mjdev.desktop.interfaces.ITheme
 import org.mjdev.desktop.interfaces.ITheme
 import org.mjdev.desktop.interfaces.IUser
 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")
 @Suppress("MemberVisibilityCanBePrivate", "unused")
 class DesktopConfig(
 class DesktopConfig(
@@ -43,26 +40,33 @@ class DesktopConfig(
             }
             }
         }
         }
 
 
-    private fun addBackground(provider: ImagesProvider) =
+    fun addBackground(provider: ImagesProvider) =
         addBackground {
         addBackground {
             provider.get()
             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 {
     companion object {
         val configCache = mutableMapOf<String, DesktopConfig>()
         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 =
         fun load(user: IUser): DesktopConfig =
             configCache[user.userName] ?: DEFAULT.apply {
             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
                 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 backdropDarkBg = lerp(bgColor, fgColor, 0.75f)
             val backdropSelBg = bgColor.darker(0.08f)
             val backdropSelBg = bgColor.darker(0.08f)
             return """
             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()
         }
         }
     }
     }