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:
parent
72f5169458
commit
c78c5510af
29 changed files with 2362 additions and 101 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
182
surfaces/quickshell/INTERFACE-ARCHITECTURE.md
Normal file
182
surfaces/quickshell/INTERFACE-ARCHITECTURE.md
Normal 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.
|
||||
180
surfaces/quickshell/SETTINGS-APP-PLAN.md
Normal file
180
surfaces/quickshell/SETTINGS-APP-PLAN.md
Normal 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.
|
||||
|
|
@ -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')"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
215
surfaces/quickshell/modules/common/ShellModel.qml
Normal file
215
surfaces/quickshell/modules/common/ShellModel.qml
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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"] });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
43
surfaces/quickshell/modules/common/widgets/ContentPage.qml
Normal file
43
surfaces/quickshell/modules/common/widgets/ContentPage.qml
Normal 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))
|
||||
}
|
||||
}
|
||||
41
surfaces/quickshell/modules/common/widgets/StyledToolTip.qml
Normal file
41
surfaces/quickshell/modules/common/widgets/StyledToolTip.qml
Normal 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
|
||||
}
|
||||
}
|
||||
288
surfaces/quickshell/modules/ii/appInventory/AppInventory.qml
Normal file
288
surfaces/quickshell/modules/ii/appInventory/AppInventory.qml
Normal 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
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
143
surfaces/quickshell/modules/ii/dock/DockManifest.qml
Normal file
143
surfaces/quickshell/modules/ii/dock/DockManifest.qml
Normal 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"; }
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
29
surfaces/quickshell/modules/ii/polkit/Polkit.qml
Normal file
29
surfaces/quickshell/modules/ii/polkit/Polkit.qml
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
44
surfaces/quickshell/modules/settings/DeviceConfig.qml
Normal file
44
surfaces/quickshell/modules/settings/DeviceConfig.qml
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
120
surfaces/quickshell/modules/settings/DockConfig.qml
Normal file
120
surfaces/quickshell/modules/settings/DockConfig.qml
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
32
surfaces/quickshell/modules/settings/KeyboardConfig.qml
Normal file
32
surfaces/quickshell/modules/settings/KeyboardConfig.qml
Normal 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.
|
||||
}
|
||||
}
|
||||
107
surfaces/quickshell/modules/settings/LockConfig.qml
Normal file
107
surfaces/quickshell/modules/settings/LockConfig.qml
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
48
surfaces/quickshell/modules/settings/PillConfig.qml
Normal file
48
surfaces/quickshell/modules/settings/PillConfig.qml
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
62
surfaces/quickshell/panelFamilies/SouveraineFamily.qml
Normal file
62
surfaces/quickshell/panelFamilies/SouveraineFamily.qml
Normal 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 {} }
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
12
surfaces/quickshell/settings-launch.sh
Normal file
12
surfaces/quickshell/settings-launch.sh
Normal 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"
|
||||
216
surfaces/quickshell/settings-phone.qml
Normal file
216
surfaces/quickshell/settings-phone.qml
Normal 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 : ""
|
||||
}
|
||||
}
|
||||
}
|
||||
45
surfaces/quickshell/shell.qml
Normal file
45
surfaces/quickshell/shell.qml
Normal 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 {}
|
||||
}
|
||||
}
|
||||
12
surfaces/quickshell/souveraine-settings.desktop
Normal file
12
surfaces/quickshell/souveraine-settings.desktop
Normal 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
|
||||
Loading…
Reference in a new issue