surfaces/quickshell: bring the phone shell under the surface tree
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.
This commit is contained in:
parent
0e780d5a05
commit
037dc06922
13 changed files with 2982 additions and 0 deletions
98
surfaces/quickshell/README.md
Normal file
98
surfaces/quickshell/README.md
Normal file
|
|
@ -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/<id>/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 `<think>` 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
|
||||
76
surfaces/quickshell/deploy.sh
Executable file
76
surfaces/quickshell/deploy.sh
Executable file
|
|
@ -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: "<repo-relative> <target-relative-to-~/.config/quickshell>"
|
||||
# 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'"
|
||||
637
surfaces/quickshell/modules/common/Config.qml
Normal file
637
surfaces/quickshell/modules/common/Config.qml
Normal file
|
|
@ -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<var> 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<string> 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<string> 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<string> pinnedApps: [ // IDs of pinned entries
|
||||
"org.kde.dolphin", "kitty",]
|
||||
property list<string> 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<string> 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<string> 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<var> 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<string> 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<var> 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<string> networkNameKeywords: ["airport", "cafe", "college", "company", "eduroam", "free", "guest", "public", "school", "university"]
|
||||
property list<string> fileKeywords: ["anime", "booru", "ecchi", "hentai", "yande.re", "konachan", "breast", "nipples", "pussy", "nsfw", "spoiler", "girl"]
|
||||
property list<string> 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<string> 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
255
surfaces/quickshell/modules/ii/dock/Dock.qml
Normal file
255
surfaces/quickshell/modules/ii/dock/Dock.qml
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
363
surfaces/quickshell/modules/ii/dock/DockAppButton.qml
Normal file
363
surfaces/quickshell/modules/ii/dock/DockAppButton.qml
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
253
surfaces/quickshell/modules/ii/dock/DockApps.qml
Normal file
253
surfaces/quickshell/modules/ii/dock/DockApps.qml
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
13
surfaces/quickshell/modules/ii/dock/DockButton.qml
Normal file
13
surfaces/quickshell/modules/ii/dock/DockButton.qml
Normal file
|
|
@ -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
|
||||
}
|
||||
11
surfaces/quickshell/modules/ii/dock/DockSeparator.qml
Normal file
11
surfaces/quickshell/modules/ii/dock/DockSeparator.qml
Normal file
|
|
@ -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
|
||||
}
|
||||
250
surfaces/quickshell/modules/ii/dock/DockStack.qml
Normal file
250
surfaces/quickshell/modules/ii/dock/DockStack.qml
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
65
surfaces/quickshell/pill/shell.qml
Normal file
65
surfaces/quickshell/pill/shell.qml
Normal file
|
|
@ -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"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
439
surfaces/quickshell/services/Ai.qml
Normal file
439
surfaces/quickshell/services/Ai.qml
Normal file
|
|
@ -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<var> defaultPrompts: []
|
||||
property list<var> userPrompts: []
|
||||
property list<var> promptFiles: [...defaultPrompts, ...userPrompts]
|
||||
property list<var> savedChats: []
|
||||
property list<var> pendingFiles: []
|
||||
|
||||
// Tool selection is owned by the agent's sensorium; keep the UI happy.
|
||||
property string currentTool: "souveraine"
|
||||
property list<var> 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</think>\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</think>\n");
|
||||
root.inThinkBlock = false;
|
||||
}
|
||||
root.appendToStreaming(event.content);
|
||||
break;
|
||||
case "reasoning_message":
|
||||
if (!root.inThinkBlock) {
|
||||
root.appendToStreaming("\n<think>\n");
|
||||
root.inThinkBlock = true;
|
||||
}
|
||||
root.appendToStreaming(event.content);
|
||||
break;
|
||||
case "tool_call_message": {
|
||||
const call = event.tool_call;
|
||||
root.appendToStreaming(`\n\n<think>\nsensor: ${call.function.name}(${call.function.arguments})\n</think>\n`);
|
||||
break;
|
||||
}
|
||||
case "tool_return_message": {
|
||||
const ret = event.tool_return;
|
||||
root.appendToStreaming(`\n<think>\n[${ret.status}] ${ret.output}\n</think>\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<think>\n" + output + "\n</think>") : ""}`,
|
||||
"rawContent": `[[ Output of ${name} ]]${includeOutputInChat ? ("\n\n<think>\n" + output + "\n</think>") : ""}`,
|
||||
"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;
|
||||
}
|
||||
}
|
||||
}
|
||||
317
surfaces/quickshell/services/Souveraine.qml
Normal file
317
surfaces/quickshell/services/Souveraine.qml
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
205
surfaces/quickshell/services/TaskbarApps.qml
Normal file
205
surfaces/quickshell/services/TaskbarApps.qml
Normal file
|
|
@ -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<string> 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<var> 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<var> toplevels
|
||||
required property bool pinned
|
||||
property bool isStack: false
|
||||
property list<var> members: []
|
||||
}
|
||||
Component {
|
||||
id: appEntryComp
|
||||
TaskbarAppEntry {}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue