Watch
1
0
Fork
You've already forked souveraine
0

quickshell: run as qs -c souveraine + dock/shell/apps method surfaces

Own config name composed by deploy.sh from our files + pristine-ii dir
borrows; ii tree stays untouched. One SouveraineFamily, two modes gated
on souveraine.phone.

Three guarded IPC surfaces for the agent (via Souveraine's harness, not
a new integration): dock.manifest/pin/unpin/restack, shell.surfaces/state
(layer registry, live-derived), apps.list/get/find/categories (.desktop
scan). Refusals are result shapes, not errors.

Settings app split into per-domain pages (Device/Lock/Dock/Pill/Keyboard).
Config backports (background.widgets, claudeUsage, fileSearch) kill the
laptop TypeErrors.
This commit is contained in:
Fimeg 2026-07-14 15:03:07 -04:00
commit c78c5510af
29 changed files with 2362 additions and 101 deletions

View file

@ -44,6 +44,11 @@ Singleton {
// no timer or secondary state.
property bool dockRevealed: false
// True while a dock icon drag is in flight. Set by DragManager, read by
// DockManifest's state checks so structural edits (pin/stack mutations)
// can't land mid-drag and rewrite dock.stacks out from under a commit.
property bool dockDragInProgress: false
function superPressDuration() {
const now = Date.now();
if (root.superPressTime > 0)

View file

@ -6,7 +6,10 @@ Edit here → `./deploy.sh --phone` → restart. Deployed via symlink; live edit
## Pill (`pill/shell.qml`) — the always-on gesture bar
- **Always visible.** `WlrLayer.Overlay` + `ExclusionMode.Ignore` + `margins.bottom:0`. Survives fullscreen, dock, OSK. Never lower the layer.
- **Must sit ABOVE the dock in z-order** (both on Overlay; later-created wins). Restart pill LAST, or it gets buried and stops taking touch.
- **Must sit ABOVE the dock in z-order.** Enforced by the `pill-above-dock`
layerrule in hyprland.lua (`order = 1` on namespace `quickshell:pill`) —
creation order alone buried the pill after an ii restart and killed its
touch. If the pill ever stops taking touch, verify that rule loaded.
- Gestures (MouseArea — pointer handlers don't get touch here):
- **double-tap** → toggles the active app's named `fullscreen` mode (whole
display, no border/gaps). Routes via `dock` IPC `fullscreen()`, which

View file

@ -0,0 +1,182 @@
# Phone shell — interface architecture
The phone has four primary interfaces the user actually touches: the **pill**,
the **dock**, the **keyboard**, and **touch/gesture**. This doc names what each
one is *meant to be*, what it is *today*, and the single seam where a hardcode
should become a small framework so the surface can grow without rewrites.
Principle: **clean beats feature-rich.** Each surface has one job and one place
its behaviour is tuned. A new feature should be a new entry in an existing
table, not a new hardcoded branch. Where a value is tuned in more than one
place today, that is the bug — not the value.
Truth hierarchy: this doc describes intent; `HOW-IT-WORKS.md` describes the
current wiring; the code is the reality. When they disagree, fix the code or
fix the doc in the same change — never leave a third story.
---
## 1. Pill — the always-on gesture bar
**Meant to be:** the one surface that is *always* reachable, on any app, in any
state. A thin, dumb input strip at the bottom edge that recognises a small
fixed vocabulary of gestures and forwards them — it owns no app state and makes
no decisions beyond "which gesture was this."
**Today (`pill/shell.qml`):** `WlrLayer.Overlay`, `ExclusionMode.Ignore`,
`margins.bottom:0`. Gesture arbitration is one MouseArea (double-tap, swipe up,
swipe down) → `dock` IPC. This part is clean and well-reasoned.
**The seam / the bug:** the pill's z-order guarantee is *documented but not
enforced*. `pill/shell.qml:23-27` sets namespace `quickshell:pill` and points
at "the `order` layerrule in hyprland.lua" — **that layerrule does not exist**
(hyprland.lua only has a commented-out generic `^my-overlay$` example). So
after an `ii` restart the pill can be buried under the dock and stop taking
touch — the exact failure the comment says is solved. Fix: add the real
`order` layerrule for `quickshell:pill`, or drop the promise from the code and
docs. Do not add more pill gestures until stacking is enforced — an always-on
surface that silently dies is worse than a plain one.
**Growth rule:** new pill gestures are new cases in the one MouseArea, each
forwarding to a *named* IPC target — never doing app logic inline.
---
## 2. Dock — the app switcher and stack surface
**Meant to be:** the surface that shows and switches running/pinned apps and
lets the user group them into stacks. It owns app-presentation state; it does
not own gesture recognition (the pill and hyprgrass feed it via IPC).
**Today:** interaction layer (`Dock.qml`, `DockAppButton.qml`, `DockStack.qml`)
is strong — drag-to-combine, fan-out arc, visibility state machine
(`computeDockState`). The weak layer is **state/config**:
- Stacks are a hand-parsed string `"stackId|appId,appId"` (`TaskbarApps.qml:25`).
A `|` or `,` in a name corrupts it silently; parsed/encoded in ~6 places.
→ move to **one JSON blob per entry** (array-of-strings the adapter tolerates,
but each string is JSON): kills the delimiter-corruption class, keeps the
adapter happy. This is the agreed direction.
- Stack ids are count-based (`nextStackName` = `"Stack " + (count+1)`,
`:87`) → collide after a delete, and `id` is the lookup key. Two stacks can
share an id and cross-contaminate members. → stable uid, display name
separate from id.
- Combining into a new stack suppresses the target's standalone pin at *render*
time (`apps` getter) but never removes it from `pinnedApps` in config
(`:99-108`) → the pin lingers; unstack works by luck. → remove-on-combine so
config matches what's shown.
- Tap-cycle `lastFocused` index (`DockAppButton :196`) is never reconciled when
windows open/close and is also written by hover (`:180`) → tapping can focus
the wrong window. → clamp/reconcile against the live toplevel model.
- Magic numbers live in the components: `pillStripHeight 32` (`Dock.qml:161`),
dwell `500` (twice: `DockAppButton:133`, `DockStack:116`), double-tap `350`,
arc geometry (`DockStack:33-36`). → lift into `Config.options.dock` next to
the `height`/`monochromeIcons`/`hoverRegionHeight` that already live there.
- `reorderStackMember` (`TaskbarApps:113`) is dead — superseded by
`setStackOrder`. → remove, or wire the menu reorder it was meant for.
**The seam:** `Config.options.dock` already exists — the framework anchor is
already there. "More structure" here means *finishing* that namespace (all
tuning knobs + a real stack schema under it), not inventing a new one.
**Growth rule:** dock feel/config knobs go under `Config.options.dock`; stack
members are structured entries, not delimited strings. Rename + ungroup are
table-stakes and should exist before any fancier stack feature.
---
## 3. Keyboard (OSK) — squeekboard
**Meant to be:** an on-screen keyboard that appears on text focus and can be
summoned by one deliberate gesture, driven through **one** IPC target so its
visibility and any focus-grab shield always move together.
**Today:** squeekboard, launched from `hyprland.lua`, auto-shows via
input-method-v2, toggled by 3-finger swipe-up routed through ii's `osk` IPC.
The *routing* is right (single `osk` target).
**The seam / the drift:** the comments and the retained
`quickshell-ii-patches/OnScreenKeyboard.qml` still describe **wvkbd**
(retired 2026-07-10) and its focus-grab shield — the `osk` IPC now drives
squeekboard, but the code comments and the history-only patch file still tell
the wvkbd story. Same doc-vs-reality drift as the pill layerrule. → update the
comments to squeekboard reality; confirm whether `OnScreenKeyboard.qml`'s
GlobalStates.oskOpen plumbing is still the thing `osk toggle` hits, or vestigial.
**Growth rule:** one `osk` IPC target owns show/hide; layout/theme is
squeekboard config, addressable later from souveraine-settings. No second path
to toggle the keyboard.
---
## 4. Touch / gesture — the input spine
**Meant to be:** one routing layer that maps a physical touch gesture to a
named interface action. Today this is the messiest surface because it is spread
across four dispatch styles.
**Today (`hyprland.lua`):**
- `hl.gesture{fingers=3,horizontal}` → workspace (Hyprland-native)
- `hl.plugin.hyprgrass.bind{...}` → close / terminal / edge-swipes →
`qs -c ii ipc call <target>`
- the pill's own MouseArea → `dock` IPC
- `touchdevice.output = "DSI-1"` binds touch to the panel for rotation
Two input sources (pill swipe-up, hyprgrass edge-swipe-up) both drive the dock;
no single arbiter. Gesture → action bindings are inline `exec_cmd` strings
scattered through the file.
**The seam:** this is the memory note "don't hardcode bindings blindly; need a
routing system to avoid overlap." The clean form is **one gesture table**
edge/finger/direction → named IPC target — read top-to-bottom so overlaps are
visible in one place. Every binding already ends in a `qs ... ipc call <target>`;
the framework is just making that table explicit and single-sourced instead of
sprinkled through `hyprland.lua`.
**Growth rule:** a new gesture is a new row in the gesture table pointing at a
named IPC target. Two rows may not target the same action from different edges
without being adjacent in the table (so overlap is obvious).
---
## Cross-cutting: the one recurring defect
Three of four surfaces have the **same** flaw — *a documented guarantee the
code doesn't enforce*: the pill layerrule (doc says pinned, code doesn't pin),
the keyboard (comments say wvkbd, code runs squeekboard), the dock stack pin
(render says suppressed, config says still pinned). Cleaning these up is mostly
making the code match its own stated contract, then giving each surface exactly
one place it's tuned.
## Status (2026-07-13)
1. ~~Pill layerrule~~ DONE — `pill-above-dock` rule added to hyprland.lua
(`order = 1` on `^quickshell:pill$`). VERIFY ON GLASS: `order`
passthrough in the Lua layer_rule wrapper is untested on this build.
2. ~~Gesture table~~ DONE — hyprgrass binds are one `touch_gestures` table
in hyprland.lua; the two other gesture sources (hl.gesture workspace
swipe, the pill's MouseArea) are listed in its header so overlap stays
visible in one read.
3. ~~Dock state layer~~ DONE — JSON-per-entry stacks (legacy pipe entries
still parse, rewritten on next write), minted `stack-N` ids that never
reuse after delete, name separate from id (+ `renameStack()`, no UI
yet), pin actually removed from config on combine, tap-cycle starts
from the truly focused window, dead `reorderStackMember` removed,
`dragDwellMs`/`pillStripHeight` lifted into `Config.options.dock`.
4. ~~Keyboard~~ DONE — hyprland.lua comments tell the squeekboard story;
`OnScreenKeyboard.qml` (the "osk" IPC owner) moved into this surface +
deploy manifest. Remaining hand-deployed patches (BarContent, Network,
QuickSliders, switchwall.sh) noted in overlays/QUICKSHELL-MOVED.md.
## Open decision — two-stage swipe-up was removed, not lost by accident?
Souveraine commit `6e21c50` ("resume agent conversations from server")
reworked the dock state machine: the Peek state, `dockRevealPulse`, and the
two-stage swipeUp (reveal → escalate to overview) all went away in favor of
a flat reveal/hide contract — under a commit title that says nothing about
the dock. Pixel3Arch CLAUDE.md still records two-stage as SHIPPED
(2026-07-11). The replacement comment is articulate, so this reads as a
deliberate redesign, but it needs Casey's confirmation: keep the flat
contract (overview = the dock's apps button), or restore the escalation.
Comments in hyprland.lua/HOW-IT-WORKS now describe the flat behavior that
actually runs.

View file

@ -0,0 +1,180 @@
# Souveraine shell — plan (2026-07-13)
## What this is now
`qs -c souveraine` — our own quickshell config, composed by `deploy.sh`:
our files (shell.qml, SouveraineFamily, services/, modules/settings/) are
symlinked from this repo; untouched ii directories are borrowed as whole-dir
symlinks; directories where we override any file are composed file-by-file.
ii's tree stays pristine — no overlay symlinks, no `.upstream` backups. We
step on end-4's toes nowhere; we borrow deliberately and vendor per-directory
only when a reason forces it.
`SouveraineFamily.qml` is the convergence scoreboard: one family, both modes
(desktop + phone). Panels that differ by form factor get an
`extraCondition: Config.options.souveraine.phone` gate; a panel is
"homogenized" when its gate is deleted. No `panelFamily` switching — the
config loads SouveraineFamily, period.
Live on laptop + phone as of 2026-07-13. Hyprland flipped: laptop via the
`qsConfig` var, phone via sed across its 4 hardcoded `qs -c ii` sites
(backup: `hyprland.lua.bak-souveraine-config`).
### The Settings app
`souveraine/settings-phone.qml` — purpose-built mobile shell (list → push →
back, 68px finger rows, fullscreen, no titlebar), launched by
`settings-launch.sh` (imports compositor env from the user manager) via
`souveraine-settings.desktop` in the phone's app grid. Page registry shape
`[{name, icon, component}]` is identical to desktop settings.qml, so pages
are interchangeable. Pages edit `Config.options` (JsonAdapter → config.json,
hot-apply + persist).
Current pages: Device (souveraine.phone flag + future per-device overrides),
Lock screen, Dock, Pill, Keyboard, About (ii's stock page verbatim).
## Doctrine — "tie back down"
Two tiers, nothing app-private:
1. **Shell settings**`Config.options.*` → config.json. Idempotent,
survives redeploy, diffable. Zero new persistence.
2. **System settings** → the owning daemon, live: NetworkManager, ModemManager
(mmcli -J), bluez, UPower, brightnessctl. The app is a *view* over system
state — kill it and the system is still the truth. No shadow copies, no
sync jobs. Privileged writes via polkit (`pkexec` — setuid fixed
2026-07-13, Pixel3Arch gap #7), zero sudo.
## Upstream drift — the decision (2026-07-13)
Our overrides were forked against the **phone's vintage** of ii. The laptop
runs a **newer** ii. Drift is large and one-directional. The question is not
"rebase everything" — it's "which upstream additions do we actually want,
and which are desktop features we're deliberately not running on a
phone-first shell."
**Laptop-upstream additions we do NOT have:**
- `background.widgets.{visualizer,stats,systemResources}` — desktop widgets
(the pctrade-style widget system landed upstream). Phone doesn't use these.
BUT: borrowed `Background.qml` reads `Config.options.background.widgets.*`
unconditionally → TypeError spam on laptop. **Fix: backport the keys into
our Config.qml (small surgical add), don't pull the widget machinery.**
- `claudeUsage` — Claude Pro/Max subscription gauge in the bar. Want later.
- `fileSearch` — indexed file search in the launcher. Want later.
- `sidebar.width`/`widthExtended`, AiChat `fontSize` live-binding. Want.
- `dpmsTimeout`, `unlockHook`, `autoIdleInhibit`, `termBgTone` — small knobs.
- GlobalStates super-press timing (`superPressTime`,
`shouldSuppressSuperReleaseSearch`) — press-and-hold-super suppresses
search. Conflicts with our `dockRevealPulse` patch (both touch the
super-release block). **Rebase needed eventually; not breaking anything
today.**
- Ai.qml: OpenAI Responses API strategy, `thoughtSignature`, FileView chat
save. Our Ai.qml is heavily customized (no hardcoded models, Bifrost
server, agent/resume) — **rebase here is the dangerous one; do carefully
or not at all.**
**What we have that upstream DROPPED:**
- `Config.options.dock.stacks` (fan-out stacks, "stackId|appId,appId") —
upstream removed it. Our `DockStack.qml` arc feature is ours alone now,
not just forked. Keep.
### Drift action items (priority order)
1. **Backport `background.widgets.*` keys into our Config.qml** — kills the
laptop TypeError spam. Small, surgical, no widget machinery pulled in.
2. GlobalStates.qml — rebase `dockRevealPulse` onto upstream's super-press
rewrite. Both touch the same block.
3. Ai.qml — decide: carry our customizations forward on the old base (safe,
current path) or rebase onto upstream's Responses-API Ai.qml (risky, gets
us thoughtSignature + FileView). Defer until we want one of those.
4. SidebarLeft/Right/Overview — dedupe the OSK flee-fix (15 lines triplicated)
into one place after any rebase.
5. Don't rebase dock/osk/TaskbarApps unless a specific breakage forces it.
## Pages still to build (deferred, "in time")
- **Display** — brightness (Brightness service), screen toggle
(`blueline-screen-toggle`), idle/suspend timeouts. hypridle.conf is
user-owned: regenerate whole from template, don't sed.
- **Network** — WiFi via existing Network service; **Cellular** needs a new
`services/Cellular.qml` wrapping `mmcli -J` (data toggle, APN, signal,
operator, SIM PIN). The real new work.
- **Bluetooth** — BluetoothStatus exists, but org.bluez activation fails on
the phone (journal, every boot). Fix the service before building UI on it.
- **App health check** — surfaced as a wanted page; not built.
- **Dock pins/stacks editor** — list editor with rename; consumes the staged
`renameStack`/`stackName` plumbing in TaskbarApps/DockStack.
## Other open items
- Phone cold-boot verification (autostart line flipped, unproven until next boot).
- `souveraine.phone` defaults false; Device page exposes the toggle but
deploy.sh --phone doesn't flip it. Tying it down on phone deploy would
satisfy idempotency doctrine.
- Services rename to be more "souveraine-esq" — deferred.
- Licensing sweep — see ~/Projects/MAKE_PROPER_LICENSES_FOR_ALL_PROJECTS.md.
## Ecosystem — the framework basics (2026-07-13)
Reference studied: `PostMarketOS-Blueline/references/phosh` (full source
checkout). Phosh's shell is built on three primitives we can learn from
without copying its C: `PhoshLayerSurface` (every visible thing subclasses
it), the zwlr_layer_shell_v1 layer it sits on (BACKGROUND/TOP/OVERLAY), and
`zphoc_stacked_layer_surface_v1` — phoc's extension for ordering
layer-surfaces above/below each other *within* a layer (bare wlr can't).
Plus a `PhoshState` bitmask (NONE / MODAL_SYSTEM_PROMPT / BLANKED / LOCKED /
SETTINGS / OVERVIEW) that the shell reads to gate layer visibility —
`use_top_layer = !phosh_shell_get_locked()` is the canonical line. State
drives layer visibility, not the reverse.
Note: `PhoshDockedManager` is NOT "the dock" in our sense — it's *hardware*
docking (phone → external display: disables OSK, stops auto-maximize, flips
`is-phone`). It's the system-level analog of our `souveraine.phone` flag:
the phone-becomes-desktop transition. They rhyme; they aren't the same
feature.
### What we're actually building (three pieces, compose incrementally)
1. **Layer registry + ShellState** — declare each surface's layer and
stack-order explicitly, driven by a state bitmask. GlobalStates is
*almost* this already (it has the state bits) but doesn't own the
layer/stack declarations. Quickshell gives us layer-shell via
`PanelWindow` anchors + exclusive zones — same protocol as Phosh, QML
binding not C. Steal the model, not the code.
2. **Live manifest per surface** — the dock (then each major surface)
projects its current state as a queryable structure: pinned apps,
stacks, positions, visibility, mode. This is a *service*, not UI —
`dock.manifest``{pinned, stacks, hidden, mode}`. The dock already
holds this state internally; the manifest is the projection for
external consumers.
3. **Method abstraction for the agent** — the agent does NOT get a new
toolcall integration. Souveraine is already a fully-fleshed harness
with its own integration state; we use THAT. What lives on the dock
side is a small method surface (`dock.pin(appId)`, `dock.restack(...)`,
`dock.reveal()`) with **state checks baked in** — validate inputs,
refuse mutation when the dock is in a state that forbids it (locked,
mid-drag), return real results. The agent calls a guarded method, not
a footgun; it genuinely can't mess up the dock through the abstraction.
### Where the lessons live — Souveraine School
The *teaching* of these methods — what they are, when to use them, the
lessons — is NOT inline in the dock or the agent. It lives in the
Souveraine School, taught to agent and human in proper time. The dock's
job is to expose the abstraction; the School's job is to teach it. Two
separate concerns; do not conflate.
### First artifact
The **dock manifest** is the smallest concrete first step: the dock is
built, working, and already has internal state (pinned apps, stacks,
reveal/pulse). Projecting it into a queryable manifest + guarded method
surface proves the pattern. The layer registry generalizes it after.
### Reference material on disk
- Phosh source: `~/Projects/PostMarketOS-Blueline/references/phosh` (full
C/meson checkout, read-only reference — do not edit).
- end4-pC fork (pctrade): `~/Projects/end4-pC` — studied for the
panelFamilies + background-widget patterns; not copied.
- Pure Maps (Kirigami/QML nav) — referenced in
`Pixel3Arch/docs/car-and-dock-references.md`; not yet pulled as source.

View file

@ -1,53 +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.
# Compose the Souveraine quickshell config (`qs -c souveraine`).
#
# deploy.sh symlink the surface into this machine's ii config
# deploy.sh -u restore upstream files, remove our additions
# Model: ~/.config/quickshell/souveraine is BUILT by this script —
# - our files (this repo) are symlinked in, repo stays source of truth
# - untouched upstream directories are borrowed as whole-dir symlinks
# into the pristine ii tree (so upstream updates flow through)
# - directories where we override any file are composed file-by-file
# ii's own tree is left pristine: no overlay symlinks, no .upstream backups.
# Cutting the cord from ii later = vendor a borrowed dir, per directory.
#
# deploy.sh compose ~/.config/quickshell/souveraine
# deploy.sh -u remove the souveraine config dir (ii untouched)
# deploy.sh --legacy-clean remove the OLD overlay symlinks from ii and
# restore its .upstream backups (one-time migration)
# 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"
SV="${QS}/souveraine"
# 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="
GlobalStates.qml ii/GlobalStates.qml
services/Souveraine.qml ii/services/Souveraine.qml
services/Ai.qml ii/services/Ai.qml
services/TaskbarApps.qml ii/services/TaskbarApps.qml
services/GlobalFocusGrab.qml ii/services/GlobalFocusGrab.qml
services/Idle.qml ii/services/Idle.qml
services/ConflictKiller.qml ii/services/ConflictKiller.qml
modules/ii/sidebarLeft/SidebarLeft.qml ii/modules/ii/sidebarLeft/SidebarLeft.qml
modules/ii/sidebarLeft/AiChat.qml ii/modules/ii/sidebarLeft/AiChat.qml
modules/ii/sidebarRight/SidebarRight.qml ii/modules/ii/sidebarRight/SidebarRight.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
modules/ii/overview/Overview.qml ii/modules/ii/overview/Overview.qml
modules/common/Persistent.qml ii/modules/common/Persistent.qml
modules/common/panels/lock/LockScreen.qml ii/modules/common/panels/lock/LockScreen.qml
modules/ii/lock/Lock.qml ii/modules/ii/lock/Lock.qml
modules/ii/lock/TouchLockSurface.qml ii/modules/ii/lock/TouchLockSurface.qml
shell.qml souveraine/shell.qml
GlobalStates.qml souveraine/GlobalStates.qml
settings-phone.qml souveraine/settings-phone.qml
panelFamilies/SouveraineFamily.qml souveraine/panelFamilies/SouveraineFamily.qml
services/Souveraine.qml souveraine/services/Souveraine.qml
services/Ai.qml souveraine/services/Ai.qml
services/TaskbarApps.qml souveraine/services/TaskbarApps.qml
services/GlobalFocusGrab.qml souveraine/services/GlobalFocusGrab.qml
services/Idle.qml souveraine/services/Idle.qml
services/ConflictKiller.qml souveraine/services/ConflictKiller.qml
modules/ii/sidebarLeft/SidebarLeft.qml souveraine/modules/ii/sidebarLeft/SidebarLeft.qml
modules/ii/sidebarLeft/AiChat.qml souveraine/modules/ii/sidebarLeft/AiChat.qml
modules/ii/sidebarRight/SidebarRight.qml souveraine/modules/ii/sidebarRight/SidebarRight.qml
modules/common/Config.qml souveraine/modules/common/Config.qml
modules/common/ShellModel.qml souveraine/modules/common/ShellModel.qml
modules/common/widgets/ContentPage.qml souveraine/modules/common/widgets/ContentPage.qml
modules/common/widgets/StyledToolTip.qml souveraine/modules/common/widgets/StyledToolTip.qml
modules/settings/DeviceConfig.qml souveraine/modules/settings/DeviceConfig.qml
modules/settings/LockConfig.qml souveraine/modules/settings/LockConfig.qml
modules/settings/DockConfig.qml souveraine/modules/settings/DockConfig.qml
modules/settings/PillConfig.qml souveraine/modules/settings/PillConfig.qml
modules/settings/KeyboardConfig.qml souveraine/modules/settings/KeyboardConfig.qml
modules/ii/polkit/Polkit.qml souveraine/modules/ii/polkit/Polkit.qml
modules/ii/dock/Dock.qml souveraine/modules/ii/dock/Dock.qml
modules/ii/dock/DockManifest.qml souveraine/modules/ii/dock/DockManifest.qml
modules/ii/dock/DockApps.qml souveraine/modules/ii/dock/DockApps.qml
modules/ii/dock/DockAppButton.qml souveraine/modules/ii/dock/DockAppButton.qml
modules/ii/dock/DockButton.qml souveraine/modules/ii/dock/DockButton.qml
modules/ii/dock/DockSeparator.qml souveraine/modules/ii/dock/DockSeparator.qml
modules/ii/dock/DockStack.qml souveraine/modules/ii/dock/DockStack.qml
modules/ii/appInventory/AppInventory.qml souveraine/modules/ii/appInventory/AppInventory.qml
modules/ii/appInventory/AppInventoryScope.qml souveraine/modules/ii/appInventory/AppInventoryScope.qml
modules/ii/overview/Overview.qml souveraine/modules/ii/overview/Overview.qml
modules/ii/onScreenKeyboard/OnScreenKeyboard.qml souveraine/modules/ii/onScreenKeyboard/OnScreenKeyboard.qml
modules/common/Persistent.qml souveraine/modules/common/Persistent.qml
modules/common/panels/lock/LockScreen.qml souveraine/modules/common/panels/lock/LockScreen.qml
modules/ii/lock/Lock.qml souveraine/modules/ii/lock/Lock.qml
modules/ii/lock/TouchLockSurface.qml souveraine/modules/ii/lock/TouchLockSurface.qml
pill/shell.qml pill/shell.qml
"
PHONE_USB=casey@172.16.42.1
PHONE_DEST="souveraine-surfaces/quickshell"
manifest_lines() { printf '%s\n' "$MANIFEST" | sed '/^[[:space:]]*$/d'; }
if [[ "${1:-}" == "--manifest" ]]; then
printf '%s\n' "$MANIFEST" | sed '/^[[:space:]]*$/d'
manifest_lines
exit 0
fi
@ -56,38 +79,90 @@ if [[ "${1:-}" == "--phone" ]]; then
"${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)."
# Phone-only: install the mobile settings launcher into the app grid.
"${ssh_i[@]}" "$PHONE_USB" "mkdir -p ~/.local/share/applications && ln -sf ~/$PHONE_DEST/souveraine-settings.desktop ~/.local/share/applications/souveraine-settings.desktop"
echo "Deployed to phone. If qsConfig is still 'ii' there, flip it:"
echo " ssh: sed -i 's/hl.env(\"qsConfig\", \"ii\")/hl.env(\"qsConfig\", \"souveraine\")/' ~/.config/hypr/hyprland/variables.lua"
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"
rm -rf "$SV"
echo "removed $SV (ii tree untouched)"
exit 0
fi
if [[ "${1:-}" == "--legacy-clean" ]]; then
# One-time migration: strip the old overlay out of ii.
# 1. remove any symlink in ii that points into a souveraine checkout
find "$II" -type l | while read -r l; do
case "$(readlink "$l")" in
*souveraine*) rm -f "$l"; echo "unlinked ${l#$QS/}" ;;
esac
done
# 2. restore upstream backups
find "$II" -name '*.upstream' | while read -r u; do
mv -f "$u" "${u%.upstream}"
echo "restored ${u%.upstream}"
done
exit 0
fi
[[ -d "$II" ]] || { echo "illogical-impulse config not found at $II" >&2; exit 1; }
mkdir -p "$QS/pill"
# --- Compose -------------------------------------------------------------
# Targets under souveraine/, relative to $SV
SV_TARGETS="$(manifest_lines | awk '$2 ~ /^souveraine\// {sub(/^souveraine\//, "", $2); print $2}')"
is_replaced() { # exact file override
grep -qxF "$1" <<< "$SV_TARGETS"
}
is_touched() { # dir contains an override somewhere below
grep -q "^$1/" <<< "$SV_TARGETS"
}
compose_dir() { # $1 = path relative to ii root ("" for root)
local rel="$1" entry name erel
mkdir -p "$SV${rel:+/$rel}"
for entry in "$II${rel:+/$rel}"/*; do
[[ -e "$entry" ]] || continue
name="$(basename "$entry")"
[[ "$name" == *.upstream ]] && continue
erel="${rel:+$rel/}$name"
if [[ -d "$entry" ]]; then
if is_touched "$erel"; then
compose_dir "$erel"
else
ln -sfn "$II/$erel" "$SV/$erel"
fi
else
# root files and files inside touched dirs: link individually,
# skipping ones our manifest replaces
is_replaced "$erel" && continue
ln -sf "$II/$erel" "$SV/$erel"
fi
done
}
# Rebuild from scratch each run: cheap, and guarantees no stale links.
rm -rf "$SV"
compose_dir ""
# Our files on top (this also creates dirs that exist only in our tree,
# e.g. brand-new settings pages)
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
mkdir -p "$(dirname "$t")"
ln -sf "$src" "$t"
done <<< "$MANIFEST"
done < <(manifest_lines)
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'"
n_ours=$(manifest_lines | grep -c .)
n_borrowed_dirs=$(find "$SV" -maxdepth 3 -type l -xtype d | wc -l)
echo "souveraine config composed at $SV ($n_ours files ours, $n_borrowed_dirs dirs borrowed from ii)"
if grep -q 'qsConfig", "ii"' "$HOME/.config/hypr/hyprland/variables.lua" 2>/dev/null; then
echo "NOTE: hyprland qsConfig is still 'ii' — flip variables.lua to 'souveraine' to switch."
fi
echo "Run with: qs -c souveraine (restart: pkill -f 'qs -c souveraine')"

View file

@ -80,6 +80,13 @@ Singleton {
property string panelFamily: "ii" // "ii", "waffle"
// Souveraine form-factor gate. Flip to true on a phone deploy:
// gates the Phone settings category and (later) the single-column
// layout. Laptop deploys stay false and see the stock page set.
property JsonObject souveraine: JsonObject {
property bool phone: false
}
property JsonObject policies: JsonObject {
property int ai: 1 // 0: No | 1: Yes | 2: Local
property int weeb: 1 // 0: No | 1: Open | 2: Closet
@ -209,6 +216,36 @@ Singleton {
property real x: 400
property real y: 100
}
// Backported 2026-07-13 from laptop-upstream Config: the
// borrowed Background.qml reads these unconditionally, and
// their absence threw TypeErrors on the laptop. Keys only
// we are NOT pulling the desktop-widget rendering machinery
// into this phone-first shell; enable defaults to false.
property JsonObject visualizer: JsonObject {
property bool enable: false
property string placementStrategy: "free"
property real x: 500
property real y: 500
property int bars: 30
property string style: "bars"
property bool vertical: false
}
property JsonObject stats: JsonObject {
property bool enable: false
property string placementStrategy: "free"
property real x: 150
property real y: 300
property string githubUsername: ""
property string codeforcesUsername: ""
property bool showGraphs: false
}
property JsonObject systemResources: JsonObject {
property bool enable: false
property string placementStrategy: "free"
property real x: 150
property real y: 600
property bool showGraphs: false
}
}
property string wallpaperPath: ""
property string thumbnailPath: ""
@ -267,6 +304,14 @@ Singleton {
property list<string> numberMap: ["1", "2"] // Characters to show instead of numbers on workspace indicator
property bool useNerdFont: false
}
// Backported 2026-07-13: borrowed ClaudeUsage service + bar
// widget read these unconditionally. Keys only, enable=false.
property JsonObject claudeUsage: JsonObject {
property bool enable: false // Show a Claude (Pro/Max) subscription usage gauge in the bar
property bool defaultWeekly: false // Start on the 7-day window; click to switch session <-> week
property int warningThreshold: 90 // Turn red at/above this utilization (%)
property int fetchInterval: 5 // minutes
}
property JsonObject weather: JsonObject {
property bool enable: false
property bool enableGPS: true // gps based location
@ -343,6 +388,13 @@ Singleton {
// "stackId|appId,appId,appId" phone-local, no Phosh sync.
// stackId is a display label; members render as an arc.
property list<string> stacks: []
// Feel knobs (single source components read these, never
// hardcode): dragDwellMs = how long a drag hovers an icon
// or stack before it reads as combine-intent (GNOME's
// rule); pillStripHeight = bottom strip the dock reserves
// for the gesture pill, visually and in its input mask.
property int dragDwellMs: 500
property real pillStripHeight: 32
}
property JsonObject interactions: JsonObject {
@ -511,6 +563,15 @@ Singleton {
property string imageSearchEngineBaseUrl: "https://lens.google.com/uploadbyurl?url="
property bool useCircleSelection: false
}
// Backported 2026-07-13: borrowed LauncherSearch reads this
// unconditionally. Key only, enable=false.
property JsonObject fileSearch: JsonObject {
property bool enable: false
property list<string> paths: [Directories.home]
property list<string> exclude: [".git", "node_modules", "target", "build"]
property bool excludeHiddenDirs: true
property int maxResults: 30
}
}
property JsonObject sidebar: JsonObject {

View file

@ -0,0 +1,215 @@
// Shell layer/state registry the declarative surface model.
//
// One place that says, for each meaningful surface: which layer-shell layer
// it lives on, which GlobalStates bit(s) gate its visibility, and whether it
// is active right now (derived from the live state). This is the registry
// half of the layer-registry + ShellState work
// (docs/tasks/souveraine-shell-ecosystem.md, section 1).
//
// This is a QtObject instantiated inside Dock.qml's Scope (as
// ShellModelLocal.ShellModel), NOT a qs.services singleton same reason as
// DockManifest: GlobalStates.qml imports qs.services, so a qs.services
// singleton that imports qs for GlobalStates forms a circular import QML
// cannot resolve. As a local type under modules/common it sidesteps that
// cycle. It carries its own imports (qs, qs.services, qs.modules.common,
// QtQuick) because a local type does NOT inherit the importing file's
// imports.
//
// IMPORTANT what this is NOT:
// - It does NOT change how panels render or claim their layer. Panels keep
// their ad-hoc WlrLayershell.layer / exclusiveZone bindings. Making
// panels READ from this registry is a later, riskier step.
// - It is a read-only projection that mirrors the truth, so the agent and
// external callers can ask "what surfaces exist, what layer, what state
// gates each, what is active" without parsing QML.
//
// Layer vocabulary mirrors the wlr-layer-shell protocol quickshell exposes
// (Quickshell.Wayland.WlrLayer): Background < Bottom < Top < Overlay. The
// lock surface is separate it uses ext-session-lock-v1
// (WlSessionLockSurface), not the layer shell recorded here as layer
// "session-lock" so consumers can tell the mechanisms apart.
//
// Quickshell's PanelWindow default layer is Top when WlrLayershell.layer is
// unset, which is how several surfaces (dock, bar, sidebarLeft, sidebarRight)
// end up on Top implicitly. Those defaults are reflected here as "top" so the
// registry matches what actually renders, not just what is declared.
import qs
import qs.services
import qs.modules.common
import QtQuick
QtObject {
id: root
// --- The declarative registry --------------------------------------
//
// Each entry is a plain object the projection returns verbatim. Fields:
// name stable id, matches the family/panel name
// layer "background" | "bottom" | "top" | "overlay" | "session-lock"
// namespace the wlr layer namespace the panel claims, when known
// layerRule "declared" (set explicitly in QML) | "default-top"
// (PanelWindow default no explicit WlrLayershell.layer)
// | "session-lock" (ext-session-lock-v1, not layer shell)
// gateStates array of GlobalStates property names whose truth gates
// visibility (the state that drives the surface being
// shown). Empty for always-resident structural surfaces.
// gateConfig Config.options.* path that enables the surface at all
// (the family extraCondition), "" when none.
// activeExpr short human-readable description of when active.
readonly property var _surfaceDefs: [
{
name: "background",
layer: "background",
namespace: "quickshell:background",
layerRule: "declared",
gateStates: [],
gateConfig: "",
activeExpr: "always (resident); raises to overlay when screenLocked"
},
{
name: "bar",
layer: "top",
namespace: "quickshell:bar",
layerRule: "default-top",
gateStates: ["barOpen"],
gateConfig: "options.bar.vertical (excludes verticalBar)",
activeExpr: "barOpen && !options.bar.vertical"
},
{
name: "verticalBar",
layer: "top",
namespace: "quickshell:verticalBar",
layerRule: "default-top",
gateStates: ["barOpen"],
gateConfig: "options.bar.vertical",
activeExpr: "barOpen && options.bar.vertical"
},
{
name: "dock",
layer: "top",
namespace: "quickshell:dock",
layerRule: "default-top",
gateStates: ["dockRevealed"],
gateConfig: "options.dock.enable",
activeExpr: "options.dock.enable && (dockRevealed || pinned-on-empty-desktop); suppressed by oskOpen + screenLocked"
},
{
name: "sidebarLeft",
layer: "top",
namespace: "quickshell:sidebarLeft",
layerRule: "default-top",
gateStates: ["sidebarLeftOpen"],
gateConfig: "",
activeExpr: "sidebarLeftOpen"
},
{
name: "sidebarRight",
layer: "top",
namespace: "quickshell:sidebarRight",
layerRule: "default-top",
gateStates: ["sidebarRightOpen"],
gateConfig: "",
activeExpr: "sidebarRightOpen"
},
{
name: "overview",
layer: "top",
namespace: "quickshell:overview",
layerRule: "declared",
gateStates: ["overviewOpen"],
gateConfig: "",
activeExpr: "overviewOpen"
},
{
name: "onScreenKeyboard",
layer: "overlay",
namespace: "quickshell:onScreenKeyboard",
layerRule: "declared",
gateStates: ["oskOpen"],
gateConfig: "",
activeExpr: "oskOpen"
},
{
name: "lock",
layer: "session-lock",
namespace: "",
layerRule: "session-lock",
gateStates: ["screenLocked"],
gateConfig: "",
activeExpr: "screenLocked (ext-session-lock-v1, not layer shell)"
}
]
// --- Read projections ----------------------------------------------
// surfaces() the registry list with a live `active` flag per entry.
// Shape rhymes with DockManifest.manifest(): a plain JS object array
// safe to serialize and hand to the agent / settings app.
function surfaces() {
return root._surfaceDefs.map(s => Object.assign({}, s, {
active: root._isActive(s.name)
}));
}
// state() the current GlobalStates bits that matter for layer gating,
// plus the high-level shell mode. Read-only snapshot.
function state() {
return {
mode: Config.options?.souveraine?.phone ? "phone" : "desktop",
barOpen: GlobalStates.barOpen,
oskOpen: GlobalStates.oskOpen,
screenLocked: GlobalStates.screenLocked,
overviewOpen: GlobalStates.overviewOpen,
sidebarLeftOpen: GlobalStates.sidebarLeftOpen,
sidebarRightOpen: GlobalStates.sidebarRightOpen,
dockRevealed: GlobalStates.dockRevealed,
dockDragInProgress: GlobalStates.dockDragInProgress,
overlayOpen: GlobalStates.overlayOpen
};
}
// --- Internal: derive active from the live state -------------------
//
// The single source of truth for "is this surface showing right now" is
// the GlobalStates bit that gates it. The dock is special-cased because
// its visibility is computed (pinned / revealed / shown-on-empty-desktop)
// rather than a bare boolean; we approximate "active" as the dock's own
// manifest hidden flag so we never disagree with the dock about itself.
function _isActive(name) {
switch (name) {
case "background":
return true; // always resident
case "bar":
return GlobalStates.barOpen && !Config.options?.bar?.vertical;
case "verticalBar":
return GlobalStates.barOpen && !!Config.options?.bar?.vertical;
case "sidebarLeft":
return GlobalStates.sidebarLeftOpen;
case "sidebarRight":
return GlobalStates.sidebarRightOpen;
case "overview":
return GlobalStates.overviewOpen;
case "onScreenKeyboard":
return GlobalStates.oskOpen;
case "lock":
return GlobalStates.screenLocked;
case "dock":
return root._dockActive();
}
return false;
}
// The dock's active flag mirrors DockManifest's hidden state when the
// dock manifest is reachable, falling back to the GlobalStates bit. We do
// NOT reach into Dock.qml directly (different Scope) the manifest is
// the agreed cross-surface contract.
function _dockActive() {
if (GlobalStates.screenLocked) return false;
if (GlobalStates.oskOpen) return false;
return GlobalStates.dockRevealed
|| !!Config.options?.dock?.pinnedOnStartup;
}
}

View file

@ -44,6 +44,13 @@ Scope {
KeyringStorage.fetchKeyringData();
}
}
// Poke the boot splash to fade out + hand DRM master to Hyprland.
// Fires once, the first time the lock surface goes up after boot.
// splash-signal no-ops if the splash isn't running, so re-locks are safe.
Process {
id: splashSignalProc
}
property bool splashPoked: false
function unlockKeyring() {
unlockKeyringProc.exec({
environment: ({
@ -65,6 +72,10 @@ Scope {
if (GlobalStates.screenLocked) {
lockContext.reset();
lockContext.tryFingerUnlock();
if (!root.splashPoked) {
root.splashPoked = true;
splashSignalProc.exec({ command: ["splash-signal"] });
}
}
}
}

View file

@ -0,0 +1,43 @@
import QtQuick
import QtQuick.Layouts
import qs.modules.common
import qs.modules.common.widgets
// Souveraine: responsive variant of ii's ContentPage. Upstream clamps the
// content column to baseWidth (600) and centers it fine on a desktop
// window, but on a narrow/touch surface it clips both edges and stretches a
// huge gap into every row. Here, when the page is narrower than 650px the
// column is anchored left+right with fixed margins, so it tracks the live
// page width (reacts to fullscreen/resize) and nothing clips. Desktop
// windows (>=650px) keep upstream's clamp-and-center behavior.
StyledFlickable {
id: root
property real baseWidth: 600
property bool forceWidth: false
property real bottomContentPadding: 100
readonly property bool narrow: root.width < 650
readonly property real sideMargin: 20
default property alias contentData: contentColumn.data
clip: true
contentHeight: contentColumn.implicitHeight + root.bottomContentPadding
implicitWidth: contentColumn.implicitWidth
ColumnLayout {
id: contentColumn
spacing: 30
// Narrow: anchor to both edges so the column IS the page width minus
// symmetric margins tracks resize, no clipping, no centered-overflow.
// Wide: fixed width, centered (upstream behavior).
anchors {
top: parent.top
margins: root.sideMargin
horizontalCenter: root.narrow ? undefined : parent.horizontalCenter
left: root.narrow ? parent.left : undefined
right: root.narrow ? parent.right : undefined
}
width: root.narrow ? (root.width - root.sideMargin * 2)
: (root.forceWidth ? root.baseWidth : Math.max(root.baseWidth, implicitWidth))
}
}

View file

@ -0,0 +1,41 @@
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// Souveraine: tooltip-trigger fix. Upstream treated a parent with no
// `hovered` property (parent.hovered === undefined) as "always show" so any
// StyledToolTip attached to a non-hoverable parent (e.g. ConfigSpinBox, a
// RowLayout) was permanently visible. That's latent on desktop and broken on
// touch. Now: a tooltip only shows on a real positive hover (hoverable parent
// actually hovered) or the explicit alternativeVisibleCondition. Non-hoverable
// parents default off.
ToolTip {
id: root
property bool extraVisibleCondition: true
property bool alternativeVisibleCondition: false
readonly property bool internalVisibleCondition: (extraVisibleCondition && (parent?.hovered ?? false)) || alternativeVisibleCondition
verticalPadding: 5
horizontalPadding: 10
background: null
font {
family: Appearance.font.family.main
variableAxes: Appearance.font.variableAxes.main
pixelSize: Appearance?.font.pixelSize.smaller ?? 14
hintingPreference: Font.PreferNoHinting // Prevent shaky text
}
delay: 0
visible: internalVisibleCondition
contentItem: StyledToolTipContent {
id: contentItem
font: root.font
text: root.text
shown: root.internalVisibleCondition
horizontalPadding: root.horizontalPadding
verticalPadding: root.verticalPadding
}
}

View file

@ -0,0 +1,288 @@
// App inventory installed-application knowledge projected for external
// consumers (the agent, the settings app, anything that needs to answer
// "what's installed" without parsing .desktop files ad hoc). See
// docs/tasks/app-inventory-manifest.md and docs/tasks/souveraine-shell-ecosystem.md.
//
// This is a QtObject instantiated inside AppInventoryScope.qml (as
// AppInvLocal.AppInventory), NOT a qs.services singleton. Same reason
// DockManifest is a local type: GlobalStates.qml imports qs.services, so a
// qs.services singleton that itself imports qs would form a circular import
// QML can't resolve. As a local type in its own dir it sidesteps that cycle.
// It carries its own imports explicitly local types do NOT inherit the
// importing file's imports.
//
// Source of truth: ~/.local/share/applications/*.desktop (user) +
// /usr/share/applications/*.desktop (system). Standard Desktop Entry spec.
// We do NOT shell out per call: a single Process run scans both dirs,
// extracts each entry's key fields, and emits one record per app in a flat
// delimiter-separated format. JSON is built in JS from that, never in shell
// the shell never quotes, so field values can't break the parse. The
// parsed list is cached on the object and refreshed on demand.
//
// User-dir entries override system-dir entries of the same appId (standard
// XDG behavior): the user dir is scanned first, and _ingest keeps the first
// occurrence of each appId.
//
// Every public method returns a result shape. Not-found is a result
// ({ok:false, reason:"not-found"}), never a throw. Inputs are validated.
import qs
import qs.services
import qs.modules.common
import qs.modules.common.functions
import QtQuick
import Quickshell
import Quickshell.Io
Item {
id: root
// Root is Item, not QtObject: AppInventory owns a Process child (the
// desktop-file scanner), and QtObject has no default property to hold
// children. Item gives us the default `data` property for free. This
// object is non-visual (width/height 0, never painted) Item is just
// the lightest type that accepts children.
// Unit separator (field) and record separator (categories within a field)
// for the scanner's flat output. Chosen because they cannot appear in
// .desktop file values in practice.
readonly property string _US: "\x1f"
readonly property string _RS: "\x1e"
// The parsed inventory. Each entry:
// { appId, name, genericName, comment, icon, categories:[], noDisplay, path }
// noDisplay entries are excluded from list/find/categories results, but
// get() still resolves them if asked by explicit appId.
property var _entries: [] // noDisplay filtered out, sorted
property var _byId: ({}) // appId -> entry (all, incl noDisplay)
property var _categoryIndex: ({}) // category -> [appId,...]
property bool _loaded: false
// --- Public API -----------------------------------------------------
// Full inventory (noDisplay excluded). Optional filter:
// { category?: string } -> only entries in that category.
function list(filter) {
root._ensureLoaded();
const cat = filter && filter.category ? String(filter.category) : "";
if (cat) {
const ids = root._categoryIndex[cat] || [];
const ents = ids.map(id => root._byId[id]).filter(Boolean);
return { ok: true, count: ents.length, entries: ents };
}
return { ok: true, count: root._entries.length, entries: root._entries };
}
// IPC-friendly list: filter by a single category string (empty/blank =
// no filter). IpcHandler can't marshal an object argument, so the IPC
// surface exposes this instead of list(filter).
function listFromCategory(category) {
const cat = String(category ?? "").trim();
if (!cat) {
root._ensureLoaded();
return { ok: true, count: root._entries.length, entries: root._entries };
}
root._ensureLoaded();
const ids = root._categoryIndex[cat] || [];
const ents = ids.map(id => root._byId[id]).filter(Boolean);
return { ok: true, count: ents.length, entries: ents, category: cat };
}
// One entry by appId. not-found is a result, not an error.
function get(appId) {
root._ensureLoaded();
const id = String(appId ?? "").trim();
if (!id) return { ok: false, reason: "empty-app-id" };
const entry = root._byId[id];
if (!entry) return { ok: false, reason: "not-found", appId: id };
return { ok: true, entry: entry };
}
// Fuzzy match across name/appId/comment. query: string. Returns ranked
// results (best first), capped at limit (default 50, max 500).
function find(query, limit) {
root._ensureLoaded();
const q = String(query ?? "").trim();
if (!q) return { ok: false, reason: "empty-query" };
const cap = Math.max(1, Math.min(500, parseInt(limit, 10) || 50));
// fuzzysort targets. Each carries the entry plus prepared search keys.
const targets = root._entries.map(e => ({
obj: e,
name: Fuzzy.prepare(e.name || e.appId),
appId: Fuzzy.prepare(e.appId),
comment: Fuzzy.prepare(e.comment || e.genericName || "")
}));
const opts = { all: false, limit: cap };
const byName = Fuzzy.go(q, targets, Object.assign({ key: "name" }, opts));
const byId = Fuzzy.go(q, targets, Object.assign({ key: "appId" }, opts));
const byComment = Fuzzy.go(q, targets, Object.assign({ key: "comment" }, opts));
// Merge and dedupe by appId; best weighted score wins. Name is the
// strongest signal, appId next (often matches typed queries),
// comment/genericName weakest.
const merged = ({});
const consider = (results, weight) => {
for (const r of (results || [])) {
const id = r.obj.obj.appId;
const score = (r._score ?? 0) * weight;
if (!merged[id] || merged[id].score < score) {
merged[id] = { entry: r.obj.obj, score: score };
}
}
};
consider(byName, 1.0);
consider(byId, 0.9);
consider(byComment, 0.6);
const ranked = Object.values(merged)
.sort((a, b) => b.score - a.score)
.slice(0, cap)
.map(m => m.entry);
return { ok: true, count: ranked.length, query: q, entries: ranked };
}
// Distinct categories with member counts, sorted by count desc then name.
function categories() {
root._ensureLoaded();
const cats = Object.keys(root._categoryIndex)
.map(c => ({ category: c, count: (root._categoryIndex[c] || []).length }))
.sort((a, b) => b.count - a.count || a.category.localeCompare(b.category));
return { ok: true, count: cats.length, categories: cats };
}
// Force a rescan (e.g. after installing an app). Async: returns the
// pre-refresh count; the new count lands when scanProc finishes and is
// logged. Callers that need the fresh value should re-query after the
// log line appears.
function refresh() {
root._loaded = false;
root._entries = [];
root._byId = ({});
root._categoryIndex = ({});
scanProc.running = true;
return { ok: true, reason: "refreshing" };
}
// --- Internals ------------------------------------------------------
function _ensureLoaded() {
if (!root._loaded && !scanProc.running) scanProc.running = true;
}
// Parse the scanner's flat output into the inventory. One line per app,
// fields joined by unit separator (\x1f) in order:
// appId, name, genericName, comment, icon, categories, path
// categories is itself record-separated (\x1e). noDisplay is encoded as
// a "1:" prefix on the appId field so it survives the flat format
// without an extra column.
function _ingest(text) {
const US = root._US;
const RS = root._RS;
const lines = (text || "").split("\n");
const all = [];
for (const raw of lines) {
const line = raw.replace(/\r$/, "");
if (!line) continue;
const f = line.split(US);
if (f.length < 6) continue;
const rawAppId = f[0];
if (!rawAppId) continue;
let noDisplay = false;
let appId = rawAppId;
if (appId.startsWith("1:")) { noDisplay = true; appId = appId.slice(2); }
all.push({
appId: appId,
name: f[1] || appId,
genericName: f[2] || "",
comment: f[3] || "",
icon: f[4] || "",
categories: (f[5] || "").split(RS).map(s => s.trim()).filter(Boolean),
noDisplay: noDisplay,
path: f[6] || ""
});
}
// Dedupe by appId, first occurrence wins (user dir scanned first).
const byId = ({});
const entries = [];
const categoryIndex = ({});
for (const e of all) {
if (byId[e.appId]) continue;
byId[e.appId] = e;
if (!e.noDisplay) {
entries.push(e);
for (const c of e.categories) {
if (!categoryIndex[c]) categoryIndex[c] = [];
categoryIndex[c].push(e.appId);
}
}
}
// Deterministic order: name, then appId.
entries.sort((a, b) =>
(a.name || "").localeCompare(b.name || "") || a.appId.localeCompare(b.appId));
root._byId = byId;
root._entries = entries;
root._categoryIndex = categoryIndex;
root._loaded = true;
}
// The scanner. One bash invocation walks both dirs and runs awk per
// .desktop file. awk extracts the fields from the [Desktop Entry] group
// and joins them with \x1f (and categories with \x1e). The shell does
// NO JSON and NO quoting field values pass through verbatim into the
// delimiter-separated stream, so values can't break the parse.
Process {
id: scanProc
command: ["bash", "-c", root._scanScript()]
stdout: StdioCollector {
onStreamFinished: {
root._ingest(this.text);
console.log("[appInventory] loaded", root._entries.length, "apps,",
Object.keys(root._categoryIndex).length, "categories");
}
}
}
// User dir first so user entries win the first-occurrence dedupe in
// _ingest (standard XDG override semantics).
//
// One awk process per .desktop file is fine (170-ish files, trivial). The
// earlier bug was calling awk WITHOUT passing the file, so it read stdin
// and hung `"$f"` MUST be the trailing arg so awk reads the file.
function _scanScript() {
return `
US=$(printf '\\037')
RS=$(printf '\\036')
export US RS
for d in "$HOME/.local/share/applications" /usr/share/applications; do
[ -d "$d" ] || continue
for f in "$d"/*.desktop; do
[ -f "$f" ] || continue
appId=$(basename "$f" .desktop)
awk -v appId="$appId" -v USC="$US" -v RSC="$RS" -v fpath="$f" '
BEGIN { inE=0; name=gn=comment=icon=cats=nd="" }
/^\\[Desktop Entry\\]/ { inE=1; next }
/^\\[/ { inE=0 }
inE && /^Name=/ { name=substr($0, 6) }
inE && /^GenericName=/ { gn=substr($0, 13) }
inE && /^Comment=/ { comment=substr($0, 9) }
inE && /^Icon=/ { icon=substr($0, 6) }
inE && /^Categories=/ { cats=substr($0, 12) }
inE && /^NoDisplay=/ { nd=substr($0, 11) }
END {
n = split(cats, a, ";"); catOut=""
for (i=1;i<=n;i++) { if(a[i]=="")continue; if(catOut!="")catOut=catOut RSC; catOut=catOut a[i] }
if (nd=="true") appId="1:" appId
print appId USC name USC gn USC comment USC icon USC catOut USC fpath
}
' "$f"
done
done
`;
}
}

View file

@ -0,0 +1,62 @@
// App inventory surface holds the AppInventory projection and the IPC
// method surface the agent (via Souveraine's harness) calls. No UI; this is
// a service-only Scope, sibling in shape to how Dock.qml hosts DockManifest
// + its IpcHandler. See docs/tasks/app-inventory-manifest.md.
//
// Instantiated from panelFamilies/SouveraineFamily.qml. The AppInventory
// type lives here as a local type (not as a qs.services singleton) to avoid
// the GlobalStates circular import see AppInventory.qml's header comment.
import qs
import qs.modules.common
import "." as AppInvLocal
import QtQuick
import Quickshell
import Quickshell.Io
Scope {
// The inventory projection + parse/cache machinery. Carries its own
// imports (qs, qs.services, qs.modules.common.functions) because local
// types do NOT inherit this file's imports.
AppInvLocal.AppInventory { id: appInventory }
// Read-only method surface. Every method delegates to appInventory and
// returns its result shape ({ok, ...}). Refusals/not-founds are results,
// not errors, so a caller learns from them instead of guessing.
//
// IPC type constraint: quickshell's IpcHandler can only marshal declared
// primitive types across the wire untyped args become QVariant and are
// rejected ("cannot be used across IPC"). So every parameter has an
// explicit type. The inventory's list(filter) takes an object in-process;
// over IPC we expose list(category: string) and build the filter here.
IpcHandler {
target: "apps"
// apps.list() -> all (noDisplay excluded)
// apps.list("Network") -> only entries in the "Network" category
function list(category: string): var {
return appInventory.listFromCategory(category);
}
// apps.get("firefox") -> {ok, entry?} or {ok:false, reason:"not-found"}
function get(appId: string): var {
return appInventory.get(appId);
}
// apps.find("fire") -> fuzzy-ranked entries (default limit 50)
// apps.find("fire", 10) -> capped at 10
function find(query: string, limit: int): var {
return appInventory.find(query, limit);
}
// apps.categories() -> {ok, categories:[{category,count}]}
function categories(): var {
return appInventory.categories();
}
// apps.refresh() -> trigger a rescan (async; re-query after the log line)
function refresh(): var {
return appInventory.refresh();
}
}
}

View file

@ -16,6 +16,7 @@ import qs
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import "." as DockLocal
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
@ -43,6 +44,11 @@ Scope { // Scope
// until the pill explicitly reveals the dock.
property bool effectivePinned: root.pinned && !GlobalStates.oskOpen
// The dock's state projection + guarded mutation surface for external
// callers (the agent via Souveraine's harness). Lives here, not as a
// qs.services singleton, to avoid a circular import with GlobalStates.
DockLocal.DockManifest { id: dockManifest }
// 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
@ -75,6 +81,10 @@ Scope { // Scope
// The pill's dock contract is intentionally just two operations:
// swipe up reveals the dock; swipe down hides it. No timer, no overview.
// The manifest + guarded pin/stack methods below extend the same target
// so the agent (via Souveraine's harness, not a new integration) reaches
// the dock through one IPC name. See services/DockManifest.qml and
// docs/tasks/souveraine-shell-ecosystem.md.
IpcHandler {
target: "dock"
@ -90,14 +100,14 @@ Scope { // Scope
GlobalStates.dockRevealed = true;
}
// Toggle app-mode fullscreen on the REAL active window. The pill can't
// use `hyprctl dispatch fullscreen` because tapping the pill makes the
// shell (org.quickshell) the focused surface, so hyprctl maximized the
// pill, not the app. ToplevelManager.activeToplevel is the last real
// app toplevel a layer-shell tap never becomes activeToplevel and
// its .wayland.fullscreen is writable (Quickshell ToplevelHandle::
// setFullscreen). Same ii-side-sees-the-real-window trick the dock
// buttons use with .activate().
// Toggle app-mode fullscreen on the REAL active window. The pill
// can't use a bare `hyprctl dispatch fullscreen` because tapping the
// pill makes the shell (org.quickshell) the focused surface, so
// hyprctl would fullscreen the pill, not the app. The mechanism is
// the one in the code below: Hyprland.activeToplevel stays the real
// app window across a layer-shell tap, and we dispatch AT its
// address. (An earlier draft went through ToplevelManager +
// ToplevelHandle::setFullscreen that path is not what runs.)
function fullscreen(): void {
// Use Hyprland 0.55's named fullscreen API. Numeric modes select
// legacy/fake fullscreen behavior on this Lua dispatcher.
@ -116,6 +126,36 @@ Scope { // Scope
Quickshell.execDetached(["hyprctl", "dispatch",
`hl.dsp.window.fullscreen({ window = "address:${addr}", mode = "fullscreen", action = "toggle" })`]);
}
// --- Manifest projection (read-only) + guarded mutation ----------
// These delegate to DockManifest, which owns the projection shape
// and the state checks. The agent calls `dock.manifest`, `dock.pin`,
// etc. never parsing QML. Refusals return {ok:false, reason}, not
// errors, so a refused mutation is information the agent learns from.
function manifest(): var {
return dockManifest.manifest();
}
function pin(appId: string): var {
return dockManifest.pin(appId);
}
function unpin(appId: string): var {
return dockManifest.unpin(appId);
}
function addToStack(stackId: string, appId: string): var {
return dockManifest.addToStack(stackId, appId);
}
function removeFromStack(stackId: string, appId: string): var {
return dockManifest.removeFromStack(stackId, appId);
}
function renameStack(stackId: string, newName: string): var {
return dockManifest.renameStack(stackId, newName);
}
}
// Settings-surface stub (entry point b). souveraine-settings doesn't
@ -137,6 +177,29 @@ Scope { // Scope
}
}
// Shell layer/state registry declarative surface model + read
// projection. Lives here (beside the dock, not as a qs.services
// singleton) for the same circular-import reason as DockManifest.
// See modules/common/ShellModel.qml and
// docs/tasks/souveraine-shell-ecosystem.md section 1.
ShellModel { id: shellModel }
IpcHandler {
target: "shell"
// surfaces() registry list, one entry per meaningful surface with
// layer, gating state, config gate, and a live `active` flag.
function surfaces(): var {
return shellModel.surfaces();
}
// state() the GlobalStates bits that matter for layer gating, plus
// the shell mode. Read-only snapshot.
function state(): var {
return shellModel.state();
}
}
Variants {
// For each monitor
model: Quickshell.screens
@ -158,7 +221,7 @@ Scope { // Scope
// This space belongs to the always-on pill. It is visually empty
// and must also be absent from the dock's *input* region; otherwise
// the dock receives touches before the pill can see them.
readonly property int pillStripHeight: 32
readonly property int pillStripHeight: Config.options?.dock.pillStripHeight ?? 32
visible: !GlobalStates.screenLocked && reveal
anchors {

View file

@ -130,7 +130,7 @@ DockButton {
}
Timer {
id: dwellTimer
interval: 500 // GNOME's dwell intent, not accident
interval: Config.options?.dock.dragDwellMs ?? 500 // GNOME's dwell intent, not accident
onTriggered: {
root.formingStack = true
appListRoot.dragTargetAppId = root.appToplevel.appId
@ -193,7 +193,11 @@ DockButton {
root.desktopEntry?.execute();
return;
}
lastFocused = (lastFocused + 1) % appToplevel.toplevels.length
// Cycle from the window that is actually focused, not the stored
// index that index goes stale as windows open/close and the hover
// handler also writes it, so tapping could focus the wrong window.
const cur = appToplevel.toplevels.findIndex(t => t.activated);
lastFocused = ((cur >= 0 ? cur : Math.max(lastFocused, -1)) + 1) % appToplevel.toplevels.length
appToplevel.toplevels[lastFocused].activate()
}

View file

@ -0,0 +1,143 @@
// Dock manifest the dock's state projected for external consumers
// (the agent, the settings app, anything that needs to read or act on the
// dock without parsing QML). See docs/tasks/souveraine-shell-ecosystem.md.
//
// This is a QtObject instantiated inside Dock.qml's Scope (as
// DockLocal.DockManifest), NOT a qs.services singleton. Reason: GlobalStates.qml
// imports qs.services, so a DockManifest singleton would form a circular import
// (GlobalStates qs.services DockManifest) that QML can't resolve. As a local
// type in the dock dir it sidesteps that cycle. It carries its own `import qs`
// (for GlobalStates) and `import qs.modules.common` (for Config) local types
// do NOT inherit the importing file's imports.
//
// Two halves:
// 1. manifest() read-only snapshot of pinned apps, stacks,
// visibility state, and mode.
// 2. guarded methods pin/unpin/restack/rename. Every method validates
// its inputs and refuses mutation when the dock is
// in a state that forbids it. Returns a result object.
//
// The agent does NOT get a new toolcall integration here Souveraine's
// existing harness integration calls these methods. The teaching of when
// to use them lives in the Souveraine School, not in this file.
import qs
import qs.services
import qs.modules.common
import QtQuick
QtObject {
id: root
// --- Read-only projection -------------------------------------------
// One shape the agent (and the dock-stacks settings editor) can rely on.
function manifest() {
const ta = TaskbarApps;
const stacks = (ta ? ta.stacksList() : []).map(s => ({
id: s.id,
name: s.name,
members: s.members
}));
const stackedMembers = new Set();
for (const s of stacks)
for (const m of s.members) stackedMembers.add(String(m).toLowerCase());
const pinned = (Config.options?.dock?.pinnedApps ?? [])
.filter(id => !stackedMembers.has(String(id).toLowerCase()));
return {
mode: Config.options?.souveraine?.phone ? "phone" : "desktop",
hidden: root._isHidden(),
pinned: root.dockState(),
pinnedApps: pinned,
stacks: stacks,
canMutate: root._canMutate(),
blockReason: root._blockReason()
};
}
// Visible state as a stable string the agent can reason about.
function dockState() {
if (GlobalStates.screenLocked) return "locked";
if (GlobalStates.oskOpen) return "suppressed-by-osk";
if (GlobalStates.dockRevealed) return "shown";
if (Config.options?.dock?.pinnedOnStartup) return "pinned";
return "hidden";
}
// --- Guarded mutation ------------------------------------------------
// Every mutation returns { ok, reason? }. No throw, no silent failure.
function pin(appId) {
const guard = root._checkMutatable(appId);
if (!guard.ok) return guard;
if (TaskbarApps.isPinned(appId)) return { ok: true, reason: "already-pinned" };
TaskbarApps.togglePin(appId);
return { ok: true };
}
function unpin(appId) {
const guard = root._checkMutatable(appId);
if (!guard.ok) return guard;
if (TaskbarApps.stackContaining(appId)) {
return { ok: false, reason: "app is in a stack; use unstackMember" };
}
if (!TaskbarApps.isPinned(appId)) return { ok: true, reason: "not-pinned" };
TaskbarApps.togglePin(appId);
return { ok: true };
}
function addToStack(stackId, appId) {
const guard = root._checkMutatable(appId);
if (!guard.ok) return guard;
if (!root._stackExists(stackId)) return { ok: false, reason: "no-such-stack" };
TaskbarApps.addToStack(stackId, appId);
return { ok: true };
}
function removeFromStack(stackId, appId) {
const guard = root._checkMutatable(appId);
if (!guard.ok) return guard;
if (!root._stackExists(stackId)) return { ok: false, reason: "no-such-stack" };
TaskbarApps.removeFromStack(stackId, appId);
return { ok: true };
}
function renameStack(stackId, newName) {
const guard = root._checkMutatable();
if (!guard.ok) return guard;
if (!root._stackExists(stackId)) return { ok: false, reason: "no-such-stack" };
const name = String(newName ?? "").trim();
if (!name) return { ok: false, reason: "empty-name" };
TaskbarApps.renameStack(stackId, name);
return { ok: true };
}
// --- State checks (the guardrails) ----------------------------------
function _canMutate() { return root._blockReason() === ""; }
function _blockReason() {
if (GlobalStates.screenLocked) return "screen-locked";
if (GlobalStates.oskOpen) return "osk-open";
if (GlobalStates.dockDragInProgress) return "drag-in-progress";
return "";
}
function _checkMutatable(appId) {
const reason = root._blockReason();
if (reason) return { ok: false, reason: reason };
if (appId !== undefined && !String(appId).trim()) {
return { ok: false, reason: "empty-app-id" };
}
return { ok: true };
}
function _stackExists(stackId) {
return TaskbarApps.stacksList().some(s => s.id === stackId);
}
function _isHidden() { return root.dockState() === "hidden"; }
}

View file

@ -113,7 +113,7 @@ DockButton {
}
Timer {
id: dwellTimer
interval: 500
interval: Config.options?.dock.dragDwellMs ?? 500
onTriggered: {
root.formingStack = true
appListRoot.dragTargetAppId = "STACK:" + root.appToplevel.appId

View file

@ -0,0 +1,131 @@
// Pixel3Arch replacement for ii's stock OnScreenKeyboard.qml (2026-07-07,
// reworked 2026-07-10: wvkbd -> squeekboard).
//
// ii's own on-screen keyboard is retired squeekboard (3-finger swipe-up
// gesture, or the pill's long-hold) is the real keyboard now. It also
// auto-shows/hides itself on text-field focus via input-method-v2, which
// wvkbd never could. See docs/phone-shell-ux.md.
//
// This file keeps ii's OSK *plumbing* (GlobalStates.oskOpen, the "osk" IPC
// target, the oskToggle/oskOpen/oskClose global shortcuts both the pill's
// long-hold and the 3-finger swipe-up gesture call `osk toggle`) all still
// working, but no longer renders a keyboard itself. It drives squeekboard
// over its D-Bus visibility interface (sm.puri.OSK0.SetVisible).
//
// Tapping the OSK while search/overview (or a sidebar) is open used to close
// it out from under you that's `GlobalFocusGrab`'s HyprlandFocusGrab
// (a real Wayland protocol, hyprland_focus_grab_v1) clearing because the
// tap landed outside its whitelisted surfaces. Two "shield window" attempts
// to make wvkbd count as "inside" that whitelist both failed on real
// device testing:
// 1. mask: Region {} (empty, no item) theory was this claims zero
// input area so taps pass through to wvkbd underneath while the
// window still counts toward the grab. Wrong in practice: search
// still closed on every tap, meaning an empty Region does NOT behave
// like zero input it behaves like an unmasked/default full-window
// area, and the shield silently ate the taps itself.
// 2. mask: Region { item: <full-rect Item> } mirrors the stock OSK's
// own working mask pattern exactly, but the stock OSK's mask matched
// ITS OWN visible key grid (so taps landed on real buttons). Our
// shield has no buttons a full-covering mask would swallow every
// tap meant for wvkbd, making the keyboard untappable. Never shipped;
// caught before deploying back to the phone.
// There is no Quickshell or hyprctl API to add an arbitrary external
// process's Wayland surface (wvkbd is not a Quickshell QObject) to
// HyprlandFocusGrab's whitelist confirmed via the actual
// hyprland-focus-grab-v1 protocol docs, which only expose whitelisting via
// the compositor-side protocol request, not anything scriptable from here
// without writing a standalone Wayland client. Not worth it for this.
//
// The actual fix lives in Overview.qml (and would need the same pattern in
// any other GlobalFocusGrab-dismissable surface if this bites there too):
// stop registering as dismissable at all while GlobalStates.oskOpen is
// true, so there's nothing for an outside tap to clear in the first place.
// See the comment there for details.
import qs
import qs.services
import qs.modules.common
import QtQuick
import Quickshell.Io
import Quickshell
import Quickshell.Hyprland
Scope {
id: root
// squeekboard exposes sm.puri.OSK0.SetVisible(b) on the session bus.
// Unlike wvkbd's signal dance this is an absolute set, so show/hide
// can't flip the wrong way. Known ceiling: squeekboard also shows and
// hides ITSELF on input-method focus, and oskOpen doesn't hear about
// that the gesture toggle can need two swipes after an auto-show.
function showOsk() {
Quickshell.execDetached(["sh", "-c",
"pgrep -x squeekboard >/dev/null || { squeekboard & sleep 1; }; " +
"busctl call --user sm.puri.OSK0 /sm/puri/OSK0 sm.puri.OSK0 SetVisible b true"])
}
function hideOsk() {
Quickshell.execDetached(["busctl", "call", "--user",
"sm.puri.OSK0", "/sm/puri/OSK0", "sm.puri.OSK0", "SetVisible", "b", "false"])
}
Connections {
target: GlobalStates
function onOskOpenChanged() {
if (GlobalStates.oskOpen) {
root.showOsk();
} else {
root.hideOsk();
}
}
}
IpcHandler {
target: "osk"
function toggle(): void {
GlobalStates.oskOpen = !GlobalStates.oskOpen;
}
function close(): void {
GlobalStates.oskOpen = false;
}
function open(): void {
GlobalStates.oskOpen = true;
}
// Double-tapping the pill while the keyboard is open calls this
// pops the (suppressed, see Dock.qml) dock back up for a few
// seconds without having to close the keyboard first.
function pulseDock(): void {
GlobalStates.pulseDockReveal();
}
}
GlobalShortcut {
name: "oskToggle"
description: "Toggles on screen keyboard on press"
onPressed: {
GlobalStates.oskOpen = !GlobalStates.oskOpen;
}
}
GlobalShortcut {
name: "oskOpen"
description: "Opens on screen keyboard on press"
onPressed: {
GlobalStates.oskOpen = true;
}
}
GlobalShortcut {
name: "oskClose"
description: "Closes on screen keyboard on press"
onPressed: {
GlobalStates.oskOpen = false;
}
}
}

View file

@ -0,0 +1,29 @@
import qs
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
import QtQuick
import Quickshell
import Quickshell.Wayland
// Souveraine: on a phone deploy, surface the on-screen keyboard when a polkit
// prompt appears. The polkit window is a wlr-layer-shell Overlay and the OSK
// is a separate layer-shell surface (squeekboard), so the OSK won't auto-rise
// to it. Since both run under this shell, we drive the OSK explicitly: open
// it when polkit activates, close it when it dismisses. Laptop deploys
// (souveraine.phone == false) are unchanged hardware keyboard handles it.
FullscreenPolkitWindow {
id: root
contentComponent: Component {
PolkitContent {}
}
Connections {
target: PolkitService
function onActiveChanged() {
if (!Config.options.souveraine || !Config.options.souveraine.phone) return;
GlobalStates.oskOpen = PolkitService.active;
}
}
}

View file

@ -0,0 +1,44 @@
import QtQuick
import QtQuick.Layouts
import qs.services
import qs.modules.common
import qs.modules.common.widgets
// Device Souveraine form-factor and (later) per-device overrides.
// Principle: pages are views over Config.options / the owning daemon;
// nothing app-private. This page holds the knobs that describe WHAT this
// device is, so behaviors elsewhere gate on config, not hardcoded checks.
ContentPage {
forceWidth: true
ContentSection {
icon: "smartphone"
title: Translation.tr("Form factor")
ConfigSwitch {
buttonIcon: "smartphone"
text: Translation.tr("Phone mode")
checked: Config.options.souveraine.phone
onCheckedChanged: {
Config.options.souveraine.phone = checked;
}
StyledToolTip {
text: Translation.tr("Gates phone behaviors: OSK rises for polkit prompts, phone-only pages, single-column layouts. Laptop deploys leave this off.")
}
}
}
ContentSection {
icon: "tune"
title: Translation.tr("Device overrides")
StyledText {
Layout.fillWidth: true
text: Translation.tr("Per-device profile overrides (panel size, sensor set, feel presets) land here as the framework grows. One config tree, many devices.")
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
wrapMode: Text.WordWrap
}
}
}

View file

@ -0,0 +1,120 @@
import QtQuick
import QtQuick.Layouts
import qs.services
import qs.modules.common
import qs.modules.common.widgets
// Dock every configurable the dock components read. Feel knobs are the
// single source (components read Config, never hardcode); stacks and pins
// are managed by drag on the dock itself an editor lands here later.
ContentPage {
forceWidth: true
ContentSection {
icon: "dock_to_bottom"
title: Translation.tr("Behavior")
ConfigSwitch {
buttonIcon: "dock_to_bottom"
text: Translation.tr("Enable dock")
checked: Config.options.dock.enable
onCheckedChanged: {
Config.options.dock.enable = checked;
}
StyledToolTip {
text: Translation.tr("The dock surface itself. This settings app runs standalone, so it can always re-enable it.")
}
}
ConfigSwitch {
buttonIcon: "swipe_up"
text: Translation.tr("Hover to reveal")
checked: Config.options.dock.hoverToReveal
onCheckedChanged: {
Config.options.dock.hoverToReveal = checked;
}
StyledToolTip {
text: Translation.tr("Reveal on edge hover. When off, the dock only shows on an empty workspace.")
}
}
ConfigSwitch {
buttonIcon: "push_pin"
text: Translation.tr("Pinned on startup")
checked: Config.options.dock.pinnedOnStartup
onCheckedChanged: {
Config.options.dock.pinnedOnStartup = checked;
}
}
ConfigSpinBox {
icon: "timer"
text: Translation.tr("Drag dwell (ms)")
value: Config.options.dock.dragDwellMs
from: 100
to: 2000
stepSize: 50
onValueChanged: {
Config.options.dock.dragDwellMs = value;
}
StyledToolTip {
text: Translation.tr("How long a drag hovers an icon or stack before it reads as combine-intent.")
}
}
}
ContentSection {
icon: "palette"
title: Translation.tr("Appearance")
ConfigSwitch {
buttonIcon: "filter_b_and_w"
text: Translation.tr("Monochrome icons")
checked: Config.options.dock.monochromeIcons
onCheckedChanged: {
Config.options.dock.monochromeIcons = checked;
}
}
ConfigSpinBox {
icon: "height"
text: Translation.tr("Dock height (px)")
value: Config.options.dock.height
from: 40
to: 120
stepSize: 2
onValueChanged: {
Config.options.dock.height = value;
}
}
ConfigSpinBox {
icon: "expand"
text: Translation.tr("Reveal region height (px)")
value: Config.options.dock.hoverRegionHeight
from: 1
to: 20
stepSize: 1
onValueChanged: {
Config.options.dock.hoverRegionHeight = value;
}
StyledToolTip {
text: Translation.tr("Height of the invisible bottom strip that triggers hover-reveal.")
}
}
}
ContentSection {
icon: "stacks"
title: Translation.tr("Pins & stacks")
StyledText {
Layout.fillWidth: true
text: Translation.tr("Pinned apps and fan-out stacks are managed by drag on the dock itself (hold to combine, drag out to split). A list editor with rename lands here.")
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
wrapMode: Text.WordWrap
}
}
}

View file

@ -0,0 +1,32 @@
import QtQuick
import QtQuick.Layouts
import qs.services
import qs.modules.common
import qs.modules.common.widgets
// On-screen keyboard.
ContentPage {
forceWidth: true
ContentSection {
icon: "keyboard"
title: Translation.tr("On-screen keyboard")
ConfigSwitch {
buttonIcon: "push_pin"
text: Translation.tr("Pinned on startup")
checked: Config.options.osk.pinnedOnStartup
onCheckedChanged: {
Config.options.osk.pinnedOnStartup = checked;
}
StyledToolTip {
text: Translation.tr("Keep the on-screen keyboard visible from launch rather than on demand.")
}
}
// osk.layout is intentionally not exposed here: its value space
// (adapter default "qwerty_full" vs the layouts.js byName registry
// keyed by display name like "English (US)") is inconsistent and
// needs reconciling before a selector is honest about it.
}
}

View file

@ -0,0 +1,107 @@
import QtQuick
import QtQuick.Layouts
import qs.services
import qs.modules.common
import qs.modules.common.widgets
// Lock screen behavior + appearance. Pure Config bindings: every control
// writes an option that already exists in Config.qml's adapter, so
// persistence + hot-apply come for free.
ContentPage {
forceWidth: true
ContentSection {
icon: "lock"
title: Translation.tr("Behavior")
ConfigSwitch {
buttonIcon: "pin"
text: Translation.tr("Touch keypad (phone)")
checked: Config.options.lock.touchKeypad
onCheckedChanged: {
Config.options.lock.touchKeypad = checked;
}
StyledToolTip {
text: Translation.tr("Show the on-lock PIN keypad. The on-screen keyboard can't rise above a session lock, so the lock surface carries its own input.")
}
}
ConfigSwitch {
buttonIcon: "rocket_launch"
text: Translation.tr("Launch lock on startup")
checked: Config.options.lock.launchOnStartup
onCheckedChanged: {
Config.options.lock.launchOnStartup = checked;
}
StyledToolTip {
text: Translation.tr("Start the session locked so a PIN is required before the shell is exposed.")
}
}
ConfigSwitch {
buttonIcon: "key_off"
text: Translation.tr("Require password to power off")
checked: Config.options.lock.security.requirePasswordToPower
onCheckedChanged: {
Config.options.lock.security.requirePasswordToPower = checked;
}
StyledToolTip {
text: Translation.tr("Guard the power menu behind the lock so the device can't be silenced without a PIN.")
}
}
ConfigSwitch {
buttonIcon: "vpn_key"
text: Translation.tr("Unlock keyring on PIN unlock")
checked: Config.options.lock.security.unlockKeyring
onCheckedChanged: {
Config.options.lock.security.unlockKeyring = checked;
}
StyledToolTip {
text: Translation.tr("Feed the PIN to the keyring so stored secrets unlock together with the session.")
}
}
}
ContentSection {
icon: "palette"
title: Translation.tr("Appearance")
ConfigSwitch {
buttonIcon: "format_align_center"
text: Translation.tr("Center the clock")
checked: Config.options.lock.centerClock
onCheckedChanged: {
Config.options.lock.centerClock = checked;
}
}
ConfigSwitch {
buttonIcon: "text_fields"
text: Translation.tr("Show locked text")
checked: Config.options.lock.showLockedText
onCheckedChanged: {
Config.options.lock.showLockedText = checked;
}
}
ConfigSwitch {
buttonIcon: "blur_on"
text: Translation.tr("Blur background")
checked: Config.options.lock.blur.enable
onCheckedChanged: {
Config.options.lock.blur.enable = checked;
}
}
ConfigSwitch {
buttonIcon: "category"
text: Translation.tr("Material shapes for PIN dots")
checked: Config.options.lock.materialShapeChars
onCheckedChanged: {
Config.options.lock.materialShapeChars = checked;
}
}
}
}

View file

@ -0,0 +1,48 @@
import QtQuick
import QtQuick.Layouts
import qs.services
import qs.modules.common
import qs.modules.common.widgets
// Pill the always-on gesture bar. Its reserved strip is stored under
// Config.options.dock (the dock subtracts it from its own input mask), but
// it is the pill's knob, so it lives on the pill's page. Gesture tuning
// (dwell, swipe thresholds, double-tap timing) lands here as the fixed
// values in pill/shell.qml graduate into config.
ContentPage {
forceWidth: true
ContentSection {
icon: "gesture"
title: Translation.tr("Layout")
ConfigSpinBox {
icon: "swap_vert"
text: Translation.tr("Pill strip height (px)")
value: Config.options.dock.pillStripHeight
from: 0
to: 96
stepSize: 2
onValueChanged: {
Config.options.dock.pillStripHeight = value;
}
StyledToolTip {
text: Translation.tr("Bottom strip reserved for the gesture pill — visually and in the dock's input mask. The pill must always win touch here.")
}
}
}
ContentSection {
icon: "swipe"
title: Translation.tr("Gestures")
StyledText {
Layout.fillWidth: true
text: Translation.tr("Current gestures are fixed: double-tap toggles app fullscreen, swipe up reveals the dock. Timing and threshold knobs graduate here from pill/shell.qml as they prove out.")
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
wrapMode: Text.WordWrap
}
}
}

View file

@ -0,0 +1,62 @@
import QtQuick
import Quickshell
import qs.modules.common
// AppInventory.qml root is Item (not QtObject) so it can own its scanner
// Process child the original QtObject root failed with "no default property."
import qs.modules.ii.appInventory
import qs.modules.ii.background
import qs.modules.ii.bar
import qs.modules.ii.cheatsheet
import qs.modules.ii.dock
import qs.modules.ii.lock
import qs.modules.ii.mediaControls
import qs.modules.ii.notificationPopup
import qs.modules.ii.onScreenDisplay
import qs.modules.ii.onScreenKeyboard
import qs.modules.ii.overview
import qs.modules.ii.polkit
import qs.modules.ii.regionSelector
import qs.modules.ii.screenCorners
import qs.modules.ii.screenTranslator
import qs.modules.ii.sessionScreen
import qs.modules.ii.sidebarLeft
import qs.modules.ii.sidebarRight
import qs.modules.ii.overlay
import qs.modules.ii.verticalBar
import qs.modules.ii.wallpaperSelector
// The Souveraine panel family one family for both modes.
// Starts at exact panel parity with IllogicalImpulseFamily (our overridden
// components resolve in naturally); per-panel form-factor gates
// (Config.options.souveraine.phone) get added only where the modes genuinely
// differ, and removed as the modes homogenize. This file is the scoreboard
// of that convergence.
Scope {
// Non-UI service scope: hosts the app-inventory IPC surface (apps.*).
// Plain child, not a PanelLoader entry it has no panel/visibility
// concerns, just a long-lived Scope holding the inventory + IpcHandler.
AppInventoryScope {}
PanelLoader { extraCondition: !Config.options.bar.vertical; component: Bar {} }
PanelLoader { component: Background {} }
PanelLoader { component: Cheatsheet {} }
PanelLoader { extraCondition: Config.options.dock.enable; component: Dock {} }
PanelLoader { component: Lock {} }
PanelLoader { component: MediaControls {} }
PanelLoader { component: NotificationPopup {} }
PanelLoader { component: OnScreenDisplay {} }
PanelLoader { component: OnScreenKeyboard {} }
PanelLoader { component: Overlay {} }
PanelLoader { component: Overview {} }
PanelLoader { component: Polkit {} }
PanelLoader { component: RegionSelector {} }
PanelLoader { component: ScreenCorners {} }
PanelLoader { component: ScreenTranslator {} }
PanelLoader { component: SessionScreen {} }
PanelLoader { component: SidebarLeft {} }
PanelLoader { component: SidebarRight {} }
PanelLoader { extraCondition: Config.options.bar.vertical; component: VerticalBar {} }
PanelLoader { component: WallpaperSelector {} }
}

View file

@ -21,18 +21,32 @@ Singleton {
}
// --- 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).
// Backing store: Config.options.dock.stacks, a list<string> string
// list because nested-object arrays don't survive the JsonAdapter.
// Each entry is a JSON object string:
// {"id":"stack-1","name":"Stack 1","members":["appId","appId"]}
// JSON-per-entry is escaping-safe (the first iteration's "name|app,app"
// format corrupted silently on a | or , in a name). id is the stable
// lookup key; name is the display label (rename never breaks lookups).
// Legacy pipe entries still parse (id doubles as name) and get
// rewritten as JSON on the next stacks write.
function parseStack(entry) {
if (entry.startsWith("{")) {
try {
const o = JSON.parse(entry);
return { id: o.id ?? "", name: o.name ?? o.id ?? "", members: o.members ?? [] };
} catch (e) {
return { id: entry, name: entry, members: [] };
}
}
// Legacy "stackId|appId,appId" format.
const bar = entry.indexOf("|");
if (bar === -1) return { id: entry, members: [] };
if (bar === -1) return { id: entry, name: 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 };
return { id: id, name: id, members: members };
}
function stacksList() {
@ -48,16 +62,16 @@ Singleton {
return "";
}
function encodeStack(id, members) {
return id + "|" + members.join(",");
function encodeStack(s) {
return JSON.stringify({ id: s.id, name: s.name, members: s.members });
}
// Rewrite the whole stacks list from a parsed [{id, members}] array,
// dropping any that end up empty.
// Rewrite the whole stacks list from a parsed [{id, name, 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));
.map(root.encodeStack);
}
function addToStack(stackId, appId) {
@ -81,10 +95,30 @@ Singleton {
}
// --- 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);
// Mint a stable id + display name for a fresh stack. Ids scan for the
// max existing numeric suffix, never reuse after a delete (the old
// count-based scheme collided: delete "Stack 1" of two and the next
// combine minted a second "Stack 2" and id is the lookup key, so two
// stacks silently shared members).
function mintStack() {
let n = 0;
for (const s of root.stacksList()) {
const mi = /^stack-(\d+)$/.exec(s.id);
if (mi) n = Math.max(n, parseInt(mi[1]));
const mn = /^Stack (\d+)$/.exec(s.name);
if (mn) n = Math.max(n, parseInt(mn[1]));
}
return { id: "stack-" + (n + 1), name: "Stack " + (n + 1) };
}
// Rename a stack's display label. The id (lookup key) never changes.
function renameStack(stackId, newName) {
if (!newName) return;
const parsed = root.stacksList();
const s = parsed.find(x => x.id === stackId);
if (!s) return;
s.name = newName;
root.writeStacks(parsed);
}
// Drag `draggedAppId` onto `targetAppId` -> combine. If target is
@ -99,28 +133,18 @@ Singleton {
if (targetStackId) {
root.addToStack(targetStackId, draggedAppId);
} else {
const id = root.nextStackName();
const fresh = root.mintStack();
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.
parsed.push({ id: fresh.id, name: fresh.name, members: [targetAppId, draggedAppId] });
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);
// An app lives in one place: its stack. The standalone pin actually
// leaves config here (it used to be only render-suppressed, leaving
// a phantom pin string behind); unstackMember re-pins on the way out.
const dropPins = [draggedAppId.toLowerCase()];
if (!targetStackId) dropPins.push(targetAppId.toLowerCase());
Config.options.dock.pinnedApps = Config.options.dock.pinnedApps.filter(
id => !dropPins.includes(id.toLowerCase()));
}
// Replace a stack's member order wholesale (arc-reorder commit).
@ -153,7 +177,8 @@ Singleton {
memberToStackKey.set(m.toLowerCase(), "STACK:" + s.id);
}
map.set("STACK:" + s.id, {
pinned: true, toplevels: [], isStack: true, members: s.members
pinned: true, toplevels: [], isStack: true, members: s.members,
name: s.name
});
}
@ -201,7 +226,8 @@ Singleton {
toplevels: value.toplevels,
pinned: value.pinned,
isStack: value.isStack ?? false,
members: value.members ?? []
members: value.members ?? [],
stackName: value.name ?? ""
}));
}
@ -215,6 +241,7 @@ Singleton {
required property bool pinned
property bool isStack: false
property list<var> members: []
property string stackName: ""
}
Component {
id: appEntryComp

View file

@ -0,0 +1,12 @@
#!/bin/sh
# Souveraine Settings must be a direct graphical-session process. Some app
# grids omit Wayland/Hyprland variables when activating .desktop entries.
set -eu
for name in WAYLAND_DISPLAY HYPRLAND_INSTANCE_SIGNATURE XDG_RUNTIME_DIR XDG_CURRENT_DESKTOP; do
value="$(systemctl --user show-environment 2>/dev/null | sed -n "s/^${name}=//p" | head -n 1)"
[ -n "$value" ] && export "${name}=${value}"
done
export QT_QPA_PLATFORM=wayland
exec qs -p "$HOME/.config/quickshell/souveraine/settings-phone.qml"

View file

@ -0,0 +1,216 @@
//@ pragma UseQApplication
//@ pragma Env QS_NO_RELOAD_POPUP=1
//@ pragma Env QT_QUICK_CONTROLS_STYLE=Basic
//@ pragma Env QT_QUICK_FLICKABLE_WHEEL_DECELERATION=10000
// Souveraine mobile settings purpose-built for portrait/touch.
// Modeled on TouchLockSurface.qml: a full-screen surface with phone-native
// stack navigation (list -> push page -> back), not the desktop settings.qml's
// ApplicationWindow with titlebar + side rail. Reuses the existing
// ConfigSwitch/ConfigSpinBox pages (PhoneConfig.qml etc.) as the pushed
// content; only the chrome is mobile.
//
// Sized for 540x1080 logical (1080x2160 @ scale 2).
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Window
import Quickshell
import Quickshell.Io
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions as CF
ApplicationWindow {
id: root
property int currentPage: -1 // -1 = list view; >=0 = drilled into that page
readonly property real rowHeight: 68 // finger-sized tap target
readonly property real edgeMargin: 16
// The page set. Same {name, icon, component} shape as the desktop
// settings.qml registry so a page is interchangeable between the two.
// One page per domain we own; About reuses ii's stock page verbatim.
property var pages: [
{
name: Translation.tr("Device"),
icon: "smartphone",
component: "modules/settings/DeviceConfig.qml"
},
{
name: Translation.tr("Lock screen"),
icon: "lock",
component: "modules/settings/LockConfig.qml"
},
{
name: Translation.tr("Dock"),
icon: "dock_to_bottom",
component: "modules/settings/DockConfig.qml"
},
{
name: Translation.tr("Pill"),
icon: "gesture",
component: "modules/settings/PillConfig.qml"
},
{
name: Translation.tr("Keyboard"),
icon: "keyboard",
component: "modules/settings/KeyboardConfig.qml"
},
{
name: Translation.tr("About"),
icon: "info",
component: "modules/settings/About.qml"
}
// Next: Display (brightness/idle), Cellular (mmcli service),
// Bluetooth (after org.bluez activation is fixed), app health.
]
visible: true
onClosing: Qt.quit()
title: "Settings"
// Full-screen on the phone: no titlebar, no window-frame padding, edge to edge.
visibility: Window.FullScreen
color: Appearance.m3colors.m3background
Component.onCompleted: {
MaterialThemeLoader.reapplyTheme()
Config.readWriteDelay = 0
}
// --- List view (idle state) ------------------------------------------
ColumnLayout {
id: listView
anchors.fill: parent
spacing: 0
visible: root.currentPage < 0
// Header
Item {
Layout.fillWidth: true
Layout.leftMargin: root.edgeMargin
Layout.rightMargin: root.edgeMargin
Layout.topMargin: root.edgeMargin + 16
Layout.bottomMargin: 8
implicitHeight: titleText.implicitHeight
StyledText {
id: titleText
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
color: Appearance.colors.colOnLayer0
text: Translation.tr("Settings")
font {
family: Appearance.font.family.title
pixelSize: Appearance.font.pixelSize.title
variableAxes: Appearance.font.variableAxes.title
}
}
}
// Full-width finger-sized rows
StyledListView {
id: pageList
Layout.fillWidth: true
Layout.fillHeight: true
model: root.pages
delegate: RippleButton {
required property var index
required property var modelData
width: pageList.width
height: root.rowHeight
buttonRadius: 0
onClicked: root.currentPage = index
contentItem: RowLayout {
anchors.fill: parent
anchors.leftMargin: root.edgeMargin
anchors.rightMargin: root.edgeMargin
spacing: 16
MaterialSymbol {
text: modelData.icon
iconSize: Appearance.font.pixelSize.larger
color: Appearance.colors.colOnLayer0
}
StyledText {
Layout.fillWidth: true
text: modelData.name
color: Appearance.colors.colOnLayer0
font.pixelSize: Appearance.font.pixelSize.larger
}
MaterialSymbol {
text: "chevron_right"
iconSize: Appearance.font.pixelSize.larger
color: Appearance.colors.colSubtext
}
}
}
}
}
// --- Page view (drilled-in state) ------------------------------------
Item {
id: pageView
anchors.fill: parent
visible: root.currentPage >= 0
// Back header
Item {
id: pageHeader
anchors {
top: parent.top
left: parent.left
right: parent.right
}
height: 56
RippleButton {
id: backButton
anchors {
left: parent.left
leftMargin: 4
verticalCenter: parent.verticalCenter
}
buttonRadius: Appearance.rounding.full
implicitWidth: 48
implicitHeight: 48
onClicked: root.currentPage = -1
contentItem: MaterialSymbol {
anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
text: "arrow_back"
iconSize: 24
}
}
StyledText {
anchors {
left: backButton.right
leftMargin: 8
right: parent.right
rightMargin: root.edgeMargin
verticalCenter: parent.verticalCenter
}
color: Appearance.colors.colOnLayer0
text: root.currentPage >= 0 ? root.pages[root.currentPage].name : ""
elide: Text.ElideRight
font {
family: Appearance.font.family.title
pixelSize: Appearance.font.pixelSize.larger
}
}
}
// Pushed page. ContentPage is a StyledFlickable that clamps to
// baseWidth 600 when forceWidth; we let it scroll and give it the
// full window width so it reads edge-to-edge.
Loader {
id: pageLoader
anchors {
top: pageHeader.bottom
left: parent.left
right: parent.right
bottom: parent.bottom
}
active: root.currentPage >= 0 && Config.ready
source: root.currentPage >= 0 ? root.pages[root.currentPage].component : ""
}
}
}

View file

@ -0,0 +1,45 @@
//@ pragma UseQApplication
//@ pragma Env QS_NO_RELOAD_POPUP=1
//@ pragma Env QT_QUICK_CONTROLS_STYLE=Basic
//@ pragma Env QT_QUICK_FLICKABLE_WHEEL_DECELERATION=10000
// Souveraine shell entry `qs -c souveraine`.
// One family, two modes (desktop and phone), converging over time. Panels
// that differ by form factor are gated on Config.options.souveraine.phone
// inside SouveraineFamily; a panel is "homogenized" when its gate is gone.
// No panelFamily switching: this config loads SouveraineFamily, period.
// ii's tree is borrowed underneath (composed by deploy.sh) until each piece
// is owned outright.
import "modules/common"
import "services"
import "panelFamilies"
import QtQuick
import QtQuick.Window
import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
ShellRoot {
id: root
// NOTE: no DeviceConnectNotification here the phone's ii snapshot
// predates it, and version-dependent types don't belong in the entry file.
ReloadPopup {}
Component.onCompleted: {
MaterialThemeLoader.reapplyTheme()
Hyprsunset.load()
FirstRunExperience.load()
ConflictKiller.load()
Cliphist.refresh()
Wallpapers.load()
Updates.load()
}
LazyLoader {
active: Config.ready
component: SouveraineFamily {}
}
}

View file

@ -0,0 +1,12 @@
[Desktop Entry]
Type=Application
Name=Settings
Comment=Souveraine phone settings
# App grids can launch this with an empty WAYLAND_DISPLAY. The launcher imports
# the live compositor values held by the user manager while remaining in the
# graphical session; a transient user service cannot open this system's socket.
Exec=/bin/sh /home/casey/souveraine-surfaces/quickshell/settings-launch.sh
Icon=settings-configure
Terminal=false
Categories=Settings;System;
NoDisplay=false