Watch
1
0
Fork
You've already forked souveraine
0

quickshell: first-party lock/nav/session layer, retire the pill

Grows Souveraine's own surfaces on top of the borrowed ii shell and drops
the separate pill shell in favor of one integrated navigation rail.

Session arbiter (functions/Session.qml): probe logind's Can* methods over
busctl instead of guessing from installed binaries -- the answer carries the
polkit tier (yes/challenge/na), so a swapless phone reports hibernate as na
and refuses honestly rather than firing a verb that no-ops. Verbs run through
a Process that logs exit codes and tracks lastAction; refusals log too. The
busctl output is parsed with awk, not a sed regex buried under four escaping
layers -- the sed version returned nothing on the phone and left every
capability stuck at "unknown" (invisible on the laptop, where timing masked
it). Every structured result is JSON-over-string; quickshell maps a var
return to void.

Lock trust: screenLocked (the shell's lock request) is now distinct from
screenLockSecure (WlSessionLock.secure, the compositor's acknowledgement,
mirrored from LockScreen). Cards that disclose personal data gate on secure,
not on a button press. LockContentPolicy centralizes the ambient/personal/
step-up tiers so no card grows its own private rule.

New first-party namespace modules/souveraine/: LockMediaCard, LockSurfaceHost,
SystemGestureRail -- owned surfaces, not ii patches. IdleCoordinator gives one
staged idle vocabulary (dim/lock) gated behind nativeCoordinatorEnabled, off
until the native Wayland idle-notify is verified on the Pixel compositor;
hypridle stays the adapter. WallpaperAssets selects aspect-aware variants for
phone-vs-laptop display shapes.

Pill retired: pill/shell.qml and PillConfig gone, replaced by NavigationConfig
and the gesture rail. Hyprland starts qs -c souveraine directly; no secondary
shell, no qsConfig flip.

Verified on the phone: session.* reports challenge/na correctly, hibernate
and unlock refuse, inhibit round-trips with its reason.
This commit is contained in:
Fimeg 2026-07-14 20:00:57 -04:00
commit e31c3aaf62
26 changed files with 1374 additions and 251 deletions

View file

@ -1,9 +1,9 @@
// Souveraine patch to ii's stock GlobalStates.qml.
//
// Adds dockRevealed: the explicit, persistent dock state driven by the
// gesture pill (pill/shell.qml). Everything else in this file is unchanged
// stock ii diff against upstream before re-applying this patch if ii
// updates.
// Souveraine's integrated navigation rail. Everything else in this file is
// unchanged stock ii diff against upstream before re-applying this patch if
// ii updates.
import qs.modules.common
import qs.services
import QtQuick
@ -27,7 +27,13 @@ Singleton {
property bool overviewOpen: false
property bool regionSelectorOpen: false
property bool searchOpen: false
// `screenLocked` is the shell's lock *request*: it drives WlSessionLock
// and hides ordinary surfaces immediately. It is deliberately separate
// from `screenLockSecure`, which is WlSessionLock.secure and only becomes
// true once the compositor has acknowledged the lock. Consumers that
// disclose personal data must gate on secure, not merely on the request.
property bool screenLocked: false
property bool screenLockSecure: false
property bool screenLockContainsCharacters: false
property bool screenUnlockFailed: false
property bool screenTranslatorOpen: false
@ -40,8 +46,8 @@ Singleton {
property bool wallpaperSelectorOpen: false
property bool workspaceShowNumbers: false
// In fullscreen this is the only way the dock becomes visible:
// pill swipe up sets it; pill swipe down clears it. It deliberately has
// no timer or secondary state.
// the navigation rail swipe sets/clears it. It deliberately has no timer
// or secondary state.
property bool dockRevealed: false
// True while a dock icon drag is in flight. Set by DragManager, read by

View file

@ -1,37 +1,36 @@
# How the phone shell works
Three surfaces: **bar** (top), **dock** (bottom, above pill), **pill** (bottom edge).
Three surfaces: **bar** (top), **dock** (bottom, above the navigation rail),
and Souveraine's integrated **navigation rail** (bottom edge).
Edit here → `./deploy.sh --phone` → restart. Deployed via symlink; live edits = git-tracked.
## Pill (`pill/shell.qml`) — the always-on gesture bar
## Navigation rail (`modules/souveraine/navigation/SystemGestureRail.qml`)
- **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.** 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.
- **Owned by Souveraine.** It is loaded by `SouveraineFamily` on phone mode;
there is no second Quickshell configuration, external IPC hop, or Hyprland
layer rule to keep in sync.
- 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
targets `Hyprland.activeToplevel.address` — NOT hyprctl on "active window"
(the tap focuses the shell).
- **swipe up**`dock` IPC `swipeUp` (reveals the dock above the pill).
- **swipe down**`dock` IPC `swipeDown` (dismiss).
- **No keyboard on the pill.** OSK = 3-finger hyprgrass swipe only.
display, no border/gaps). Targets `Hyprland.activeToplevel.address` — not
hyprctl's focus (the tap focuses the shell).
- **swipe up** → reveals the dock.
- **swipe down** → dismisses the dock.
- **No keyboard on the rail.** OSK = 3-finger hyprgrass swipe only.
## Dock (`modules/ii/dock/Dock.qml`)
- **On `WlrLayer.Overlay`** so the pill can reveal it over fullscreen apps.
- **On `WlrLayer.Overlay`** so the navigation rail can reveal it over fullscreen apps.
- **Hidden = layer unmounted** (`visible:false`), not just tucked — else it paints over fullscreen.
- **Visibility** (`computeDockState`, first match wins):
1. fullscreen app on focused monitor → **Hidden** (unless pill-revealed)
1. fullscreen app on focused monitor → **Hidden** (unless rail-revealed)
2. pinned → **Pinned** (only state that reserves exclusive zone)
3. preview-hover → **Shown**
4. empty desktop / no focused app → **Shown**
5. else (normal app focused) → **Hidden**
- **Reserves a 32px pill strip** at the bottom visually *and in its
layer-shell input mask*, so the dock cannot intercept pill touches.
- **Bar height is content-driven**: the window sizes itself to the button row (64px buttons) + row margin + pill strip; `Config.options.dock.height` is only a floor. Don't tune the config height to "fix" icon clipping.
- **Reserves the navigation rail height** at the bottom visually *and in its
layer-shell input mask*, so the dock cannot intercept rail touches.
- **Bar height is content-driven**: the window sizes itself to the button row (64px buttons) + row margin + rail height; `Config.options.dock.height` is only a floor. Don't tune the config height to "fix" icon clipping.
- **App list width is capped** to the screen (`DockApps.maxWidth`); past that the icon row scrolls horizontally by touch (flick is only enabled when overflowing, so drag-to-combine keeps working when everything fits).
- **DockStack renders with the same content block as DockAppButton** (icon + half-reserved dot strip, centered as one unit). Keep them structurally identical or they drift apart on the bar.
- Depends on **`GlobalStates.dockRevealed`** — lives in the surface tree (`GlobalStates.qml`, stock ii + patch) and the deploy manifest. If ii updates its GlobalStates, re-diff and re-apply the patch.
@ -52,5 +51,5 @@ the dock.
## Fullscreen API (Hyprland 0.55, Lua dispatch)
- Dispatch: `hl.dsp.window.fullscreen({ window="address:0x…", mode="fullscreen", action="toggle" })`.
- **`fullscreen`** = whole display, no borders or gaps. ← the pill uses this.
- **`fullscreen`** = whole display, no borders or gaps. ← the navigation rail uses this.
- **`maximized`** = keeps the normal workspace layout margins.

View file

@ -0,0 +1,176 @@
# Lock, idle, and wallpaper reference extraction
This is a design extraction, not a dependency list. Souveraine should retain
one session authority across desktop, laptop, and phone; form factor changes
the chosen layout and cards, never the meaning of lock, idle, or auth.
Migration rule: ii is a temporary compatibility substrate, not a source of
new architecture. New behavior belongs in `surfaces/quickshell` under a
Souveraine-owned module or service. Borrowed code is replaced deliberately,
with the replacement's contract documented before the borrowed file leaves.
## What the active shell has today
The phone lock has a credential keypad, fingerprint attempt, battery state,
and guarded power actions. It does **not** yet have lockscreen media,
notifications, timers, weather, calls, agent output policy, a staged idle
coordinator, or a real sleep-inhibitor backend. `Idle.qml` currently controls
hypridle because the tested invisible Wayland idle inhibitor is not honoured
on the Pixel 3 compositor build.
That is a sound small base. It is not yet a phone-grade lock surface.
## Ideas to extract
| Source | Keep | Do not inherit |
| --- | --- | --- |
| Sailfish/Lipstick lockscreen | A lock screen as layered/pannable content: glance surface, credential surface, and event surface. Wake/display-off are separate from lock. | Its device-specific Lipstick and DeviceLock plumbing; source provenance must be audited before copying. |
| Glacier Home | A compact MPRIS card with capability-gated previous/play/next controls; media is loaded only if a player exists. Clock, media, keypad, and notifications are separate components. | Its older Nemo/Amber APIs and visual code verbatim unless copyright notices travel with it. |
| DankMaterialShell | Independent idle monitors for dim/DPMS/lock/suspend, explicit re-arming, AC-vs-battery thresholds, and a wake monitor after screen-off. | Treating its shell state as authority or blindly importing its settings model. |
| ii/iNiR | MPRIS card mechanics, downloaded artwork, palette adaptation, and image "least busy region" placement. ii already calculates content placement against wallpaper pixels. | The current one-file wallpaper assumption and arbitrary widget dimensions. |
| Ambxst | Screen capture at lock, short-lived credential handling, failure lockout feedback, and listening to logind Lock/PrepareForSleep events. | Its AGPL code, polling `axctl` layer, and its separate lockscreen authority. Concepts only. |
DankMaterialShell and iNiR are MIT; Glacier carries MIT/BSD notices. Ambxst is
AGPL and Vast is GPL. Do not copy from the latter two into this repository
without intentionally accepting their licence obligations. Sailfish files in
the local research extraction need per-file provenance review before reuse.
## One lock surface, composable cards
The lock surface should be a host with slots rather than a new shell for each
device:
```
WlSessionLockSurface
└─ LockSurfaceHost
├─ glance slot clock · date · battery · weather
├─ continuity slot timer · ongoing call · navigation · media transport
├─ event slot notification summaries / previews
├─ credential slot PIN/password/fingerprint PAM conversation
└─ ambient-agent slot explicitly safe question/answer cards
```
The host chooses a layout profile:
| Profile | Layout |
| --- | --- |
| phone portrait | One vertical glance surface; a swipe/press brings up credentials; continuity cards occupy the lower third. |
| laptop | Center credentials with a side or lower card rail; multi-monitor cards remain local to their screen. |
| desktop | Center credentials and a sparse, large-screen card rail; no phone gesture assumptions. |
Each card declares a tier and a lock-surface visibility policy. This prevents
an attractive card from silently becoming a data leak.
| Tier | Suitable cards | Rule |
| --- | --- | --- |
| ambient | clock, battery, weather, timer, transport buttons, an ongoing-call *indicator* | no user data beyond the explicitly permitted setting |
| personal | track title/art, notification body, calendar title, agent conversation, call identity | hidden until unlock unless the user opts that field into ambient |
| step-up | send/reply/delete/purchase/unlock a physical thing | never performed by a card tap alone; needs a recent step-up grant |
An ambient agent is not a diluted personal agent. It has a separate tool and
memory allowlist, cannot read conversation history or sensors, and its output
is labelled ambient. If the lock starts while a personal response streams, the
host immediately withdraws it.
## Idle is a transition graph, not a timer
The useful DMS extraction is the vocabulary and re-arm discipline. The actual
authority remains Wayland idle protocols plus logind:
```
active ─idle timeout→ dimmed ─timeout→ lock-requested
WlSessionLock.secure
lock-secure ─timeout→ display-off
│ │
PrepareForSleep(true) ─────────────────────────────────────┘
suspending → asleep → waking → active
```
Input can cancel dimming and display-off before lock. It cannot cancel an
already secure lock; credentials do that. Every transition has an event source
and an observable result. `screenLocked` is the request; `screenLockSecure` is
the compositor acknowledgement.
The implementation sequence is:
1. Add one `IdleCoordinator` that owns the staged state and form-factor
thresholds. It consumes native `IdleMonitor` events when verified on the
target; the Pixel can retain the hypridle adapter behind that interface.
2. Add a logind event watcher for this session's `Lock` and the manager's
`PrepareForSleep` signal.
3. Hold a delay-mode sleep inhibitor while the shell is running. Release it
only after the Wayland lock is secure; re-acquire it on wake. Measure the
actual logind delay budget on the Pixel 3.
4. Make `idle`, `sleep`, `logout`, and `user-switch` explicit inhibitor kinds.
Only expose a kind after it owns a real backend. `idle` is the sole
implemented kind today.
## Wallpaper should be an asset, not a path
The current background path gives every output the same image and asks crop to
solve incompatible aspect ratios. A downloaded landscape image consequently
loses its subject on the phone. Preserve originals, but track a portable asset
record:
```
id, originalPath, origin, licence, palette,
focalPoint(x,y), safeRegions[],
variants: { landscape, portrait, square },
previewPath, generatedAt
```
Selection order for a screen is:
1. Use an explicit per-device assignment when the user set one.
2. Select a sufficiently close aspect-ratio variant.
3. Crop a derived local variant around `focalPoint` while preserving a
`safeRegion`; never overwrite the original.
4. Fall back to a blurred/palette background with the image contained, rather
than crop the only meaningful part away.
ii's existing least-busy-region analysis is useful twice: place a clock/card
where it does not cover the subject, and propose an initial safe region.
However, it must run against the **actual output crop** and card dimensions,
not a fixed 300×300 desktop widget. A manual focal-point editor is the escape
hatch; an analyser may propose, never silently decide.
For the lock surface, prefer a frozen screencopy or an already-derived local
variant over downloading or decoding a remote wallpaper at lock time. Locking
must remain instant and work offline.
## Proposed Souveraine map
```
modules/common/functions/Session.qml lifecycle verbs + IPC projection
services/SessionEvents.qml logind Lock / PrepareForSleep ingress
services/IdleCoordinator.qml transition graph + target adapters
services/LockContentPolicy.qml card tier / field visibility decisions
modules/ii/lock/LockSurfaceHost.qml shared card host
modules/ii/lock/cards/ Clock · Media · Notifications · Continuity · AmbientAgent
services/StepUpAuth.qml independent PamContext + expiring grants
services/WallpaperAssets.qml asset records, variants, safe regions
```
None of these are separate session managers. They are consumers of the single
`session` lifecycle and its state projection.
## Build order
1. **Truth before visuals:** logind event ingress, delay inhibitor, and
`IdleCoordinator` with a visible transition log.
2. **First lock cards:** clock/date, battery, transport-only MPRIS. Add the
metadata privacy switch before title/artist/artwork.
3. **Continuity:** timers, ongoing call, navigation, and notification summary
cards with per-field redaction.
4. **Trust:** ambient-agent allowlist, lock-time response redaction, then
`StepUpAuth` and action-family grants.
5. **Wallpaper assets:** per-device assignments, focal-point editor, derived
portrait/landscape variants, and crop-aware card placement.
The first useful vertical slice is therefore not a huge lockscreen redesign:
one shared `LockSurfaceHost`, a transport-only media card, a real staged idle
state projection, and a wallpaper asset descriptor. It proves the architecture
on both shapes without committing personal data to the lock surface.

View file

@ -0,0 +1,101 @@
# Session and interaction trust model
This is the contract for the Souveraine shell on laptop and phone. It records
the boundary between orchestration we own and the Linux/Wayland authorities we
must consume rather than replace.
## Authorities and projections
| Question | Authority | Souveraine's role |
| --- | --- | --- |
| Can this machine suspend, hibernate, power off, or reboot? | logind `Can*` methods | Query and expose the answer; execute the matching logind/systemd verb. |
| Has the compositor secured the display? | `WlSessionLock.secure` | Mirror it as `screenLockSecure`; never infer it from a button press. |
| Does the shell want its lock surface shown? | `GlobalStates.screenLocked` | Maintain this request state and persist it across a shell crash. |
| Has the user authenticated? | PAM and the lock's `LockContext` | Start a PAM conversation; only it may release `WlSessionLock`. |
| Why is automatic idle sleep suppressed? | The active idle mechanism | Keep a reason/cookie registry, but report only mechanisms that are actually applied. |
`screenLocked` and `screenLockSecure` must stay distinct. The first closes
ordinary shell surfaces immediately. The second is the only proof suitable for
personal-data disclosure. A failed or delayed Wayland lock is therefore
visible as `lockRequested: true, locked: false`, not mistaken for success.
## Session IPC
All structured IPC results are JSON strings. Quickshell IPC only marshals
primitive QML types, so `: var` silently becomes `void` and loses its payload.
`session.lock()` means “the Wayland lock was requested”; it does not claim
the compositor is secure yet. `session.state()` exposes the subsequent
`locked` acknowledgement and the most recent asynchronous power-action result.
Power verbs return `status: "started"` when their command has started; a later
exit is available in `lastAction`. Refusals always log and return a reason.
The current public inhibitor surface implements `idle` only. It is intentionally
wrong to accept `sleep`, `shutdown`, `logout`, or `user-switch` until each owns
a real mechanism. The eventual contract is:
| Kind | Backend |
| --- | --- |
| `idle` | Native Wayland idle inhibitor once verified on both targets; until then the tested hypridle control path. |
| `sleep` | A live logind/systemd inhibitor FD or `systemd-inhibit` process, held for the cookie lifetime. |
| `logout`, `user-switch` | Shell policy checks at those operations, not fake logind inhibitors. |
Every inhibitor must have a non-empty human reason, a cookie, and a visible
holder in `state()`. Releasing an unknown cookie is a refusal.
## Lock and suspend sequencing
The next lifecycle change is not another cached state machine. It is a logind
event ingress:
1. Subscribe to the current logind session's `Lock` signal and request the
Wayland lock on receipt. This makes external `loginctl lock-session` calls
meaningful without assuming logind implements a screen locker.
2. Hold one delay-mode sleep inhibitor from shell startup.
3. On logind `PrepareForSleep(true)`, request the Wayland lock and wait for
`WlSessionLock.secure`; release the delay inhibitor only then.
4. Reacquire it after `PrepareForSleep(false)`.
The delay budget is logind's `InhibitDelayMaxUSec` (commonly five seconds), so
this must be timed on the Pixel 3 before it becomes a security claim. The
failure policy is fail closed: if the lock is not secure before the deadline,
leave a diagnostic event and do not pretend the session was locked.
## Interaction capability tiers
The lock screen, media controls, and agent are one policy system, not three.
Each operation and each response is labelled with a minimum tier:
| Tier | Requirement | Examples |
| --- | --- | --- |
| `ambient` | None | clock, weather, timers, transport controls, non-personal answers |
| `personal` | No lock is requested and the compositor is not secure (`!screenLocked && !screenLockSecure`) | messages, calendar, memories, fleet state, conversation history |
| `stepUp` | Recent successful reauthentication | sending, pushes, deletion, payments, physical access |
Media metadata is `ambient` by default but must be configurable to `personal`.
When the lock occurs, in-flight personal or step-up agent output is withheld or
redacted; only ambient output remains visible.
Step-up authentication is a separate `PamContext`, using a dedicated PAM
service such as `souveraine-stepup`. It never unlocks the session and it never
accepts a boolean from an agent as proof. A successful result mints a
short-lived, in-memory grant bound to the local action family. The grant must
be cleared on lock, session end, PAM failure, and expiry. The exact freshness
window is a deliberate policy setting, not an implementation accident.
Do not ship that PAM service from the user shell: it is root-owned system
configuration and must be installed/audited separately. Before enabling it,
verify that one polkit agent owns the session and that no legacy desktop power
manager is competing for idle or sleep policy.
## Required verification
- `session.capabilities()` matches logind `Can*` on laptop and phone.
- `session.lock()` reaches `locked: true` only after `WlSessionLock.secure`.
- `loginctl lock-session` and suspend both take the same Wayland lock path.
- Unsupported inhibitors refuse; each supported cookie visibly changes its
mechanism and is released exactly once.
- A lock during personal agent output hides it; a lock during an ambient timer
leaves only the timer visible.
- Step-up grants cannot survive lock, timeout, shell restart, or action-family
changes.

View file

@ -32,6 +32,9 @@ 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/IdleCoordinator.qml souveraine/services/IdleCoordinator.qml
services/LockContentPolicy.qml souveraine/services/LockContentPolicy.qml
services/WallpaperAssets.qml souveraine/services/WallpaperAssets.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
@ -44,7 +47,7 @@ modules/common/widgets/StyledToolTip.qml souveraine/modules/common/widgets/Style
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/NavigationConfig.qml souveraine/modules/settings/NavigationConfig.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
@ -62,7 +65,12 @@ modules/common/Persistent.qml souveraine/modules/common/Persistent.q
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
modules/souveraine/lock/LockMediaCard.qml souveraine/modules/souveraine/lock/LockMediaCard.qml
modules/souveraine/lock/LockSurfaceHost.qml souveraine/modules/souveraine/lock/LockSurfaceHost.qml
modules/souveraine/lock/qmldir souveraine/modules/souveraine/lock/qmldir
modules/souveraine/navigation/SystemGestureRail.qml souveraine/modules/souveraine/navigation/SystemGestureRail.qml
modules/souveraine/navigation/qmldir souveraine/modules/souveraine/navigation/qmldir
modules/ii/sessionScreen/SessionScreen.qml souveraine/modules/ii/sessionScreen/SessionScreen.qml
"
PHONE_USB=casey@172.16.42.1
@ -82,8 +90,7 @@ if [[ "${1:-}" == "--phone" ]]; then
"${ssh_i[@]}" "$PHONE_USB" "bash ~/$PHONE_DEST/deploy.sh"
# 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"
echo "Deployed to phone. Hyprland starts qs -c souveraine; no secondary shell is required."
exit 0
fi

View file

@ -249,6 +249,13 @@ Singleton {
}
property string wallpaperPath: ""
property string thumbnailPath: ""
// Optional local derivatives for unlike display shapes. The
// original wallpaper remains canonical; variants are never a
// destructive replacement for it.
property string portraitVariantPath: ""
property string landscapeVariantPath: ""
property real wallpaperFocalX: 0.5
property real wallpaperFocalY: 0.5
property bool hideWhenFullscreen: true
property JsonObject parallax: JsonObject {
property bool vertical: false
@ -391,10 +398,11 @@ Singleton {
// 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.
// rule); gestureRailHeight = bottom strip the dock reserves
// for Souveraine's integrated navigation rail, visually and
// in its input mask.
property int dragDwellMs: 500
property real pillStripHeight: 32
property real gestureRailHeight: 32
}
property JsonObject interactions: JsonObject {
@ -448,6 +456,20 @@ Singleton {
}
property bool centerClock: true
property bool showLockedText: true
// Souveraine-owned lock cards. Transport controls are
// ambient; media metadata remains personal by default.
property JsonObject content: JsonObject {
property bool showMediaControls: true
property bool mediaMetadataAmbient: false
property bool showBattery: true
}
property JsonObject idle: JsonObject {
// Native idle-notify remains opt-in until verified on the
// Pixel compositor; hypridle is the current adapter.
property bool nativeCoordinatorEnabled: false
property int dimAfterSeconds: 45
property int lockAfterSeconds: 90
}
property JsonObject security: JsonObject {
property bool unlockKeyring: true
property bool requirePasswordToPower: false

View file

@ -12,8 +12,9 @@
// the session menus are unchanged) and adds the parts a real session arbiter
// needs:
//
// 1. Capability detection. We probe loginctl/systemctl/hibernate ONCE at
// startup instead of assuming `systemctl X || loginctl X` will work.
// 1. Capability detection. We query logind's Can* methods once at startup
// instead of treating a command being installed, or /sys/power/state
// advertising "disk", as proof that an action is usable.
// caps() reports what this machine can actually do, so a caller can ask
// before it acts and the session menu can grey out what is unavailable.
//
@ -27,9 +28,9 @@
// takes a reason, returns a cookie, and state() lists every holder. "Why
// is the phone not sleeping" becomes a question with an answer.
//
// 4. State that is re-derived, not cached. locked reads GlobalStates (which
// is bound to WlSessionLock) at call time; we never keep our own "I
// locked it" bool that could drift from what the compositor actually did.
// 4. State that is re-derived, not cached. `secure` is WlSessionLock's
// compositor acknowledgement; it is distinct from `lockRequested`, the
// shell input that asks WlSessionLock to lock.
//
// The trust boundary here is deliberately trivial and stated so it stays that
// way: this surface is local, single-user, reachable only over quickshell's
@ -55,19 +56,26 @@ Singleton {
property bool probed: false
property bool hasLoginctl: false
property bool hasSystemctl: false
property bool canHibernate: false
property string suspendCapability: "unknown"
property string hibernateCapability: "unknown"
property string poweroffCapability: "unknown"
property string rebootCapability: "unknown"
// logind is the preferred backend when present: it is the thing that
// actually owns the session, and it works under elogind as well as
// systemd. systemctl is the fallback for the poweroff/reboot verbs.
readonly property bool canSuspend: root.hasLoginctl || root.hasSystemctl
readonly property bool canPoweroff: root.hasLoginctl || root.hasSystemctl
readonly property bool canReboot: root.hasLoginctl || root.hasSystemctl
// "challenge" means logind can do it after polkit authentication. It is
// available to a normal desktop session with a functioning polkit agent,
// but callers still learn that a prompt may be required through caps().
readonly property bool canSuspend: ["yes", "challenge"].includes(root.suspendCapability)
readonly property bool canHibernate: ["yes", "challenge"].includes(root.hibernateCapability)
readonly property bool canPoweroff: ["yes", "challenge"].includes(root.poweroffCapability)
readonly property bool canReboot: ["yes", "challenge"].includes(root.rebootCapability)
// Live lock state. Read through, never stored: GlobalStates.screenLocked is
// what WlSessionLock.locked is bound to, so this reports what the
// compositor is actually doing rather than what we last asked it to do.
readonly property bool locked: GlobalStates.screenLocked
// Live compositor acknowledgement, mirrored from WlSessionLock.secure by
// LockScreen.qml. `screenLocked` remains the requested state that drives
// the lock surface; do not treat it as proof that the session is secure.
readonly property bool locked: GlobalStates.screenLockSecure
signal actionFailed(string action, int exitCode)
@ -76,14 +84,27 @@ Singleton {
// Runs at construction: the probe must land before anything asks
// caps(), and every capability reads false until it does.
running: true
// One shell, one round trip. Prints three lines: loginctl, systemctl,
// hibernate. /sys/power/state carries "disk" only when hibernation is
// actually available on this kernel, which is the honest test the
// phone has no swap and cannot hibernate, and we must not offer it.
// One shell, one round trip. logind's Can* methods incorporate the
// policy and configuration that /sys/power/state cannot see (notably
// swap/resume setup for hibernation). Possible values include yes,
// no, challenge, and na; retain the value rather than flattening it.
// busctl prints `s "challenge"`; awk pulls the second field verbatim
// and the quotes come off in JS below. An earlier version parsed it
// with sed inside single quotes, where sh does not process the \" and
// sed ended up matching a literal backslash-quote that busctl never
// emits so on the phone the probe returned nothing and every
// capability stuck at "unknown". Keep the shell here quote-free; do
// the string work in QML where there is no second escaping layer.
command: ["sh", "-c",
"command -v loginctl >/dev/null && echo loginctl; " +
"command -v systemctl >/dev/null && echo systemctl; " +
"grep -qw disk /sys/power/state 2>/dev/null && echo hibernate; " +
"if command -v busctl >/dev/null; then " +
"for cap in CanSuspend CanHibernate CanPowerOff CanReboot; do " +
"value=$(busctl --system call org.freedesktop.login1 /org/freedesktop/login1 " +
"org.freedesktop.login1.Manager $cap 2>/dev/null | awk '{print $2}'); " +
"[ -n \"$value\" ] && echo $cap=$value; " +
"done; " +
"fi; " +
"true"]
stdout: StdioCollector {
@ -91,12 +112,24 @@ Singleton {
const lines = text.split("\n").map(l => l.trim());
root.hasLoginctl = lines.includes("loginctl");
root.hasSystemctl = lines.includes("systemctl");
root.canHibernate = lines.includes("hibernate");
const capability = (name) => {
const prefix = name + "=";
const line = lines.find(l => l.startsWith(prefix));
// Value arrives quoted from busctl (e.g. "challenge").
return line ? line.slice(prefix.length).replace(/"/g, "") : "unknown";
};
root.suspendCapability = capability("CanSuspend");
root.hibernateCapability = capability("CanHibernate");
root.poweroffCapability = capability("CanPowerOff");
root.rebootCapability = capability("CanReboot");
root.probed = true;
console.log("[session] capabilities:",
"loginctl=" + root.hasLoginctl,
"systemctl=" + root.hasSystemctl,
"hibernate=" + root.canHibernate);
"suspend=" + root.suspendCapability,
"hibernate=" + root.hibernateCapability,
"poweroff=" + root.poweroffCapability,
"reboot=" + root.rebootCapability);
}
}
}
@ -108,6 +141,11 @@ Singleton {
id: verbProc
property string verb: ""
onExited: (exitCode, exitStatus) => {
root.lastAction = {
action: verbProc.verb,
status: exitCode === 0 ? "succeeded" : "failed",
exitCode: exitCode
};
if (exitCode !== 0) {
console.log(`[session] ${verbProc.verb} failed (exit ${exitCode})`);
root.actionFailed(verbProc.verb, exitCode);
@ -116,11 +154,22 @@ Singleton {
}
function runVerb(verb, argv) {
// Process has one command slot. Overwriting it while a prior action
// is still running makes the eventual exit code belong to the wrong
// action, which is another form of silent failure.
if (verbProc.running) return false;
verbProc.verb = verb;
verbProc.command = argv;
root.lastAction = { action: verb, status: "running", exitCode: null };
verbProc.running = true;
return true;
}
// IPC returns when an action is accepted, not when the kernel has already
// suspended or powered off. This records the later Process outcome so a
// caller can distinguish "started" from "succeeded".
property var lastAction: ({ action: "", status: "idle", exitCode: null })
// Prefer logind (owns the session, works under elogind) and fall back to
// systemctl. Returns [] when neither exists, which callers treat as a
// refusal rather than firing a command that cannot work.
@ -140,14 +189,22 @@ Singleton {
readonly property bool inhibited: Object.keys(root.inhibitors).length > 0
function inhibit(what, reason) {
if (!reason) return root.refuse("inhibit", "an inhibit must carry a reason");
const kind = String(what || "idle").trim().toLowerCase();
const why = String(reason || "").trim();
if (!why) return root.refuse("inhibit", "an inhibit must carry a reason");
// Only idle is wired today. Recording a sleep/logout/user-switch
// inhibitor without applying its mechanism would create a dangerous
// success-shaped no-op, so reject those until their real backends
// (systemd-inhibit or session policy) land.
if (kind !== "idle")
return root.refuse("inhibit", `unsupported inhibit kind ${kind}; only idle is implemented`);
const cookie = String(root.nextCookie++);
// Reassign rather than mutate: QML only notifies on assignment, so an
// in-place insert would leave `inhibited` and any binding on it stale.
const next = Object.assign({}, root.inhibitors);
next[cookie] = { what: what || "idle", reason: reason };
next[cookie] = { what: kind, reason: why };
root.inhibitors = next;
console.log(`[session] inhibit ${cookie}: ${what || "idle"} ${reason}`);
console.log(`[session] inhibit ${cookie}: ${kind} ${why}`);
root.applyIdleInhibit();
return { ok: true, cookie: cookie };
}
@ -182,8 +239,14 @@ Singleton {
}));
return {
locked: root.locked,
lockRequested: GlobalStates.screenLocked,
idle: {
stage: IdleCoordinator.state,
nativeCoordinatorEnabled: IdleCoordinator.nativeEnabled
},
idleInhibited: Idle.inhibit,
inhibitors: holders,
lastAction: root.lastAction,
capabilities: root.caps()
};
}
@ -197,9 +260,14 @@ Singleton {
return {
probed: root.probed,
suspend: root.canSuspend,
suspendStatus: root.suspendCapability,
hibernate: root.canHibernate,
hibernateStatus: root.hibernateCapability,
poweroff: root.canPoweroff,
reboot: root.canReboot
poweroffStatus: root.poweroffCapability,
reboot: root.canReboot,
rebootStatus: root.rebootCapability,
inhibitors: ["idle"]
};
}
@ -234,17 +302,18 @@ Singleton {
}
function lock() {
// loginctl lock-session is the right door: it tells logind, which
// signals the session, which raises our WlSessionLock. Setting
// GlobalStates directly would lock the surface without logind ever
// knowing the session was locked.
if (root.hasLoginctl) {
root.runVerb("lock", ["loginctl", "lock-session"]);
return { ok: true };
}
// No logind: fall back to raising the lock ourselves. Still a real
// WlSessionLock, just without logind's knowledge of it.
// Raise our Wayland lock ourselves: logind's Lock signal is a request
// for session software to lock, not a Wayland lock implementation.
// We also notify logind when it is available so other consumers see
// the standard session event. The safe lock does not depend on that
// asynchronous notification returning successfully.
GlobalStates.screenLocked = true;
if (root.hasLoginctl) {
const notified = root.runVerb("lock", ["loginctl", "lock-session"]);
return notified
? { ok: true, status: "requested" }
: { ok: true, status: "requested", degraded: "logind notification skipped; another action is running" };
}
return { ok: true, degraded: "no loginctl; locked without logind" };
}
@ -260,43 +329,48 @@ Singleton {
if (!root.probed) return root.refuse("suspend", "capabilities not probed yet");
if (!root.canSuspend) return root.refuse("suspend", "no loginctl or systemctl on this machine");
pauseAllPlayers();
root.runVerb("suspend", root.powerCommand("suspend"));
return { ok: true };
if (!root.runVerb("suspend", root.powerCommand("suspend")))
return root.refuse("suspend", "another session action is still running");
return { ok: true, status: "started" };
}
function hibernate() {
if (!root.probed) return root.refuse("hibernate", "capabilities not probed yet");
// The phone has no swap: /sys/power/state carries no "disk", so this
// refuses instead of firing a hibernate that would quietly do nothing.
// logind validates swap/resume configuration as well as kernel support,
// so a phone without hibernation refuses instead of firing a no-op.
if (!root.canHibernate)
return root.refuse("hibernate", "no hibernate support (no disk in /sys/power/state)");
return root.refuse("hibernate", "hibernate unavailable (logind: " + root.hibernateCapability + ")");
pauseAllPlayers();
root.runVerb("hibernate", root.powerCommand("hibernate"));
return { ok: true };
if (!root.runVerb("hibernate", root.powerCommand("hibernate")))
return root.refuse("hibernate", "another session action is still running");
return { ok: true, status: "started" };
}
function poweroff() {
if (!root.probed) return root.refuse("poweroff", "capabilities not probed yet");
if (!root.canPoweroff) return root.refuse("poweroff", "no loginctl or systemctl on this machine");
closeAllWindows();
root.runVerb("poweroff", root.powerCommand("poweroff"));
return { ok: true };
if (!root.runVerb("poweroff", root.powerCommand("poweroff")))
return root.refuse("poweroff", "another session action is still running");
return { ok: true, status: "started" };
}
function reboot() {
if (!root.probed) return root.refuse("reboot", "capabilities not probed yet");
if (!root.canReboot) return root.refuse("reboot", "no loginctl or systemctl on this machine");
closeAllWindows();
root.runVerb("reboot", root.powerCommand("reboot"));
return { ok: true };
if (!root.runVerb("reboot", root.powerCommand("reboot")))
return root.refuse("reboot", "another session action is still running");
return { ok: true, status: "started" };
}
function rebootToFirmware() {
if (!root.hasSystemctl)
return root.refuse("rebootToFirmware", "firmware-setup reboot needs systemctl");
closeAllWindows();
root.runVerb("rebootToFirmware", ["systemctl", "reboot", "--firmware-setup"]);
return { ok: true };
if (!root.runVerb("rebootToFirmware", ["systemctl", "reboot", "--firmware-setup"]))
return root.refuse("rebootToFirmware", "another session action is still running");
return { ok: true, status: "started" };
}
function logout() {
@ -305,11 +379,13 @@ Singleton {
// down the scope and the seat); pkill Hyprland just kills the
// compositor and leaves logind believing the session is alive.
if (root.hasLoginctl) {
root.runVerb("logout", ["loginctl", "terminate-session", ""]);
return { ok: true };
if (!root.runVerb("logout", ["loginctl", "terminate-session", ""]))
return root.refuse("logout", "another session action is still running");
return { ok: true, status: "started" };
}
root.runVerb("logout", ["pkill", "-i", "Hyprland"]);
return { ok: true, degraded: "no loginctl; killed the compositor" };
if (!root.runVerb("logout", ["pkill", "-i", "Hyprland"]))
return root.refuse("logout", "another session action is still running");
return { ok: true, status: "started", degraded: "no loginctl; killed the compositor" };
}
function changePassword() {

View file

@ -112,6 +112,15 @@ Scope {
id: lock
locked: GlobalStates.screenLocked
surface: root.sessionLockSurface
// This is the compositor acknowledgement, not our requested bool.
// Keep it separate so an IPC caller cannot mistake a queued lock for
// a secure surface when it is deciding whether personal content may
// be exposed.
onSecureChanged: {
GlobalStates.screenLockSecure = secure;
console.log("[lock] session lock secure=" + secure);
}
}
function lock() {

View file

@ -10,8 +10,8 @@
// keyboard-side math account for wherever the dock happens to be.
//
// GlobalStates.oskOpen suppresses both reveal-when-idle and the pinned
// exclusive zone. In fullscreen the pill explicitly reveals or hides the
// dock; it never times out or opens another surface.
// exclusive zone. In fullscreen Souveraine's navigation rail explicitly
// reveals or hides the dock; it never times out or opens another surface.
import qs
import qs.services
import qs.modules.common
@ -41,7 +41,7 @@ Scope { // Scope
// The normal dock pin is suppressed while the OSK is open. Fullscreen
// always takes precedence, so fullscreen remains genuinely edge-to-edge
// until the pill explicitly reveals the dock.
// until the navigation rail explicitly reveals the dock.
property bool effectivePinned: root.pinned && !GlobalStates.oskOpen
// The dock's state projection + guarded mutation surface for external
@ -79,7 +79,7 @@ Scope { // Scope
property int dockState: computeDockState()
// The pill's dock contract is intentionally just two operations:
// The navigation rail'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
@ -100,10 +100,10 @@ Scope { // Scope
GlobalStates.dockRevealed = true;
}
// 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
// Toggle app-mode fullscreen on the REAL active window. The rail
// can't use a bare `hyprctl dispatch fullscreen` because tapping it
// makes the shell (org.quickshell) the focused surface, so hyprctl
// would fullscreen the rail, 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 +
@ -114,7 +114,7 @@ Scope { // Scope
// Clear a revealed dock before either direction of the toggle.
GlobalStates.dockRevealed = false;
// Hyprland.activeToplevel is Hyprland's real active APP window and
// its .address is a stable window handle a layer-shell pill tap
// its .address is a stable window handle a layer-shell rail tap
// never becomes a Hyprland toplevel, so this stays the app even
// after the tap focuses the shell. Dispatch AT that address so we
// fullscreen the app, not whatever hyprctl thinks is focused.
@ -228,10 +228,10 @@ Scope { // Scope
// keeps the strip alive for desktop pointer use.
property bool reveal: root.dockState !== Dock.DockState.Hidden
|| (Config.options?.dock.hoverToReveal && dockMouseArea.containsMouse)
// 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: Config.options?.dock.pillStripHeight ?? 32
// This space belongs to the always-on navigation rail. It is
// visually empty and must also be absent from the dock's *input*
// region; otherwise the dock receives touches before the rail.
readonly property int gestureRailHeight: Config.options?.dock.gestureRailHeight ?? 32
visible: !GlobalStates.screenLocked && reveal
anchors {
@ -251,7 +251,7 @@ Scope { // Scope
color: "transparent"
// Content-driven: tall enough for one 64px dock button + the
// row's 8px bottom margin + the 32px pill strip, with Config
// row's 8px bottom margin + the navigation rail, with Config
// dock.height as a floor. A fixed config height (72) left the
// visible bar ~36px for 64px buttons icons poked out the
// bottom and count dots landed under the bar. Size off the
@ -260,7 +260,7 @@ Scope { // Scope
// made the bar overshoot (content then top-aligned with a dead
// band underneath).
implicitHeight: Math.max(Config.options?.dock.height ?? 70,
overviewButton.implicitHeight + 8 + pillStripHeight)
overviewButton.implicitHeight + 8 + gestureRailHeight)
+ Appearance.sizes.elevationMargin + Appearance.sizes.hyprlandGapsOut
mask: Region {
@ -275,7 +275,7 @@ Scope { // Scope
anchors.top: parent.top
anchors.horizontalCenter: parent.horizontalCenter
width: dockMouseArea.width
height: Math.max(0, dockMouseArea.height - dockRoot.pillStripHeight)
height: Math.max(0, dockMouseArea.height - dockRoot.gestureRailHeight)
}
MouseArea {
@ -302,16 +302,15 @@ Scope { // Scope
id: dockBackground
anchors {
top: parent.top
// Reserve the pill strip: bottom-anchor short of
// the window's bottom by thePillZone so the dock
// floats above the pill and never covers it.
// Reserve the rail: bottom-anchor short of the
// window's bottom so the dock never covers it.
bottom: parent.bottom
bottomMargin: dockRoot.pillStripHeight
bottomMargin: dockRoot.gestureRailHeight
horizontalCenter: parent.horizontalCenter
}
implicitWidth: dockRow.implicitWidth + 5 * 2
height: parent.height - Appearance.sizes.elevationMargin - Appearance.sizes.hyprlandGapsOut - dockRoot.pillStripHeight
height: parent.height - Appearance.sizes.elevationMargin - Appearance.sizes.hyprlandGapsOut - dockRoot.gestureRailHeight
StyledRectangularShadow {
target: dockVisualBackground

View file

@ -31,12 +31,11 @@ LockScreen {
// shell.* and apps.*. `lock` (target: "lock") stays as it was: it is the
// surface's own activate/focus pair, not the session lifecycle.
//
// Named "sessionctl", not "session": ii's SessionScreen already owns the
// "session" target (its toggle/open/close for the power menu). Quickshell
// does not merge duplicate IPC targets the second one is silently
// dropped, with no error so a collision here would have quietly produced
// a surface that simply is not there. The menu is "session"; the session
// lifecycle is "sessionctl".
// `session` is the one lifecycle authority across phone, laptop, and
// desktop. The visual power-menu toggles live at `sessionMenu`; a menu is
// a presentation concern, whereas this target is the system contract.
// The ii screen is vendored with that one target rename so Quickshell
// never has to silently choose between duplicate `session` handlers.
// Every method returns `string`, not `var`, and the payload is JSON.
// This is not a style choice quickshell marshals exactly five types over
// IPC (string, int, bool, double, color; see src/io/ipc.cpp ipcType()) and
@ -45,7 +44,7 @@ LockScreen {
// `(): void`, and silently returns nothing to the caller. JSON-over-string
// is the only way a structured {ok, reason} result actually crosses.
IpcHandler {
target: "sessionctl"
target: "session"
// Read-only projection: lock state (read through from the compositor,
// never a cached bool), idle inhibitors with their reasons, and what

View file

@ -4,8 +4,9 @@
// the OSK is a layershell surface and can never appear above the session
// lock, so the lock surface must carry its own input. Auth goes through the
// same LockContext/PAM machinery as the desktop surface; hardware keyboards
// still work via the Keys handlers. The big clock comes from Background.qml
// (Overlay layer while locked), so none is drawn here.
// still work via the Keys handlers. Ambient glance content is supplied by the
// Souveraine-owned LockSurfaceHost; ii Background remains only a temporary
// compatibility layer while the shell is progressively brought in-tree.
//
// Sized for 540x1080 logical (1080x2160 @ scale 2).
import QtQuick
@ -16,6 +17,7 @@ import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
import qs.modules.common.panels.lock
import qs.modules.souveraine.lock
import Quickshell
import Quickshell.Services.UPower
@ -39,6 +41,19 @@ MouseArea {
Component.onCompleted: forceFieldFocus()
onPressed: forceFieldFocus()
// Souveraine-owned ambient content. Credentials stay below this host, so
// phone, laptop, and desktop can rearrange the same cards later without
// inventing separate lock/session behavior.
LockSurfaceHost {
anchors {
top: parent.top
topMargin: 56
horizontalCenter: parent.horizontalCenter
}
width: Math.max(0, parent.width - 40)
z: 1
}
function pressDigit(d) {
root.context.resetClearTimer();
root.context.currentText += d;
@ -67,6 +82,7 @@ MouseArea {
bottom: parent.bottom
bottomMargin: 48
}
z: 2
spacing: 20
// Entered-PIN dots, with the empty-state hint behind them.

View file

@ -0,0 +1,338 @@
import qs
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import Quickshell.Hyprland
Scope {
id: root
property var focusedScreen: Quickshell.screens.find(s => s.name === Hyprland.focusedMonitor?.name)
Loader {
id: sessionLoader
active: GlobalStates.sessionOpen
onActiveChanged: {
if (sessionLoader.active)
SessionWarnings.refresh();
}
Connections {
target: GlobalStates
function onScreenLockedChanged() {
if (GlobalStates.screenLocked) {
GlobalStates.sessionOpen = false;
}
}
}
sourceComponent: PanelWindow { // Session menu
id: sessionRoot
visible: sessionLoader.active
property string subtitle
function hide() {
GlobalStates.sessionOpen = false;
}
exclusionMode: ExclusionMode.Ignore
WlrLayershell.namespace: "quickshell:session"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
color: ColorUtils.transparentize(Appearance.m3colors.m3background, Appearance.m3colors.darkmode ? 0.05 : 0.12)
anchors {
top: true
left: true
right: true
}
implicitWidth: root.focusedScreen?.width ?? 0
implicitHeight: root.focusedScreen?.height ?? 0
MouseArea {
id: sessionMouseArea
anchors.fill: parent
onClicked: {
sessionRoot.hide();
}
}
ColumnLayout { // Content column
id: contentColumn
anchors.centerIn: parent
spacing: 15
Keys.onPressed: event => {
if (event.key === Qt.Key_Escape) {
sessionRoot.hide();
}
}
ColumnLayout {
Layout.alignment: Qt.AlignHCenter
spacing: 0
StyledText {
// Title
Layout.alignment: Qt.AlignHCenter
horizontalAlignment: Text.AlignHCenter
font {
family: Appearance.font.family.title
pixelSize: Appearance.font.pixelSize.title
variableAxes: Appearance.font.variableAxes.title
}
text: Translation.tr("Session")
}
StyledText {
// Small instruction
Layout.alignment: Qt.AlignHCenter
horizontalAlignment: Text.AlignHCenter
font.pixelSize: Appearance.font.pixelSize.normal
text: Translation.tr("Arrow keys to navigate, Enter to select\nEsc or click anywhere to cancel")
}
}
GridLayout {
columns: 4
columnSpacing: 15
rowSpacing: 15
SessionActionButton {
id: sessionLock
focus: sessionRoot.visible
buttonIcon: "lock"
buttonText: Translation.tr("Lock")
onClicked: {
Session.lock();
sessionRoot.hide();
}
onFocusChanged: {
if (focus)
sessionRoot.subtitle = buttonText;
}
KeyNavigation.right: sessionSleep
KeyNavigation.down: sessionHibernate
}
SessionActionButton {
id: sessionSleep
buttonIcon: "dark_mode"
buttonText: Translation.tr("Sleep")
onClicked: {
Session.suspend();
sessionRoot.hide();
}
onFocusChanged: {
if (focus)
sessionRoot.subtitle = buttonText;
}
KeyNavigation.left: sessionLock
KeyNavigation.right: sessionLogout
KeyNavigation.down: sessionShutdown
}
SessionActionButton {
id: sessionLogout
buttonIcon: "logout"
buttonText: Translation.tr("Logout")
onClicked: {
Session.logout();
sessionRoot.hide();
}
onFocusChanged: {
if (focus)
sessionRoot.subtitle = buttonText;
}
KeyNavigation.left: sessionSleep
KeyNavigation.right: sessionTaskManager
KeyNavigation.down: sessionReboot
}
SessionActionButton {
id: sessionTaskManager
buttonIcon: "browse_activity"
buttonText: Translation.tr("Task Manager")
onClicked: {
Session.launchTaskManager();
sessionRoot.hide();
}
onFocusChanged: {
if (focus)
sessionRoot.subtitle = buttonText;
}
KeyNavigation.left: sessionLogout
KeyNavigation.down: sessionFirmwareReboot
}
SessionActionButton {
id: sessionHibernate
buttonIcon: "downloading"
buttonText: Translation.tr("Hibernate")
onClicked: {
Session.hibernate();
sessionRoot.hide();
}
onFocusChanged: {
if (focus)
sessionRoot.subtitle = buttonText;
}
KeyNavigation.up: sessionLock
KeyNavigation.right: sessionShutdown
}
SessionActionButton {
id: sessionShutdown
buttonIcon: "power_settings_new"
buttonText: Translation.tr("Shutdown")
onClicked: {
Session.poweroff();
sessionRoot.hide();
}
onFocusChanged: {
if (focus)
sessionRoot.subtitle = buttonText;
}
KeyNavigation.left: sessionHibernate
KeyNavigation.right: sessionReboot
KeyNavigation.up: sessionSleep
}
SessionActionButton {
id: sessionReboot
buttonIcon: "restart_alt"
buttonText: Translation.tr("Reboot")
onClicked: {
Session.reboot();
sessionRoot.hide();
}
onFocusChanged: {
if (focus)
sessionRoot.subtitle = buttonText;
}
KeyNavigation.left: sessionShutdown
KeyNavigation.right: sessionFirmwareReboot
KeyNavigation.up: sessionLogout
}
SessionActionButton {
id: sessionFirmwareReboot
buttonIcon: "settings_applications"
buttonText: Translation.tr("Reboot to firmware settings")
onClicked: {
Session.rebootToFirmware();
sessionRoot.hide();
}
onFocusChanged: {
if (focus)
sessionRoot.subtitle = buttonText;
}
KeyNavigation.up: sessionTaskManager
KeyNavigation.left: sessionReboot
}
}
DescriptionLabel {
Layout.alignment: Qt.AlignHCenter
text: sessionRoot.subtitle
}
}
ColumnLayout {
anchors {
top: contentColumn.bottom
topMargin: 10
horizontalCenter: contentColumn.horizontalCenter
}
spacing: 10
Loader {
Layout.alignment: Qt.AlignHCenter
active: SessionWarnings.downloadRunning
visible: active
sourceComponent: DescriptionLabel {
text: Translation.tr("There might be a download in progress. Check your Downloads folder.")
textColor: Appearance.m3colors.m3onErrorContainer
color: Appearance.m3colors.m3errorContainer
}
}
Loader {
Layout.alignment: Qt.AlignHCenter
active: SessionWarnings.packageManagerRunning
visible: active
sourceComponent: DescriptionLabel {
text: Translation.tr("Your package manager is running")
textColor: Appearance.m3colors.m3onErrorContainer
color: Appearance.m3colors.m3errorContainer
}
}
}
}
}
component DescriptionLabel: Rectangle {
id: descriptionLabel
property string text
property color textColor: Appearance.colors.colOnTooltip
color: Appearance.colors.colTooltip
clip: true
radius: Appearance.rounding.normal
implicitHeight: descriptionLabelText.implicitHeight + 10 * 2
implicitWidth: descriptionLabelText.implicitWidth + 15 * 2
Behavior on implicitWidth {
animation: Appearance.animation.elementMove.numberAnimation.createObject(this)
}
StyledText {
id: descriptionLabelText
anchors.centerIn: parent
color: descriptionLabel.textColor
text: descriptionLabel.text
}
}
IpcHandler {
target: "sessionMenu"
function toggle(): void {
GlobalStates.sessionOpen = !GlobalStates.sessionOpen;
}
function close(): void {
GlobalStates.sessionOpen = false;
}
function open(): void {
GlobalStates.sessionOpen = true;
}
}
GlobalShortcut {
name: "sessionToggle"
description: "Toggles session screen on press"
onPressed: {
GlobalStates.sessionOpen = !GlobalStates.sessionOpen;
}
}
GlobalShortcut {
name: "sessionOpen"
description: "Opens session screen on press"
onPressed: {
GlobalStates.sessionOpen = true;
}
}
GlobalShortcut {
name: "sessionClose"
description: "Closes session screen on press"
onPressed: {
GlobalStates.sessionOpen = false;
}
}
}

View file

@ -104,4 +104,37 @@ ContentPage {
}
}
}
ContentSection {
icon: "visibility"
title: Translation.tr("Lock screen content")
ConfigSwitch {
buttonIcon: "music_note"
text: Translation.tr("Show media controls")
checked: Config.options.lock.content.showMediaControls
onCheckedChanged: Config.options.lock.content.showMediaControls = checked
StyledToolTip {
text: Translation.tr("Shows previous, play/pause, and next. Track details remain private unless enabled below.")
}
}
ConfigSwitch {
buttonIcon: "visibility"
text: Translation.tr("Show media title and artist")
checked: Config.options.lock.content.mediaMetadataAmbient
enabled: Config.options.lock.content.showMediaControls
onCheckedChanged: Config.options.lock.content.mediaMetadataAmbient = checked
StyledToolTip {
text: Translation.tr("Treats current media metadata as ambient. Leave off to keep it hidden until unlock.")
}
}
ConfigSwitch {
buttonIcon: "battery_android_full"
text: Translation.tr("Show battery on lock screen")
checked: Config.options.lock.content.showBattery
onCheckedChanged: Config.options.lock.content.showBattery = checked
}
}
}

View file

@ -0,0 +1,42 @@
import QtQuick
import QtQuick.Layouts
import qs.services
import qs.modules.common
import qs.modules.common.widgets
// Souveraine's integrated phone navigation surface.
ContentPage {
forceWidth: true
ContentSection {
icon: "gesture"
title: Translation.tr("Layout")
ConfigSpinBox {
icon: "swap_vert"
text: Translation.tr("Navigation rail height (px)")
value: Config.options.dock.gestureRailHeight
from: 0
to: 96
stepSize: 2
onValueChanged: Config.options.dock.gestureRailHeight = value
StyledToolTip {
text: Translation.tr("Bottom strip reserved for Souveraine navigation — visually and in the dock's input mask. The navigation rail must always win touch here.")
}
}
}
ContentSection {
icon: "swipe"
title: Translation.tr("Gestures")
StyledText {
Layout.fillWidth: true
text: Translation.tr("Double-tap toggles app fullscreen; swipe up reveals the dock; swipe down hides it. Timing and threshold controls will appear here once their defaults prove out.")
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
wrapMode: Text.WordWrap
}
}
}

View file

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

View file

@ -0,0 +1,121 @@
// Souveraine-owned ambient MPRIS card. Transport is safe while locked; title,
// artist, and artwork are personal by default and require an explicit opt-in.
import QtQuick
import QtQuick.Layouts
import Quickshell.Services.Mpris
import qs.services
Item {
id: root
function candidates() {
return Mpris.players.values.filter(player =>
player && (player.canTogglePlaying || player.canGoPrevious || player.canGoNext));
}
readonly property var player: {
const players = root.candidates();
return players.find(player => player.isPlaying) || players[0] || null;
}
readonly property bool hasPlayer: root.player !== null
readonly property bool revealMetadata: LockContentPolicy.mediaMetadataVisible
visible: LockContentPolicy.mediaControlsVisible && root.hasPlayer
implicitHeight: visible ? card.implicitHeight : 0
Rectangle {
id: card
anchors.horizontalCenter: parent.horizontalCenter
width: parent.width
implicitHeight: content.implicitHeight + 24
radius: 20
color: "#b3181b20"
border.width: 1
border.color: "#55ffffff"
ColumnLayout {
id: content
anchors.fill: parent
anchors.margins: 12
spacing: 8
Text {
Layout.fillWidth: true
visible: root.revealMetadata
text: root.player?.trackTitle || "Media"
color: "white"
font.pixelSize: 17
font.bold: true
elide: Text.ElideRight
horizontalAlignment: Text.AlignHCenter
}
Text {
Layout.fillWidth: true
visible: root.revealMetadata && (root.player?.trackArtist || "").length > 0
text: root.player?.trackArtist || ""
color: "#d9ffffff"
font.pixelSize: 13
elide: Text.ElideRight
horizontalAlignment: Text.AlignHCenter
}
Text {
Layout.fillWidth: true
visible: !root.revealMetadata
text: "Media controls"
color: "#d9ffffff"
font.pixelSize: 13
horizontalAlignment: Text.AlignHCenter
}
RowLayout {
Layout.alignment: Qt.AlignHCenter
spacing: 18
TransportButton {
glyph: ""
available: root.player?.canGoPrevious ?? false
onClicked: root.player.previous()
}
TransportButton {
glyph: root.player?.isPlaying ? "Ⅱ" : "▶"
available: root.player?.canTogglePlaying ?? false
primary: true
onClicked: root.player.togglePlaying()
}
TransportButton {
glyph: ""
available: root.player?.canGoNext ?? false
onClicked: root.player.next()
}
}
}
}
component TransportButton: Rectangle {
required property string glyph
required property bool available
property bool primary: false
signal clicked()
implicitWidth: primary ? 50 : 40
implicitHeight: primary ? 50 : 40
radius: implicitWidth / 2
color: primary ? "#e6ffffff" : "#26ffffff"
opacity: available ? 1 : 0.35
Text {
anchors.centerIn: parent
text: parent.glyph
color: parent.primary ? "#1c1b20" : "white"
font.pixelSize: parent.primary ? 19 : 16
font.bold: true
}
MouseArea {
anchors.fill: parent
enabled: parent.available
onClicked: parent.clicked()
}
}
}

View file

@ -0,0 +1,69 @@
// Shared lock-surface glance host. Credentials are supplied by the parent
// lock surface; this component owns only ambient cards so every form factor
// shares the same policy and data model.
import QtQuick
import QtQuick.Layouts
import Quickshell.Services.UPower
import qs.services
import "."
Item {
id: root
readonly property bool compact: width < 700
implicitHeight: content.implicitHeight
Timer {
interval: 1000
running: true
repeat: true
triggeredOnStart: true
onTriggered: clock.now = new Date()
}
QtObject {
id: clock
property date now: new Date()
}
ColumnLayout {
id: content
anchors.horizontalCenter: parent.horizontalCenter
width: Math.min(parent.width, root.compact ? 460 : 620)
spacing: root.compact ? 10 : 16
Text {
Layout.fillWidth: true
text: Qt.formatTime(clock.now, "hh:mm")
color: "white"
font.pixelSize: root.compact ? 60 : 76
font.weight: Font.Light
horizontalAlignment: Text.AlignHCenter
}
Text {
Layout.fillWidth: true
text: Qt.formatDate(clock.now, "dddd, MMMM d")
color: "#d9ffffff"
font.pixelSize: root.compact ? 17 : 21
horizontalAlignment: Text.AlignHCenter
}
Text {
Layout.alignment: Qt.AlignHCenter
visible: LockContentPolicy.batteryVisible && UPower.displayDevice?.isPresent
text: {
const pct = Math.round(UPower.displayDevice?.percentage ?? 0);
return (UPower.onBattery ? "Battery " : "Charging ") + pct + "%";
}
color: "#d9ffffff"
font.pixelSize: 14
}
LockMediaCard {
Layout.topMargin: root.compact ? 8 : 14
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
}
}
}

View file

@ -0,0 +1,3 @@
module qs.modules.souveraine.lock
LockMediaCard 1.0 LockMediaCard.qml
LockSurfaceHost 1.0 LockSurfaceHost.qml

View file

@ -0,0 +1,95 @@
// Souveraine's phone navigation rail.
//
// This is deliberately part of the primary Souveraine shell, rather than a
// second `qs -c ` configuration. It owns the small bottom input region that
// stays available over fullscreen applications: swipe up/down controls the
// dock and a double tap toggles fullscreen for the real active toplevel.
import QtQuick
import Quickshell
import Quickshell.Hyprland
import Quickshell.Wayland
import qs
import qs.modules.common
PanelWindow {
id: rail
anchors.bottom: true
implicitWidth: 200
implicitHeight: Config.options?.dock.gestureRailHeight ?? 32
margins.bottom: 0
exclusionMode: ExclusionMode.Ignore
color: "transparent"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "souveraine:navigation-rail"
Rectangle {
id: handle
anchors.centerIn: parent
width: 150
height: 7
radius: height / 2
color: "#e6ffffff"
Behavior on width {
NumberAnimation { duration: 120 }
}
}
MouseArea {
anchors.fill: parent
property real startY: 0
property real lastTapAt: -1
readonly property int tapSlop: 24
readonly property int swipeDistance: 48
readonly property int doubleTapInterval: 350
function toggleFullscreen(): void {
GlobalStates.dockRevealed = false
const raw = Hyprland.activeToplevel?.address
if (!raw)
return
const address = raw.startsWith("0x") ? raw : "0x" + raw
Quickshell.execDetached(["hyprctl", "dispatch",
`hl.dsp.window.fullscreen({ window = "address:${address}", mode = "fullscreen", action = "toggle" })`])
}
onPressed: mouse => {
startY = mouse.y
handle.width = 170
}
onReleased: mouse => {
handle.width = 150
const deltaY = mouse.y - startY
if (deltaY <= -swipeDistance) {
lastTapAt = -1
GlobalStates.dockRevealed = true
return
}
if (deltaY >= swipeDistance) {
lastTapAt = -1
GlobalStates.dockRevealed = false
return
}
if (Math.abs(deltaY) > tapSlop) {
lastTapAt = -1
return
}
const now = Date.now()
if (lastTapAt > 0 && now - lastTapAt <= doubleTapInterval) {
lastTapAt = -1
toggleFullscreen()
} else {
lastTapAt = now
}
}
onCanceled: {
handle.width = 150
lastTapAt = -1
}
}
}

View file

@ -0,0 +1,2 @@
module qs.modules.souveraine.navigation
SystemGestureRail 1.0 SystemGestureRail.qml

View file

@ -25,6 +25,7 @@ import qs.modules.ii.sidebarRight
import qs.modules.ii.overlay
import qs.modules.ii.verticalBar
import qs.modules.ii.wallpaperSelector
import qs.modules.souveraine.navigation
// The Souveraine panel family one family for both modes.
// Starts at exact panel parity with IllogicalImpulseFamily (our overridden
@ -57,6 +58,10 @@ Scope {
PanelLoader { component: SessionScreen {} }
PanelLoader { component: SidebarLeft {} }
PanelLoader { component: SidebarRight {} }
PanelLoader {
extraCondition: Config.options.souveraine.phone
component: SystemGestureRail {}
}
PanelLoader { extraCondition: Config.options.bar.vertical; component: VerticalBar {} }
PanelLoader { component: WallpaperSelector {} }
}

View file

@ -1,91 +0,0 @@
// pill Phosh-style gesture bar for the Pixel 3.
// Gesture map (2026-07-12):
// double-tap : toggle true app fullscreen (whole display) of active win
// swipe up : reveal the dock above the pill
// swipe down : hide the revealed dock
// Keyboard is NOT on the pill. The OSK lives on the 3-finger-swipe-up
// hyprgrass bind alone until the grip sensor arrives.
import Quickshell
import Quickshell.Wayland
import QtQuick
ShellRoot {
PanelWindow {
id: pillWin
anchors.bottom: true
implicitWidth: 200
implicitHeight: 26
margins.bottom: 0
// stick to the physical screen edge: ignore other surfaces'
// exclusive zones (dock, OSK) instead of being pushed up by them
exclusionMode: ExclusionMode.Ignore
color: "transparent"
WlrLayershell.layer: WlrLayer.Overlay
// Stable namespace so a hyprland layerrule can pin the pill above the
// dock (both live on Overlay; without a rule, z-order is creation
// order and an ii restart buries the pill under the dock, killing its
// touch). See the `order` layerrule in hyprland.lua.
WlrLayershell.namespace: "quickshell:pill"
Rectangle {
id: pill
anchors.centerIn: parent
width: 150
height: 7
radius: 3.5
color: "#e6ffffff"
Behavior on width { NumberAnimation { duration: 120 } }
}
// Keep gesture arbitration in one place. Qt's onDoubleClicked is too
// eager to treat the small drift in a touchscreen double-tap as a
// drag, so a double tap is recognized from two released tap events.
MouseArea {
anchors.fill: parent
property real startY: 0
property real lastTapAt: -1
readonly property int tapSlop: 24
readonly property int swipeDistance: 48
readonly property int doubleTapInterval: 350
onPressed: (mouse) => {
startY = mouse.y
pill.width = 170
}
onReleased: (mouse) => {
pill.width = 150
const deltaY = mouse.y - startY
if (deltaY <= -swipeDistance) {
lastTapAt = -1
Quickshell.execDetached(["qs", "-c", "ii", "ipc", "call", "dock", "swipeUp"])
return
}
if (deltaY >= swipeDistance) {
lastTapAt = -1
Quickshell.execDetached(["qs", "-c", "ii", "ipc", "call", "dock", "swipeDown"])
return
}
// A small move is still a tap; intermediate moves are neither
// a tap nor a swipe, which prevents accidental actions.
if (Math.abs(deltaY) > tapSlop) {
lastTapAt = -1
return
}
const now = Date.now()
if (lastTapAt > 0 && now - lastTapAt <= doubleTapInterval) {
lastTapAt = -1
Quickshell.execDetached(["qs", "-c", "ii", "ipc", "call", "dock", "fullscreen"])
} else {
lastTapAt = now
}
}
onCanceled: {
pill.width = 150
lastTapAt = -1
}
}
}
}

View file

@ -0,0 +1,85 @@
// Souveraine's staged idle projection.
//
// It does not replace logind or hypridle. It gives all surfaces one state
// vocabulary while target-specific adapters evolve. Native idle-notify stays
// opt-in until it is verified on the Pixel compositor.
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Wayland
import qs
import qs.modules.common
import qs.modules.common.functions
Singleton {
id: root
enum State { Active, Dimmed, LockRequested, LockSecure }
property int state: IdleCoordinator.Active
readonly property bool nativeEnabled: Config.options.lock.idle.nativeCoordinatorEnabled
readonly property bool lockSecure: GlobalStates.screenLockSecure
readonly property bool lockRequested: GlobalStates.screenLocked
signal dimRequested()
signal activeRequested()
signal stateTransitioned(int state)
function setState(next) {
if (root.state === next) return;
root.state = next;
root.stateTransitioned(next);
console.log("[idle-coordinator] state=" + next);
}
function returnActive() {
if (root.lockRequested || root.lockSecure) return;
root.setState(IdleCoordinator.Active);
root.activeRequested();
}
IdleMonitor {
id: dimMonitor
enabled: root.nativeEnabled
timeout: Math.max(1, Config.options.lock.idle.dimAfterSeconds) * 1000
respectInhibitors: true
onIsIdleChanged: {
if (isIdle && !root.lockRequested) {
root.setState(IdleCoordinator.Dimmed);
root.dimRequested();
} else if (!isIdle) {
root.returnActive();
}
}
}
IdleMonitor {
id: lockMonitor
enabled: root.nativeEnabled
timeout: Math.max(1, Config.options.lock.idle.lockAfterSeconds) * 1000
respectInhibitors: true
onIsIdleChanged: {
if (isIdle && !root.lockRequested) {
root.setState(IdleCoordinator.LockRequested);
Session.lock();
} else if (!isIdle) {
root.returnActive();
}
}
}
Connections {
target: GlobalStates
function onScreenLockedChanged() {
if (GlobalStates.screenLocked)
root.setState(IdleCoordinator.LockRequested);
else
root.returnActive();
}
function onScreenLockSecureChanged() {
if (GlobalStates.screenLockSecure)
root.setState(IdleCoordinator.LockSecure);
}
}
}

View file

@ -0,0 +1,27 @@
// Lock-surface information policy. Every card asks this singleton instead of
// growing an accidental privacy rule of its own.
pragma Singleton
import QtQuick
import Quickshell
import qs.modules.common
Singleton {
id: root
readonly property string ambient: "ambient"
readonly property string personal: "personal"
readonly property string stepUp: "step-up"
function allowsOnLock(tier, promotedAmbient = false) {
if (tier === root.ambient) return true;
// Promotion is intentionally field-specific (for example media title)
// and never applies to credentials, memories, agent output, or actions.
return tier === root.personal && promotedAmbient;
}
readonly property bool mediaControlsVisible: Config.options.lock.content.showMediaControls
readonly property bool mediaMetadataVisible: root.allowsOnLock(
root.personal, Config.options.lock.content.mediaMetadataAmbient)
readonly property bool batteryVisible: Config.options.lock.content.showBattery
}

View file

@ -0,0 +1,32 @@
// Aspect-aware wallpaper selection for unlike display shapes. This retains the
// original as canonical and selects only explicit local derivatives.
pragma Singleton
import QtQuick
import Quickshell
import qs.modules.common
Singleton {
id: root
function aspectFor(screen) {
if (!screen || !screen.height) return 1;
return screen.width / screen.height;
}
function pathFor(screen) {
const aspect = root.aspectFor(screen);
const portrait = Config.options.background.portraitVariantPath;
const landscape = Config.options.background.landscapeVariantPath;
if (aspect < 0.9 && portrait) return portrait;
if (aspect > 1.1 && landscape) return landscape;
return Config.options.background.wallpaperPath;
}
function focalPoint() {
return {
x: Math.max(0, Math.min(1, Config.options.background.wallpaperFocalX)),
y: Math.max(0, Math.min(1, Config.options.background.wallpaperFocalY))
};
}
}

View file

@ -49,9 +49,9 @@ ApplicationWindow {
component: "modules/settings/DockConfig.qml"
},
{
name: Translation.tr("Pill"),
name: Translation.tr("Navigation"),
icon: "gesture",
component: "modules/settings/PillConfig.qml"
component: "modules/settings/NavigationConfig.qml"
},
{
name: Translation.tr("Keyboard"),