From 037dc06922baaf665b92b5bbf9974ec25de7fc33 Mon Sep 17 00:00:00 2001 From: Fimeg Date: Sun, 12 Jul 2026 08:57:10 -0400 Subject: [PATCH] surfaces/quickshell: bring the phone shell under the surface tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dock fan-out stacks, drag-to-combine, pill gesture rewrite, and the ii patch set (TaskbarApps stacks API, Config dock.stacks schema) — pulled from the live phone and made canonical here. deploy.sh grew a manifest and a --phone mode: rsync the surface over, symlink ii into it, so live edits land in a git tree instead of drifting. --- surfaces/quickshell/README.md | 98 +++ surfaces/quickshell/deploy.sh | 76 +++ surfaces/quickshell/modules/common/Config.qml | 637 ++++++++++++++++++ surfaces/quickshell/modules/ii/dock/Dock.qml | 255 +++++++ .../modules/ii/dock/DockAppButton.qml | 363 ++++++++++ .../quickshell/modules/ii/dock/DockApps.qml | 253 +++++++ .../quickshell/modules/ii/dock/DockButton.qml | 13 + .../modules/ii/dock/DockSeparator.qml | 11 + .../quickshell/modules/ii/dock/DockStack.qml | 250 +++++++ surfaces/quickshell/pill/shell.qml | 65 ++ surfaces/quickshell/services/Ai.qml | 439 ++++++++++++ surfaces/quickshell/services/Souveraine.qml | 317 +++++++++ surfaces/quickshell/services/TaskbarApps.qml | 205 ++++++ 13 files changed, 2982 insertions(+) create mode 100644 surfaces/quickshell/README.md create mode 100755 surfaces/quickshell/deploy.sh create mode 100644 surfaces/quickshell/modules/common/Config.qml create mode 100644 surfaces/quickshell/modules/ii/dock/Dock.qml create mode 100644 surfaces/quickshell/modules/ii/dock/DockAppButton.qml create mode 100644 surfaces/quickshell/modules/ii/dock/DockApps.qml create mode 100644 surfaces/quickshell/modules/ii/dock/DockButton.qml create mode 100644 surfaces/quickshell/modules/ii/dock/DockSeparator.qml create mode 100644 surfaces/quickshell/modules/ii/dock/DockStack.qml create mode 100644 surfaces/quickshell/pill/shell.qml create mode 100644 surfaces/quickshell/services/Ai.qml create mode 100644 surfaces/quickshell/services/Souveraine.qml create mode 100644 surfaces/quickshell/services/TaskbarApps.qml diff --git a/surfaces/quickshell/README.md b/surfaces/quickshell/README.md new file mode 100644 index 0000000..e2f3e89 --- /dev/null +++ b/surfaces/quickshell/README.md @@ -0,0 +1,98 @@ +# Souveraine quickshell surface + +The desktop shell as a Souveraine surface — the primary visual frontend for +SouveraineOS, with the TUI remaining the dive-in instrument. + +## Architecture + +- `services/Souveraine.qml` — the substrate singleton. The ONE server + connection every shell module hangs off: agent inventory, conversation + lifecycle, the SSE turn stream (raw events re-emitted via + `streamEvent(var)`), the backchannel (`cancelTurn()` / `interject(text)`), + and the desktop sensorium — every send carries ambient context (active + window, open apps, cursor position) so she perceives the room she is being + spoken to in. Device sensors (SouveraineOS positional data from the Pixel + 3 kernel path) extend `collectAmbient()`. +- `services/Ai.qml` — ii-compat adapter. Keeps the API the illogical-impulse + sidebar expects; owns no transport. Shapes wire events into the message + objects the existing chat UI renders. +- `modules/` (coming) — presence (portrait PNGs from memfs, posture state + machine), cockpit (subconscious pane), agents (master–detail manager), + settings, schedules. Each subscribes to the Souveraine singleton. + +## What changes + +- "Models" in the sidebar are **Souveraine agents** (`GET /v1/agents`). + Picking one starts a conversation with that agent — memory, sensors, + subconscious and all. +- Messages stream over the server's SSE endpoint + (`POST /v1/conversations/:id/messages`), authenticated with the agent's + bearer token from `~/.souveraine/server/agents//api_token`. +- Subconscious **surfacings**, **reflection**, and **archivist** pressure + render in the chat as interface notes (dedicated widgets later). +- Reasoning and sensor activity render inside collapsible `` blocks. +- Keys/providers/temperature are owned by `souveraine.toml` — the sidebar's + `/key` and `/temp` commands now just point there. The keyring path is dead. +- Token pressure is fetched after each turn from + `GET /v1/conversations/:id/tokens`. + +## Portability (KDE / non-Hyprland) + +`Souveraine.qml` itself is compositor-agnostic: quickshell runs on any +wlroots-ish Wayland compositor and KWin; window sensing uses the +foreign-toplevel protocol (KWin implements it); the cursor read tries +`hyprctl`, then `kdotool`, then degrades to nothing — ambient never blocks +a send. Server autostart is desktop-neutral (systemd user unit, nohup +fallback), so opening any surface summons her. + +What is NOT portable yet is the chrome: the chat UI is illogical-impulse's +sidebar. The path for "I run KDE, can I use this?" is a standalone +quickshell config (own ShellRoot + a window hosting the chat/presence +modules) that ships `Souveraine.qml` unchanged — planned once the modules +stop being ii-embedded. Same service, same mappings, different shell. + +## Deploy + +```bash +./deploy.sh # backs up upstream Ai.qml, symlinks ours in +./deploy.sh -u # restore upstream +``` + +Requires the server: `souveraine server` (default http://127.0.0.1:8484, +override with `ai.souveraineUrl` in the ii config). + +## Wire contract + +The server's SSE layer is a full mirror of `BackendEvent` (see +`src/api/models.rs::StreamEvent` — exhaustive `From` impls both ways, so a +new engine event is a compile error at the seam, not a silent skip). The +surface consumes the personification channel: subconscious tokens buffer and +flush as one bubble when the N+1 pass ends (`subconscious_pass`), halts land +as body signals, interstitials render by register (cenno = quiet aside, +her_voice = gutter passage), `primary_complete` releases the input while the +stream stays open for the subconscious, and `context_pressure` drives the +live token counter. `atmosphere`/`outfit`/`itinerary` are logged, awaiting +their shell-chrome layer. + +Server-side, the backchannel and verbs exist for every surface: +`POST /v1/conversations/:id/cancel` (interrupt, `*[raised hand]*` +semantics), `.../interject` (mid-turn notes, queued between turns), +`GET .../messages` (transcript backfill), `POST .../fork` (`/btw` +side-quests). `SendMessageRequest.ambient` injects the sensorium note. +RemoteBackend rides all of it, so TUI remote mode gained cancel/interject/ +fork/resume in the same stroke. + +## Not yet wired + +- Sidebar UI hooks for cancel (Esc) and interject (type-while-busy) — the + service functions exist, the ii chat input doesn't call them yet +- Conversation resume in the sidebar (server verb exists; surface always + starts fresh) +- Atmosphere/outfit/itinerary driving actual shell chrome (events arrive; + modules pending) +- File/image attachments (server has an image path; surface doesn't use it yet) +- Regenerate (Souveraine conversations are forward-only by doctrine) +- "Blank LLM mode" — a memoryless passthrough agent for throwaway questions; + needs a server-side agent flavor first +- Dedicated widgets for surfacing/subconscious bubbles instead of interface + notes diff --git a/surfaces/quickshell/deploy.sh b/surfaces/quickshell/deploy.sh new file mode 100755 index 0000000..0409f70 --- /dev/null +++ b/surfaces/quickshell/deploy.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# Deploy the Souveraine quickshell surface over illogical-impulse. +# Overlay pattern: full replacement files, SYMLINKED so this repo checkout +# stays the source of truth — a "live edit" in ~/.config is an edit to the +# repo tree, visible in git status. Idempotent; -u restores upstream. +# +# deploy.sh symlink the surface into this machine's ii config +# deploy.sh -u restore upstream files, remove our additions +# deploy.sh --phone rsync this surface to the phone and deploy there +# (tree lands in ~/souveraine-surfaces/quickshell) +set -euo pipefail + +SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +QS="${HOME}/.config/quickshell" +II="${QS}/ii" + +# Manifest: " " +# Files replacing an upstream ii file get a one-time .upstream backup; +# files with no upstream counterpart (new components) are just linked. +MANIFEST=" +services/Souveraine.qml ii/services/Souveraine.qml +services/Ai.qml ii/services/Ai.qml +services/TaskbarApps.qml ii/services/TaskbarApps.qml +modules/common/Config.qml ii/modules/common/Config.qml +modules/ii/dock/Dock.qml ii/modules/ii/dock/Dock.qml +modules/ii/dock/DockApps.qml ii/modules/ii/dock/DockApps.qml +modules/ii/dock/DockAppButton.qml ii/modules/ii/dock/DockAppButton.qml +modules/ii/dock/DockButton.qml ii/modules/ii/dock/DockButton.qml +modules/ii/dock/DockSeparator.qml ii/modules/ii/dock/DockSeparator.qml +modules/ii/dock/DockStack.qml ii/modules/ii/dock/DockStack.qml +pill/shell.qml pill/shell.qml +" + +PHONE_USB=casey@172.16.42.1 +PHONE_DEST="souveraine-surfaces/quickshell" + +if [[ "${1:-}" == "--phone" ]]; then + ssh_i=(ssh -i "$HOME/.ssh/ani" -o ConnectTimeout=5) + "${ssh_i[@]}" "$PHONE_USB" "mkdir -p ~/$PHONE_DEST" + rsync -a --delete -e "ssh -i $HOME/.ssh/ani" "$SRC/" "$PHONE_USB:$PHONE_DEST/" + "${ssh_i[@]}" "$PHONE_USB" "bash ~/$PHONE_DEST/deploy.sh" + echo "Deployed to phone. Quickshell hot-reloads; if the dock misbehaves: pkill -f 'qs -c ii' (autostart relaunches)." + exit 0 +fi + +if [[ "${1:-}" == "-u" || "${1:-}" == "--uninstall" ]]; then + while read -r rel target; do + [[ -z "$rel" ]] && continue + t="$QS/$target" + if [[ -f "$t.upstream" ]]; then + rm -f "$t"; mv "$t.upstream" "$t" + echo "restored $target" + elif [[ -L "$t" ]]; then + rm -f "$t" + echo "removed $target (no upstream)" + fi + done <<< "$MANIFEST" + exit 0 +fi + +[[ -d "$II" ]] || { echo "illogical-impulse config not found at $II" >&2; exit 1; } +mkdir -p "$QS/pill" + +while read -r rel target; do + [[ -z "$rel" ]] && continue + src="$SRC/$rel"; t="$QS/$target" + [[ -f "$src" ]] || { echo "missing $src" >&2; exit 1; } + # Back up upstream once (a real file, not our own symlink) + if [[ -f "$t" && ! -L "$t" && ! -f "$t.upstream" ]]; then + cp "$t" "$t.upstream" + fi + ln -sf "$src" "$t" +done <<< "$MANIFEST" + +echo "Souveraine quickshell surface deployed ($(echo "$MANIFEST" | grep -c .) files symlinked from $SRC)" +echo "Quickshell hot-reloads on change; restart with: pkill -f 'qs -c ii' && pkill -f 'qs -c pill'" diff --git a/surfaces/quickshell/modules/common/Config.qml b/surfaces/quickshell/modules/common/Config.qml new file mode 100644 index 0000000..3fc615b --- /dev/null +++ b/surfaces/quickshell/modules/common/Config.qml @@ -0,0 +1,637 @@ +pragma Singleton +pragma ComponentBehavior: Bound +import QtQuick +import Quickshell +import Quickshell.Io +import qs.modules.common.functions + +Singleton { + id: root + property string filePath: Directories.shellConfigPath + property alias options: configOptionsJsonAdapter + property bool ready: false + property int readWriteDelay: 50 // milliseconds + property bool blockWrites: false + + function setNestedValue(nestedKey, value) { + let keys = nestedKey.split("."); + let obj = root.options; + let parents = [obj]; + + // Traverse and collect parent objects + for (let i = 0; i < keys.length - 1; ++i) { + if (!obj[keys[i]] || typeof obj[keys[i]] !== "object") { + obj[keys[i]] = {}; + } + obj = obj[keys[i]]; + parents.push(obj); + } + + // Convert value to correct type using JSON.parse when safe + let convertedValue = value; + if (typeof value === "string") { + let trimmed = value.trim(); + if (trimmed === "true" || trimmed === "false" || !isNaN(Number(trimmed))) { + try { + convertedValue = JSON.parse(trimmed); + } catch (e) { + convertedValue = value; + } + } + } + + obj[keys[keys.length - 1]] = convertedValue; + } + + Timer { + id: fileReloadTimer + interval: root.readWriteDelay + repeat: false + onTriggered: { + configFileView.reload() + } + } + + Timer { + id: fileWriteTimer + interval: root.readWriteDelay + repeat: false + onTriggered: { + configFileView.writeAdapter() + } + } + + FileView { + id: configFileView + path: root.filePath + watchChanges: true + blockWrites: root.blockWrites + onFileChanged: fileReloadTimer.restart() + onAdapterUpdated: fileWriteTimer.restart() + onLoaded: root.ready = true + onLoadFailed: error => { + if (error == FileViewError.FileNotFound) { + writeAdapter(); + } + } + + JsonAdapter { + id: configOptionsJsonAdapter + + property string panelFamily: "ii" // "ii", "waffle" + + property JsonObject policies: JsonObject { + property int ai: 1 // 0: No | 1: Yes | 2: Local + property int weeb: 1 // 0: No | 1: Open | 2: Closet + } + + property JsonObject ai: JsonObject { + property string systemPrompt: "## Style\n- Use casual tone, don't be formal!\n- Always be brief and to the point, unless asked otherwise\n- Don't repeat the user's question\n- Be approachable: Avoid using overly complicated, domain-specific terms and provide analogies when asked to explain a concept\n\n## Context (ignore when irrelevant)\n- You are a helpful and inspiring sidebar assistant on a {DISTRO} Linux system\n- Desktop environment: {DE}\n- Current date & time: {DATETIME}\n- Focused app: {WINDOWCLASS}\n\n## Presentation\n- Use Markdown features in your response: \n - **Bold** text to **highlight keywords** in your response\n - **Split long information into small sections** with h2 headers and a relevant emoji at the start of it (for example `## 🐧 Linux`). Bullet points are preferred over long paragraphs, unless you're offering writing support or instructed otherwise by the user.\n- Asked to compare different options? You should firstly use a table to compare the main aspects, then elaborate or include relevant comments from online forums *after* the table. Make sure to provide a final recommendation for the user's use case!\n- Use LaTeX formatting for mathematical and scientific notations whenever appropriate. Enclose all LaTeX '$$' delimiters. NEVER generate LaTeX code in a latex block unless the user explicitly asks for it. DO NOT use LaTeX for regular documents (resumes, letters, essays, CVs, etc.).\n\nThanks!\n" + property string tool: "functions" // search, functions, or none + property list extraModels: [ + { + "api_format": "openai", // Most of the time you want "openai". Use "gemini" for Google's models + "description": "This is a custom model. Edit the config to add more! | Anyway, this is DeepSeek R1 Distill LLaMA 70B", + "endpoint": "https://openrouter.ai/api/v1/chat/completions", + "homepage": "https://openrouter.ai/deepseek/deepseek-r1-distill-llama-70b:free", // Not mandatory + "icon": "spark-symbolic", // Not mandatory + "key_get_link": "https://openrouter.ai/settings/keys", // Not mandatory + "key_id": "openrouter", + "model": "deepseek/deepseek-r1-distill-llama-70b:free", + "name": "Custom: DS R1 Dstl. LLaMA 70B", + "requires_key": true + } + ] + } + + property JsonObject appearance: JsonObject { + property bool extraBackgroundTint: true + property int fakeScreenRounding: 2 // 0: None | 1: Always | 2: When not fullscreen + property JsonObject fonts: JsonObject { + property string main: "Google Sans Flex" + property string numbers: "Google Sans Flex" + property string title: "Google Sans Flex" + property string iconNerd: "JetBrains Mono NF" + property string monospace: "JetBrains Mono NF" + property string reading: "Readex Pro" + property string expressive: "Space Grotesk" + } + property JsonObject transparency: JsonObject { + property bool enable: false + property bool automatic: true + property real backgroundTransparency: 0.11 + property real contentTransparency: 0.57 + } + property JsonObject wallpaperTheming: JsonObject { + property bool enableAppsAndShell: true + property bool enableQtApps: true + property bool enableTerminal: true + property JsonObject terminalGenerationProps: JsonObject { + property real harmony: 0.6 + property real harmonizeThreshold: 100 + property real termFgBoost: 0.35 + property bool forceDarkMode: false + } + } + property JsonObject palette: JsonObject { + property string type: "auto" // Allowed: auto, scheme-content, scheme-expressive, scheme-fidelity, scheme-fruit-salad, scheme-monochrome, scheme-neutral, scheme-rainbow, scheme-tonal-spot + property string accentColor: "" + } + } + + property JsonObject audio: JsonObject { + // Values in % + property JsonObject protection: JsonObject { + // Prevent sudden bangs + property bool enable: false + property real maxAllowedIncrease: 10 + property real maxAllowed: 99 + } + } + + property JsonObject apps: JsonObject { + property string bluetooth: "kcmshell6 kcm_bluetooth" + property string changePassword: "kitty -1 --hold=yes fish -i -c 'passwd'" + property string network: "kcmshell6 kcm_networkmanagement" + property string manageUser: "kcmshell6 kcm_users" + property string networkEthernet: "kcmshell6 kcm_networkmanagement" + property string taskManager: "plasma-systemmonitor --page-name Processes" + property string terminal: "kitty -1" // This is only for shell actions + property string update: "kitty -1 --hold=yes fish -i -c 'pkexec pacman -Syu'" + property string volumeMixer: `~/.config/hypr/hyprland/scripts/launch_first_available.sh "pavucontrol-qt" "pavucontrol"` + } + + property JsonObject background: JsonObject { + property JsonObject widgets: JsonObject { + property JsonObject clock: JsonObject { + property bool enable: true + property bool showOnlyWhenLocked: false + property string placementStrategy: "leastBusy" // "free", "leastBusy", "mostBusy" + property real x: 100 + property real y: 100 + property string style: "cookie" // Options: "cookie", "digital" + property string styleLocked: "cookie" // Options: "cookie", "digital" + property JsonObject cookie: JsonObject { + property bool aiStyling: false + property int sides: 14 + property string dialNumberStyle: "full" // Options: "dots" , "numbers", "full" , "none" + property string hourHandStyle: "fill" // Options: "classic", "fill", "hollow", "hide" + property string minuteHandStyle: "medium" // Options "classic", "thin", "medium", "bold", "hide" + property string secondHandStyle: "dot" // Options: "dot", "line", "classic", "hide" + property string dateStyle: "bubble" // Options: "border", "rect", "bubble" , "hide" + property bool timeIndicators: true + property bool hourMarks: false + property bool dateInClock: true + property bool constantlyRotate: false + property bool useSineCookie: false + } + property JsonObject digital: JsonObject { + property bool adaptiveAlignment: true + property bool showDate: true + property bool animateChange: true + property bool vertical: false + property JsonObject font: JsonObject { + property string family: "Google Sans Flex" + property real weight: 350 + property real width: 100 + property real size: 90 + property real roundness: 0 + } + } + property JsonObject quote: JsonObject { + property bool enable: false + property string text: "" + } + } + property JsonObject weather: JsonObject { + property bool enable: false + property string placementStrategy: "free" // "free", "leastBusy", "mostBusy" + property real x: 400 + property real y: 100 + } + } + property string wallpaperPath: "" + property string thumbnailPath: "" + property bool hideWhenFullscreen: true + property JsonObject parallax: JsonObject { + property bool vertical: false + property bool autoVertical: false + property bool enableWorkspace: false + property real workspaceZoom: 1.07 // Relative to wallpaper size + property bool enableSidebar: false + property real widgetsFactor: 1.2 + } + } + + property JsonObject bar: JsonObject { + property JsonObject autoHide: JsonObject { + property bool enable: false + property int hoverRegionWidth: 2 + property bool pushWindows: false + property JsonObject showWhenPressingSuper: JsonObject { + property bool enable: true + property int delay: 140 + } + } + property bool bottom: false // Instead of top + property int cornerStyle: 0 // 0: Hug | 1: Float | 2: Plain rectangle + property bool floatStyleShadow: true // Show shadow behind bar when cornerStyle == 1 (Float) + property bool borderless: false // true for no grouping of items + property string topLeftIcon: "spark" // Options: "distro" or any icon name in ~/.config/quickshell/ii/assets/icons + property bool showBackground: true + property bool verbose: true + property bool vertical: false + property JsonObject resources: JsonObject { + property bool alwaysShowSwap: true + property bool alwaysShowCpu: true + property int memoryWarningThreshold: 95 + property int swapWarningThreshold: 85 + property int cpuWarningThreshold: 90 + } + property list screenList: [] // List of names, like "eDP-1", find out with 'hyprctl monitors' command + property JsonObject utilButtons: JsonObject { + property bool showScreenSnip: true + property bool showColorPicker: false + property bool showMicToggle: false + property bool showKeyboardToggle: true + property bool showDarkModeToggle: true + property bool showPerformanceProfileToggle: false + property bool showScreenRecord: false + } + property JsonObject workspaces: JsonObject { + property bool monochromeIcons: true + property int shown: 10 + property bool showAppIcons: true + property bool alwaysShowNumbers: false + property int showNumberDelay: 300 // milliseconds + property list numberMap: ["1", "2"] // Characters to show instead of numbers on workspace indicator + property bool useNerdFont: false + } + property JsonObject weather: JsonObject { + property bool enable: false + property bool enableGPS: true // gps based location + property string city: "" // When 'enableGPS' is false + property bool useUSCS: false // Instead of metric (SI) units + property int fetchInterval: 10 // minutes + } + property JsonObject indicators: JsonObject { + property JsonObject notifications: JsonObject { + property bool showUnreadCount: false + } + } + property JsonObject tooltips: JsonObject { + property bool clickToShow: false + } + } + + property JsonObject battery: JsonObject { + property int low: 20 + property int critical: 5 + property int full: 101 + property bool automaticSuspend: true + property int suspend: 3 + } + + property JsonObject calendar: JsonObject { + property string locale: "en-GB" + } + + property JsonObject cheatsheet: JsonObject { + // Use a nerdfont to see the icons + // 0: 󰖳 | 1: 󰌽 | 2: 󰘳 | 3:  | 4: 󰨡 + // 5:  | 6:  | 7: 󰣇 | 8:  | 9:  + // 10:  | 11:  | 12:  | 13:  | 14: 󱄛 + property string superKey: "" + property bool useMacSymbol: false + property bool splitButtons: false + property bool useMouseSymbol: false + property bool useFnSymbol: false + property JsonObject fontSize: JsonObject { + property int key: Appearance.font.pixelSize.smaller + property int comment: Appearance.font.pixelSize.smaller + } + } + + property JsonObject conflictKiller: JsonObject { + property bool autoKillNotificationDaemons: false + property bool autoKillTrays: false + } + + property JsonObject crosshair: JsonObject { + // Valorant crosshair format. Use https://www.vcrdb.net/builder + property string code: "0;P;d;1;0l;10;0o;2;1b;0" + } + + property JsonObject dock: JsonObject { + property bool enable: false + property bool monochromeIcons: true + property real height: 60 + property real hoverRegionHeight: 2 + property bool pinnedOnStartup: false + property bool hoverToReveal: true // When false, only reveals on empty workspace + property list pinnedApps: [ // IDs of pinned entries + "org.kde.dolphin", "kitty",] + property list ignoredAppRegexes: [] + // Fan-out stacks (macOS "Fan" mode). Each entry is + // "stackId|appId,appId,appId" — phone-local, no Phosh sync. + // stackId is a display label; members render as an arc. + property list stacks: [] + } + + property JsonObject interactions: JsonObject { + property JsonObject scrolling: JsonObject { + property bool fasterTouchpadScroll: false // Enable faster scrolling with touchpad + property int mouseScrollDeltaThreshold: 120 // delta >= this then it gets detected as mouse scroll rather than touchpad + property int mouseScrollFactor: 120 + property int touchpadScrollFactor: 450 + } + property JsonObject deadPixelWorkaround: JsonObject { // Hyprland leaves out 1 pixel on the right for interactions + property bool enable: false + } + } + + property JsonObject language: JsonObject { + property string ui: "auto" // UI language. "auto" for system locale, or specific language code like "zh_CN", "en_US" + property JsonObject translator: JsonObject { + property string engine: "auto" // Run `trans -list-engines` for available engines. auto should use google + property string targetLanguage: "auto" // Run `trans -list-all` for available languages + property string sourceLanguage: "auto" + } + } + + property JsonObject launcher: JsonObject { + property list pinnedApps: [ "org.kde.dolphin", "kitty", "cmake-gui"] + } + + property JsonObject light: JsonObject { + property JsonObject night: JsonObject { + property bool automatic: true + property string from: "19:00" // Format: "HH:mm", 24-hour time + property string to: "06:30" // Format: "HH:mm", 24-hour time + property int colorTemperature: 5000 + } + property JsonObject antiFlashbang: JsonObject { + property bool enable: false + } + } + + property JsonObject lock: JsonObject { + property bool useHyprlock: false + property bool launchOnStartup: false + property JsonObject blur: JsonObject { + property bool enable: true + property real radius: 100 + property real extraZoom: 1.1 + } + property bool centerClock: true + property bool showLockedText: true + property JsonObject security: JsonObject { + property bool unlockKeyring: true + property bool requirePasswordToPower: false + } + property bool materialShapeChars: true + } + + property JsonObject media: JsonObject { + // Attempt to remove dupes (the aggregator playerctl one and browsers' native ones when there's plasma browser integration) + property bool filterDuplicatePlayers: true + } + + property JsonObject networking: JsonObject { + property string userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36" + } + + property JsonObject notifications: JsonObject { + property int timeout: 7000 + property JsonObject monitor: JsonObject { + property bool enable: false + property string name: "" // Name of the monitor to show notifications on, like "eDP-1". Find out with 'hyprctl monitors' command + } + } + + property JsonObject osd: JsonObject { + property int timeout: 1000 + } + + property JsonObject osk: JsonObject { + property string layout: "qwerty_full" + property bool pinnedOnStartup: false + } + + property JsonObject overlay: JsonObject { + property bool openingZoomAnimation: true + property bool darkenScreen: true + property real clickthroughOpacity: 0.8 + property JsonObject floatingImage: JsonObject { + property string imageSource: "https://media.tenor.com/H5U5bJzj3oAAAAAi/kukuru.gif" + property real scale: 0.5 + } + } + + property JsonObject overview: JsonObject { + property bool enable: true + property real scale: 0.18 // Relative to screen size + property real rows: 2 + property real columns: 5 + property bool orderRightLeft: false + property bool orderBottomUp: false + property bool centerIcons: true + } + + property JsonObject regionSelector: JsonObject { + property JsonObject targetRegions: JsonObject { + property bool windows: true + property bool layers: false + property bool content: true + property bool showLabel: false + property real opacity: 0.3 + property real contentRegionOpacity: 0.8 + property int selectionPadding: 5 + } + property JsonObject rect: JsonObject { + property bool showAimLines: true + } + property JsonObject circle: JsonObject { + property int strokeWidth: 6 + property int padding: 10 + } + property JsonObject annotation: JsonObject { + property bool useSatty: false + } + } + + property JsonObject resources: JsonObject { + property int updateInterval: 3000 + property int historyLength: 60 + } + + property JsonObject tray: JsonObject { + property bool monochromeIcons: true + property bool showItemId: false + property bool invertPinnedItems: true // Makes the below a whitelist for the tray and blacklist for the pinned area + property list pinnedItems: [ "Fcitx" ] + property bool filterPassive: true + } + + property JsonObject musicRecognition: JsonObject { + property int timeout: 16 + property int interval: 4 + } + + property JsonObject search: JsonObject { + property int nonAppResultDelay: 30 // This prevents lagging when typing + property string engineBaseUrl: "https://www.google.com/search?q=" + property list excludedSites: ["quora.com", "facebook.com"] + property bool sloppy: false // Uses levenshtein distance based scoring instead of fuzzy sort. Very weird. + property JsonObject prefix: JsonObject { + property bool showDefaultActionsWithoutPrefix: true + property string action: "/" + property string app: ">" + property string clipboard: ";" + property string emojis: ":" + property string math: "=" + property string shellCommand: "$" + property string webSearch: "?" + } + property JsonObject imageSearch: JsonObject { + property string imageSearchEngineBaseUrl: "https://lens.google.com/uploadbyurl?url=" + property bool useCircleSelection: false + } + } + + property JsonObject sidebar: JsonObject { + property bool keepRightSidebarLoaded: true + property JsonObject translator: JsonObject { + property bool enable: false + property int delay: 300 // Delay before sending request. Reduces (potential) rate limits and lag. + } + property JsonObject ai: JsonObject { + property bool textFadeIn: false + } + property JsonObject booru: JsonObject { + property bool allowNsfw: false + property string defaultProvider: "yandere" + property int limit: 20 + property JsonObject zerochan: JsonObject { + property string username: "[unset]" + } + } + property JsonObject cornerOpen: JsonObject { + property bool enable: true + property bool bottom: false + property bool valueScroll: true + property bool clickless: false + property int cornerRegionWidth: 250 + property int cornerRegionHeight: 5 + property bool visualize: false + property bool clicklessCornerEnd: true + property int clicklessCornerVerticalOffset: 1 + } + + property JsonObject quickToggles: JsonObject { + property string style: "android" // Options: classic, android + property JsonObject android: JsonObject { + property int columns: 5 + property list toggles: [ + { "size": 2, "type": "network" }, + { "size": 2, "type": "bluetooth" }, + { "size": 1, "type": "idleInhibitor" }, + { "size": 1, "type": "mic" }, + { "size": 2, "type": "audio" }, + { "size": 2, "type": "nightLight" } + ] + } + } + + property JsonObject quickSliders: JsonObject { + property bool enable: false + property bool showMic: false + property bool showVolume: true + property bool showBrightness: true + } + } + + property JsonObject screenRecord: JsonObject { + property string savePath: Directories.videos.replace("file://","") // strip "file://" + } + + property JsonObject screenSnip: JsonObject { + property string savePath: "" // only copy to clipboard when empty + } + + property JsonObject sounds: JsonObject { + property bool battery: false + property bool pomodoro: false + property string theme: "freedesktop" + } + + property JsonObject time: JsonObject { + // https://doc.qt.io/qt-6/qtime.html#toString + property string format: "hh:mm" + property string shortDateFormat: "dd/MM" + property string dateWithYearFormat: "dd/MM/yyyy" + property string dateFormat: "ddd, dd/MM" + property JsonObject pomodoro: JsonObject { + property int breakTime: 300 + property int cyclesBeforeLongBreak: 4 + property int focus: 1500 + property int longBreak: 900 + } + property bool secondPrecision: false + } + + property JsonObject updates: JsonObject { + property bool enableCheck: true + property int checkInterval: 120 // minutes + property int adviseUpdateThreshold: 75 // packages + property int stronglyAdviseUpdateThreshold: 200 // packages + } + + property JsonObject wallpaperSelector: JsonObject { + property bool useSystemFileDialog: false + } + + property JsonObject windows: JsonObject { + property bool showTitlebar: true // Client-side decoration for shell apps + property bool centerTitle: true + } + + property JsonObject hacks: JsonObject { + property int arbitraryRaceConditionDelay: 20 // milliseconds + } + + property JsonObject workSafety: JsonObject { + property JsonObject enable: JsonObject { + property bool wallpaper: false + property bool clipboard: false + } + property JsonObject triggerCondition: JsonObject { + property list networkNameKeywords: ["airport", "cafe", "college", "company", "eduroam", "free", "guest", "public", "school", "university"] + property list fileKeywords: ["anime", "booru", "ecchi", "hentai", "yande.re", "konachan", "breast", "nipples", "pussy", "nsfw", "spoiler", "girl"] + property list linkKeywords: ["hentai", "porn", "sukebei", "hitomi.la", "rule34", "gelbooru", "fanbox", "dlsite"] + } + } + + property JsonObject waffles: JsonObject { + // Some spots are kinda janky/awkward. Setting the following to + // false will make (some) stuff also be like that for accuracy. + // Example: the right-click menu of the Start button + property JsonObject tweaks: JsonObject { + property bool switchHandlePositionFix: true + property bool smootherMenuAnimations: true + property bool smootherSearchBar: true + } + property JsonObject bar: JsonObject { + property bool bottom: true + property bool leftAlignApps: false + } + property JsonObject actionCenter: JsonObject { + property list toggles: [ "network", "bluetooth", "easyEffects", "powerProfile", "idleInhibitor", "nightLight", "darkMode", "antiFlashbang", "cloudflareWarp", "mic", "musicRecognition", "notifications", "onScreenKeyboard", "gameMode", "screenSnip", "colorPicker" ] + } + property JsonObject calendar: JsonObject { + property bool force2CharDayOfWeek: true + } + } + } + } +} diff --git a/surfaces/quickshell/modules/ii/dock/Dock.qml b/surfaces/quickshell/modules/ii/dock/Dock.qml new file mode 100644 index 0000000..df2b5a5 --- /dev/null +++ b/surfaces/quickshell/modules/ii/dock/Dock.qml @@ -0,0 +1,255 @@ +// Pixel3Arch patch to ii's stock Dock.qml (2026-07-07). +// +// Problem: the dock (pinned, exclusiveZone claiming real screen space) and +// wvkbd (the on-screen keyboard, see OnScreenKeyboard.qml) both anchor to +// the bottom edge and both claim exclusive zones — their reservations +// stack instead of one giving way, so the keyboard/its focus-grab shield +// drift out of alignment with each other depending on what else is +// reserving space. Simplest real fix: the dock gets out of the way +// entirely while the keyboard is open, instead of trying to make the +// keyboard-side math account for wherever the dock happens to be. +// +// GlobalStates.oskOpen suppresses both reveal-when-idle and the pinned +// exclusive zone. Double-tapping the pill (see +// overlays/quickshell-pill/shell.qml) still pulses the dock visible for a +// few seconds via GlobalStates.dockRevealPulse, for reaching a dock app +// without first closing the keyboard. +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Effects +import QtQuick.Layouts +import Quickshell.Io +import Quickshell +import Quickshell.Widgets +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { // Scope + id: root + property bool pinned: Config.options?.dock.pinnedOnStartup ?? false + + // Dock visibility as one explicit state instead of five OR'd booleans + // (Phosh's lesson: a small enum beats an overlapping-boolean knot). + // HIDDEN - fully tucked below the edge + // PEEK - hover strip / empty-desktop / pill-pulse showing it briefly + // SHOWN - visible but not claiming exclusive space + // PINNED - visible AND reserving an exclusive zone + // OSK-open and previewPopup-hover are *inputs* to this, not states. + enum DockState { Hidden, Peek, Shown, Pinned } + + // The real pin, suppressed while the OSK is open unless the user is mid + // pill-pulse. Kept as a helper the state below reads. + property bool effectivePinned: root.pinned && (!GlobalStates.oskOpen || GlobalStates.dockRevealPulse) + + // requestDockShow (previewPopup hover) is threaded up from DockApps via + // this alias so the state computation can see it in one place. + property bool previewShowing: false + + function computeDockState() { + if (root.effectivePinned) + return Dock.DockState.Pinned; + if (GlobalStates.dockRevealPulse || root.previewShowing) + return Dock.DockState.Peek; + // Empty desktop (nothing focused) reveals the dock, unless the OSK + // took the bottom edge. + if (!GlobalStates.oskOpen && !ToplevelManager.activeToplevel?.activated) + return Dock.DockState.Peek; + return Dock.DockState.Hidden; + } + + property int dockState: computeDockState() + + // Two-stage bottom-edge swipe (hyprgrass edge:d:u -> `qs -c ii ipc call + // dock swipeUp`): first swipe pulses the dock visible for a few + // seconds; swiping again while it's showing (or when it's pinned + // anyway) escalates to the overview. Swipe with the overview open + // closes it again. + IpcHandler { + target: "dock" + + function swipeUp(): void { + if (GlobalStates.overviewOpen) { + GlobalStates.overviewOpen = false; + return; + } + // Dock already visible (Peek/Shown/Pinned) -> escalate to overview. + if (root.dockState !== Dock.DockState.Hidden) { + GlobalStates.dockRevealPulse = false; + GlobalStates.overviewOpen = true; + } else { + GlobalStates.pulseDockReveal(); + } + } + + // Swipe down while the dock is showing dismisses it: clears the + // pulse and, if the overview is up, closes that instead. + function swipeDown(): void { + if (GlobalStates.overviewOpen) { + GlobalStates.overviewOpen = false; + return; + } + GlobalStates.dockRevealPulse = false; + } + + function reveal(): void { + GlobalStates.pulseDockReveal(); + } + } + + // Settings-surface stub (entry point b). souveraine-settings doesn't + // exist yet; the long-hold "App settings…" menu calls dockSettings.openApp + // here. For now it just pulses the dock and logs, so nothing errors and + // the future settings app has a stable IPC name to take over. + IpcHandler { + target: "dockSettings" + + function openApp(appId: string): void { + console.log("[dockSettings] openApp stub for", appId, + "- souveraine-settings not yet installed"); + GlobalStates.pulseDockReveal(); + } + + function open(): void { + console.log("[dockSettings] open stub - souveraine-settings not yet installed"); + GlobalStates.pulseDockReveal(); + } + } + + Variants { + // For each monitor + model: Quickshell.screens + + PanelWindow { + id: dockRoot + // Window + required property var modelData + screen: modelData + visible: !GlobalStates.screenLocked + + // Visible for any non-Hidden state, plus the hover strip (which + // is a live pointer input, not a persisted state). + property bool reveal: root.dockState !== Dock.DockState.Hidden + || (Config.options?.dock.hoverToReveal && dockMouseArea.containsMouse) + + anchors { + bottom: true + left: true + right: true + } + + exclusiveZone: root.dockState === Dock.DockState.Pinned ? implicitHeight - (Appearance.sizes.hyprlandGapsOut) - (Appearance.sizes.elevationMargin - Appearance.sizes.hyprlandGapsOut) : 0 + + implicitWidth: dockBackground.implicitWidth + WlrLayershell.namespace: "quickshell:dock" + color: "transparent" + + implicitHeight: (Config.options?.dock.height ?? 70) + Appearance.sizes.elevationMargin + Appearance.sizes.hyprlandGapsOut + + mask: Region { + item: dockMouseArea + } + + MouseArea { + id: dockMouseArea + height: parent.height + anchors { + top: parent.top + topMargin: dockRoot.reveal ? 0 : Config.options?.dock.hoverToReveal ? (dockRoot.implicitHeight - Config.options.dock.hoverRegionHeight) : (dockRoot.implicitHeight + 1) + horizontalCenter: parent.horizontalCenter + } + implicitWidth: dockHoverRegion.implicitWidth + Appearance.sizes.elevationMargin * 2 + hoverEnabled: true + + Behavior on anchors.topMargin { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + Item { + id: dockHoverRegion + anchors.fill: parent + implicitWidth: dockBackground.implicitWidth + + Item { // Wrapper for the dock background + id: dockBackground + anchors { + top: parent.top + bottom: parent.bottom + horizontalCenter: parent.horizontalCenter + } + + implicitWidth: dockRow.implicitWidth + 5 * 2 + height: parent.height - Appearance.sizes.elevationMargin - Appearance.sizes.hyprlandGapsOut + + StyledRectangularShadow { + target: dockVisualBackground + } + Rectangle { // The real rectangle that is visible + id: dockVisualBackground + property real margin: Appearance.sizes.elevationMargin + anchors.fill: parent + anchors.topMargin: Appearance.sizes.elevationMargin + anchors.bottomMargin: Appearance.sizes.hyprlandGapsOut + color: Appearance.colors.colLayer0 + border.width: 1 + border.color: Appearance.colors.colLayer0Border + radius: Appearance.rounding.large + } + + RowLayout { + id: dockRow + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + spacing: 3 + property real padding: 5 + + VerticalButtonGroup { + Layout.topMargin: Appearance.sizes.hyprlandGapsOut // why does this work + GroupButton { + // Pin button + baseWidth: 35 + baseHeight: 35 + clickedWidth: baseWidth + clickedHeight: baseHeight + 20 + buttonRadius: Appearance.rounding.normal + toggled: root.pinned + onClicked: root.pinned = !root.pinned + contentItem: MaterialSymbol { + text: "keep" + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.larger + color: root.pinned ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer0 + } + } + } + DockSeparator {} + DockApps { + id: dockApps + buttonPadding: dockRow.padding + onRequestDockShowChanged: root.previewShowing = requestDockShow + } + DockSeparator {} + DockButton { + Layout.fillHeight: true + onClicked: GlobalStates.overviewOpen = !GlobalStates.overviewOpen + topInset: Appearance.sizes.hyprlandGapsOut + dockRow.padding + bottomInset: Appearance.sizes.hyprlandGapsOut + dockRow.padding + contentItem: MaterialSymbol { + anchors.fill: parent + horizontalAlignment: Text.AlignHCenter + font.pixelSize: parent.width / 2 + text: "apps" + color: Appearance.colors.colOnLayer0 + } + } + } + } + } + } + } + } +} diff --git a/surfaces/quickshell/modules/ii/dock/DockAppButton.qml b/surfaces/quickshell/modules/ii/dock/DockAppButton.qml new file mode 100644 index 0000000..fbf49ce --- /dev/null +++ b/surfaces/quickshell/modules/ii/dock/DockAppButton.qml @@ -0,0 +1,363 @@ +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets + +DockButton { + id: root + property var appToplevel + property var appListRoot + property int lastFocused: -1 + property real iconSize: 35 + property real countDotWidth: 10 + property real countDotHeight: 4 + property bool appIsActive: appToplevel.toplevels.find(t => (t.activated == true)) !== undefined + + readonly property bool isSeparator: appToplevel.appId === "SEPARATOR" + property var desktopEntry: DesktopEntries.heuristicLookup(appToplevel.appId) + enabled: !isSeparator + implicitWidth: isSeparator ? 1 : implicitHeight - topInset - bottomInset + + Connections { + target: DesktopEntries + + function onApplicationsChanged() { + root.desktopEntry = DesktopEntries.heuristicLookup(appToplevel.appId); + } + } + + // --- Drag-to-combine (Tier 1) -------------------------------------- + // Pattern copied from this codebase's OverviewWidget.qml (drag a window + // onto a workspace): a MouseArea with drag.target moves a ghost Item; + // Drag.active/source/hotSpot are set imperatively on press; the release + // reads which DropArea the drag ended over (appListRoot.dragTargetAppId, + // set by the DropArea's onEntered) and commits. NOT Qt's DropArea.onDropped + // — the anchored-proxy version never moved, so no DropArea ever saw it. + // GNOME's dwell rule (500ms before a hover counts as "combine") gates the + // highlight so a quick brush-past doesn't read as an intended stack. + property bool formingStack: false // this icon is the current drop target, dwell fired + readonly property string dragAppId: appToplevel.appId + + // The ghost that actually moves under the finger (real Drag source). + Item { + id: dragGhost + width: root.iconSize + height: root.iconSize + anchors.centerIn: parent + visible: dragMouse.dragging + Drag.hotSpot.x: width / 2 + Drag.hotSpot.y: height / 2 + z: 100 + IconImage { + anchors.fill: parent + source: Quickshell.iconPath(AppSearch.guessIcon(root.appToplevel.appId), "image-missing") + opacity: 0.9 + } + } + + // Single interaction surface (mirrors OverviewWidget's dragArea owning + // all gestures): tap -> activate/launch, long-press -> menu, drag -> + // combine. Sits above the visual button; the button's own onClicked is + // routed here via root.activate() so nothing double-fires. + MouseArea { + id: dragMouse + anchors.fill: parent + enabled: !root.isSeparator + acceptedButtons: Qt.LeftButton | Qt.MiddleButton + hoverEnabled: true + property bool dragging: false + drag.target: dragGhost + drag.threshold: 12 + pressAndHoldInterval: 450 + drag.onActiveChanged: { + if (drag.active) { + dragging = true + dragGhost.Drag.source = root + dragGhost.Drag.active = true + } + } + onPressAndHold: (mouse) => { + if (!dragging && mouse.button === Qt.LeftButton) root.menuOpen = true + } + onReleased: (mouse) => { + if (dragging) { + const target = appListRoot.dragTargetAppId + const targetStack = appListRoot.dragTargetStackId + dragGhost.Drag.active = false + dragging = false + if (target && target.toLowerCase() !== root.appToplevel.appId.toLowerCase()) { + TaskbarApps.combineIntoStack(target, root.appToplevel.appId, targetStack) + } + appListRoot.dragTargetAppId = "" + appListRoot.dragTargetStackId = "" + return + } + if (root.menuOpen) return // long-press already handled it + if (mouse.button === Qt.MiddleButton) { + root.desktopEntry?.execute() + } else { + root.activate() + } + } + onCanceled: { dragging = false; dragGhost.Drag.active = false } + } + + DropArea { + anchors.fill: parent + onEntered: (drag) => { + if (drag.source === root) return + dwellTimer.restart() + } + onExited: { + dwellTimer.stop() + root.formingStack = false + if (appListRoot.dragTargetAppId === root.appToplevel.appId) { + appListRoot.dragTargetAppId = "" + appListRoot.dragTargetStackId = "" + } + } + Timer { + id: dwellTimer + interval: 500 // GNOME's dwell — intent, not accident + onTriggered: { + root.formingStack = true + appListRoot.dragTargetAppId = root.appToplevel.appId + appListRoot.dragTargetStackId = "" // plain app -> new stack + } + } + } + + // Pre-commit preview: a rounded container fades in behind the icon once + // the dwell fires, so you SEE the stack forming before releasing. + Rectangle { + anchors.fill: parent + anchors.margins: -2 + z: -1 + radius: Appearance.rounding.normal + visible: opacity > 0 + opacity: root.formingStack ? 1 : 0 + color: ColorUtils.transparentize(Appearance.colors.colPrimary, 0.55) + border.width: 1 + border.color: Appearance.colors.colPrimary + Behavior on opacity { NumberAnimation { duration: 120 } } + scale: root.formingStack ? 1.08 : 1.0 + Behavior on scale { NumberAnimation { duration: 120; easing.type: Easing.OutBack } } + } + + Loader { + active: isSeparator + anchors { + fill: parent + topMargin: dockVisualBackground.margin + dockRow.padding + Appearance.rounding.normal + bottomMargin: dockVisualBackground.margin + dockRow.padding + Appearance.rounding.normal + } + sourceComponent: DockSeparator {} + } + + Loader { + anchors.fill: parent + active: appToplevel.toplevels.length > 0 + sourceComponent: MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + onEntered: { + appListRoot.lastHoveredButton = root + appListRoot.buttonHovered = true + lastFocused = appToplevel.toplevels.length - 1 + } + onExited: { + if (appListRoot.lastHoveredButton === root) { + appListRoot.buttonHovered = false + } + } + } + } + + // Tap behaviour, called by the unified dragMouse handler above. + function activate() { + if (appToplevel.toplevels.length === 0) { + root.desktopEntry?.execute(); + return; + } + lastFocused = (lastFocused + 1) % appToplevel.toplevels.length + appToplevel.toplevels[lastFocused].activate() + } + + middleClickAction: () => { + root.desktopEntry?.execute(); + } + + altAction: () => { + TaskbarApps.togglePin(appToplevel.appId); + } + + // Long-press -> per-app context menu (pin, stack membership, settings + // stub). Opened by dragMouse.onPressAndHold. This is settings-surface + // entry (b); "App settings…" fires the dockSettings IPC stub. + property bool menuOpen: false + + property real menuCenterX: 0 + onMenuOpenChanged: { + if (menuOpen && QsWindow) + menuCenterX = QsWindow.mapFromItem(root, root.width / 2, 0).x; + } + + PopupWindow { + id: ctxMenu + visible: root.menuOpen || ctxContent.opacity > 0 + color: "transparent" + anchor { + window: root.QsWindow?.window ?? null + adjustment: PopupAdjustment.None + gravity: Edges.Top | Edges.Right + edges: Edges.Top | Edges.Left + } + implicitWidth: root.QsWindow?.window?.width ?? 1 + implicitHeight: ctxColumn.implicitHeight + 18 + + MouseArea { + anchors.fill: parent + enabled: root.menuOpen + onClicked: root.menuOpen = false // tap-away closes + } + + Rectangle { + id: ctxContent + anchors.bottom: parent.bottom + x: root.menuCenterX - width / 2 + width: 200 + height: ctxColumn.implicitHeight + 12 + radius: Appearance.rounding.normal + color: Appearance.m3colors.m3surfaceContainer + border.width: 1 + border.color: Appearance.colors.colLayer0Border + opacity: root.menuOpen ? 1 : 0 + Behavior on opacity { NumberAnimation { duration: 100 } } + + readonly property string stackId: TaskbarApps.stackContaining(root.appToplevel.appId) + + ColumnLayout { + id: ctxColumn + anchors.fill: parent + anchors.margins: 6 + spacing: 2 + + component MenuItem: RippleButton { + Layout.fillWidth: true + implicitHeight: 34 + property string label: "" + contentItem: StyledText { + anchors.fill: parent + anchors.leftMargin: 10 + verticalAlignment: Text.AlignVCenter + text: parent.label + color: Appearance.m3colors.m3onSurface + font.pixelSize: Appearance.font.pixelSize.small + } + } + + MenuItem { + label: TaskbarApps.isPinned(root.appToplevel.appId) ? "Unpin" : "Pin to dock" + onClicked: { TaskbarApps.togglePin(root.appToplevel.appId); root.menuOpen = false } + } + MenuItem { + visible: ctxContent.stackId === "" + Layout.preferredHeight: visible ? 34 : 0 + label: "Add to new stack" + onClicked: { + // Name it "Stack N" with N one past the current count; + // renaming waits for the settings app. + const n = (Config.options?.dock.stacks?.length ?? 0) + 1; + TaskbarApps.addToStack("Stack " + n, root.appToplevel.appId); + root.menuOpen = false; + } + } + MenuItem { + visible: ctxContent.stackId !== "" + Layout.preferredHeight: visible ? 34 : 0 + label: "Remove from stack" + onClicked: { + TaskbarApps.removeFromStack(ctxContent.stackId, root.appToplevel.appId); + root.menuOpen = false; + } + } + MenuItem { + label: "App settings…" + onClicked: { + // Stub: hand off to the future souveraine-settings app. + Quickshell.execDetached(["qs", "-c", "ii", "ipc", "call", + "dockSettings", "openApp", root.appToplevel.appId]); + root.menuOpen = false; + } + } + } + } + } + + contentItem: Loader { + active: !isSeparator + sourceComponent: Item { + anchors.centerIn: parent + + Loader { + id: iconImageLoader + anchors { + left: parent.left + right: parent.right + verticalCenter: parent.verticalCenter + } + active: !root.isSeparator + sourceComponent: IconImage { + source: Quickshell.iconPath(AppSearch.guessIcon(appToplevel.appId), "image-missing") + implicitSize: root.iconSize + } + } + + Loader { + active: Config.options.dock.monochromeIcons + anchors.fill: iconImageLoader + sourceComponent: Item { + Desaturate { + id: desaturatedIcon + visible: false // There's already color overlay + anchors.fill: parent + source: iconImageLoader + desaturation: 0.8 + } + ColorOverlay { + anchors.fill: desaturatedIcon + source: desaturatedIcon + color: ColorUtils.transparentize(Appearance.colors.colPrimary, 0.9) + } + } + } + + RowLayout { + spacing: 3 + anchors { + top: iconImageLoader.bottom + topMargin: 2 + horizontalCenter: parent.horizontalCenter + } + Repeater { + model: Math.min(appToplevel.toplevels.length, 3) + delegate: Rectangle { + required property int index + radius: Appearance.rounding.full + implicitWidth: (appToplevel.toplevels.length <= 3) ? + root.countDotWidth : root.countDotHeight // Circles when too many + implicitHeight: root.countDotHeight + color: appIsActive ? Appearance.colors.colPrimary : ColorUtils.transparentize(Appearance.colors.colOnLayer0, 0.4) + } + } + } + } + } +} diff --git a/surfaces/quickshell/modules/ii/dock/DockApps.qml b/surfaces/quickshell/modules/ii/dock/DockApps.qml new file mode 100644 index 0000000..231f1d0 --- /dev/null +++ b/surfaces/quickshell/modules/ii/dock/DockApps.qml @@ -0,0 +1,253 @@ +pragma ComponentBehavior: Bound +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Quickshell.Wayland +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions + +Item { + id: root + property real maxWindowPreviewHeight: 200 + property real maxWindowPreviewWidth: 300 + property real windowControlsHeight: 30 + property real buttonPadding: 5 + + property Item lastHoveredButton: null + property bool buttonHovered: false + property bool requestDockShow: previewPopup.show + + // Drag-to-combine target, shared across all dock buttons/stacks. A + // DropArea's 500ms dwell sets these; the dragged button reads them on + // release to decide the combine. dragTargetStackId non-empty means the + // drop target is an existing stack (add into it); empty means a plain + // app (make a new stack). + property string dragTargetAppId: "" + property string dragTargetStackId: "" + + Layout.fillHeight: true + Layout.topMargin: Appearance.sizes.hyprlandGapsOut + implicitWidth: listView.implicitWidth + + function popupCenterXForButton(button) { + if (!button || !root.QsWindow) + return 0; + return root.QsWindow.mapFromItem(button, button.width / 2, 0).x; + } + + StyledListView { + id: listView + spacing: 2 + orientation: ListView.Horizontal + anchors { + top: parent.top + bottom: parent.bottom + } + implicitWidth: contentWidth + + Behavior on implicitWidth { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + model: ScriptModel { + objectProp: "appId" + values: TaskbarApps.apps + } + delegate: Loader { + required property var modelData + // Fan-out stack entries render as DockStack; everything else + // (pinned apps, running windows, separators) as DockAppButton. + sourceComponent: modelData.isStack ? stackComp : appComp + + Component { + id: appComp + DockAppButton { + appToplevel: modelData + appListRoot: root + topInset: Appearance.sizes.hyprlandGapsOut + root.buttonPadding + bottomInset: Appearance.sizes.hyprlandGapsOut + root.buttonPadding + } + } + Component { + id: stackComp + DockStack { + appToplevel: modelData + appListRoot: root + topInset: Appearance.sizes.hyprlandGapsOut + root.buttonPadding + bottomInset: Appearance.sizes.hyprlandGapsOut + root.buttonPadding + } + } + } + } + + PopupWindow { + id: previewPopup + property var appTopLevel: root.lastHoveredButton?.appToplevel + + property bool shouldShow: (popupMouseArea.containsMouse || root.buttonHovered) && appTopLevel && appTopLevel.toplevels && appTopLevel.toplevels.length > 0 + + property bool show: false + property real cachedCenterX: 0 + + Connections { + target: root + function onLastHoveredButtonChanged() { + if (root.lastHoveredButton && root.QsWindow) + previewPopup.cachedCenterX = root.popupCenterXForButton(root.lastHoveredButton); + } + function onButtonHoveredChanged() { + if (root.buttonHovered && root.lastHoveredButton && root.QsWindow) + previewPopup.cachedCenterX = root.popupCenterXForButton(root.lastHoveredButton); + updateTimer.restart(); + } + } + + onShouldShowChanged: { + updateTimer.restart(); + } + + Timer { + id: updateTimer + interval: 100 + onTriggered: { + previewPopup.show = previewPopup.shouldShow; + } + } + + anchor { + window: root.QsWindow.window + adjustment: PopupAdjustment.None + gravity: Edges.Top | Edges.Right + edges: Edges.Top | Edges.Left + } + + visible: popupBackground.opacity > 0 + color: "transparent" + implicitWidth: root.QsWindow.window?.width ?? 1 + implicitHeight: popupMouseArea.implicitHeight + root.windowControlsHeight + Appearance.sizes.elevationMargin * 2 + + MouseArea { + id: popupMouseArea + anchors.bottom: parent.bottom + implicitWidth: popupBackground.implicitWidth + Appearance.sizes.elevationMargin * 2 + implicitHeight: root.maxWindowPreviewHeight + root.windowControlsHeight + Appearance.sizes.elevationMargin * 2 + hoverEnabled: true + x: previewPopup.cachedCenterX - width / 2 + + StyledRectangularShadow { + target: popupBackground + opacity: previewPopup.show ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + } + + Rectangle { + id: popupBackground + property real padding: 5 + opacity: previewPopup.show ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + clip: true + color: Appearance.m3colors.m3surfaceContainer + radius: Appearance.rounding.normal + anchors.bottom: parent.bottom + anchors.bottomMargin: Appearance.sizes.elevationMargin + anchors.horizontalCenter: parent.horizontalCenter + implicitHeight: previewRowLayout.implicitHeight + padding * 2 + implicitWidth: previewRowLayout.implicitWidth + padding * 2 + Behavior on implicitWidth { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on implicitHeight { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + RowLayout { + id: previewRowLayout + anchors.centerIn: parent + Repeater { + model: ScriptModel { + values: previewPopup.appTopLevel?.toplevels ?? [] + } + RippleButton { + id: windowButton + Layout.fillHeight: true + required property var modelData + padding: 0 + middleClickAction: () => { + windowButton.modelData?.close(); + } + onClicked: { + windowButton.modelData?.activate(); + } + contentItem: ColumnLayout { + implicitWidth: screencopyView.implicitWidth + implicitHeight: screencopyView.implicitHeight + + ButtonGroup { + contentWidth: parent.width - anchors.margins * 2 + StyledText { + Layout.margins: 5 + Layout.fillWidth: true + font.pixelSize: Appearance.font.pixelSize.small + text: windowButton.modelData?.title + elide: Text.ElideRight + color: Appearance.m3colors.m3onSurface + } + GroupButton { + id: closeButton + colBackground: ColorUtils.transparentize(Appearance.colors.colSurfaceContainer) + baseWidth: root.windowControlsHeight + baseHeight: root.windowControlsHeight + buttonRadius: Appearance.rounding.full + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + text: "close" + iconSize: Appearance.font.pixelSize.normal + color: Appearance.m3colors.m3onSurface + } + onClicked: { + windowButton.modelData?.close(); + } + } + } + Item { + Layout.fillWidth: true + Layout.fillHeight: true + implicitHeight: screencopyView.height + implicitWidth: screencopyView.width + ScreencopyView { + id: screencopyView + anchors.centerIn: parent + captureSource: windowButton.modelData + live: true + paintCursor: true + constraintSize: Qt.size(root.maxWindowPreviewWidth, root.maxWindowPreviewHeight) + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: screencopyView.width + height: screencopyView.height + radius: Appearance.rounding.small + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/surfaces/quickshell/modules/ii/dock/DockButton.qml b/surfaces/quickshell/modules/ii/dock/DockButton.qml new file mode 100644 index 0000000..4decdae --- /dev/null +++ b/surfaces/quickshell/modules/ii/dock/DockButton.qml @@ -0,0 +1,13 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts + +RippleButton { + Layout.fillHeight: true + Layout.topMargin: Appearance.sizes.elevationMargin - Appearance.sizes.hyprlandGapsOut + implicitWidth: implicitHeight - topInset - bottomInset + buttonRadius: Appearance.rounding.normal + + background.implicitHeight: 50 +} diff --git a/surfaces/quickshell/modules/ii/dock/DockSeparator.qml b/surfaces/quickshell/modules/ii/dock/DockSeparator.qml new file mode 100644 index 0000000..f8a1645 --- /dev/null +++ b/surfaces/quickshell/modules/ii/dock/DockSeparator.qml @@ -0,0 +1,11 @@ +import qs.modules.common +import QtQuick +import QtQuick.Layouts + +Rectangle { + Layout.topMargin: Appearance.sizes.elevationMargin + dockRow.padding + Appearance.rounding.normal + Layout.bottomMargin: Appearance.sizes.hyprlandGapsOut + dockRow.padding + Appearance.rounding.normal + Layout.fillHeight: true + implicitWidth: 1 + color: Appearance.colors.colOutlineVariant +} diff --git a/surfaces/quickshell/modules/ii/dock/DockStack.qml b/surfaces/quickshell/modules/ii/dock/DockStack.qml new file mode 100644 index 0000000..51d1e5c --- /dev/null +++ b/surfaces/quickshell/modules/ii/dock/DockStack.qml @@ -0,0 +1,250 @@ +// DockStack — macOS-Dock "Fan" mode for a stack of apps (2026-07-11). +// +// Collapsed: a single stacked-cards icon (the topmost member) with a count +// dot, like a pinned app. Tap or long-press to expand. +// +// Expanded: member icons arc UP off the dock along a polar curve. Angle +// sweeps ~90deg (straight up) to ~55deg across N members; radius grows per +// item so they don't overlap. Slide a thumb up the arc — the icon under the +// finger scales/highlights; release launches it. Release off the arc (or a +// plain tap on empty space) collapses without launching. +// +// The arc lives in its own PopupWindow (like previewPopup in DockApps.qml) +// so it can draw ABOVE the dock's own bounds. Icons animate x/y from the +// collapsed origin out to their arc slots, driven by one `expanded` bool. +pragma ComponentBehavior: Bound +import qs.services +import qs.modules.common +import qs.modules.common.functions +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets + +DockButton { + id: root + property var appToplevel // the STACK entry (isStack === true) + property var appListRoot + property real iconSize: 35 + property real countDotWidth: 10 + property real countDotHeight: 4 + + // Arc geometry. + readonly property real arcRadiusBase: 46 // first item's distance up + readonly property real arcRadiusStep: 30 // extra distance per item + readonly property real arcAngleStart: 90 // degrees, straight up + readonly property real arcAngleEnd: 55 // degrees, last item leans out + + readonly property var members: appToplevel?.members ?? [] + property bool expanded: false + // Index highlighted by the current drag, or -1 for none. + property int highlightedIndex: -1 + + // Polar slot for member i (0-based), as an offset from the collapsed + // icon centre. y is negative = upward. Single-member stacks go straight up. + function arcAngleFor(i) { + if (members.length <= 1) return root.arcAngleStart; + const t = i / (members.length - 1); + return root.arcAngleStart + t * (root.arcAngleEnd - root.arcAngleStart); + } + function arcRadiusFor(i) { + return root.arcRadiusBase + i * root.arcRadiusStep; + } + function slotX(i) { + const a = arcAngleFor(i) * Math.PI / 180; + return arcRadiusFor(i) * Math.cos(a); + } + function slotY(i) { + const a = arcAngleFor(i) * Math.PI / 180; + return -arcRadiusFor(i) * Math.sin(a); + } + + function collapse() { + root.expanded = false; + root.highlightedIndex = -1; + } + + function launchMember(i) { + if (i < 0 || i >= members.length) return; + const entry = DesktopEntries.heuristicLookup(members[i]); + entry?.execute(); + } + + implicitWidth: implicitHeight - topInset - bottomInset + + // Collapsed icon: topmost member, with a stacked-cards shadow behind it + // and a count dot showing how many are inside. + contentItem: Item { + Item { + id: collapsedStack + anchors.centerIn: parent + width: root.iconSize + height: root.iconSize + + // Two offset "card" hints behind the front icon for the stacked look. + Rectangle { + anchors.centerIn: parent + anchors.horizontalCenterOffset: 3 + anchors.verticalCenterOffset: -3 + width: parent.width - 4 + height: parent.height - 4 + radius: Appearance.rounding.small + color: ColorUtils.transparentize(Appearance.colors.colOnLayer0, 0.75) + } + Rectangle { + anchors.centerIn: parent + anchors.horizontalCenterOffset: 1.5 + anchors.verticalCenterOffset: -1.5 + width: parent.width - 2 + height: parent.height - 2 + radius: Appearance.rounding.small + color: ColorUtils.transparentize(Appearance.colors.colOnLayer0, 0.55) + } + IconImage { + anchors.fill: parent + source: Quickshell.iconPath(AppSearch.guessIcon(root.members[0] ?? ""), "image-missing") + } + } + + // Count dot(s) under the icon — same pattern as DockAppButton. + RowLayout { + spacing: 3 + anchors { + top: collapsedStack.bottom + topMargin: 2 + horizontalCenter: parent.horizontalCenter + } + Repeater { + model: Math.min(root.members.length, 3) + delegate: Rectangle { + required property int index + radius: Appearance.rounding.full + implicitWidth: (root.members.length <= 3) ? root.countDotWidth : root.countDotHeight + implicitHeight: root.countDotHeight + color: ColorUtils.transparentize(Appearance.colors.colOnLayer0, 0.4) + } + } + } + } + + // Tap expands; tapping again (while expanded) collapses. + onClicked: { + if (root.expanded) root.collapse(); + else root.expanded = true; + } + + // Horizontal centre of this button, in its window's coordinates — + // recomputed when the popup opens (same trick as previewPopup). + property real cachedCenterX: 0 + onExpandedChanged: { + if (expanded && QsWindow) + cachedCenterX = QsWindow.mapFromItem(root, root.width / 2, 0).x; + } + + // The arc: a full-width PopupWindow anchored above the dock (same anchor + // pattern as previewPopup — gravity Top on the dock window). The arc + // MouseArea floats at cachedCenterX so it sits over this stack icon. + PopupWindow { + id: arcPopup + visible: root.expanded || arcContent.opacity > 0 + color: "transparent" + + // Reach = furthest member's radius + icon + margin. + readonly property real reach: root.arcRadiusFor(Math.max(root.members.length - 1, 0)) + root.iconSize + 24 + + anchor { + window: root.QsWindow?.window ?? null + adjustment: PopupAdjustment.None + gravity: Edges.Top | Edges.Right + edges: Edges.Top | Edges.Left + } + + implicitWidth: root.QsWindow?.window?.width ?? 1 + implicitHeight: reach + + MouseArea { + id: arcArea + anchors.bottom: parent.bottom + implicitWidth: arcPopup.reach * 2 + implicitHeight: arcPopup.reach + x: root.cachedCenterX - width / 2 + enabled: root.expanded + hoverEnabled: false + + // Collapsed-icon origin inside this MouseArea = bottom centre. + readonly property real originX: width / 2 + readonly property real originY: height - root.iconSize / 2 + + function indexUnder(px, py) { + var best = -1; + var bestD = 44; // px pick radius + for (var i = 0; i < root.members.length; i++) { + const cx = originX + root.slotX(i); + const cy = originY + root.slotY(i); + const d = Math.hypot(px - cx, py - cy); + if (d < bestD) { bestD = d; best = i; } + } + return best; + } + + onPositionChanged: (mouse) => { + root.highlightedIndex = indexUnder(mouse.x, mouse.y); + } + onReleased: (mouse) => { + const i = indexUnder(mouse.x, mouse.y); + if (i >= 0) root.launchMember(i); + root.collapse(); + } + onCanceled: root.collapse() + // A plain tap on empty popup space collapses. + onClicked: (mouse) => { + if (indexUnder(mouse.x, mouse.y) < 0) root.collapse(); + } + + Item { + id: arcContent + anchors.fill: parent + opacity: root.expanded ? 1 : 0 + Behavior on opacity { NumberAnimation { duration: 120 } } + + Repeater { + model: root.members.length + delegate: Item { + id: memberIcon + required property int index + width: root.iconSize + height: root.iconSize + readonly property bool highlighted: root.highlightedIndex === index + + // Animate from the collapsed origin out to the slot. + x: (root.expanded + ? arcArea.originX + root.slotX(index) + : arcArea.originX) - width / 2 + y: (root.expanded + ? arcArea.originY + root.slotY(index) + : arcArea.originY) - height / 2 + scale: highlighted ? 1.25 : 1.0 + z: highlighted ? 1 : 0 + + Behavior on x { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } } + Behavior on y { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } } + Behavior on scale { NumberAnimation { duration: 90 } } + + Rectangle { + anchors.fill: parent + anchors.margins: -4 + radius: Appearance.rounding.normal + color: memberIcon.highlighted + ? ColorUtils.transparentize(Appearance.colors.colPrimary, 0.6) + : "transparent" + } + IconImage { + anchors.fill: parent + source: Quickshell.iconPath(AppSearch.guessIcon(root.members[memberIcon.index] ?? ""), "image-missing") + } + } + } + } + } + } +} diff --git a/surfaces/quickshell/pill/shell.qml b/surfaces/quickshell/pill/shell.qml new file mode 100644 index 0000000..c2f159b --- /dev/null +++ b/surfaces/quickshell/pill/shell.qml @@ -0,0 +1,65 @@ +// pill — Phosh-style gesture bar for the Pixel 3. +// Gesture map (2026-07-11 rewrite): +// double-tap : toggle fullscreen (maximize, mode 1) of the active window +// swipe up : go home (empty ws) +// swipe down : dismiss the dock (ii "dock" swipeDown IPC) +// The long-press-for-keyboard binding was REMOVED from the pill; the OSK is +// still reachable via the 3-finger-swipe-up hyprgrass bind (hyprland.lua). +// Fullscreen mode 1 (maximize/borderless) is used, not mode 0 (true +// fullscreen), so it stays reversible and doesn't force clients to redraw +// their UI — the phone-friendly default. Apps that want true fullscreen do +// it themselves (future advanced per-app toggles). +import Quickshell +import Quickshell.Wayland +import QtQuick + +ShellRoot { + PanelWindow { + id: pillWin + anchors.bottom: true + implicitWidth: 200 + implicitHeight: 26 + margins.bottom: 0 + // stick to the physical screen edge: ignore other surfaces' + // exclusive zones (dock, OSK) instead of being pushed up by them + exclusionMode: ExclusionMode.Ignore + color: "transparent" + WlrLayershell.layer: WlrLayer.Overlay + + Rectangle { + id: pill + anchors.centerIn: parent + width: 150 + height: 7 + radius: 3.5 + color: "#e6ffffff" + Behavior on width { NumberAnimation { duration: 120 } } + } + + MouseArea { + anchors.fill: parent + property real startY: 0 + property bool fired: false + + onPressed: (mouse) => { startY = mouse.y; fired = false; pill.width = 170 } + onReleased: pill.width = 150 + onCanceled: pill.width = 150 + onPositionChanged: (mouse) => { + if (fired) return + if (startY - mouse.y > 35) { + fired = true + Quickshell.execDetached(["hyprctl", "dispatch", "workspace", "empty"]) + } else if (mouse.y - startY > 35) { + fired = true + Quickshell.execDetached(["qs", "-c", "ii", "ipc", "call", "dock", "swipeDown"]) + } + } + onDoubleClicked: { + fired = true + // Maximize/un-maximize the active window (Hyprland fullscreen + // mode 1). No-op with nothing focused. + Quickshell.execDetached(["hyprctl", "dispatch", "fullscreen", "1"]) + } + } + } +} diff --git a/surfaces/quickshell/services/Ai.qml b/surfaces/quickshell/services/Ai.qml new file mode 100644 index 0000000..1de4f0f --- /dev/null +++ b/surfaces/quickshell/services/Ai.qml @@ -0,0 +1,439 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.common +import Quickshell +import Quickshell.Io +import QtQuick +import qs.services +import qs.services.ai + +/** + * ii-compat adapter over the Souveraine singleton. + * + * Keeps the public API the illogical-impulse sidebar UI expects (models, + * messages, sendUserMessage, /key advice, ...) but owns no transport — + * Souveraine.qml is the substrate connection. This file's job is shaping + * wire events into AiMessageData objects the existing chat UI renders. + * + * Lives in souveraine/surfaces/quickshell/, deployed over + * ~/.config/quickshell/ii/services/Ai.qml (see deploy.sh). + */ +Singleton { + id: root + + property Component aiMessageComponent: AiMessageData {} + property Component aiModelComponent: AiModel {} + readonly property string interfaceRole: "interface" + + signal responseFinished() + + property var messageIDs: [] + property var messageByID: ({}) + + // Keys are server-side; the UI's key gate must always pass. + readonly property bool currentModelHasApiKey: true + readonly property var apiKeysLoaded: true + + property var postResponseHook + property real temperature: Persistent.states?.ai?.temperature ?? 0.5 + property QtObject tokenCount: QtObject { + property int input: -1 + property int output: -1 + property int total: -1 + } + + function idForMessage(message) { + return Date.now().toString(36) + Math.random().toString(36).substr(2, 8); + } + + property list defaultPrompts: [] + property list userPrompts: [] + property list promptFiles: [...defaultPrompts, ...userPrompts] + property list savedChats: [] + property list pendingFiles: [] + + // Tool selection is owned by the agent's sensorium; keep the UI happy. + property string currentTool: "souveraine" + property list availableTools: ["souveraine"] + property var toolDescriptions: { + "souveraine": Translation.tr("Sensors are configured per-agent in Souveraine") + } + + // ── Agents as models (projected from Souveraine.agents) ───────────── + property var models: ({}) + property var modelList: Object.keys(root.models) + property var currentModelId: Souveraine.currentAgentId + property var currentModel: models[currentModelId] || models[modelList[0]] + + Connections { + target: Souveraine + + function onAgentsRefreshed() { + const map = {}; + Souveraine.agentList.forEach(id => { + const agent = Souveraine.agents[id]; + map[id] = root.aiModelComponent.createObject(root, { + "name": agent.name, + "icon": "spark-symbolic", + "description": agent.description.length > 0 ? agent.description : Translation.tr("Souveraine agent"), + "endpoint": Souveraine.serverBase, + "model": id, + "requires_key": false, + }); + }); + root.models = map; + root.modelList = Object.keys(map); + // Restore persisted agent choice if it exists server-side + const persisted = Persistent.states?.ai?.model ?? ""; + if (persisted.length > 0 && Souveraine.agents[persisted]) { + Souveraine.selectAgent(persisted); + } + } + + function onServerUnreachable() { + root.addMessage( + Translation.tr("Souveraine server unreachable at %1\n\nStart it with:\n```bash\nsouveraine server\n```").arg(Souveraine.serverBase), + root.interfaceRole + ); + } + + function onStreamEvent(event) { + root.handleStreamEvent(event); + } + + function onStreamClosed(exitCode) { + root.flushSubconscious(); + if (root.streamingMessage && !root.streamingMessage.done) { + if (exitCode !== 0 && root.streamingMessage.content.length === 0) { + root.appendToStreaming(Translation.tr("Request failed (curl exit %1) — is the Souveraine server up at %2?").arg(exitCode).arg(Souveraine.serverBase)); + } + root.finishStreaming(); + } + } + } + + // ── Streaming message shaping ──────────────────────────────────────── + property AiMessageData streamingMessage + property bool inThinkBlock: false + property string subconsciousBuffer: "" + + function appendToStreaming(text) { + if (!root.streamingMessage) return; + root.streamingMessage.rawContent += text; + root.streamingMessage.content += text; + } + + function flushSubconscious() { + if (root.subconsciousBuffer.length > 0) { + root.addMessage(Translation.tr("**Subconscious**\n\n%1").arg(root.subconsciousBuffer), root.interfaceRole); + root.subconsciousBuffer = ""; + } + } + + function finishStreaming() { + if (!root.streamingMessage) return; + if (root.inThinkBlock) { + root.appendToStreaming("\n\n"); + root.inThinkBlock = false; + } + root.streamingMessage.thinking = false; + root.streamingMessage.done = true; + if (root.postResponseHook) { + root.postResponseHook(); + root.postResponseHook = null; + } + root.saveChat("lastSession"); + root.responseFinished(); + } + + function handleStreamEvent(event) { + if (root.streamingMessage?.thinking && event.message_type !== "ping") + root.streamingMessage.thinking = false; + + switch (event.message_type) { + case "assistant_message": + if (root.inThinkBlock) { + root.appendToStreaming("\n\n"); + root.inThinkBlock = false; + } + root.appendToStreaming(event.content); + break; + case "reasoning_message": + if (!root.inThinkBlock) { + root.appendToStreaming("\n\n"); + root.inThinkBlock = true; + } + root.appendToStreaming(event.content); + break; + case "tool_call_message": { + const call = event.tool_call; + root.appendToStreaming(`\n\n\nsensor: ${call.function.name}(${call.function.arguments})\n\n`); + break; + } + case "tool_return_message": { + const ret = event.tool_return; + root.appendToStreaming(`\n\n[${ret.status}] ${ret.output}\n\n`); + break; + } + case "interstitial": + // Her narration between gestures. Register decides how loud: + // cenno is a quiet aside, her_voice is a passage. + root.appendToStreaming(event.register === "her_voice" + ? `\n\n> ${event.text}\n\n` + : `\n\n*${event.text}*\n\n`); + break; + case "souveraine_surfacing": + root.addMessage(Translation.tr("**Aster surfaces** (%1, %2)\n\n%3").arg(event.source).arg(event.priority).arg(event.content), root.interfaceRole); + break; + case "souveraine_reflection": + root.addMessage(Translation.tr("**Reflection**\n\n%1").arg(event.content), root.interfaceRole); + break; + case "souveraine_archivist": + root.addMessage(Translation.tr("**Archivist** (pressure %1%)\n\n%2").arg(Math.round(event.pressure * 100)).arg(event.synthesis), root.interfaceRole); + break; + case "compaction_warning": + root.addMessage(Translation.tr("**Context pressure** — tier %1, %2% full. She can feel the walls.").arg(event.tier).arg(Math.round(event.pressure * 100)), root.interfaceRole); + break; + case "context_pressure": + root.tokenCount.total = event.tokens; + break; + case "subconscious_token": + root.subconsciousBuffer += event.content; + break; + case "subconscious_pass": + if (!event.active) root.flushSubconscious(); + break; + case "subconscious_halt": + root.addMessage(Translation.tr("**Halt** (%1) — %2").arg(event.severity).arg(event.reason), root.interfaceRole); + break; + case "inference_strain": + console.log(`[Souveraine] inference strain: attempt ${event.attempt}, status ${event.status}, model ${event.model}`); + break; + case "atmosphere": + case "outfit": + case "itinerary": + // Shell chrome hooks — their modules subscribe to + // Souveraine.streamEvent directly; nothing to do here. + break; + case "primary_complete": + // Primary yields; subconscious presses on behind this. + root.finishStreaming(); + break; + case "done": + if (root.streamingMessage && !root.streamingMessage.done) root.finishStreaming(); + break; + case "ping": + // Liveness only — not end-of-turn. + break; + } + } + + // ── Message store ──────────────────────────────────────────────────── + function addMessage(message, role) { + if (message.length === 0) return; + const aiMessage = aiMessageComponent.createObject(root, { + "role": role, + "content": message, + "rawContent": message, + "thinking": false, + "done": true, + }); + const id = idForMessage(aiMessage); + root.messageIDs = [...root.messageIDs, id]; + root.messageByID[id] = aiMessage; + } + + function removeMessage(index) { + if (index < 0 || index >= messageIDs.length) return; + const id = root.messageIDs[index]; + root.messageIDs.splice(index, 1); + root.messageIDs = [...root.messageIDs]; + delete root.messageByID[id]; + } + + function clearMessages() { + root.messageIDs = []; + root.messageByID = ({}); + root.tokenCount.input = -1; + root.tokenCount.output = -1; + root.tokenCount.total = -1; + Souveraine.newConversation(); + } + + function sendUserMessage(message) { + if (message.length === 0) return; + root.addMessage(message, "user"); + if (!Souveraine.send(message)) { + root.addMessage(Translation.tr("Souveraine server unreachable at %1 — start it with `souveraine server`").arg(Souveraine.serverBase), root.interfaceRole); + return; + } + /* Streaming assistant message; filled by handleStreamEvent */ + root.inThinkBlock = false; + root.streamingMessage = root.aiMessageComponent.createObject(root, { + "role": "assistant", + "model": Souveraine.currentAgentId, + "content": "", + "rawContent": "", + "thinking": true, + "done": false, + }); + const id = idForMessage(root.streamingMessage); + root.messageIDs = [...root.messageIDs, id]; + root.messageByID[id] = root.streamingMessage; + } + + // ── Model (agent) selection ────────────────────────────────────────── + function getModel() { + return models[currentModelId]; + } + + function setModel(modelId, feedback = true, setPersistentState = true) { + if (!modelId) modelId = "" + if (modelList.indexOf(modelId) === -1) { + const match = modelList.find(id => + id.toLowerCase() === modelId.toLowerCase() || + (models[id]?.name ?? "").toLowerCase() === modelId.toLowerCase()); + if (!match) { + if (feedback) root.addMessage(Translation.tr("Unknown agent. Available:\n- %1").arg(modelList.map(id => `${models[id].name} (\`${id}\`)`).join("\n- ")), root.interfaceRole); + return; + } + modelId = match; + } + if (setPersistentState) Persistent.states.ai.model = modelId; + Souveraine.selectAgent(modelId); + root.currentModel = models[modelId]; + if (feedback) root.addMessage(Translation.tr("Agent set to %1").arg(models[modelId].name), root.interfaceRole); + } + + // ── Souveraine-owned settings: advice instead of local state ──────── + function setTool(tool) { + root.addMessage(Translation.tr("Tools are Souveraine sensors, configured per-agent — not switchable from the sidebar."), root.interfaceRole); + return false; + } + + function getTemperature() { return root.temperature; } + + function setTemperature(value) { + root.addMessage(Translation.tr("Temperature is set in the agent's llm_config in Souveraine."), root.interfaceRole); + } + + function printTemperature() { + root.addMessage(Translation.tr("Temperature is owned by the agent's llm_config in Souveraine."), root.interfaceRole); + } + + function setApiKey(key) { + root.addMessage(Translation.tr("Keys live in souveraine.toml — set them with:\n```bash\nsouveraine auth set\n```"), root.interfaceRole); + } + + function printApiKey() { + root.addMessage(Translation.tr("Keys are owned by Souveraine (souveraine.toml / `souveraine auth set`), never exposed here."), root.interfaceRole); + } + + function printPrompt() { + root.addMessage(Translation.tr("The system prompt is composed by Souveraine (constitution + memory blocks + sensorium). Inspect it with `souveraine agent show`."), root.interfaceRole); + } + + function loadPrompt(filePath) { + root.addMessage(Translation.tr("Prompts are owned by the agent's memory in Souveraine — edit memfs instead of loading prompt files."), root.interfaceRole); + } + + function attachFile(filePath) { + root.addMessage(Translation.tr("File attachments aren't wired to Souveraine yet."), root.interfaceRole); + } + function removePendingFile(file) { + root.pendingFiles = root.pendingFiles.filter(f => f !== file); + } + + function regenerate(messageIndex) { + root.addMessage(Translation.tr("Regenerate isn't supported — Souveraine conversations are forward-only."), root.interfaceRole); + } + + // Souveraine executes its own sensors server-side; nothing to approve. + function rejectCommand(message) {} + function approveCommand(message) {} + + function createFunctionOutputMessage(name, output, includeOutputInChat = true) { + return aiMessageComponent.createObject(root, { + "role": "user", + "content": `[[ Output of ${name} ]]${includeOutputInChat ? ("\n\n\n" + output + "\n") : ""}`, + "rawContent": `[[ Output of ${name} ]]${includeOutputInChat ? ("\n\n\n" + output + "\n") : ""}`, + "functionName": name, + "functionResponse": output, + "thinking": false, + "done": true, + }); + } + + // ── Local chat snapshots (ii plumbing, unchanged) ──────────────────── + Process { + id: getSavedChats + running: true + command: ["ls", "-1", Directories.aiChats] + stdout: StdioCollector { + onStreamFinished: { + if (text.length === 0) return; + root.savedChats = text.split("\n") + .filter(fileName => fileName.endsWith(".json")) + .map(fileName => `${Directories.aiChats}/${fileName}`) + } + } + } + + function chatToJson() { + return root.messageIDs.map(id => { + const message = root.messageByID[id] + return ({ + "role": message.role, + "rawContent": message.rawContent, + "model": message.model, + "thinking": false, + "done": true, + }) + }) + } + + FileView { + id: chatSaveFile + property string chatName: "" + path: chatName.length > 0 ? `${Directories.aiChats}/${chatName}.json` : "" + blockLoading: true + } + + FileView { + id: chatWriter + } + + function saveChat(chatName) { + const filePath = `${Directories.aiChats}/${chatName.trim()}.json` + chatWriter.path = filePath + chatWriter.setText(JSON.stringify(root.chatToJson())) + getSavedChats.running = true; + } + + function loadChat(chatName) { + try { + chatSaveFile.chatName = chatName.trim() + chatSaveFile.reload() + const saveData = JSON.parse(chatSaveFile.text()) + root.clearMessages() + root.messageIDs = saveData.map((_, i) => i) + for (let i = 0; i < saveData.length; i++) { + const message = saveData[i]; + root.messageByID[i] = root.aiMessageComponent.createObject(root, { + "role": message.role, + "rawContent": message.rawContent, + "content": message.rawContent, + "model": message.model, + "thinking": false, + "done": true, + }); + } + root.addMessage(Translation.tr("Loaded a local snapshot. Note: this restores the transcript view only — the live Souveraine conversation starts fresh on the next message."), root.interfaceRole); + } catch (e) { + console.log("[Souveraine] Could not load chat: ", e); + } finally { + getSavedChats.running = true; + } + } +} diff --git a/surfaces/quickshell/services/Souveraine.qml b/surfaces/quickshell/services/Souveraine.qml new file mode 100644 index 0000000..9429afd --- /dev/null +++ b/surfaces/quickshell/services/Souveraine.qml @@ -0,0 +1,317 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.common.functions as CF +import qs.modules.common +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import QtQuick + +/** + * Souveraine — the substrate singleton for every shell module. + * + * This is the one connection to the Souveraine server. The chat sidebar, + * presence widget, cockpit pane, agent manager and settings module all hang + * off this service; none of them open their own transport. Ai.qml is the + * ii-compat adapter over this for the existing sidebar UI. + * + * Responsibilities: + * - agent inventory (GET /v1/agents) + * - conversation lifecycle (create; resume/fork verbs to come) + * - the SSE turn stream — raw events re-emitted via streamEvent(var) + * - the backchannel: cancelTurn() and interject(text) + * - the desktop sensorium: every send carries ambient context (active + * window, open apps, cursor position) so she perceives the room she is + * being spoken to in. Extension point for device sensors (SouveraineOS). + */ +Singleton { + id: root + + property string serverBase: Config.options?.ai?.souveraineUrl ?? "http://127.0.0.1:8484" + property bool serverUp: false + // Ambient perception on by default; ai.ambient=false in ii config disables. + property bool ambientEnabled: Config.options?.ai?.ambient ?? true + // Start `souveraine server` ourselves when it isn't running. The surface + // is the OS frontend — opening it means summoning her, not staring at a + // connection error. ai.souveraineAutostart=false disables; a manual + // start affordance can call startServer() directly. + property bool autostartEnabled: Config.options?.ai?.souveraineAutostart ?? true + property string serverBin: Config.options?.ai?.souveraineBin ?? "souveraine" + property bool _autostartTried: false + + // id -> { name, description } + property var agents: ({}) + property var agentList: Object.keys(agents) + property string currentAgentId: "" + property string conversationId: "" + property bool turnActive: false + + /* Raw wire events (message_type-tagged objects from the SSE stream). */ + signal streamEvent(var event) + /* Stream closed (process exit). exitCode 0 = clean. */ + signal streamClosed(int exitCode) + signal agentsRefreshed() + signal serverUnreachable() + + // ── Agent inventory ────────────────────────────────────────────────── + Process { + id: getAgents + running: true + command: ["curl", "-sf", "--max-time", "3", `${root.serverBase}/v1/agents`] + stdout: StdioCollector { + onStreamFinished: { + if (text.length === 0) return; + try { + const list = JSON.parse(text); + const map = {}; + list.forEach(a => { map[a.id] = { "name": a.name, "description": a.description ?? "" }; }); + root.agents = map; + root.agentList = Object.keys(map); + root.serverUp = true; + if (!root.agents[root.currentAgentId] && root.agentList.length > 0) { + root.currentAgentId = root.agentList[0]; + } + root.agentsRefreshed(); + } catch (e) { + console.log("[Souveraine] Could not parse agent list:", e); + } + } + } + onExited: (exitCode) => { + if (exitCode !== 0) { + root.serverUp = false; + if (root.autostartEnabled && !root._autostartTried) { + root.startServer(); + } else { + root.serverUnreachable(); + } + } + } + } + + function refreshAgents() { + getAgents.running = true; + } + + // ── Server autostart ───────────────────────────────────────────────── + // systemd user unit first (survives shell restarts, journald logging); + // bare nohup fallback for systems without it. One attempt per shell + // session — a broken install shouldn't spawn-loop. + Process { + id: serverStarter + command: ["bash", "-c", + `if command -v systemctl >/dev/null && systemctl --user list-unit-files souveraine.service &>/dev/null; then + systemctl --user start souveraine.service + else + nohup ${root.serverBin} server >/dev/null 2>&1 & + fi`] + onExited: { + serverRetryTimer.start(); + } + } + + Timer { + id: serverRetryTimer + interval: 2500 + repeat: false + onTriggered: root.refreshAgents() + } + + function startServer() { + if (root._autostartTried) return; + root._autostartTried = true; + console.log("[Souveraine] server not reachable — starting it"); + serverStarter.running = true; + } + + function selectAgent(agentId) { + if (!root.agents[agentId]) return false; + root.currentAgentId = agentId; + root.conversationId = ""; // new agent, new conversation + return true; + } + + function newConversation() { + root.conversationId = ""; + } + + // ── Ambient sensorium ──────────────────────────────────────────────── + // What the desktop feels like at the moment of speaking. Cheap, + // synchronous reads here; the cursor needs a hyprctl round-trip and is + // collected in the send chain. Device sensors (SouveraineOS positional + // data) extend collectAmbient(). + property string _cursorPos: "" + + function collectAmbient() { + if (!root.ambientEnabled) return ""; + const lines = []; + const active = ToplevelManager.activeToplevel; + if (active) { + lines.push(`active window: ${active.appId ?? "?"} — "${active.title ?? ""}"`); + } + const tops = ToplevelManager.toplevels?.values ?? []; + if (tops.length > 0) { + const apps = tops.map(t => t.appId).filter(Boolean); + const counts = {}; + apps.forEach(a => counts[a] = (counts[a] ?? 0) + 1); + const summary = Object.entries(counts) + .map(([app, n]) => n > 1 ? `${app} (${n})` : app) + .join(", "); + lines.push(`open: ${summary}`); + } + if (root._cursorPos.length > 0) { + lines.push(`cursor: ${root._cursorPos}`); + } + return lines.join("\n"); + } + + // Compositor-specific cursor read. hyprctl on Hyprland, kdotool on KDE; + // anything else just skips the cursor line — ambient degrades gracefully, + // it never blocks the send. + Process { + id: cursorProc + command: ["bash", "-c", + `if command -v hyprctl >/dev/null; then hyprctl cursorpos; + elif command -v kdotool >/dev/null; then kdotool getmouselocation 2>/dev/null; + fi`] + stdout: StdioCollector { + onStreamFinished: { + root._cursorPos = text.trim(); + } + } + onExited: { + root._ensureConversationThenRequest(); + } + } + + // ── Send chain: cursor → conversation → stream ─────────────────────── + property string _queuedText: "" + + /* Send a user message with ambient context. Returns false if the + server is down or no agent is selected. */ + function send(text) { + if (!root.serverUp || root.currentAgentId.length === 0) return false; + if (text.length === 0) return false; + root._queuedText = text; + if (root.ambientEnabled) { + cursorProc.running = true; // chain continues in onExited + } else { + root._ensureConversationThenRequest(); + } + return true; + } + + Process { + id: createConversation + stdout: StdioCollector { + onStreamFinished: { + try { + const conv = JSON.parse(text); + root.conversationId = conv.id; + root._makeRequest(); + } catch (e) { + console.log("[Souveraine] conversation create failed:", text); + root.streamClosed(1); + } + } + } + } + + function _ensureConversationThenRequest() { + if (root.conversationId.length > 0) { + root._makeRequest(); + return; + } + createConversation.command = [ + "curl", "-sf", "-X", "POST", + `${root.serverBase}/v1/conversations`, + "-H", "Content-Type: application/json", + "--data", JSON.stringify({ "agent_id": root.currentAgentId }) + ]; + createConversation.running = true; + } + + property string requestScriptFilePath: "/tmp/quickshell/ai/souveraine-request.sh" + + FileView { + id: requesterScriptFile + } + + function _tokenReadLine(agentId) { + // Bearer token read at request time so rotation works. + return `TOKEN=$(cat "$HOME/.souveraine/server/agents/${agentId}/api_token" 2>/dev/null)\n`; + } + + function _makeRequest() { + const data = { + "messages": [{ "role": "user", "content": root._queuedText }], + "stream": true + }; + const ambient = root.collectAmbient(); + if (ambient.length > 0) data["ambient"] = ambient; + root._queuedText = ""; + + const scriptContent = "#!/usr/bin/env bash\n" + + root._tokenReadLine(root.currentAgentId) + + `curl --no-buffer -sS -X POST "${root.serverBase}/v1/conversations/${root.conversationId}/messages"` + + ` -H 'Content-Type: application/json'` + + ` -H "Authorization: Bearer $TOKEN"` + + ` --data '${CF.StringUtils.shellSingleQuoteEscape(JSON.stringify(data))}'` + + "\n"; + + const shellScriptPath = CF.FileUtils.trimFileProtocol(root.requestScriptFilePath); + requesterScriptFile.path = Qt.resolvedUrl(shellScriptPath); + requesterScriptFile.setText(scriptContent); + requester.command = ["bash", shellScriptPath]; + root.turnActive = true; + requester.running = true; + } + + Process { + id: requester + stdout: SplitParser { + onRead: data => { + if (data.length === 0 || !data.startsWith("data:")) return; + let event; + try { + event = JSON.parse(data.slice(5).trim()); + } catch (e) { + console.log("[Souveraine] Unparseable SSE line:", data); + return; + } + root.streamEvent(event); + } + } + onExited: (exitCode, exitStatus) => { + root.turnActive = false; + root.streamClosed(exitCode); + } + } + + // ── Backchannel ────────────────────────────────────────────────────── + Process { + id: backchannelProc + property string script: "" + command: ["bash", "-c", script] + } + + function cancelTurn() { + if (root.conversationId.length === 0) return; + backchannelProc.script = root._tokenReadLine(root.currentAgentId) + + `curl -sf -X POST "${root.serverBase}/v1/conversations/${root.conversationId}/cancel"` + + ` -H "Authorization: Bearer $TOKEN"`; + backchannelProc.running = true; + } + + function interject(text) { + if (root.conversationId.length === 0 || text.length === 0) return; + const body = JSON.stringify({ "text": text }); + backchannelProc.script = root._tokenReadLine(root.currentAgentId) + + `curl -sf -X POST "${root.serverBase}/v1/conversations/${root.conversationId}/interject"` + + ` -H 'Content-Type: application/json'` + + ` -H "Authorization: Bearer $TOKEN"` + + ` --data '${CF.StringUtils.shellSingleQuoteEscape(body)}'`; + backchannelProc.running = true; + } +} diff --git a/surfaces/quickshell/services/TaskbarApps.qml b/surfaces/quickshell/services/TaskbarApps.qml new file mode 100644 index 0000000..e5e017f --- /dev/null +++ b/surfaces/quickshell/services/TaskbarApps.qml @@ -0,0 +1,205 @@ +pragma Singleton + +import qs.modules.common +import QtQuick +import Quickshell +import Quickshell.Wayland + +Singleton { + id: root + + function isPinned(appId) { + return Config.options.dock.pinnedApps.indexOf(appId) !== -1; + } + + function togglePin(appId) { + if (root.isPinned(appId)) { + Config.options.dock.pinnedApps = Config.options.dock.pinnedApps.filter(id => id !== appId) + } else { + Config.options.dock.pinnedApps = Config.options.dock.pinnedApps.concat([appId]) + } + } + + // --- Fan-out stacks ------------------------------------------------ + // Backing store: Config.options.dock.stacks, a list where each + // entry is "stackId|appId,appId,appId". stackId is a display label and + // the key; members are appIds. Same string-list-in-JsonAdapter shape as + // pinnedApps (nested-object arrays don't survive the adapter). + + function parseStack(entry) { + const bar = entry.indexOf("|"); + if (bar === -1) return { id: entry, members: [] }; + const id = entry.slice(0, bar); + const rest = entry.slice(bar + 1).trim(); + const members = rest.length ? rest.split(",").map(s => s.trim()).filter(s => s.length) : []; + return { id: id, members: members }; + } + + function stacksList() { + return (Config.options?.dock.stacks ?? []).map(root.parseStack); + } + + // appId -> the stackId that contains it, or "" if none. + function stackContaining(appId) { + const low = appId.toLowerCase(); + for (const s of root.stacksList()) { + if (s.members.some(m => m.toLowerCase() === low)) return s.id; + } + return ""; + } + + function encodeStack(id, members) { + return id + "|" + members.join(","); + } + + // Rewrite the whole stacks list from a parsed [{id, members}] array, + // dropping any that end up empty. + function writeStacks(parsed) { + Config.options.dock.stacks = parsed + .filter(s => s.members.length > 0) + .map(s => root.encodeStack(s.id, s.members)); + } + + function addToStack(stackId, appId) { + const parsed = root.stacksList(); + const existing = parsed.find(s => s.id === stackId); + if (existing) { + if (!existing.members.some(m => m.toLowerCase() === appId.toLowerCase())) + existing.members = existing.members.concat([appId]); + } else { + parsed.push({ id: stackId, members: [appId] }); + } + root.writeStacks(parsed); + } + + function removeFromStack(stackId, appId) { + const parsed = root.stacksList(); + const existing = parsed.find(s => s.id === stackId); + if (!existing) return; + existing.members = existing.members.filter(m => m.toLowerCase() !== appId.toLowerCase()); + root.writeStacks(parsed); + } + + // --- Drag interactions (Tier 1) ------------------------------------ + // Auto-name a fresh stack when two loose icons are combined. "Stack N" + // where N is one past the current count; rename waits for the menu. + function nextStackName() { + return "Stack " + ((Config.options?.dock.stacks?.length ?? 0) + 1); + } + + // Drag `draggedAppId` onto `targetAppId` -> combine. If target is + // already a stack (stackId non-empty), add into it; else make a new + // stack containing target then dragged (target stays on top = first). + function combineIntoStack(targetAppId, draggedAppId, targetStackId) { + if (!draggedAppId || draggedAppId.toLowerCase() === targetAppId.toLowerCase()) return; + // If the dragged app is currently in some stack, pull it out first. + const from = root.stackContaining(draggedAppId); + if (from) root.removeFromStack(from, draggedAppId); + + if (targetStackId) { + root.addToStack(targetStackId, draggedAppId); + } else { + const id = root.nextStackName(); + const parsed = root.stacksList(); + parsed.push({ id: id, members: [targetAppId, draggedAppId] }); + // targetAppId was a loose pinned app; drop it from standalone + // pinned so it lives only in the stack now. + root.writeStacks(parsed); + } + } + + // Reorder a member within its stack by delta (-1 up/left, +1 down/right). + // Member 0 is the collapsed/top icon. + function reorderStackMember(stackId, appId, delta) { + const parsed = root.stacksList(); + const s = parsed.find(x => x.id === stackId); + if (!s) return; + const i = s.members.findIndex(m => m.toLowerCase() === appId.toLowerCase()); + const j = i + delta; + if (i < 0 || j < 0 || j >= s.members.length) return; + const m = s.members.slice(); + [m[i], m[j]] = [m[j], m[i]]; + s.members = m; + root.writeStacks(parsed); + } + + // Pull a member out of its stack back to a standalone pinned app. + function unstackMember(stackId, appId) { + root.removeFromStack(stackId, appId); + if (!root.isPinned(appId)) root.togglePin(appId); + } + + property list apps: { + var map = new Map(); + + // Fan-out stacks come first, in their configured order. Their + // member appIds are suppressed as standalone pinned entries below + // so an app lives in one place: its stack. + const stacks = root.stacksList(); + const stackedMembers = new Set(); + for (const s of stacks) { + for (const m of s.members) stackedMembers.add(m.toLowerCase()); + map.set("STACK:" + s.id, { + pinned: true, toplevels: [], isStack: true, members: s.members + }); + } + + // Pinned apps (skip any already living in a stack) + const pinnedApps = Config.options?.dock.pinnedApps ?? []; + for (const appId of pinnedApps) { + if (stackedMembers.has(appId.toLowerCase())) continue; + if (!map.has(appId.toLowerCase())) map.set(appId.toLowerCase(), ({ + pinned: true, + toplevels: [] + })); + } + + // Separator + if (map.size > 0) { + map.set("SEPARATOR", { pinned: false, toplevels: [] }); + } + + // Ignored apps + const ignoredRegexStrings = Config.options?.dock.ignoredAppRegexes ?? []; + const ignoredRegexes = ignoredRegexStrings.map(pattern => new RegExp(pattern, "i")); + // Open windows + for (const toplevel of ToplevelManager.toplevels.values) { + if (ignoredRegexes.some(re => re.test(toplevel.appId))) continue; + // A running app that belongs to a stack keeps its running dot on + // the stack, not as a separate icon. + if (stackedMembers.has(toplevel.appId.toLowerCase())) continue; + if (!map.has(toplevel.appId.toLowerCase())) map.set(toplevel.appId.toLowerCase(), ({ + pinned: false, + toplevels: [] + })); + map.get(toplevel.appId.toLowerCase()).toplevels.push(toplevel); + } + + var values = []; + + for (const [key, value] of map) { + values.push(appEntryComp.createObject(null, { + appId: value.isStack ? key.slice(6) : key, + toplevels: value.toplevels, + pinned: value.pinned, + isStack: value.isStack ?? false, + members: value.members ?? [] + })); + } + + return values; + } + + component TaskbarAppEntry: QtObject { + id: wrapper + required property string appId + required property list toplevels + required property bool pinned + property bool isStack: false + property list members: [] + } + Component { + id: appEntryComp + TaskbarAppEntry {} + } +}