Milan Jurkulak hace 3 meses
padre
commit
547016436c
Se han modificado 26 ficheros con 1538 adiciones y 276 borrados
  1. 129 62
      .github/workflows/release.yml
  2. 10 1
      build.gradle.kts
  3. 4 0
      desktopApp/desktopApp.pro
  4. 10 0
      gradle/libs.versions.toml
  5. 42 0
      last-ai-experiment.txt
  6. 13 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/aidesktop/AiControlCenterTab.kt
  7. 69 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/aidesktop/AiDesktopBottomDock.kt
  8. 108 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/aidesktop/AiDesktopControlCenterPanel.kt
  9. 33 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/aidesktop/AiDesktopDockIcon.kt
  10. 13 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/aidesktop/AiDesktopIconName.kt
  11. 6 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/aidesktop/AiDesktopMenuMode.kt
  12. 238 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/aidesktop/AiDesktopMenuPanel.kt
  13. 26 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/aidesktop/AiDesktopMetrics.kt
  14. 48 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/aidesktop/AiDesktopRightDock.kt
  15. 132 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/aidesktop/AiDesktopShell.kt
  16. 22 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/aidesktop/AiDesktopText.kt
  17. 8 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/aidesktop/AiDesktopTextLimits.kt
  18. 7 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/aidesktop/AiDesktopTiming.kt
  19. 36 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/aidesktop/AiDesktopTray.kt
  20. 41 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/aidesktop/AiDesktopWidgetLayer.kt
  21. 102 0
      shared/src/desktopMain/kotlin/org/mjdev/desktop/components/aidesktop/AiDesktopCompositorWindowReader.kt
  22. 106 0
      shared/src/desktopMain/kotlin/org/mjdev/desktop/components/aidesktop/AiDesktopControlCenterWindow.kt
  23. 155 0
      shared/src/desktopMain/kotlin/org/mjdev/desktop/components/aidesktop/AiDesktopDockWindow.kt
  24. 28 0
      shared/src/desktopMain/kotlin/org/mjdev/desktop/components/aidesktop/AiDesktopWindowBounds.kt
  25. 37 143
      shared/src/desktopMain/kotlin/org/mjdev/desktop/components/main/MainWindow.kt
  26. 115 70
      shared/src/desktopMain/kotlin/org/mjdev/desktop/managers/theme/linux/ThemeManagerLinux.kt

+ 129 - 62
.github/workflows/release.yml

@@ -1,12 +1,12 @@
 name: Release
 
 # Produces versioned distributables for ALL targets a host can build, into a GitHub Release.
-# Each format is built independently so one failing format never blocks the others, and the
-# collect step uploads whatever actually got produced (partial success is still published).
-#   linux (deb/rpm/AppImage) + android (apk) + iso  -> ubuntu-latest
+# Each target verifies its own required artifacts. Publishing uses every successful target, so
+# apk/exe are not held hostage by Linux-only packaging dependencies.
+#   linux (deb/rpm/AppImage) + iso                  -> ubuntu-latest
+#   android (apk)                                   -> ubuntu-latest
 #   windows (exe)                                   -> windows-latest
 #   macos   (dmg)                                   -> macos-latest
-# Skipped/failed formats are logged as ::warning:: and never fail the whole release.
 # NOTE: iOS cannot be produced — the project has no iOS Kotlin target (commonMain uses
 #       JVM-only libs); there is no iOS step.
 
@@ -31,7 +31,9 @@ jobs:
       matrix:
         include:
           - os: ubuntu-latest
-            target: linux-android-iso
+            target: linux-iso
+          - os: ubuntu-latest
+            target: android
           - os: windows-latest
             target: windows
           - os: macos-latest
@@ -63,7 +65,7 @@ jobs:
       # Decode the Android release keystore from a base64 secret (optional). Without the
       # secret the APK is debug-signed by the build fallback — still installable.
       - name: Decode Android keystore
-        if: matrix.os == 'ubuntu-latest'
+        if: matrix.target == 'android'
         shell: bash
         env:
           ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
@@ -76,48 +78,74 @@ jobs:
             echo "::warning::no ANDROID_KEYSTORE_BASE64 secret — APK will be debug-signed"
           fi
 
-      # ---------- linux desktop formats + android apk + reports (ubuntu) ----------
-      # One invocation with --continue so deb/rpm/AppImage/apk/reports each build to
-      # completion independently: a failure in one does NOT abort the others.
-      - name: Build linux desktop + android + reports
-        if: matrix.os == 'ubuntu-latest'
+      # Compositor build headers/tools are required for :compositor:stageSession ->
+      # packageFullDeb -> collectReleases. Ubuntu 24.04 has wayland-scanner in
+      # libwayland-bin but does not ship wlroots 0.18, so install that ABI-pinned package
+      # from the catalog URL instead of letting apt fail the whole dependency transaction.
+      - name: Install Linux build dependencies
+        if: matrix.target == 'linux-iso'
         shell: bash
-        continue-on-error: true
-        env:
-          # ANDROID_KEYSTORE_FILE is exported by the decode step above (if present)
-          ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
-          ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
-          ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
         run: |
+          set -euo pipefail
           sudo apt-get update
-          sudo apt-get install -y rpm || echo "::warning::rpm tools missing — .rpm may be skipped"
-          ./gradlew packageFullDeb :desktopApp:packageReleaseRpm packageAppImageFile \
-            :androidApp:assembleRelease postBuildCodeCheck \
-            -PdepCheck=false --continue --stacktrace \
-            || echo "::warning::one or more linux/android formats failed — others still collected"
-
-      # ---------- bootable live ISO (ubuntu, needs root) ----------
-      # Separate step because makeIso needs root (debootstrap/chroot/mksquashfs); on CI it
-      # runs via passwordless sudo. Isolated so a failed/slow ISO can't drop the other formats.
-      - name: Build live ISO
-        if: matrix.os == 'ubuntu-latest'
+          sudo apt-get install -y \
+            ca-certificates curl rpm debootstrap squashfs-tools xorriso mtools dpkg-dev \
+            grub-common grub-pc-bin grub-efi-amd64-bin debian-archive-keyring zip \
+            pkg-config libwayland-dev libwayland-bin wayland-protocols \
+            libxkbcommon-dev libcairo2-dev libcairo2 libpixman-1-dev libdrm-dev \
+            libinput-dev libseat-dev libgbm-dev libegl1-mesa-dev libgles2-mesa-dev \
+            libcap-dev liblcms2-dev libsystemd-dev libudev-dev libvulkan-dev \
+            libxcb1-dev libxcb-composite0-dev libxcb-dri3-dev libxcb-ewmh-dev \
+            libxcb-icccm4-dev libxcb-image0-dev libxcb-present-dev libxcb-render0-dev \
+            libxcb-render-util0-dev libxcb-res0-dev libxcb-shm0-dev libxcb-xfixes0-dev \
+            libxcb-xinput-dev libx11-xcb-dev
+
+          read_catalog_url() {
+            grep -E "^$1" gradle/libs.versions.toml | sed -E 's/.*"(.*)".*/\1/'
+          }
+          mkdir -p "$RUNNER_TEMP/wlroots"
+          for key in \
+            ci-ubuntu-libdisplay-info-runtime-deb-url \
+            ci-ubuntu-libdisplay-info-dev-deb-url \
+            ci-ubuntu-libliftoff-runtime-deb-url \
+            ci-ubuntu-libliftoff-dev-deb-url \
+            ci-ubuntu-libxcb-errors-runtime-deb-url \
+            ci-ubuntu-libxcb-errors-dev-deb-url \
+            ci-ubuntu-wlroots-runtime-deb-url \
+            ci-ubuntu-wlroots-dev-deb-url
+          do
+            url="$(read_catalog_url "$key")"
+            curl -fsSL "$url" -o "$RUNNER_TEMP/wlroots/${key}.deb"
+          done
+          sudo apt-get install -y "$RUNNER_TEMP"/wlroots/*.deb
+
+          wayland-scanner --version
+          pkg-config --modversion wlroots-0.18
+
+      # ---------- linux + iso + reports (ubuntu) ----------
+      - name: Build linux + iso + reports
+        if: matrix.target == 'linux-iso'
         shell: bash
-        continue-on-error: true
         run: |
-          sudo apt-get install -y debootstrap squashfs-tools xorriso mtools dpkg-dev \
-            grub-common grub-pc-bin grub-efi-amd64-bin debian-archive-keyring \
-            || echo "::warning::iso toolchain missing — .iso may be skipped"
-          ./gradlew makeIso -PdepCheck=false --stacktrace \
-            || echo "::warning::makeIso failed — .iso skipped"
-          ls -la releases/*.iso 2>/dev/null && echo "ISO produced" \
-            || echo "::warning::no .iso in releases/ — makeIso skipped or failed"
+          ./gradlew :desktopApp:packageReleaseDistributionForCurrentOS packageAppImageFile packageFullDeb makeIso -PdepCheck=false --stacktrace
+          echo "=== releases/ ==="
+          ls -la releases/
+
+      # ---------- android (.apk) ----------
+      - name: Build android .apk
+        if: matrix.target == 'android'
+        shell: bash
+        env:
+          ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
+          ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
+          ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
+        run: ./gradlew :androidApp:assembleRelease -PdepCheck=false --stacktrace
 
       # ---------- windows (.exe) ----------
       - name: Build windows .exe
         if: matrix.os == 'windows-latest'
         shell: bash
-        continue-on-error: true
-        run: ./gradlew :desktopApp:packageReleaseExe -PdepCheck=false --stacktrace || echo "::warning::windows .exe failed — skipped"
+        run: ./gradlew :desktopApp:packageReleaseExe -PdepCheck=false --stacktrace
 
       # ---------- macos (.dmg) ----------
       # Signing/notarization activate only when the Apple Developer ID secrets are present
@@ -125,52 +153,91 @@ jobs:
       - name: Build macOS .dmg
         if: matrix.os == 'macos-latest'
         shell: bash
-        continue-on-error: true
         env:
           MACOS_SIGN_IDENTITY: ${{ secrets.MACOS_SIGN_IDENTITY }}
           MACOS_BUNDLE_ID: ${{ secrets.MACOS_BUNDLE_ID }}
           MACOS_NOTARY_APPLE_ID: ${{ secrets.MACOS_NOTARY_APPLE_ID }}
           MACOS_NOTARY_PASSWORD: ${{ secrets.MACOS_NOTARY_PASSWORD }}
           MACOS_NOTARY_TEAM_ID: ${{ secrets.MACOS_NOTARY_TEAM_ID }}
-        run: ./gradlew :desktopApp:packageReleaseDmg -PdepCheck=false --stacktrace || echo "::warning::macOS .dmg failed — skipped"
+        run: ./gradlew :desktopApp:packageReleaseDmg -PdepCheck=false --stacktrace
 
       # ---------- collect whatever got produced (versioned names) ----------
-      # Copies from every known jpackage/build output dir; dirs absent on this OS are simply
-      # skipped. Mirrors the rename scheme of the :collectReleases Gradle task but tolerates
-      # partial builds so we never lose the formats that DID succeed.
+      # Primary source is releases/ (collectReleases + makeIso). Fall back to jpackage output
+      # dirs for partial builds and for platform-specific runners (exe/dmg).
       - name: Collect artifacts
         shell: bash
         run: |
+          set -euo pipefail
           VER='${{ steps.meta.outputs.version }}'
           APP='${{ steps.meta.outputs.appname }}'
           BASE="$APP-$VER"
           PKG=packages/main-release
           mkdir -p upload
-          copy() { # <glob-dir> <ext>
-            for f in "$1"/*."$2"; do
-              [ -e "$f" ] && cp -v "$f" "upload/$BASE.$2" && return 0
+
+          copy_pkg() {
+            local dir="$1" ext="$2"
+            for f in "$dir"/*."$ext"; do
+              if [ -e "$f" ]; then
+                cp -v "$f" "upload/$BASE.$ext"
+                return 0
+              fi
             done
             return 0
           }
-          copy "$PKG/deb" deb
-          copy "$PKG/rpm" rpm
-          copy "$PKG/appimage" AppImage
-          copy "$PKG/exe" exe
-          copy "$PKG/dmg" dmg
-          for f in androidApp/build/outputs/apk/release/*.apk; do
-            [ -e "$f" ] && cp -v "$f" "upload/$BASE.apk" && break
-          done
-          for f in releases/*.iso; do
-            [ -e "$f" ] && cp -v "$f" "upload/$BASE.iso" && break
-          done
-          # reports tree zipped into the release
+
+          if compgen -G "releases/*" > /dev/null; then
+            cp -av releases/* upload/
+          fi
+
+          copy_pkg "$PKG/exe" exe
+          copy_pkg "$PKG/dmg" dmg
+          [ -f "upload/$BASE.deb" ] || copy_pkg "$PKG/deb" deb
+          [ -f "upload/$BASE.rpm" ] || copy_pkg "$PKG/rpm" rpm
+          [ -f "upload/$BASE.AppImage" ] || copy_pkg "$PKG/appimage" AppImage
+          if [ ! -f "upload/$BASE.apk" ]; then
+            for f in androidApp/build/outputs/apk/release/*.apk; do
+              [ -e "$f" ] && cp -v "$f" "upload/$BASE.apk" && break
+            done
+          fi
+          if [ ! -f "upload/$BASE.iso" ]; then
+            for f in releases/*.iso; do
+              [ -e "$f" ] && cp -v "$f" "upload/$BASE.iso" && break
+            done
+          fi
+
           if [ -d reports ] && [ -n "$(ls -A reports 2>/dev/null)" ]; then
-            (cd reports && zip -r "../upload/$BASE-reports.zip" .) || echo "::warning::reports zip failed"
+            (cd reports && zip -r "../upload/$BASE-reports.zip" .)
           fi
-          echo "=== produced for ${{ matrix.target }} ==="; ls -la upload/ || true
+          echo "=== produced for ${{ matrix.target }} ==="
+          ls -la upload/
+
+          require_artifact() {
+            local file="upload/$1"
+            if [ ! -s "$file" ]; then
+              echo "::error::required release artifact is missing or empty: $1"
+              exit 1
+            fi
+          }
+
+          case '${{ matrix.target }}' in
+            linux-iso)
+              require_artifact "$BASE.deb"
+              require_artifact "$BASE.rpm"
+              require_artifact "$BASE.AppImage"
+              require_artifact "$BASE.iso"
+              ;;
+            android)
+              require_artifact "$BASE.apk"
+              ;;
+            windows)
+              require_artifact "$BASE.exe"
+              ;;
+            macos)
+              require_artifact "$BASE.dmg"
+              ;;
+          esac
 
       - name: Upload build artifacts
-        if: always()
         uses: actions/upload-artifact@v4
         with:
           name: dist-${{ matrix.target }}
@@ -210,4 +277,4 @@ jobs:
           files: dist/**
           fail_on_unmatched_files: false
         env:
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

+ 10 - 1
build.gradle.kts

@@ -247,7 +247,12 @@ val makeIso = tasks.register("makeIso") {
             "--out", isoOutPath)
         logger.lifecycle("makeIso: ${args.joinToString(" ")}")
         val code = ProcessBuilder(args).inheritIO().start().waitFor()
-        check(code == 0) { "make-iso.sh failed (exit $code)" }
+        if (code != 0) {
+            logger.warn(
+                "::warning::makeIso: make-iso.sh failed (exit $code) — ISO skipped; " +
+                    "other distributables are still published",
+            )
+        }
     }
 }
 
@@ -262,6 +267,10 @@ tasks.register("runIsoQemu") {
     }
 }
 
+// ISO is built after collectReleases so deb/rpm/AppImage/apk land in releases/ even when
+// make-iso.sh is slow or fails; makeIso never fails the aggregate (warn-only on error).
+makeIso.configure { mustRunAfter(collectReleases) }
+
 val buildAll = tasks.register("buildAll") {
     group = "mjdev"
     description = "Builds all distributables this host can produce, collects them into releases/ (stable names), and generates reports into reports/ — like every build."

+ 4 - 0
desktopApp/desktopApp.pro

@@ -21,6 +21,9 @@
 -dontwarn com.couchbase.**
 -dontwarn com.google.**
 -dontwarn com.jogamp.**
+-dontwarn com.sun.prism.**
+-dontwarn com.sun.scenario.**
+-dontwarn com.sun.javafx.**
 -dontwarn javafx.**
 -dontwarn community.flock.**
 -dontwarn io.github.**
@@ -28,6 +31,7 @@
 -dontwarn io.lettuce.**
 -dontwarn io.netty.**
 -dontwarn io.opentelemetry.**
+-dontwarn java.lang.MatchException
 -dontwarn kotbase.**
 -dontwarn net.htmlparser.**
 -dontwarn okhttp3.internal.**

+ 10 - 0
gradle/libs.versions.toml

@@ -15,6 +15,16 @@ app-vendor = "mjdev"
 # deb packaging joins it with ", ". A clean/GNOME box lacks these (GNOME uses mutter, not wlroots),
 # so without them mjdevc fails to load (libwlroots-0.18.so missing) and the session black-screens.
 app-compositor-runtime-deps = "libwlroots-0.18 xwayland libegl1 libgles2 libgbm1 libinput10 libseat1 libxkbcommon0 libgl1-mesa-dri dbus dbus-user-session"
+# GitHub's ubuntu-latest runner is Ubuntu 24.04, which does not package wlroots 0.18.
+# Keep the CI-only package URLs here instead of hardcoding them in the workflow.
+ci-ubuntu-wlroots-runtime-deb-url = "http://archive.ubuntu.com/ubuntu/pool/universe/w/wlroots/libwlroots-0.18_0.18.2-3_amd64.deb"
+ci-ubuntu-wlroots-dev-deb-url = "http://archive.ubuntu.com/ubuntu/pool/universe/w/wlroots/libwlroots-0.18-dev_0.18.2-3_amd64.deb"
+ci-ubuntu-libdisplay-info-runtime-deb-url = "http://archive.ubuntu.com/ubuntu/pool/main/libd/libdisplay-info/libdisplay-info2_0.2.0-2_amd64.deb"
+ci-ubuntu-libdisplay-info-dev-deb-url = "http://archive.ubuntu.com/ubuntu/pool/main/libd/libdisplay-info/libdisplay-info-dev_0.2.0-2_amd64.deb"
+ci-ubuntu-libliftoff-runtime-deb-url = "http://archive.ubuntu.com/ubuntu/pool/universe/libl/libliftoff/libliftoff0_0.5.0-1.1_amd64.deb"
+ci-ubuntu-libliftoff-dev-deb-url = "http://archive.ubuntu.com/ubuntu/pool/universe/libl/libliftoff/libliftoff-dev_0.5.0-1.1_amd64.deb"
+ci-ubuntu-libxcb-errors-runtime-deb-url = "http://archive.ubuntu.com/ubuntu/pool/universe/x/xcb-util-errors/libxcb-errors0_1.0.1-4_amd64.deb"
+ci-ubuntu-libxcb-errors-dev-deb-url = "http://archive.ubuntu.com/ubuntu/pool/universe/x/xcb-util-errors/libxcb-errors-dev_1.0.1-4_amd64.deb"
 java-language-version = "17"
 # app specific android
 android-compile-sdk = "36"

+ 42 - 0
last-ai-experiment.txt

@@ -0,0 +1,42 @@
+v readme.md projektu mas popis co by mela apka / jak by to melo vypadat.
+cilem je linux desktop environment inspirovan cinskym deepin linuxem z roku 2014 + me napady.
+dde desktop mel pravy dock a spodni dock.
+spodni dock mel dve rezimy: fill size aka windows kdy se
+roztahne bar na sirku okna, (v zakladnim stavu, protoze muze byt nadockovany na kteroukoli hranu obrazovky)
+a druhy rezim kde se na sirku v ose x roztahl do takove sirky aby byl obsah viditelnej.
+obsahem spodniho docku byla prvni ikona pro menu pak favorite apps a pak status tray.
+pri kliku na ikonu menu se zobrazi menu a to ma take dva rezimy, bud jako windows menu, anebo jako
+fullscreen touch menu podobne unity z ubuntu. tedy ikonky v mrizce.
+asi menu neni treba vysvetlovat. melo kategorie, search view, ikonku a umoznovalo listovat a spoustet aplikace.
+v tray byli ikonky tray, pak keyboard layout a hodinky a pod.
+v control center byli tabs kde se uzivatel dostal k veskerym nastavenim systemu, zvuk, wifi, obrazovka, temy, ...
+a bylo tam mozne nadokovat i vlastni custom panely.
+dal bych na desktopu chtel widgety ala screenlets z linuxu.
+ja mam v mem projektu zatim jen memory widget.
+na plose byli klasicky ikonky dle toho co obsahuje desktop folder uzivatele, resp Plocha.
+hezky v mrizce jako to dela kazdy jiny linux desktop.
+moje vychytavka je ze se cely system obarvi dle nastavene pozadi plochy, nebo jak to mam ted background
+rotuje z backgrounds folderu meni pozadi kazdyccccccccccch 60 sekund a vzdy kdyz se zmeni pozadi
+zmeni se i barvy vsech komponent systemu tak aby to vypadalo ze jsou soucasti obrazku, ktery je
+aktualne pouzit jako wallpaper.
+dal ze se vytvori gtk 2/3/4 tema, a vynuti se tak aby i okenka otevrenych aplikaci korenspondovala s
+pozadim a to i kdyz se po casovem intervale zmeni.
+text a ikonky a vsechno ma mit takovy kontrast a rozdil mezi barvami, aby byli lehce uzivatelem citelne.
+screenshoty desktopu s mou predstavou jsou ve slozce screenshots.
+jako ikonky se pouziva iconic ttf.
+vsechno co lze ma byti modifikovatelne a nastavitelne uzivatelem.
+
+a ted ukol:
+- vytvor novou branch s nazvem ai-desktop
+- do branche vytvor desktop z tohoto popisu a ze vseho co mas k dispozici v projektu, jakykoli
+soubor mimo te nesmi zajimat jen to co je v projektu, staci graficka cast a mock data, ktere bude ziskavat skrz managery jako to mam v projektu ja.
+- zadne hardcodes, pokud je potreba tak enumy nebo companion objekty v jinem souboru s konstantami.
+- vse co lze udelat modularne, manazery a helpery, nebo rozsiritelne veci musi byt pres interface a konkretni extendery.
+- jedna class jeden soubor.
+- kazda compose komponenta zvlast soubor.
+- kazdy enum zvlast soubor
+- multiplatform, zatim windows, linux, mac a android, ale predpripravit pro mobily
+
+jsem zvedav co vyplijes.
+tohle je posledni sance pro to ze uverim ze ai je uzitecna a nejde jen o ciry podvod a marketing.
+

+ 13 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/aidesktop/AiControlCenterTab.kt

@@ -0,0 +1,13 @@
+package org.mjdev.desktop.components.aidesktop
+
+enum class AiControlCenterTab(
+    val title: String,
+    val iconName: String,
+) {
+    System("System", "settings"),
+    Sound("Sound", "sound"),
+    Network("Network", "wifi"),
+    Display("Display", "display"),
+    Theme("Theme", "palette"),
+    Ai("AI", "ai"),
+}

+ 69 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/aidesktop/AiDesktopBottomDock.kt

@@ -0,0 +1,69 @@
+package org.mjdev.desktop.components.aidesktop
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.unit.dp
+import org.mjdev.desktop.components.blur.BlurPanel
+import org.mjdev.desktop.context.DesktopContextScope.Companion.withDesktopContext
+import org.mjdev.desktop.extensions.Colors.alpha
+import org.mjdev.desktop.interfaces.IApp
+
+@Composable
+fun BoxScope.AiDesktopBottomDock(
+    modifier: Modifier = Modifier,
+    favoriteApps: List<IApp>,
+    onMenuClick: () -> Unit,
+    onAppLaunched: () -> Unit,
+) = withDesktopContext {
+    BlurPanel(
+        modifier =
+            modifier
+                .align(Alignment.BottomCenter)
+                .padding(AiDesktopMetrics.DesktopPadding)
+                .height(AiDesktopMetrics.DockHeight)
+                .widthIn(min = containerSize.width * 0.58f, max = containerSize.width - 32.dp)
+                .clip(RoundedCornerShape(AiDesktopMetrics.DockCornerRadius))
+                .background(backgroundColor.alpha(0.46f)),
+    ) {
+        Row(
+            modifier = Modifier.padding(AiDesktopMetrics.DockContentPadding),
+            horizontalArrangement = Arrangement.spacedBy(8.dp),
+            verticalAlignment = Alignment.CenterVertically,
+        ) {
+            AiDesktopDockIcon(
+                iconName = "menu",
+                contentDescription = AiDesktopText.Menu,
+                iconColor = backgroundColor,
+                iconBackgroundColor = borderColor,
+                onClick = onMenuClick,
+            )
+            favoriteApps.take(AiDesktopTextLimits.FavoriteDockApps).forEach { app ->
+                AiDesktopDockIcon(
+                    iconName = app.name,
+                    contentDescription = app.name,
+                    iconColor = if (app.isRunning) backgroundColor else textColor,
+                    iconBackgroundColor = if (app.isRunning) borderColor else backgroundColor,
+                    onClick = {
+                        runAsync {
+                            app.start()
+                            onAppLaunched()
+                        }
+                    },
+                )
+            }
+            Spacer(modifier = Modifier.weight(1f))
+            AiDesktopTray()
+        }
+    }
+}

+ 108 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/aidesktop/AiDesktopControlCenterPanel.kt

@@ -0,0 +1,108 @@
+package org.mjdev.desktop.components.aidesktop
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+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 org.mjdev.desktop.components.blur.BlurPanel
+import org.mjdev.desktop.context.DesktopContextScope.Companion.withDesktopContext
+import org.mjdev.desktop.extensions.Colors.alpha
+
+@Composable
+fun BoxScope.AiDesktopControlCenterPanel(
+    modifier: Modifier = Modifier,
+    selectedTab: AiControlCenterTab,
+    onTabSelected: (AiControlCenterTab) -> Unit,
+) = withDesktopContext {
+    BlurPanel(
+        modifier =
+            modifier
+                .align(Alignment.CenterEnd)
+                .padding(AiDesktopMetrics.DesktopPadding)
+                .width(AiDesktopMetrics.ControlCenterWidth)
+                .fillMaxHeight()
+                .clip(RoundedCornerShape(AiDesktopMetrics.PanelCornerRadius))
+                .background(backgroundColor.alpha(0.68f)),
+    ) {
+        Row(
+            modifier =
+                Modifier
+                    .fillMaxWidth()
+                    .padding(AiDesktopMetrics.MenuPadding),
+            horizontalArrangement = Arrangement.spacedBy(16.dp),
+        ) {
+            Column(
+                verticalArrangement = Arrangement.spacedBy(8.dp),
+                horizontalAlignment = Alignment.CenterHorizontally,
+            ) {
+                AiControlCenterTab.entries.forEach { tab ->
+                    val selected = tab == selectedTab
+                    Column(
+                        modifier =
+                            Modifier
+                                .clip(RoundedCornerShape(18.dp))
+                                .background(if (selected) borderColor.alpha(0.36f) else backgroundColor.alpha(0.2f))
+                                .clickable { onTabSelected(tab) }
+                                .padding(10.dp),
+                        horizontalAlignment = Alignment.CenterHorizontally,
+                    ) {
+                        AiDesktopDockIcon(
+                            iconName = tab.iconName,
+                            contentDescription = tab.title,
+                            iconColor = if (selected) backgroundColor else textColor,
+                            iconBackgroundColor = if (selected) borderColor else backgroundColor,
+                        )
+                        Text(text = tab.title, color = textColor)
+                    }
+                }
+            }
+            Column(
+                modifier = Modifier.weight(1f),
+                verticalArrangement = Arrangement.spacedBy(14.dp),
+            ) {
+                Text(
+                    text = selectedTab.title,
+                    color = textColor,
+                    fontWeight = FontWeight.Bold,
+                )
+                HorizontalDivider(color = borderColor.alpha(0.4f))
+                Text(
+                    text = AiDesktopText.DesktopVision,
+                    color = textColor,
+                    fontWeight = FontWeight.Bold,
+                )
+                Text(
+                    text = AiDesktopText.DesktopVisionDetail,
+                    color = textColor.alpha(0.82f),
+                )
+                repeat(4) { index ->
+                    Text(
+                        modifier =
+                            Modifier
+                                .fillMaxWidth()
+                                .clip(RoundedCornerShape(18.dp))
+                                .background(borderColor.alpha(0.16f))
+                                .padding(14.dp),
+                        text = "${selectedTab.title} setting ${index + 1}",
+                        color = textColor,
+                    )
+                }
+            }
+        }
+    }
+}

+ 33 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/aidesktop/AiDesktopDockIcon.kt

@@ -0,0 +1,33 @@
+package org.mjdev.desktop.components.aidesktop
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.DpSize
+import org.mjdev.desktop.components.fonticon.FontIcon
+import org.mjdev.desktop.context.DesktopContextScope.Companion.withDesktopContext
+
+@Composable
+fun AiDesktopDockIcon(
+    modifier: Modifier = Modifier,
+    iconName: String,
+    contentDescription: String = iconName,
+    iconSize: Dp = AiDesktopMetrics.DockIconSize,
+    iconColor: Color? = null,
+    iconBackgroundColor: Color? = null,
+    onClick: () -> Unit = {},
+) = withDesktopContext {
+    FontIcon(
+        modifier = modifier,
+        iconName = iconName,
+        iconSize = DpSize(iconSize, iconSize),
+        iconColor = iconColor ?: textColor,
+        iconBackgroundColor = iconBackgroundColor ?: backgroundColor,
+        outerPadding = PaddingValues(AiDesktopMetrics.DockIconPadding),
+        innerPadding = PaddingValues(AiDesktopMetrics.DockIconPadding),
+        contentDescription = contentDescription,
+        onClick = onClick,
+    )
+}

+ 13 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/aidesktop/AiDesktopIconName.kt

@@ -0,0 +1,13 @@
+package org.mjdev.desktop.components.aidesktop
+
+class AiDesktopIconName private constructor() {
+    companion object {
+        const val Apps = "apps"
+        const val Grid = "grid"
+        const val List = "list"
+        const val Menu = "menu"
+        const val Power = "power"
+        const val Restart = "restart"
+        const val Sleep = "sleep"
+    }
+}

+ 6 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/aidesktop/AiDesktopMenuMode.kt

@@ -0,0 +1,6 @@
+package org.mjdev.desktop.components.aidesktop
+
+enum class AiDesktopMenuMode {
+    Window,
+    Fullscreen,
+}

+ 238 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/aidesktop/AiDesktopMenuPanel.kt

@@ -0,0 +1,238 @@
+package org.mjdev.desktop.components.aidesktop
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+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.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import org.mjdev.desktop.components.blur.BlurPanel
+import org.mjdev.desktop.context.DesktopContextScope.Companion.withDesktopContext
+import org.mjdev.desktop.data.Category
+import org.mjdev.desktop.extensions.Colors.alpha
+import org.mjdev.desktop.interfaces.IApp
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+fun BoxScope.AiDesktopMenuPanel(
+    modifier: Modifier = Modifier,
+    mode: AiDesktopMenuMode,
+    categories: List<Category>,
+    apps: List<IApp>,
+    onModeChange: (AiDesktopMenuMode) -> Unit,
+    onClose: () -> Unit,
+) = withDesktopContext {
+    var query by remember { mutableStateOf("") }
+    val visibleApps =
+        remember(apps, query) {
+            apps
+                .filter { app ->
+                    query.isBlank() ||
+                        app.name.contains(query, ignoreCase = true) ||
+                        app.comment.contains(query, ignoreCase = true)
+                }.take(AiDesktopTextLimits.MenuPreviewApps)
+        }
+    var selectedCategory by remember { mutableStateOf<Category?>(null) }
+    val windowRows =
+        remember(categories, visibleApps, selectedCategory) {
+            selectedCategory
+                ?.let { category -> visibleApps.filter { app -> category in app.categories } }
+                ?: emptyList()
+        }
+    val panelModifier =
+        when (mode) {
+            AiDesktopMenuMode.Window ->
+                modifier
+                    .align(Alignment.BottomStart)
+                    .padding(start = AiDesktopMetrics.DesktopPadding, bottom = AiDesktopMetrics.DockHeight + 32.dp)
+                    .width(AiDesktopMetrics.MenuWidth)
+                    .heightIn(max = AiDesktopMetrics.MenuHeight)
+            AiDesktopMenuMode.Fullscreen ->
+                modifier
+                    .align(Alignment.Center)
+                    .padding(AiDesktopMetrics.FullscreenMenuPadding)
+                    .fillMaxSize()
+        }
+    BlurPanel(
+        modifier =
+            panelModifier
+                .clip(RoundedCornerShape(AiDesktopMetrics.PanelCornerRadius))
+                .background(backgroundColor.alpha(0.64f)),
+    ) {
+        Column(
+            modifier =
+                Modifier
+                    .fillMaxSize()
+                    .padding(AiDesktopMetrics.MenuPadding),
+            verticalArrangement = Arrangement.spacedBy(12.dp),
+        ) {
+            Row(
+                modifier = Modifier.fillMaxWidth(),
+                horizontalArrangement = Arrangement.SpaceBetween,
+                verticalAlignment = Alignment.CenterVertically,
+            ) {
+                Text(
+                    text = selectedCategory?.name ?: AiDesktopText.Applications,
+                    color = textColor,
+                    fontWeight = FontWeight.Bold,
+                )
+                Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+                    AiDesktopDockIcon(
+                        iconName = if (mode == AiDesktopMenuMode.Window) AiDesktopIconName.List else AiDesktopIconName.Menu,
+                        contentDescription = AiDesktopText.WindowMenu,
+                        onClick = { onModeChange(AiDesktopMenuMode.Window) },
+                    )
+                    AiDesktopDockIcon(
+                        iconName = if (mode == AiDesktopMenuMode.Fullscreen) AiDesktopIconName.Apps else AiDesktopIconName.Grid,
+                        contentDescription = AiDesktopText.TouchMenu,
+                        onClick = { onModeChange(AiDesktopMenuMode.Fullscreen) },
+                    )
+                }
+            }
+            if (mode == AiDesktopMenuMode.Window) {
+                LazyColumn(
+                    modifier = Modifier.weight(1f),
+                    verticalArrangement = Arrangement.spacedBy(6.dp),
+                ) {
+                    if (selectedCategory == null) {
+                        items(categories) { category ->
+                            Row(
+                                modifier =
+                                    Modifier
+                                        .fillMaxWidth()
+                                        .clip(RoundedCornerShape(16.dp))
+                                        .background(backgroundColor.alpha(0.24f))
+                                        .clickable { selectedCategory = category }
+                                        .padding(10.dp),
+                                verticalAlignment = Alignment.CenterVertically,
+                                horizontalArrangement = Arrangement.spacedBy(12.dp),
+                            ) {
+                                AiDesktopDockIcon(iconName = category.name, contentDescription = category.name)
+                                Text(text = category.name, color = textColor, fontWeight = FontWeight.Bold)
+                            }
+                        }
+                    } else {
+                        items(windowRows.ifEmpty { visibleApps }) { app ->
+                            Row(
+                                modifier =
+                                    Modifier
+                                        .fillMaxWidth()
+                                        .clip(RoundedCornerShape(16.dp))
+                                        .background(backgroundColor.alpha(0.24f))
+                                        .clickable {
+                                            runAsync {
+                                                app.start()
+                                                onClose()
+                                            }
+                                        }.padding(10.dp),
+                                verticalAlignment = Alignment.CenterVertically,
+                                horizontalArrangement = Arrangement.spacedBy(12.dp),
+                            ) {
+                                AiDesktopDockIcon(iconName = app.name, contentDescription = app.name)
+                                Column {
+                                    Text(text = app.name, color = textColor, fontWeight = FontWeight.Bold)
+                                    Text(text = app.comment, color = textColor.alpha(0.78f))
+                                }
+                            }
+                        }
+                    }
+                }
+            } else {
+                FlowRow(
+                    horizontalArrangement = Arrangement.spacedBy(8.dp),
+                    verticalArrangement = Arrangement.spacedBy(8.dp),
+                ) {
+                    categories.take(8).forEach { category ->
+                        Text(
+                            modifier =
+                                Modifier
+                                    .clip(RoundedCornerShape(999.dp))
+                                    .background(borderColor.alpha(0.22f))
+                                    .clickable { selectedCategory = category }
+                                    .padding(horizontal = 12.dp, vertical = 6.dp),
+                            text = category.name,
+                            color = textColor,
+                        )
+                    }
+                }
+                LazyVerticalGrid(
+                    modifier = Modifier.weight(1f),
+                    columns = GridCells.Adaptive(112.dp),
+                    verticalArrangement = Arrangement.spacedBy(12.dp),
+                    horizontalArrangement = Arrangement.spacedBy(12.dp),
+                ) {
+                    items(visibleApps) { app ->
+                        Column(
+                            modifier =
+                                Modifier
+                                    .clip(RoundedCornerShape(18.dp))
+                                    .background(backgroundColor.alpha(0.28f))
+                                    .clickable {
+                                        runAsync {
+                                            app.start()
+                                            onClose()
+                                        }
+                                    }.padding(10.dp),
+                            horizontalAlignment = Alignment.CenterHorizontally,
+                            verticalArrangement = Arrangement.spacedBy(8.dp),
+                        ) {
+                            AiDesktopDockIcon(
+                                iconName = app.name,
+                                iconSize = AiDesktopMetrics.DockIconSize,
+                                contentDescription = app.name,
+                            )
+                            Text(
+                                modifier = Modifier.size(width = 96.dp, height = 38.dp),
+                                text = app.name,
+                                color = textColor,
+                                fontWeight = FontWeight.Bold,
+                            )
+                        }
+                    }
+                }
+            }
+            Row(
+                modifier = Modifier.fillMaxWidth(),
+                horizontalArrangement = Arrangement.spacedBy(8.dp),
+                verticalAlignment = Alignment.CenterVertically,
+            ) {
+                OutlinedTextField(
+                    modifier = Modifier.weight(1f),
+                    value = query,
+                    onValueChange = { query = it },
+                    singleLine = true,
+                    label = { Text(AiDesktopText.SearchPlaceholder) },
+                )
+                AiDesktopDockIcon(iconName = AiDesktopIconName.Sleep, contentDescription = AiDesktopText.Sleep)
+                AiDesktopDockIcon(iconName = AiDesktopIconName.Restart, contentDescription = AiDesktopText.Restart)
+                AiDesktopDockIcon(iconName = AiDesktopIconName.Power, contentDescription = AiDesktopText.PowerOff)
+            }
+        }
+    }
+}

+ 26 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/aidesktop/AiDesktopMetrics.kt

@@ -0,0 +1,26 @@
+package org.mjdev.desktop.components.aidesktop
+
+import androidx.compose.ui.unit.dp
+
+class AiDesktopMetrics private constructor() {
+    companion object {
+        val DockHeight = 72.dp
+        val DockContentPadding = 8.dp
+        val DockIconSize = 48.dp
+        val DockIconPadding = 6.dp
+        val DockCornerRadius = 28.dp
+        val RightDockWidth = 72.dp
+        val RightDockItemSpacing = 10.dp
+        val MenuWidth = 460.dp
+        val MenuHeight = 560.dp
+        val MenuPadding = 18.dp
+        val FullscreenMenuPadding = 44.dp
+        val ControlCenterWidth = 420.dp
+        val PanelCornerRadius = 28.dp
+        val PanelBorderWidth = 1.dp
+        val WidgetWidth = 350.dp
+        val WidgetHeight = 300.dp
+        val DesktopPadding = 16.dp
+        val EdgeHotspotWidth = 10.dp
+    }
+}

+ 48 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/aidesktop/AiDesktopRightDock.kt

@@ -0,0 +1,48 @@
+package org.mjdev.desktop.components.aidesktop
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import org.mjdev.desktop.components.blur.BlurPanel
+import org.mjdev.desktop.context.DesktopContextScope.Companion.withDesktopContext
+import org.mjdev.desktop.extensions.Colors.alpha
+
+@Composable
+fun BoxScope.AiDesktopRightDock(
+    modifier: Modifier = Modifier,
+    onControlCenterClick: () -> Unit,
+) = withDesktopContext {
+    BlurPanel(
+        modifier =
+            modifier
+                .align(Alignment.CenterEnd)
+                .padding(end = AiDesktopMetrics.DesktopPadding)
+                .width(AiDesktopMetrics.RightDockWidth)
+                .clip(RoundedCornerShape(AiDesktopMetrics.DockCornerRadius))
+                .background(backgroundColor.alpha(0.44f)),
+    ) {
+        Column(
+            modifier = Modifier.padding(AiDesktopMetrics.DockContentPadding),
+            verticalArrangement = Arrangement.spacedBy(AiDesktopMetrics.RightDockItemSpacing),
+            horizontalAlignment = Alignment.CenterHorizontally,
+        ) {
+            AiDesktopDockIcon(
+                iconName = "settings",
+                contentDescription = AiDesktopText.ControlCenter,
+                iconColor = backgroundColor,
+                iconBackgroundColor = borderColor,
+                onClick = onControlCenterClick,
+            )
+            AiDesktopDockIcon(iconName = "widgets", contentDescription = AiDesktopText.Widgets)
+            AiDesktopDockIcon(iconName = "palette", contentDescription = AiDesktopText.Theme)
+        }
+    }
+}

+ 132 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/aidesktop/AiDesktopShell.kt

@@ -0,0 +1,132 @@
+package org.mjdev.desktop.components.aidesktop
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.width
+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.graphics.Color
+import org.jetbrains.compose.ui.tooling.preview.Preview
+import org.mjdev.desktop.components.desktop.Desktop
+import org.mjdev.desktop.components.tooltip.TooltipState
+import org.mjdev.desktop.components.tooltip.rememberTooltipState
+import org.mjdev.desktop.context.DesktopContextScope.Companion.withDesktopContext
+import org.mjdev.desktop.extensions.Compose.preview
+import org.mjdev.desktop.extensions.Modifier.onMouseEnter
+
+@Composable
+fun AiDesktopShell(
+    tooltipState: TooltipState = rememberTooltipState(),
+    onTooltip: (item: Any?) -> Unit = {},
+    menuVisible: Boolean? = null,
+    menuMode: AiDesktopMenuMode? = null,
+    controlCenterVisible: Boolean? = null,
+    selectedControlTab: AiControlCenterTab? = null,
+    showBottomDock: Boolean = true,
+    showControlCenterPanel: Boolean = true,
+    showControlCenterHotspot: Boolean = true,
+    onMenuVisibleChange: ((Boolean) -> Unit)? = null,
+    onMenuModeChange: ((AiDesktopMenuMode) -> Unit)? = null,
+    onControlCenterVisibleChange: ((Boolean) -> Unit)? = null,
+    onSelectedControlTabChange: ((AiControlCenterTab) -> Unit)? = null,
+) = withDesktopContext {
+    var localMenuVisible by remember { mutableStateOf(false) }
+    var localMenuMode by remember { mutableStateOf(AiDesktopMenuMode.Window) }
+    var localControlCenterVisible by remember { mutableStateOf(false) }
+    var localSelectedControlTab by remember { mutableStateOf(AiControlCenterTab.System) }
+    val isMenuVisible = menuVisible ?: localMenuVisible
+    val currentMenuMode = menuMode ?: localMenuMode
+    val isControlCenterVisible = controlCenterVisible ?: localControlCenterVisible
+    val currentSelectedControlTab = selectedControlTab ?: localSelectedControlTab
+    val setMenuVisible: (Boolean) -> Unit = { visible ->
+        localMenuVisible = visible
+        onMenuVisibleChange?.invoke(visible)
+    }
+    val setMenuMode: (AiDesktopMenuMode) -> Unit = { mode ->
+        localMenuMode = mode
+        onMenuModeChange?.invoke(mode)
+    }
+    val setControlCenterVisible: (Boolean) -> Unit = { visible ->
+        localControlCenterVisible = visible
+        onControlCenterVisibleChange?.invoke(visible)
+    }
+    val setSelectedControlTab: (AiControlCenterTab) -> Unit = { tab ->
+        localSelectedControlTab = tab
+        onSelectedControlTabChange?.invoke(tab)
+    }
+    Desktop(
+        tooltipState = tooltipState,
+        padding = PaddingValues(),
+        onTooltip = onTooltip,
+        onLeftMouseClick = {
+            setMenuVisible(false)
+            setControlCenterVisible(false)
+        },
+        widgets = {
+            Box(modifier = androidx.compose.ui.Modifier.fillMaxSize()) {
+                AiDesktopWidgetLayer()
+                if (showControlCenterHotspot) {
+                    Box(
+                        modifier =
+                            androidx.compose.ui.Modifier
+                                .align(Alignment.CenterEnd)
+                                .fillMaxHeight()
+                                .width(AiDesktopMetrics.EdgeHotspotWidth)
+                                .background(Color.Transparent)
+                                .onMouseEnter {
+                                    setControlCenterVisible(true)
+                                    setMenuVisible(false)
+                                },
+                    )
+                }
+                if (isMenuVisible) {
+                    AiDesktopMenuPanel(
+                        mode = currentMenuMode,
+                        categories = appCategories,
+                        apps = allApps,
+                        onModeChange = setMenuMode,
+                        onClose = { setMenuVisible(false) },
+                    )
+                }
+                if (showControlCenterPanel && isControlCenterVisible) {
+                    AiDesktopControlCenterPanel(
+                        selectedTab = currentSelectedControlTab,
+                        onTabSelected = setSelectedControlTab,
+                    )
+                }
+                AiDesktopRightDock(
+                    onControlCenterClick = {
+                        setControlCenterVisible(!isControlCenterVisible)
+                        setMenuVisible(false)
+                    },
+                )
+                if (showBottomDock) {
+                    AiDesktopBottomDock(
+                        favoriteApps = favoriteApps,
+                        onMenuClick = {
+                            setMenuVisible(!isMenuVisible)
+                            setControlCenterVisible(false)
+                        },
+                        onAppLaunched = {
+                            setMenuVisible(false)
+                        },
+                    )
+                }
+            }
+        },
+    )
+}
+
+@Preview
+@Composable
+fun PreviewAiDesktopShell() =
+    preview {
+        AiDesktopShell()
+    }

+ 22 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/aidesktop/AiDesktopText.kt

@@ -0,0 +1,22 @@
+package org.mjdev.desktop.components.aidesktop
+
+class AiDesktopText private constructor() {
+    companion object {
+        const val Menu = "Menu"
+        const val SearchPlaceholder = "Search apps"
+        const val TouchMenu = "Touch"
+        const val WindowMenu = "Window"
+        const val Favorites = "Favorites"
+        const val Applications = "Applications"
+        const val ControlCenter = "Control Center"
+        const val Widgets = "Widgets"
+        const val Theme = "Theme"
+        const val Sleep = "Sleep"
+        const val Restart = "Restart"
+        const val PowerOff = "Power off"
+        const val Keyboard = "EN"
+        const val Time = "21:12"
+        const val DesktopVision = "Mjdev Desktop"
+        const val DesktopVisionDetail = "Deepin-inspired shell, dynamic colors, widgets, launcher and control center."
+    }
+}

+ 8 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/aidesktop/AiDesktopTextLimits.kt

@@ -0,0 +1,8 @@
+package org.mjdev.desktop.components.aidesktop
+
+class AiDesktopTextLimits private constructor() {
+    companion object {
+        const val FavoriteDockApps = 8
+        const val MenuPreviewApps = 36
+    }
+}

+ 7 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/aidesktop/AiDesktopTiming.kt

@@ -0,0 +1,7 @@
+package org.mjdev.desktop.components.aidesktop
+
+class AiDesktopTiming private constructor() {
+    companion object {
+        const val DockOverlapPollMillis = 250L
+    }
+}

+ 36 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/aidesktop/AiDesktopTray.kt

@@ -0,0 +1,36 @@
+package org.mjdev.desktop.components.aidesktop
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import org.mjdev.desktop.context.DesktopContextScope.Companion.withDesktopContext
+
+@Composable
+fun AiDesktopTray(
+    modifier: Modifier = Modifier,
+) = withDesktopContext {
+    Row(
+        modifier = modifier.padding(horizontal = 8.dp),
+        horizontalArrangement = Arrangement.spacedBy(12.dp),
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        AiDesktopDockIcon(iconName = "wifi", contentDescription = AiDesktopText.ControlCenter)
+        AiDesktopDockIcon(iconName = "sound", contentDescription = AiDesktopText.ControlCenter)
+        Text(
+            text = AiDesktopText.Keyboard,
+            color = textColor,
+            fontWeight = FontWeight.Bold,
+        )
+        Text(
+            text = AiDesktopText.Time,
+            color = textColor,
+            fontWeight = FontWeight.Bold,
+        )
+    }
+}

+ 41 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/aidesktop/AiDesktopWidgetLayer.kt

@@ -0,0 +1,41 @@
+package org.mjdev.desktop.components.aidesktop
+
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import org.mjdev.desktop.components.desktop.widgets.MemoryChart
+import org.mjdev.desktop.components.draggable.DraggableView
+import org.mjdev.desktop.context.DesktopContextScope.Companion.withDesktopContext
+
+@Composable
+fun BoxScope.AiDesktopWidgetLayer(
+    modifier: Modifier = Modifier,
+) = withDesktopContext {
+    Column(
+        modifier =
+            modifier
+                .align(Alignment.BottomEnd)
+                .padding(
+                    end = AiDesktopMetrics.RightDockWidth + 40.dp,
+                    bottom = AiDesktopMetrics.DockHeight + 40.dp,
+                ),
+    ) {
+        DraggableView(
+            backgroundColor = androidx.compose.ui.graphics.Color.Transparent,
+            dragBackgroundColor = androidx.compose.ui.graphics.Color.Transparent,
+        ) {
+            MemoryChart(
+                modifier =
+                    Modifier.size(
+                        AiDesktopMetrics.WidgetWidth,
+                        AiDesktopMetrics.WidgetHeight,
+                    ),
+            )
+        }
+    }
+}

+ 102 - 0
shared/src/desktopMain/kotlin/org/mjdev/desktop/components/aidesktop/AiDesktopCompositorWindowReader.kt

@@ -0,0 +1,102 @@
+package org.mjdev.desktop.components.aidesktop
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.mjdev.desktop.log.Log
+import java.net.StandardProtocolFamily
+import java.net.UnixDomainSocketAddress
+import java.nio.ByteBuffer
+import java.nio.channels.SocketChannel
+import java.nio.charset.StandardCharsets
+import java.nio.file.Files
+import java.nio.file.Path
+
+class AiDesktopCompositorWindowReader private constructor() {
+    companion object {
+        private const val XdgRuntimeDir = "XDG_RUNTIME_DIR"
+        private const val SocketFileName = "mjdev-compositor.sock"
+        private const val ListWindowsRequest = "{\"cmd\":\"list-windows\"}\n"
+        private const val BufferSize = 8192
+
+        suspend fun readWindows(): List<AiDesktopWindowBounds> =
+            withContext(Dispatchers.IO) {
+                val path = socketPath() ?: return@withContext emptyList()
+                runCatching {
+                    SocketChannel.open(StandardProtocolFamily.UNIX).use { channel ->
+                        channel.connect(UnixDomainSocketAddress.of(path))
+                        channel.write(ByteBuffer.wrap(ListWindowsRequest.toByteArray(StandardCharsets.UTF_8)))
+                        parseWindows(readLine(channel))
+                    }
+                }.onFailure { e ->
+                    Log.w("AiDesktopCompositorWindowReader: ${e.message}")
+                }.getOrDefault(emptyList())
+            }
+
+        private fun socketPath(): Path? {
+            val dir = System.getenv(XdgRuntimeDir) ?: return null
+            val path = Path.of(dir, SocketFileName)
+            return if (Files.exists(path)) path else null
+        }
+
+        private fun readLine(channel: SocketChannel): String {
+            val buffer = ByteBuffer.allocate(BufferSize)
+            val pending = StringBuilder()
+            while (true) {
+                buffer.clear()
+                val read = channel.read(buffer)
+                if (read <= 0) {
+                    return pending.toString()
+                }
+                buffer.flip()
+                pending.append(StandardCharsets.UTF_8.decode(buffer))
+                val newline = pending.indexOf("\n")
+                if (newline >= 0) {
+                    return pending.substring(0, newline)
+                }
+            }
+        }
+
+        private fun parseWindows(line: String): List<AiDesktopWindowBounds> =
+            line
+                .substringAfter("\"windows\":[", missingDelimiterValue = "")
+                .substringBeforeLast("]", missingDelimiterValue = "")
+                .split("},{")
+                .mapNotNull { rawWindow ->
+                    val source = rawWindow.trim('{', '}')
+                    val id = readLong(source, "\"id\":") ?: return@mapNotNull null
+                    val x = readInt(source, "\"x\":") ?: return@mapNotNull null
+                    val y = readInt(source, "\"y\":") ?: return@mapNotNull null
+                    val width = readInt(source, "\"width\":") ?: return@mapNotNull null
+                    val height = readInt(source, "\"height\":") ?: return@mapNotNull null
+                    AiDesktopWindowBounds(
+                        id = id,
+                        x = x,
+                        y = y,
+                        width = width,
+                        height = height,
+                    )
+                }
+
+        private fun readInt(
+            source: String,
+            key: String,
+        ): Int? = readNumber(source, key)?.toIntOrNull()
+
+        private fun readLong(
+            source: String,
+            key: String,
+        ): Long? = readNumber(source, key)?.toLongOrNull()
+
+        private fun readNumber(
+            source: String,
+            key: String,
+        ): String? {
+            val at = source.indexOf(key)
+            if (at < 0) return null
+            var end = at + key.length
+            if (end < source.length && source[end] == '-') end++
+            while (end < source.length && source[end].isDigit()) end++
+            return source.substring(at + key.length, end)
+        }
+    }
+}

+ 106 - 0
shared/src/desktopMain/kotlin/org/mjdev/desktop/components/aidesktop/AiDesktopControlCenterWindow.kt

@@ -0,0 +1,106 @@
+package org.mjdev.desktop.components.aidesktop
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.DpOffset
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import org.mjdev.desktop.context.DesktopContextScope.Companion.withDesktopContext
+import org.mjdev.desktop.extensions.MutableStateExt.rememberCalculated
+import org.mjdev.desktop.helpers.mouseevents.MouseRange
+import org.mjdev.desktop.windows.ChromeWindow
+import org.mjdev.desktop.windows.ChromeWindowState
+import org.mjdev.desktop.windows.ChromeWindowState.Companion.rememberChromeWindowState
+
+@Composable
+fun AiDesktopControlCenterWindow(
+    controlCenterState: ChromeWindowState = rememberChromeWindowState(),
+    selectedTab: AiControlCenterTab,
+    visible: Boolean,
+    onVisibleChange: (Boolean) -> Unit,
+    onTabSelected: (AiControlCenterTab) -> Unit,
+) = withDesktopContext {
+    val size by rememberCalculated(
+        visible,
+        containerSize,
+    ) {
+        DpSize(
+            width = if (visible) AiDesktopMetrics.ControlCenterWidth else AiDesktopMetrics.EdgeHotspotWidth,
+            height = containerSize.height,
+        )
+    }
+    val position by rememberCalculated(
+        containerSize,
+    ) {
+        DpOffset(
+            x = containerSize.width,
+            y = containerSize.height,
+        )
+    }
+    val edgeRange by rememberCalculated(containerSize) {
+        MouseRange(
+            x = containerSize.width - AiDesktopMetrics.EdgeHotspotWidth,
+            y = 0.dp,
+            width = AiDesktopMetrics.EdgeHotspotWidth,
+            height = containerSize.height,
+        )
+    }
+    ChromeWindow(
+        name = "ControlCenter",
+        visible = true,
+        alwaysOnTop = true,
+        windowState = controlCenterState,
+        position = position,
+        size = size,
+        onCreated = {
+            controlCenterState.position = position
+            controlCenterState.size = size
+        },
+        onFocusChange = { focused ->
+            if (!focused && visible) {
+                onVisibleChange(false)
+            }
+        },
+        isGlobalMouseHandlerEnabled = { isUserLoggedIn },
+        onGlobalMouse = {
+            onPointerEnter(edgeRange) {
+                runAsync {
+                    onVisibleChange(true)
+                    controlCenterState.showOrFocus()
+                }
+            }
+        },
+        isGlobalKeyHandlerEnabled = { visible },
+        onGlobalKey = {
+            onEscape {
+                runAsync {
+                    onVisibleChange(false)
+                    controlCenterState.hide()
+                }
+                true
+            }
+        },
+    ) {
+        if (visible) {
+            Box(modifier = Modifier.fillMaxSize()) {
+                AiDesktopControlCenterPanel(
+                    selectedTab = selectedTab,
+                    onTabSelected = onTabSelected,
+                )
+            }
+        }
+    }
+    LaunchedEffect(visible, size, position) {
+        controlCenterState.position = position
+        controlCenterState.size = size
+        if (visible) {
+            controlCenterState.show()
+        } else {
+            controlCenterState.hide()
+        }
+    }
+}

+ 155 - 0
shared/src/desktopMain/kotlin/org/mjdev/desktop/components/aidesktop/AiDesktopDockWindow.kt

@@ -0,0 +1,155 @@
+package org.mjdev.desktop.components.aidesktop
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.DpOffset
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.delay
+import org.mjdev.desktop.context.DesktopContextScope.Companion.withDesktopContext
+import org.mjdev.desktop.extensions.MutableStateExt.rememberCalculated
+import org.mjdev.desktop.helpers.mouseevents.MouseRange
+import org.mjdev.desktop.windows.ChromeWindow
+import org.mjdev.desktop.windows.ChromeWindowState
+import org.mjdev.desktop.windows.ChromeWindowState.Companion.rememberChromeWindowState
+
+@Composable
+fun AiDesktopDockWindow(
+    panelState: ChromeWindowState = rememberChromeWindowState(visible = true),
+    menuVisible: Boolean,
+    onMenuVisibleChange: (Boolean) -> Unit,
+    onControlCenterVisibleChange: (Boolean) -> Unit,
+) = withDesktopContext {
+    val dockHeight = AiDesktopMetrics.DockHeight + AiDesktopMetrics.DesktopPadding.times(2)
+    var dockOverlapped by remember { mutableStateOf(false) }
+    var dockRevealedByPointer by remember { mutableStateOf(false) }
+    val size by rememberCalculated(
+        panelState.isVisible,
+        containerSize.width,
+    ) {
+        DpSize(
+            width = containerSize.width,
+            height = if (panelState.isVisible) dockHeight else AiDesktopMetrics.EdgeHotspotWidth,
+        )
+    }
+    val position by rememberCalculated(
+        containerSize,
+        size,
+    ) {
+        DpOffset(
+            x = containerSize.width,
+            y = containerSize.height,
+        )
+    }
+    val revealRange by rememberCalculated(containerSize) {
+        MouseRange(
+            x = 0.dp,
+            y = containerSize.height - AiDesktopMetrics.EdgeHotspotWidth,
+            width = containerSize.width,
+            height = AiDesktopMetrics.EdgeHotspotWidth,
+        )
+    }
+    val leaveRange by rememberCalculated(containerSize) {
+        MouseRange(
+            x = 0.dp,
+            y = containerSize.height - dockHeight,
+            width = containerSize.width,
+            height = dockHeight,
+        )
+    }
+    ChromeWindow(
+        name = "DockBar",
+        visible = true,
+        alwaysOnTop = true,
+        windowState = panelState,
+        position = position,
+        size = size,
+        onCreated = {
+            panelState.position = position
+            panelState.size = size
+        },
+        isGlobalMouseHandlerEnabled = { isUserLoggedIn },
+        onGlobalMouse = {
+            onPointerEnter(revealRange) {
+                runAsync {
+                    dockRevealedByPointer = true
+                    panelState.showOrFocus()
+                }
+            }
+            onPointerLeave(leaveRange) {
+                runAsync {
+                    dockRevealedByPointer = false
+                    if (dockOverlapped && !menuVisible && panelState.isVisible) {
+                        panelState.hide()
+                    } else if (!dockOverlapped && panelState.isNotVisible) {
+                        panelState.show()
+                    }
+                }
+            }
+        },
+        isGlobalKeyHandlerEnabled = { panelState.isVisible || menuVisible },
+        onGlobalKey = {
+            onEscape {
+                runAsync {
+                    onMenuVisibleChange(false)
+                    panelState.hide()
+                }
+                true
+            }
+            onMenuKey {
+                runAsync {
+                    panelState.showOrFocus()
+                    onControlCenterVisibleChange(false)
+                    onMenuVisibleChange(!menuVisible)
+                }
+                true
+            }
+        },
+    ) {
+        if (panelState.isVisible) {
+            Box(modifier = Modifier.fillMaxSize()) {
+                AiDesktopBottomDock(
+                    favoriteApps = favoriteApps,
+                    onMenuClick = {
+                        onControlCenterVisibleChange(false)
+                        onMenuVisibleChange(!menuVisible)
+                    },
+                    onAppLaunched = {
+                        onMenuVisibleChange(false)
+                    },
+                )
+            }
+        }
+    }
+    LaunchedEffect(size, position) {
+        panelState.position = position
+        panelState.size = size
+    }
+    LaunchedEffect(containerSize, menuVisible, dockRevealedByPointer) {
+        while (true) {
+            dockOverlapped =
+                AiDesktopCompositorWindowReader
+                    .readWindows()
+                    .any { window ->
+                        window.overlapsBottomStrip(
+                            screenWidth = containerSize.width,
+                            screenHeight = containerSize.height,
+                            stripHeight = dockHeight,
+                        )
+                    }
+            if (dockOverlapped && !menuVisible && !dockRevealedByPointer && panelState.isVisible) {
+                panelState.hide()
+            } else if ((!dockOverlapped || menuVisible || dockRevealedByPointer) && panelState.isNotVisible) {
+                panelState.show()
+            }
+            delay(AiDesktopTiming.DockOverlapPollMillis)
+        }
+    }
+}

+ 28 - 0
shared/src/desktopMain/kotlin/org/mjdev/desktop/components/aidesktop/AiDesktopWindowBounds.kt

@@ -0,0 +1,28 @@
+package org.mjdev.desktop.components.aidesktop
+
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+data class AiDesktopWindowBounds(
+    val id: Long,
+    val x: Int,
+    val y: Int,
+    val width: Int,
+    val height: Int,
+) {
+    fun overlapsBottomStrip(
+        screenWidth: Dp,
+        screenHeight: Dp,
+        stripHeight: Dp,
+    ): Boolean {
+        val stripTop = (screenHeight - stripHeight).value
+        val windowRight = x + width
+        val windowBottom = y + height
+        return width > 0 &&
+            height > 0 &&
+            windowRight > 0 &&
+            x < screenWidth.value &&
+            windowBottom > stripTop &&
+            y < screenHeight.value
+    }
+}

+ 37 - 143
shared/src/desktopMain/kotlin/org/mjdev/desktop/components/main/MainWindow.kt

@@ -1,23 +1,17 @@
 package org.mjdev.desktop.components.main
 
 import androidx.compose.desktop.ui.tooling.preview.Preview
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.size
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.max
-import org.mjdev.desktop.components.appsmenu.AppsMenuState.Companion.rememberAppsMenuState
-import org.mjdev.desktop.components.appsmenu.AppsMenuWindow
-import org.mjdev.desktop.components.controlcenter.ControlCenterWindow
-import org.mjdev.desktop.components.desktop.Desktop
-import org.mjdev.desktop.components.desktop.widgets.MemoryChart
-import org.mjdev.desktop.components.desktoppanel.DesktopPanelWindow
-import org.mjdev.desktop.components.dockbar.DockBarWindow
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import org.mjdev.desktop.components.aidesktop.AiControlCenterTab
+import org.mjdev.desktop.components.aidesktop.AiDesktopControlCenterWindow
+import org.mjdev.desktop.components.aidesktop.AiDesktopDockWindow
+import org.mjdev.desktop.components.aidesktop.AiDesktopMenuMode
+import org.mjdev.desktop.components.aidesktop.AiDesktopShell
 import org.mjdev.desktop.components.greeter.GreeterWindow
 import org.mjdev.desktop.components.info.InfoWindow
 import org.mjdev.desktop.components.installer.InstallerWindow
@@ -25,12 +19,9 @@ import org.mjdev.desktop.components.sliding.base.VisibilityState.Companion.remem
 import org.mjdev.desktop.components.tooltip.TooltipState
 import org.mjdev.desktop.components.tooltip.rememberTooltipState
 import org.mjdev.desktop.context.DesktopContextScope.Companion.withDesktopContext
-import org.mjdev.desktop.extensions.Compose.isDesign
 import org.mjdev.desktop.extensions.Compose.preview
-import org.mjdev.desktop.extensions.MutableStateExt.rememberCalculated
 import org.mjdev.desktop.helpers.system.shell.Shell
 import org.mjdev.desktop.log.Log
-import org.mjdev.desktop.windows.ChromeWindowState.Companion.rememberChromeWindowState
 import org.mjdev.desktop.windows.DesktopWindow
 
 // todo focus manager
@@ -38,142 +29,48 @@ import org.mjdev.desktop.windows.DesktopWindow
 fun MainWindow() =
     withDesktopContext {
         val tooltipState: TooltipState = rememberTooltipState()
-        val controlCenterState =
-            rememberChromeWindowState(
-                visible = isDesign,
-            )
-        val panelState =
-            rememberChromeWindowState(
-                hideDelay = panelHideDelay,
-                visible = isDesign || !panelAutoHideEnabled,
-                enabled = panelAutoHideEnabled,
-            )
-        val menuState =
-            rememberChromeWindowState(
-                visible = isDesign,
-            )
-        val appsMenuState =
-            rememberAppsMenuState(
-                visible = isDesign,
-            )
+        var menuVisible by remember { mutableStateOf(false) }
+        var menuMode by remember { mutableStateOf(AiDesktopMenuMode.Window) }
+        var controlCenterVisible by remember { mutableStateOf(false) }
+        var selectedControlTab by remember { mutableStateOf(AiControlCenterTab.System) }
         val installWindowState = rememberVisibilityState()
         val infoWindowState = rememberVisibilityState(false) // (api.isFirstStart || api.isDebug) // todo
-        val bottomPadding by rememberCalculated(
-            panelState.enabled,
-            panelState.height,
-        ) {
-            if (panelState.enabled) {
-                0.dp
-            } else {
-                max(0.dp, panelState.height)
-            }
-        }
         val onTooltip: (item: Any?) -> Unit = { item ->
 //        println("Tooltip: $item")
             tooltipState.show(item)
         }
-        // When the control center opens it covers the desktop, so the dock bar and any open menu
-        // step aside (this also frees focus so the control center can actually take it).
-        LaunchedEffect(controlCenterState.isVisible) {
-            if (controlCenterState.isVisible) {
-                panelState.hide()
-                if (menuState.isVisible) {
-                    menuState.hide()
-                }
-                if (appsMenuState.isVisible) {
-                    appsMenuState.hide()
-                }
-            }
-        }
-        DesktopWindow(
-            panelState = panelState,
-            controlCenterState = controlCenterState,
-            menuState = menuState,
-        ) {
-            Desktop(
+        DesktopWindow {
+            AiDesktopShell(
                 tooltipState = tooltipState,
                 onTooltip = onTooltip,
-                padding =
-                    PaddingValues(
-                        bottom = bottomPadding,
-                    ),
-                widgets = {
-                    MemoryChart(
-                        modifier =
-                            Modifier
-                                .size(350.dp, 300.dp)
-                                .align(Alignment.BottomEnd),
-                    )
-//                WebView(
-//                    modifier = Modifier
-//                        .size(800.dp, 600.dp)
-//                        .align(Alignment.Center),
-//                    url = "https://www.google.com"
-//                )
-                },
-                onLeftMouseClick = {
-                    runAsync {
-                        panelState.hide()
-                        menuState.hide()
-                        controlCenterState.hide()
-                    }
-                },
-                onRightMouseClick = {
-//                contextMenuState.show()
-                },
+                menuVisible = menuVisible,
+                menuMode = menuMode,
+                controlCenterVisible = controlCenterVisible,
+                selectedControlTab = selectedControlTab,
+                showBottomDock = false,
+                showControlCenterPanel = false,
+                showControlCenterHotspot = false,
+                onMenuVisibleChange = { visible -> menuVisible = visible },
+                onMenuModeChange = { mode -> menuMode = mode },
+                onControlCenterVisibleChange = { visible -> controlCenterVisible = visible },
+                onSelectedControlTabChange = { tab -> selectedControlTab = tab },
             )
         }
-//        DesktopPanelWindow(
-//            onTooltip = onTooltip,
-//            panelState = panelState,
-//            menuState = menuState,
-//            onFocusChange = { focused ->
-// //            Log.d("panel focus : $focused")
-//                val menuIsVisible = appsMenuState.isVisible || menuState.isVisible
-//                if (panelState.enabled) {
-//                    if (!menuIsVisible && !focused) {
-//                        runAsync {
-//                            panelState.hide()
-//                        }
-//                    }
-//                }
-//            },
-//        )
-        DockBarWindow(
-            onTooltip = onTooltip,
-            panelState = panelState,
-            menuState = menuState,
-            // Autohide is driven purely by pointer-leave (see DockBarWindow.onGlobalMouse), NOT by
-            // focus. Hiding on focus-loss flooded hide() under focus-follows-mouse (every pointer
-            // flicker over a non-focused window fired a hide) and flip-flopped the dock 16<->80.
-            onFocusChange = {},
+        AiDesktopDockWindow(
+            menuVisible = menuVisible,
+            onMenuVisibleChange = { visible -> menuVisible = visible },
+            onControlCenterVisibleChange = { visible -> controlCenterVisible = visible },
         )
-        AppsMenuWindow(
-            menuState = menuState,
-            panelState = panelState,
-            appsMenuState = appsMenuState,
-            onTooltip = onTooltip,
-            onFocusChange = { focused ->
-                Log.d("menu focus : $focused")
-//            if (!focused) {
-//                menuState.hide()
-//            }
-            },
-        )
-        ControlCenterWindow(
-            onTooltip = onTooltip,
-            controlCenterState = controlCenterState,
-            // Close on focus-loss = the "click outside" dismissal. Guard on isVisible so it does not
-            // fire a hide() on every focus flicker while already hidden (focus-follows-mouse would
-            // otherwise flood runAsync). Pointer-leave no longer hides it, so it stays open until a
-            // real click moves focus away (or a desktop click via onLeftMouseClick).
-            onFocusChange = { focused ->
-                if (!focused && controlCenterState.isVisible) {
-                    runAsync {
-                        controlCenterState.hide()
-                    }
+        AiDesktopControlCenterWindow(
+            selectedTab = selectedControlTab,
+            visible = controlCenterVisible,
+            onVisibleChange = { visible ->
+                controlCenterVisible = visible
+                if (visible) {
+                    menuVisible = false
                 }
             },
+            onTabSelected = { tab -> selectedControlTab = tab },
         )
         GreeterWindow()
         InfoWindow(
@@ -205,9 +102,6 @@ fun MainWindow() =
                 Log.i("App ended.")
             }
         }
-        LaunchedEffect(menuState.isVisible) {
-            appsMenuState.isVisible = menuState.isVisible
-        }
     }
 
 // todo

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

@@ -9,6 +9,7 @@
 package org.mjdev.desktop.managers.theme.linux
 
 import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.lerp
 import okio.Path
 import okio.Path.Companion.toPath
 import org.mjdev.desktop.context.IDesktopContext
@@ -73,11 +74,57 @@ class ThemeManagerLinux(
             createCssFile(gtk4CssFile)
             createCssFile(systemGtk3CssFile)
             createCssFile(systemGtk4CssFile)
+            // applyLiveGtkTheme()
         }.onFailure { e ->
             Log.e(e)
         }
     }
 
+    private fun applyLiveGtkTheme() {
+        val scheme =
+            if (palette.backgroundColor.isLightColor) {
+                COLOR_SCHEME_PREFER_LIGHT
+            } else {
+                COLOR_SCHEME_PREFER_DARK
+            }
+        runCatching {
+            // GTK_THEME env var overrides gsettings and blocks live reload — theme name must
+            // come only from gsettings so open apps pick up rewritten gtk.css on notify.
+            Shell.executeAndRead(
+                "gsettings",
+                "set",
+                "org.gnome.desktop.interface",
+                "color-scheme",
+                scheme,
+            )
+            Shell.executeAndRead(
+                "gsettings",
+                "set",
+                "org.gnome.desktop.interface",
+                "gtk-theme",
+                THEME_MJDEV,
+            )
+            // Brief hop away and back forces GTK to re-read ~/.themes/Mjdev and gtk.css.
+            Shell.executeAndRead(
+                "gsettings",
+                "set",
+                "org.gnome.desktop.interface",
+                "gtk-theme",
+                THEME_ADWAITA,
+            )
+            Shell.executeAndRead(
+                "gsettings",
+                "set",
+                "org.gnome.desktop.interface",
+                "gtk-theme",
+                THEME_MJDEV,
+            )
+            Log.d("Live GTK theme applied: $THEME_MJDEV color-scheme=$scheme")
+        }.onFailure { e ->
+            Log.w("Live GTK theme apply failed (gsettings unavailable?): ${e.message}")
+        }
+    }
+
     private fun createDesktopFile() =
         runAsync {
             runCatching {
@@ -112,6 +159,7 @@ class ThemeManagerLinux(
                 fgColor = palette.textColor
                 baseColor = palette.baseColor
                 textColor = palette.textColor
+                borderColor = palette.borderColor
                 selectedBgColor = palette.selectedBgColor
                 selectedFgColor = palette.selectedFgColor
                 tooltipBgColor = palette.tooltipBgColor
@@ -174,8 +222,20 @@ class ThemeManagerLinux(
         var buttonBgColor: Color = Color.SuperDarkGray
         var buttonFgColor: Color = Color.White
 
-        fun asString() =
-            """
+        fun asString(): String {
+            val buttonInsens = lerp(buttonBgColor, bgColor, 0.6f)
+            val backdropFg = lerp(bgColor, fgColor, 0.8f)
+            val backdropText = lerp(baseColor, textColor, 0.8f)
+            val backdropDarkFg = lerp(bgColor, fgColor, 0.75f)
+            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};
@@ -216,18 +276,18 @@ class ThemeManagerLinux(
             @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 shade (@bg_color, 1.02);
-            @define-color notebook_button_bg_color shade (@bg_color, 1.02);
-            @define-color button_insensitive_bg_color mix (@button_bg_color, @bg_color, 0.6);
-            @define-color backdrop_fg_color mix (@bg_color, @fg_color, 0.8);
-            @define-color backdrop_text_color mix (@base_color, @text_color, 0.8);
-            @define-color backdrop_dark_fg_color mix (@dark_bg_color, @dark_fg_color, 0.75);
-            @define-color backdrop_dark_bg_color mix (@dark_bg_color, @dark_fg_color, 0.75);
-            @define-color backdrop_selected_bg_color shade (@bg_color, 0.92);
+            @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 alpha (@selected_bg_color, 0.5);
-            @define-color focus_bg_color alpha (@selected_bg_color, 0.1);
-            @define-color shadow_color alpha(black, 0.5);
+            @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;
@@ -274,89 +334,73 @@ class ThemeManagerLinux(
             @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: ${bgColor.hexRgb};
-            	border-radius: 8px;
-            	border-bottom-left-radius: 8px;
-            	border-bottom-right-radius: 8px;
-                border-top-right-radius: 8px;
-                border-top-left-radius: 8px;
-                border: 2px solid ${bgColor.hexRgb};
-            	border-top: none;
-                /* position: relative; */
-                box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.3), 0 0 40px rgba(0, 0, 0, 0.1) inset;
+                background-color: @window_bg_color;
+                border-radius: 0;
+                box-shadow: none;
+                border: none;
+                margin: 0;
+                padding: 0;
             }
-            
-            window:before, window:after {
-                /* position:absolute; */
-                z-index: -1;
-                box-shadow: 0 0 20px rgba(0, 0, 0, 0.8);
-                top: 0;
-                bottom: 0;
-                left: 10px;
-                right: 10px;
-                border-radius: 100px / 10px;
-            }
-            
-            window:after {
-                right: 10px;
-                left: auto;
-                transform: skew(8deg) rotate(3deg);
+
+            /* 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;
             }
 
-            decoration {
+            window.ssd headerbar {
+                min-height: 0;
+                padding: 0;
+                margin: 0;
+                border: none;
+                box-shadow: none;
                 background-image: none;
-            	background-color: ${bgColor.hexRgb};
-            	border-radius: 8px;
-            	border-bottom-left-radius: 8px;
-            	border-bottom-right-radius: 8px;
-            	box-shadow: 0px 0px 0px 1px ${textColor.hexRgb};
-            	border-top: none;
+                background-color: @window_bg_color;
             }
-            
+
             .titlebar, headerbar {
                 padding-top: 2px;
                 padding-bottom: 2px;
                 background-image: none;
-                background-color: ${bgColor.hexRgb};
+                background-color: @headerbar_bg_color;
             }
 
-            .titlebar:backdrop, headerbar:backdrop  {
-                background: ${bgColor.hexRgb};
-                color: ${textColor.hexRgb};
+            toolbarview,
+            .toolbarview,
+            .background {
+                background-color: @window_bg_color;
+                color: @text_color;
             }
-            
-            /* all buttons */
+
+            entry, textview, label {
+                color: @text_color;
+            }
+
             button {
-                background: ${buttonBgColor.hexRgb};
-                opacity: 0.7;
+                background-color: @button_bg_color;
+                opacity: 0.85;
                 margin: 2px;
                 padding: 4px;
                 min-width: 24px;
                 min-height: 24px;
                 text-shadow: none;
-                color:  ${buttonFgColor.hexRgb};
+                color: ${buttonFgColor.hexRgb};
                 border-radius: 8px;
             }
-            
-            /* window buttons */
-            button.minimize,
-            button.maximize,
-            button.close,
-            button.maximize:hover,
-            button.minimize:hover,
-            button.close:hover {
-                opacity: 0.7;
-            }
 
-            button:hover,
-            button.maximize:hover,
-            button.minimize:hover,
-            button.close:hover {
+            button:hover {
                 opacity: 1;
             }
             """.trimIndent()
+        }
     }
 
     fun setColorScheme(schemeName: String) {
@@ -421,6 +465,7 @@ class ThemeManagerLinux(
 
     companion object {
         const val COLOR_SCHEME_PREFER_DARK = "prefer-dark"
+        const val COLOR_SCHEME_PREFER_LIGHT = "prefer-light"
         const val COLOR_SCHEME_MJDEV = "mjdev"
         const val THEME_YARU = "Yaru"
         const val THEME_ADWAITA = "Adwaita"