Watch
1
0
Fork
You've already forked souveraine
0

publish: the public projection begins here

This is a projection, not a development branch. The tree above was constructed
from the internal source named below under a manifest that decides which paths
may leave, then scanned as a whole tree rather than as a series of patches, and
only then published.

Public history starts here because the history before it was not admissible,
and neither was the tree. What used to stand in this repository included a
rescue copy of another machine, a directory of phone handoffs, deployment
wired to one house, and a submodule pointing at a forge no stranger can reach.
None of that was ever the product. It stays in the private forge, which is
allowed to hold the whole working organism, and this is what was deliberately
sent out instead.

Three mechanisms produced this tree, in decreasing order of trust. A top-level
path the manifest does not name never arrives at all, which is the one that
catches directories nobody has thought of yet. Named internal files inside
admitted roots are dropped. A short, reviewed table replaces deployment
defaults that a public build must not carry -- an endpoint aimed at one LAN, a
VPN profile belonging to one phone, packaging built from one checkout path.

Everything after this commit is an ordinary publication with the same three
trailers, so a force push stops being routine and starts meaning that
something deliberate happened. The trailers bind the projection to its source
without pretending the public SHA is the private one: same lineage, different
tree, and the record says so.

Source-Sha: 8f27b1e76a8fef560a336aba18e6990713ff1047
Policy-Sha: 6b261d2f3e6e1fb19874846ba4bb1dfe15565d25b8618c1c1afba0419c101d27
Tree-Digest: 18ec3563c5e5ef9a414993a9f6734b251ff9ed3cd56eebdd6cac01e45c6e3067
This commit is contained in:
Fimeg 2026-09-04 15:55:48 -04:00
commit 8f42fc953d
1476 changed files with 238455 additions and 0 deletions

View file

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

View file

@ -0,0 +1,67 @@
// 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.
// The same five-type limit applies to RETURNS, not just arguments: a `var`
// return is mapped to VOID and the payload is dropped without an error
// (src/io/ipc.cpp ipcType(); "void and var get mixed by qml engine"). These
// were declared `: var` and so registered as `(): void` — every call
// returned nothing. Returning JSON as a string is what actually crosses.
IpcHandler {
target: "apps"
// apps.list() -> all (noDisplay excluded)
// apps.list("Network") -> only entries in the "Network" category
function list(category: string): string {
return JSON.stringify(appInventory.listFromCategory(category));
}
// apps.get("firefox") -> {ok, entry?} or {ok:false, reason:"not-found"}
function get(appId: string): string {
return JSON.stringify(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): string {
return JSON.stringify(appInventory.find(query, limit));
}
// apps.categories() -> {ok, categories:[{category,count}]}
function categories(): string {
return JSON.stringify(appInventory.categories());
}
// apps.refresh() -> trigger a rescan (async; re-query after the log line)
function refresh(): string {
return JSON.stringify(appInventory.refresh());
}
}
}

View file

@ -0,0 +1,2 @@
AppInventory 1.0 AppInventory.qml
AppInventoryScope 1.0 AppInventoryScope.qml

View file

@ -0,0 +1,294 @@
pragma ComponentBehavior: Bound
// Souveraine fork of ii's CookieClock. One change: the category-preset gate
// reads cookie.aiPreset instead of cookie.aiStyling (see the comment at
// setClockPreset). Diff against ii-base before re-applying if ii updates.
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
import QtQuick
import QtQuick.Layouts
import Qt5Compat.GraphicalEffects
import Quickshell.Io
import qs.modules.ii.background.widgets.clock.dateIndicator
import qs.modules.ii.background.widgets.clock.minuteMarks
Item {
id: root
readonly property string clockStyle: Config.options.background.widgets.clock.style
property real implicitSize: 230
property color colShadow: Appearance.colors.colShadow
property color colBackground: Appearance.colors.colPrimaryContainer
property color colOnBackground: ColorUtils.mix(Appearance.colors.colSecondary, Appearance.colors.colPrimaryContainer, 0.15)
property color colBackgroundInfo: ColorUtils.mix(Appearance.colors.colPrimary, Appearance.colors.colPrimaryContainer, 0.55)
property color colHourHand: Appearance.colors.colPrimary
property color colMinuteHand: Appearance.colors.colTertiary
property color colSecondHand: Appearance.colors.colPrimary
readonly property list<string> clockNumbers: DateTime.time.split(/[: ]/)
readonly property int clockHour: parseInt(clockNumbers[0]) % 12
readonly property int clockMinute: DateTime.clock.minutes
readonly property int clockSecond: DateTime.clock.seconds
implicitWidth: implicitSize
implicitHeight: implicitSize
function applyStyle(sides, dialStyle, hourHandStyle, minuteHandStyle, secondHandStyle, dateStyle) {
Config.options.background.widgets.clock.cookie.sides = sides
Config.options.background.widgets.clock.cookie.dialNumberStyle = dialStyle
Config.options.background.widgets.clock.cookie.hourHandStyle = hourHandStyle
Config.options.background.widgets.clock.cookie.minuteHandStyle = minuteHandStyle
Config.options.background.widgets.clock.cookie.secondHandStyle = secondHandStyle
Config.options.background.widgets.clock.cookie.dateStyle = dateStyle
}
function setClockPreset(category) {
// Souveraine fork (2026-08-05): gate is cookie.aiPreset, not
// cookie.aiStyling. Upstream runs both the wallpaper-categorizer
// (switchwall.sh) and this preset override off the one flag, so
// turning categorization on rewrote users' configured clock styles
// via applyStyle. aiStyling stays true for the pipeline; this
// surface only moves when aiPreset is explicitly on. Verbatim
// upstream otherwise.
if (!Config.options.background.widgets.clock.cookie.aiPreset) return;
if (category === "") return;
print("[Cookie clock] Setting clock preset for category: " + category)
// "abstract", "anime", "city", "minimalist", "landscape", "plants", "person", "space"
if (category == "abstract") {
applyStyle(9, "none", "fill", "medium", "dot", "bubble")
} else if (category == "anime") {
applyStyle(7, "none", "fill", "bold", "dot", "bubble")
} else if (category == "city" || category == "space") {
applyStyle(23, "full", "hollow", "thin", "classic", "bubble")
} else if (category == "minimalist") {
applyStyle(6, "none", "fill", "bold", "dot", "hide")
} else if (category == "landscape") {
applyStyle(14, "full", "hollow", "medium", "classic", "bubble")
} else if (category == "plants") {
applyStyle(9, "dots", "fill", "bold", "dot", "border")
} else if (category == "person") {
applyStyle(14, "full", "classic", "classic", "classic", "rect")
}
}
FileView {
id: categoryFileView
path: Config.ready ? Directories.generatedWallpaperCategoryPath : ""
watchChanges: true
onFileChanged: reload()
onLoaded: {
root.setClockPreset(categoryFileView.text().trim())
}
}
property bool useSineCookie: Config.options.background.widgets.clock.cookie.useSineCookie
StyledDropShadow {
target: root.useSineCookie ? sineCookieLoader : roundedPolygonCookieLoader
RotationAnimation on rotation {
running: Config.options.background.widgets.clock.cookie.constantlyRotate
duration: 30000
easing.type: Easing.Linear
loops: Animation.Infinite
from: 360
to: 0
}
}
Loader {
id: sineCookieLoader
z: 0
visible: false // The DropShadow already draws it
active: root.useSineCookie
sourceComponent: SineCookie {
implicitSize: root.implicitSize
sides: Config.options.background.widgets.clock.cookie.sides
color: root.colBackground
}
}
Loader {
id: roundedPolygonCookieLoader
z: 0
visible: false // The DropShadow already draws it
active: !root.useSineCookie
sourceComponent: MaterialCookie {
implicitSize: root.implicitSize
sides: Config.options.background.widgets.clock.cookie.sides
color: root.colBackground
}
}
// Hour/minutes numbers/dots/lines
MinuteMarks {
anchors.fill: parent
color: root.colOnBackground
}
// Stupid extra hour marks in the middle
FadeLoader {
id: hourMarksLoader
anchors.centerIn: parent
shown: Config.options.background.widgets.clock.cookie.hourMarks
sourceComponent: HourMarks {
implicitSize: 135 * (1.75 - 0.75 * hourMarksLoader.opacity)
color: root.colOnBackground
colOnBackground: ColorUtils.mix(root.colBackgroundInfo, root.colOnBackground, 0.5)
}
}
// Number column in the middle
FadeLoader {
id: timeColumnLoader
anchors.centerIn: parent
shown: Config.options.background.widgets.clock.cookie.timeIndicators
scale: 1.4 - 0.4 * timeColumnLoader.shown
Behavior on scale {
animation: Appearance.animation.elementResize.numberAnimation.createObject(this)
}
sourceComponent: TimeColumn {
color: root.colBackgroundInfo
}
}
// Minute hand
FadeLoader {
anchors.fill: parent
z: 1
shown: Config.options.background.widgets.clock.cookie.minuteHandStyle !== "hide"
sourceComponent: MinuteHand {
anchors.fill: parent
clockMinute: root.clockMinute
style: Config.options.background.widgets.clock.cookie.minuteHandStyle
color: root.colMinuteHand
}
}
// Hour hand
FadeLoader {
anchors.fill: parent
z: item?.style === "hollow" ? 0 : 2
shown: Config.options.background.widgets.clock.cookie.hourHandStyle !== "hide"
sourceComponent: HourHand {
clockHour: root.clockHour
clockMinute: root.clockMinute
style: Config.options.background.widgets.clock.cookie.hourHandStyle
color: root.colHourHand
}
}
// Second hand
FadeLoader {
id: secondHandLoader
z: (Config.options.background.widgets.clock.cookie.secondHandStyle === "line") ? 2 : 3
shown: Config.options.time.secondPrecision && Config.options.background.widgets.clock.cookie.secondHandStyle !== "hide"
anchors.fill: parent
sourceComponent: SecondHand {
id: secondHand
clockSecond: root.clockSecond
style: Config.options.background.widgets.clock.cookie.secondHandStyle
color: root.colSecondHand
}
}
// Center dot
FadeLoader {
z: 4
anchors.centerIn: parent
shown: Config.options.background.widgets.clock.cookie.minuteHandStyle !== "bold"
sourceComponent: Rectangle {
color: Config.options.background.widgets.clock.cookie.minuteHandStyle === "medium" ? root.colBackground : root.colMinuteHand
implicitWidth: 6
implicitHeight: implicitWidth
radius: width / 2
}
}
// Date
FadeLoader {
anchors.fill: parent
shown: Config.options.background.widgets.clock.cookie.dateStyle !== "hide"
sourceComponent: DateIndicator {
color: root.colBackgroundInfo
style: Config.options.background.widgets.clock.cookie.dateStyle
}
}
// Double tap summons Annie. Casey, 2026-08-05: *"I double tap on the clock
// widget and I get the avatar."* The desktop clock, not the bar's — this is
// the one on the wallpaper, on home, where she has room to stand.
//
// A toggle, because the same gesture has to be the way out: a presence you
// cannot dismiss is the user's column being ignored (doctrine §13). The
// flip/fade/puff the clock should do on the way is deliberately not here
// yet — the trigger first, the theatre after.
// Touching the singleton here is what constructs it. Quickshell builds
// singletons lazily, so `Face`'s IpcHandler did not register until
// something reached for it — `qs ipc call face status` answered "Target not
// found" while the double tap below worked fine, because the tap was the
// first reference. The dial and the agent both need it addressable before
// anyone has tapped anything.
readonly property bool faceUp: Face.joined
// She takes this spot, so the clock gets out of it.
//
// `Face.rigPresent` used to be read here for the same construct-the-
// singleton reason and **no such property exists** — the pre-flight rig
// check was deleted from `Face.qml` (a guard that answered false for a
// directory that was plainly there) and this reference was left behind,
// binding to `undefined` ever since. `joined` is real, and it is also the
// thing worth watching, so the touch and the meaning are the same read.
opacity: root.faceUp ? 0 : 1
scale: root.faceUp ? 0.86 : 1
// Out faster than in. Arriving is her entrance and wants to be seen;
// leaving is just the clock getting out of the way, and a slow fade there
// reads as lag rather than as motion. The 0.86 shrink is the same value
// the overview cards use for their intro, so the two pieces of theatre on
// this device move alike rather than each inventing a number.
Behavior on opacity {
NumberAnimation {
duration: root.faceUp ? 180 : 260
easing.type: Easing.OutCubic
}
}
Behavior on scale {
NumberAnimation {
duration: root.faceUp ? 180 : 260
easing.type: Easing.OutCubic
}
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton
// A faded clock is not a target. Without this the way back is a double
// tap on an invisible object, which is not a way back — it is a thing
// you have to already know. Dismissing her is a double tap on *her*
// instead, which is the same gesture in the same place as summoning.
enabled: !root.faceUp
// Above the clock's children, not below. `z: -1` was the first guess
// and it is why the first version did nothing: the clock's own visual
// items sit at the default z, so the handler was underneath every one
// of them. Nothing here competes for the tap — the clock has no other
// input at all, and never has.
z: 1
property real lastTapAt: -1
onClicked: {
const now = Date.now();
console.log("[face] clock tap at " + now
+ " (delta " + (lastTapAt > 0 ? now - lastTapAt : -1) + ")");
if (lastTapAt > 0 && now - lastTapAt <= 350) {
lastTapAt = -1;
Face.toggle();
} else {
lastTapAt = now;
}
}
}
}

View file

@ -0,0 +1,89 @@
// Souveraine fork of ii's CookieQuote. One change: it yields to her face.
//
// The quote is the clock's sibling inside `ClockWidget`'s Column, not its
// child, so `CookieClock`'s own fade cannot reach it — and the parent that
// holds both lives in `ii-base`, which never reaches the phone (the base tree
// has drifted ~900 files; overrides are how a fix arrives). So the yield is
// duplicated here rather than written once above them both. If a third
// widget ever needs it, that is the moment to override `ClockWidget` instead
// of adding a third copy of this binding.
import qs.modules.common
import qs.modules.common.widgets
import qs.services
import QtQuick
import Qt5Compat.GraphicalEffects
Item {
id: root
readonly property string quoteText: Config.options.background.widgets.clock.quote.text
implicitWidth: quoteBox.implicitWidth
implicitHeight: quoteBox.implicitHeight
// Out of her way, on the same timings the clock uses, because they are one
// piece of furniture to look at even though they are two items in a
// column. Left behind, the quote sits across her chest — which is where it
// was, and how this was noticed.
readonly property bool faceUp: Face.joined
opacity: root.faceUp ? 0 : 1
scale: root.faceUp ? 0.86 : 1
Behavior on opacity {
NumberAnimation {
duration: root.faceUp ? 180 : 260
easing.type: Easing.OutCubic
}
}
Behavior on scale {
NumberAnimation {
duration: root.faceUp ? 180 : 260
easing.type: Easing.OutCubic
}
}
DropShadow {
source: quoteBox
anchors.fill: quoteBox
horizontalOffset: 0
verticalOffset: 2
radius: 12
samples: radius * 2 + 1
color: Appearance.colors.colShadow
transparentBorder: true
}
Rectangle {
id: quoteBox
implicitWidth: quoteRow.implicitWidth + 8 * 2
implicitHeight: quoteRow.implicitHeight + 4 * 2
radius: Appearance.rounding.small
color: Appearance.colors.colSecondaryContainer
Row {
id: quoteRow
anchors.centerIn: parent
spacing: 4
MaterialSymbol {
id: quoteIcon
anchors.top: parent.top
iconSize: Appearance.font.pixelSize.huge
text: "format_quote"
color: Appearance.colors.colOnSecondaryContainer
}
StyledText {
id: quoteStyledText
horizontalAlignment: Text.AlignLeft
text: Config.options.background.widgets.clock.quote.text
color: Appearance.colors.colOnSecondaryContainer
font {
family: Appearance.font.family.reading
pixelSize: Appearance.font.pixelSize.large
weight: Font.Normal
}
}
}
}
}

View file

@ -0,0 +1,582 @@
import qs.modules.ii.bar.weather
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Services.UPower
import qs
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
// BarContent — ONE bar for every device. Souveraine-owned override of the ii
// original, replacing BOTH ii-base/modules/ii/bar/BarContent.qml and the
// ii-phone fork of the same file.
//
// WHY THIS FILE EXISTS
// ii-base and ii-phone each carried a 357-line BarContent to express exactly
// eight differences, and every one of the eight was either "is this widget
// shown" or "which slot is it in". No behaviour differed. A 357-line fork
// maintained against a pin, forever, to reorder three widgets — so every
// future bar edit had to be made twice or silently diverge.
//
// The eight, for the record (ii-base -> ii-phone):
// 1. cellular carrier readout added, far left
// 2. leftCenterGroup (resources/media/claudeUsage) removed entirely
// 3. clock moved to the middle group
// 4. workspaces moved to the right-of-centre group
// 5. battery moved out of the centre group to the right section, ungated
// 6. resources added to the right section
// 7. xkb + bluetooth indicators dropped from the pill
// 8. pomodoro dropped from the right section
//
// HOW IT REPLACES THEM
// Widgets are declared once as Components in the registry below. Each slot is
// a Repeater over a list of widget NAMES, so placement and order are data.
// Config wins if it names a slot; otherwise the slot comes from a device
// profile. An unknown name loads nothing, so ["none"] is how a slot is
// deliberately emptied, and a typo degrades to a gap rather than an error.
//
// Loaders are `active` only when their name is listed, so an unplaced widget
// is never constructed — placement is also the lazy-loading boundary.
//
// DEVICE TYPES ARE STILL REAL
// "auto" derives the profile from the same cramped-ness test the bar already
// uses for useShortenedForm, so the phone keeps its current arrangement with
// no config file at all, and a narrow bar behaves like a narrow bar wherever
// it appears. Setting bar.layout.profile or naming slots overrides it — which
// is what makes this reachable from souveraine-settings instead of from a
// second copy of the file.
Item { // Bar content region
id: root
property var screen: root.QsWindow.window?.screen
property var brightnessMonitor: Brightness.getMonitorForScreen(screen)
property real useShortenedForm: (Appearance.sizes.barHellaShortenScreenWidthThreshold >= screen?.width) ? 2 : (Appearance.sizes.barShortenScreenWidthThreshold >= screen?.width) ? 1 : 0
readonly property int centerSideModuleWidth: (useShortenedForm == 2) ? Appearance.sizes.barCenterSideModuleWidthHellaShortened : (useShortenedForm == 1) ? Appearance.sizes.barCenterSideModuleWidthShortened : Appearance.sizes.barCenterSideModuleWidth
// ── Layout resolution ────────────────────────────────────────────────
// One authority for "is this bar cramped": the same threshold that drives
// useShortenedForm. The island asks the same question independently and
// gets the same answer, so it narrows when its neighbours do.
readonly property string profile: {
const p = Config.options.bar.layout?.profile ?? "auto";
if (p !== "auto")
return p;
return root.useShortenedForm >= 1 ? "compact" : "desktop";
}
// Profile defaults. `desktop` is the ii-base arrangement verbatim;
// `compact` is the ii-phone arrangement verbatim. Changing a bar layout
// is now editing a list here (or in config), not forking a file.
readonly property var layoutDefaults: ({
"desktop": {
"left": ["activeWindow"],
"centerLeft": ["resources", "media", "claudeUsage"],
"centerMiddle": ["workspaces"],
"centerRight": ["clock", "utilButtons", "battery"],
"right": ["pomodoro", "systray"]
},
"compact": {
"left": ["cellular", "activeWindow"],
"centerLeft": ["none"],
"centerMiddle": ["clock"],
"centerRight": ["workspaces", "utilButtons"],
"right": ["battery", "resources", "systray"]
}
})
// Config names a slot -> config wins. Otherwise the profile default.
// An empty config list means "unset", not "empty"; use ["none"] to empty.
function slot(name) {
const cfg = Config.options.bar.layout?.[name] ?? null;
if (cfg && cfg.length > 0)
return cfg;
const prof = root.layoutDefaults[root.profile] ?? root.layoutDefaults["desktop"];
return prof[name] ?? [];
}
function slotHas(name, widget) {
return root.slot(name).indexOf(widget) !== -1;
}
// Layout hints belong to the Loader, not the loaded item: an item inside a
// Loader inside a layout has its own Layout.* ignored. So the few widgets
// that stretch declare it here, by name.
function fillWidthFor(name) {
if (name === "resources")
return root.useShortenedForm === 2;
if (name === "media" || name === "clock" || name === "activeWindow")
return true;
return false;
}
function fillHeightFor(name) {
return name === "workspaces" || name === "systray" || name === "activeWindow";
}
// The registry. Every bar widget, declared exactly once.
readonly property var widgets: ({
"activeWindow": activeWindowComp,
"cellular": cellularComp,
"resources": resourcesComp,
"media": mediaComp,
"claudeUsage": claudeUsageComp,
"workspaces": workspacesComp,
"clock": clockComp,
"utilButtons": utilButtonsComp,
"battery": batteryComp,
"pomodoro": pomodoroComp,
"systray": systrayComp
})
component VerticalBarSeparator: Rectangle {
Layout.topMargin: Appearance.sizes.baseBarHeight / 3
Layout.bottomMargin: Appearance.sizes.baseBarHeight / 3
Layout.fillHeight: true
implicitWidth: 1
color: Appearance.colors.colOutlineVariant
}
// A slot: order and membership from config, construction gated on
// placement so an unplaced widget costs nothing.
component WidgetSlot: Repeater {
required property string slotName
model: root.slot(slotName)
delegate: Loader {
required property var modelData
Layout.alignment: Qt.AlignVCenter
Layout.fillWidth: root.fillWidthFor(modelData)
Layout.fillHeight: root.fillHeightFor(modelData)
active: !!root.widgets[modelData]
visible: active
sourceComponent: root.widgets[modelData] ?? null
}
}
// ── Widget registry ──────────────────────────────────────────────────
Component {
id: activeWindowComp
ActiveWindow {
Layout.leftMargin: 10 + (leftSidebarButton.visible ? 0 : Appearance.rounding.screenRounding)
Layout.rightMargin: Appearance.rounding.screenRounding
visible: root.useShortenedForm === 0
}
}
// Phone-only in practice, but not phone-*gated*: Cellular.available is
// false where there is no modem, so the desktop needs no special case.
Component {
id: cellularComp
RowLayout {
spacing: 4
visible: Cellular.available
MaterialSymbol {
Layout.alignment: Qt.AlignVCenter
text: Cellular.materialSymbol
iconSize: Appearance.font.pixelSize.larger
color: Appearance.colors.colOnLayer0
}
StyledText {
Layout.alignment: Qt.AlignVCenter
text: (Cellular.operatorName + " " + Cellular.accessTech).trim()
color: Appearance.colors.colOnLayer0
font.pixelSize: Appearance.font.pixelSize.normal
}
}
}
Component {
id: resourcesComp
Resources {
autoRotate: root.profile === "compact"
alwaysShowAllResources: root.useShortenedForm === 2
}
}
Component {
id: mediaComp
Media {
visible: root.useShortenedForm < 2
}
}
Component {
id: claudeUsageComp
Loader {
active: Config.options.bar.claudeUsage.enable
visible: root.useShortenedForm < 2 && active
sourceComponent: ClaudeUsageBar {}
}
}
Component {
id: workspacesComp
Workspaces {
id: workspacesWidget
MouseArea {
// Right-click to toggle overview
anchors.fill: parent
acceptedButtons: Qt.RightButton
onPressed: event => {
if (event.button === Qt.RightButton) {
GlobalStates.overviewOpen = !GlobalStates.overviewOpen;
}
}
}
}
}
// The compact profile wants a dense one-line clock; the desktop follows
// bar.verbose as before. Format is config-overridable for either.
Component {
id: clockComp
ClockWidget {
showDate: root.profile === "compact" ? false : (Config.options.bar.verbose && root.useShortenedForm < 2)
customFormat: Config.options.bar.clock?.format || (root.profile === "compact" ? "ddd. dd/MM h:mmAP" : "")
}
}
Component {
id: utilButtonsComp
UtilButtons {
visible: root.profile === "compact" ? true : (Config.options.bar.verbose && root.useShortenedForm === 0)
}
}
Component {
id: batteryComp
// Ungated under `compact`: ii-phone showed the battery at every width
// (note 5 in the header), and the unification applied the desktop gate
// to every device, so a phone at useShortenedForm 2 lost its icon
// silently while the critical-battery alert kept firing. 2026-08-13.
BatteryIndicator {
visible: Battery.available && (root.profile === "compact" || root.useShortenedForm < 2)
}
}
Component {
id: pomodoroComp
// The child is deliberately NOT anchored to the Revealer's centre.
// Revealer takes implicitHeight from childrenRect, so a child anchored
// to its parent closes a real cycle once the Revealer sits in a Loader
// (implicitHeight -> height -> child.y -> childrenRect -> ...). ii-base
// hid it by letting the layout drive the Revealer's height directly.
// The indicator has a fixed implicitHeight and the Loader carries
// Layout.alignment, so it centres without the anchor.
Revealer {
reveal: TimerService.pomodoroRunning
PomodoroBarIndicator {}
}
}
Component {
id: systrayComp
SysTray {
visible: root.useShortenedForm === 0
invertSide: Config?.options.bar.bottom
}
}
// ── Structure ────────────────────────────────────────────────────────
// Background shadow
Loader {
active: Config.options.bar.showBackground && Config.options.bar.cornerStyle === 1 && Config.options.bar.floatStyleShadow
anchors.fill: barBackground
sourceComponent: StyledRectangularShadow {
anchors.fill: undefined // The loader's anchors act on this, and this should not have any anchor
target: barBackground
}
}
// Background
Rectangle {
id: barBackground
anchors {
fill: parent
margins: Config.options.bar.cornerStyle === 1 ? (Appearance.sizes.hyprlandGapsOut) : 0 // idk why but +1 is needed
}
color: Config.options.bar.showBackground ? Appearance.colors.colLayer0 : "transparent"
radius: Config.options.bar.cornerStyle === 1 ? Appearance.rounding.windowRounding : 0
border.width: Config.options.bar.cornerStyle === 1 ? 1 : 0
border.color: Appearance.colors.colLayer0Border
}
FocusedScrollMouseArea { // Left side | scroll to change brightness
id: barLeftSideMouseArea
anchors {
top: parent.top
bottom: parent.bottom
left: parent.left
right: middleSection.left
}
implicitWidth: leftSectionRowLayout.implicitWidth
implicitHeight: Appearance.sizes.baseBarHeight
onScrollDown: Brightness.decreaseBrightness()
onScrollUp: Brightness.increaseBrightness()
onMovedAway: GlobalStates.osdBrightnessOpen = false
onPressed: event => {
if (event.button === Qt.LeftButton)
GlobalStates.sidebarLeftOpen = !GlobalStates.sidebarLeftOpen;
}
// Visual content
ScrollHint {
reveal: barLeftSideMouseArea.hovered
icon: Hyprsunset.gamma === 100 ? "light_mode" : "wb_twilight"
tooltipText: Translation.tr("Scroll to change brightness")
side: "left"
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
}
RowLayout {
id: leftSectionRowLayout
anchors.fill: parent
spacing: 0
LeftSidebarButton { // Left sidebar button
id: leftSidebarButton
Layout.alignment: Qt.AlignVCenter
Layout.leftMargin: Appearance.rounding.screenRounding
colBackground: barLeftSideMouseArea.hovered ? Appearance.colors.colLayer1Hover : ColorUtils.transparentize(Appearance.colors.colLayer1Hover, 1)
}
WidgetSlot {
slotName: "left"
}
}
}
Row { // Middle section
id: middleSection
anchors {
top: parent.top
bottom: parent.bottom
horizontalCenter: parent.horizontalCenter
}
spacing: 4
BarGroup {
id: leftCenterGroup
anchors.verticalCenter: parent.verticalCenter
visible: root.slot("centerLeft").length > 0 && implicitWidth > padding * 2
WidgetSlot {
slotName: "centerLeft"
}
}
VerticalBarSeparator {
visible: (Config.options?.bar.borderless ?? false) && leftCenterGroup.visible
}
BarGroup {
id: middleCenterGroup
anchors.verticalCenter: parent.verticalCenter
// Workspaces sets its own padding wherever it lands.
padding: root.slotHas("centerMiddle", "workspaces") ? 2 : 5
visible: root.slot("centerMiddle").length > 0 && implicitWidth > padding * 2
WidgetSlot {
slotName: "centerMiddle"
}
}
VerticalBarSeparator {
visible: (Config.options?.bar.borderless ?? false) && middleCenterGroup.visible
}
MouseArea {
id: rightCenterGroup
anchors.verticalCenter: parent.verticalCenter
implicitWidth: rightCenterGroupContent.implicitWidth
implicitHeight: rightCenterGroupContent.implicitHeight
onPressed: {
GlobalStates.sidebarRightOpen = !GlobalStates.sidebarRightOpen;
}
BarGroup {
id: rightCenterGroupContent
anchors.fill: parent
padding: root.slotHas("centerRight", "workspaces") ? 2 : 5
WidgetSlot {
slotName: "centerRight"
}
}
}
}
FocusedScrollMouseArea { // Right side | scroll to change volume
id: barRightSideMouseArea
anchors {
top: parent.top
bottom: parent.bottom
left: middleSection.right
right: parent.right
}
implicitWidth: rightSectionRowLayout.implicitWidth
implicitHeight: Appearance.sizes.baseBarHeight
onScrollDown: Audio.decrementVolume()
onScrollUp: Audio.incrementVolume()
onMovedAway: GlobalStates.osdVolumeOpen = false
onPressed: event => {
if (event.button === Qt.LeftButton) {
GlobalStates.sidebarRightOpen = !GlobalStates.sidebarRightOpen;
}
}
// Visual content
ScrollHint {
reveal: barRightSideMouseArea.hovered
icon: "volume_up"
tooltipText: Translation.tr("Scroll to change volume")
side: "right"
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
}
RowLayout {
id: rightSectionRowLayout
anchors.fill: parent
spacing: 5
layoutDirection: Qt.RightToLeft
RippleButton { // Right sidebar button
id: rightSidebarButton
Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
Layout.rightMargin: Appearance.rounding.screenRounding
Layout.fillWidth: false
implicitWidth: indicatorsRowLayout.implicitWidth + 10 * 2
implicitHeight: indicatorsRowLayout.implicitHeight + 5 * 2
buttonRadius: Appearance.rounding.full
colBackground: barRightSideMouseArea.hovered ? Appearance.colors.colLayer1Hover : ColorUtils.transparentize(Appearance.colors.colLayer1Hover, 1)
colBackgroundHover: Appearance.colors.colLayer1Hover
colRipple: Appearance.colors.colLayer1Active
colBackgroundToggled: Appearance.colors.colSecondaryContainer
colBackgroundToggledHover: Appearance.colors.colSecondaryContainerHover
colRippleToggled: Appearance.colors.colSecondaryContainerActive
toggled: GlobalStates.sidebarRightOpen
property color colText: toggled ? Appearance.m3colors.m3onSecondaryContainer : Appearance.colors.colOnLayer0
Behavior on colText {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
onPressed: {
GlobalStates.sidebarRightOpen = !GlobalStates.sidebarRightOpen;
}
// The indicator pill stays hand-ordered rather than
// slot-driven: these Revealers interlock through
// realSpacing margins that depend on their neighbours'
// reveal state, and a Repeater would have to reproduce that
// coupling to gain an ordering nobody has asked to change.
// Visibility is config, which is the whole delta that
// existed between the two forks.
RowLayout {
id: indicatorsRowLayout
anchors.centerIn: parent
property real realSpacing: 15
spacing: 0
Revealer {
reveal: Audio.sink?.audio?.muted ?? false
Layout.fillHeight: true
Layout.rightMargin: reveal ? indicatorsRowLayout.realSpacing : 0
Behavior on Layout.rightMargin {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
MaterialSymbol {
text: "volume_off"
iconSize: Appearance.font.pixelSize.larger
color: rightSidebarButton.colText
}
}
Revealer {
reveal: Audio.source?.audio?.muted ?? false
Layout.fillHeight: true
Layout.rightMargin: reveal ? indicatorsRowLayout.realSpacing : 0
Behavior on Layout.rightMargin {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
MaterialSymbol {
text: "mic_off"
iconSize: Appearance.font.pixelSize.larger
color: rightSidebarButton.colText
}
}
Loader {
active: Config.tristate(Config.options.bar.indicators?.showXkb, root.profile !== "compact")
visible: active
Layout.alignment: Qt.AlignVCenter
Layout.rightMargin: indicatorsRowLayout.realSpacing
sourceComponent: HyprlandXkbIndicator {
color: rightSidebarButton.colText
}
}
Revealer {
reveal: Notifications.silent || Notifications.unread > 0
Layout.fillHeight: true
Layout.rightMargin: reveal ? indicatorsRowLayout.realSpacing : 0
implicitHeight: reveal ? notificationUnreadCount.implicitHeight : 0
implicitWidth: reveal ? notificationUnreadCount.implicitWidth : 0
Behavior on Layout.rightMargin {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
NotificationUnreadCount {
id: notificationUnreadCount
}
}
// On a compact bar this is the pill's always-visible face
// (the carrier readout holds the far left), so it fills
// height there; on the desktop it sits inline as before.
MaterialSymbol {
Layout.fillHeight: root.profile === "compact"
Layout.alignment: Qt.AlignVCenter
text: Network.materialSymbol
iconSize: Appearance.font.pixelSize.larger
color: rightSidebarButton.colText
}
Loader {
active: Config.tristate(Config.options.bar.indicators?.showBluetooth, root.profile !== "compact") && BluetoothStatus.available
visible: active
Layout.leftMargin: indicatorsRowLayout.realSpacing
sourceComponent: MaterialSymbol {
text: BluetoothStatus.connected ? "bluetooth_connected" : BluetoothStatus.enabled ? "bluetooth" : "bluetooth_disabled"
iconSize: Appearance.font.pixelSize.larger
color: rightSidebarButton.colText
}
}
}
}
WidgetSlot {
slotName: "right"
}
Item {
Layout.fillWidth: true
Layout.fillHeight: true
}
// Weather
Loader {
Layout.leftMargin: 4
active: Config.options.bar.weather.enable
sourceComponent: BarGroup {
WeatherBar {}
}
}
}
}
}

View file

@ -0,0 +1,58 @@
import qs.modules.common
import qs.modules.common.widgets
import qs.services
import QtQuick
import QtQuick.Layouts
Item {
id: root
property bool borderless: Config.options.bar.borderless
property bool showDate: Config.options.bar.verbose
// Per-instance Qt date format; empty = the global DateTime.time
// (time.format in config.json, which the desktop clock also uses).
//
// Souveraine-owned override of the ii ClockWidget. This property was the
// ENTIRE content of ii-phone's 8-line fork of this file, and BarContent
// now sets it on every device — so without unifying here, the desktop
// clock would be handed a property that does not exist on it. Additive
// and defaulted to "", so the ii behaviour is unchanged when unset.
property string customFormat: ""
implicitWidth: rowLayout.implicitWidth
implicitHeight: Appearance.sizes.barHeight
RowLayout {
id: rowLayout
anchors.centerIn: parent
spacing: 4
StyledText {
font.pixelSize: Appearance.font.pixelSize.large
color: Appearance.colors.colOnLayer1
text: root.customFormat ? Qt.locale().toString(DateTime.clock.date, root.customFormat) : DateTime.time
}
StyledText {
visible: root.showDate
font.pixelSize: Appearance.font.pixelSize.small
color: Appearance.colors.colOnLayer1
text: "•"
}
StyledText {
visible: root.showDate
font.pixelSize: Appearance.font.pixelSize.small
color: Appearance.colors.colOnLayer1
text: DateTime.longDate
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: !Config.options.bar.tooltips.clickToShow
ClockWidgetPopup {
hoverTarget: mouseArea
}
}
}

View file

@ -0,0 +1,181 @@
import qs.modules.common
import qs.modules.common.widgets
import qs.services
import QtQuick
import QtQuick.Layouts
// Resources — Souveraine-owned override of the ii original, replacing the
// ii-phone fork of the same file.
//
// This was the one bar fork carrying a genuine behavioural difference rather
// than pure layout: a 540px bar cannot show three stat circles plus a network
// readout, so the phone showed one stat at a time and rotated it. That is a
// real mode, so it stays a real mode — it just stops being a second copy of
// the file. `rotate` selects it; everything else is shared.
//
// The heavy half (network traffic, with its TextMetrics and two RowLayouts)
// is behind a Loader that is inactive while rotating, so the compact mode
// does not construct what it will never show.
MouseArea {
id: root
property bool borderless: Config.options.bar.borderless
property bool alwaysShowAllResources: false
// The host (BarContent) offers a default from the device profile; an
// explicit config value outranks it. Same precedence as the bar layout:
// config wins if it says anything, profile decides otherwise.
property bool autoRotate: false
readonly property bool rotate: {
const v = Config.options.bar.resources?.rotate;
if (v === "on" || v === true) return true;
if (v === "off" || v === false) return false;
return root.autoRotate;
}
implicitWidth: rowLayout.implicitWidth + rowLayout.anchors.leftMargin + rowLayout.anchors.rightMargin
implicitHeight: Appearance.sizes.barHeight
hoverEnabled: !Config.options.bar.tooltips.clickToShow
// Rotating mode: memory -> cpu -> swap. Tap still opens the full popup,
// so nothing is unreachable, only unshown.
property int shownResource: 0
Timer {
interval: (Config.options.bar.resources?.rotateInterval ?? 4) * 1000
running: root.rotate
repeat: true
onTriggered: root.shownResource = (root.shownResource + 1) % 3
}
RowLayout {
id: rowLayout
spacing: 0
anchors.fill: parent
anchors.leftMargin: 4
anchors.rightMargin: 4
Loader {
active: !root.rotate && NetworkTraffic.available
visible: active
Layout.rightMargin: active ? 16 : 0
sourceComponent: Item {
implicitWidth: speedMeasure.implicitWidth
implicitHeight: Appearance.sizes.barHeight
clip: true
TextMetrics {
id: speedTextMetrics
text: "8888G/s"
font.pixelSize: Appearance.font.pixelSize.small
font.family: Appearance.font.family.main
font.variableAxes: Appearance.font.variableAxes.main
}
RowLayout {
id: speedMeasure
visible: false
MaterialSymbol {
text: "south"
iconSize: Appearance.font.pixelSize.normal
}
Item {
implicitWidth: speedTextMetrics.width
implicitHeight: 1
}
Item {
implicitWidth: 2
implicitHeight: 1
}
MaterialSymbol {
text: "north"
iconSize: Appearance.font.pixelSize.normal
}
Item {
implicitWidth: speedTextMetrics.width
implicitHeight: 1
}
}
RowLayout {
id: speedRow
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 2
MaterialSymbol {
text: "south"
iconSize: Appearance.font.pixelSize.normal
color: Appearance.colors.colOnLayer1
}
StyledText {
width: speedTextMetrics.width
text: NetworkTraffic.downloadSpeedCompactText
font.pixelSize: Appearance.font.pixelSize.small
color: Appearance.colors.colOnLayer1
horizontalAlignment: Text.AlignRight
elide: Text.ElideLeft
}
MaterialSymbol {
text: "north"
iconSize: Appearance.font.pixelSize.normal
color: Appearance.colors.colOnLayer1
Layout.leftMargin: 2
}
StyledText {
width: speedTextMetrics.width
text: NetworkTraffic.uploadSpeedCompactText
font.pixelSize: Appearance.font.pixelSize.small
color: Appearance.colors.colOnLayer1
horizontalAlignment: Text.AlignRight
elide: Text.ElideLeft
}
}
}
}
// Compact: a single circle, cycling.
Resource {
visible: root.rotate
iconName: root.shownResource === 0 ? "memory" : root.shownResource === 1 ? "planner_review" : "swap_horiz"
percentage: root.shownResource === 0 ? ResourceUsage.memoryUsedPercentage : root.shownResource === 1 ? ResourceUsage.cpuUsage : ResourceUsage.swapUsedPercentage
warningThreshold: root.shownResource === 0 ? Config.options.bar.resources.memoryWarningThreshold : root.shownResource === 1 ? Config.options.bar.resources.cpuWarningThreshold : Config.options.bar.resources.swapWarningThreshold
}
// Full: all three, each with its own reveal rule.
Resource {
visible: !root.rotate
iconName: "memory"
percentage: ResourceUsage.memoryUsedPercentage
warningThreshold: Config.options.bar.resources.memoryWarningThreshold
}
Resource {
iconName: "swap_horiz"
percentage: ResourceUsage.swapUsedPercentage
shown: !root.rotate && ((Config.options.bar.resources.alwaysShowSwap && percentage > 0) || (MprisController.activePlayer?.trackTitle == null) || root.alwaysShowAllResources)
Layout.leftMargin: shown ? 6 : 0
warningThreshold: Config.options.bar.resources.swapWarningThreshold
}
Resource {
iconName: "planner_review"
percentage: ResourceUsage.cpuUsage
shown: !root.rotate && (Config.options.bar.resources.alwaysShowCpu || !(MprisController.activePlayer?.trackTitle?.length > 0) || root.alwaysShowAllResources)
Layout.leftMargin: shown ? 6 : 0
warningThreshold: Config.options.bar.resources.cpuWarningThreshold
}
}
ResourcesPopup {
hoverTarget: root
}
}

View file

@ -0,0 +1,158 @@
import qs
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Hyprland
import Quickshell.Services.UPower
Item {
id: root
property bool borderless: Config.options.bar.borderless
implicitWidth: rowLayout.implicitWidth + rowLayout.spacing * 2
implicitHeight: rowLayout.implicitHeight
RowLayout {
id: rowLayout
spacing: 4
anchors.centerIn: parent
Loader {
active: Config.options.bar.utilButtons.showScreenSnip
visible: Config.options.bar.utilButtons.showScreenSnip
sourceComponent: CircleUtilButton {
Layout.alignment: Qt.AlignVCenter
onClicked: Quickshell.execDetached(["qs", "-p", Quickshell.shellPath(""), "ipc", "call", "region", "screenshot"])
MaterialSymbol {
horizontalAlignment: Qt.AlignHCenter
fill: 1
text: "screenshot_region"
iconSize: Appearance.font.pixelSize.large
color: Appearance.colors.colOnLayer2
}
}
}
Loader {
active: Config.options.bar.utilButtons.showScreenRecord
visible: Config.options.bar.utilButtons.showScreenRecord
sourceComponent: CircleUtilButton {
Layout.alignment: Qt.AlignVCenter
onClicked: Quickshell.execDetached([Directories.recordScriptPath])
MaterialSymbol {
horizontalAlignment: Qt.AlignHCenter
fill: 1
text: "videocam"
iconSize: Appearance.font.pixelSize.large
color: Appearance.colors.colOnLayer2
}
}
}
Loader {
active: Config.options.bar.utilButtons.showColorPicker
visible: Config.options.bar.utilButtons.showColorPicker
sourceComponent: CircleUtilButton {
Layout.alignment: Qt.AlignVCenter
onClicked: Quickshell.execDetached(["hyprpicker", "-a"])
MaterialSymbol {
horizontalAlignment: Qt.AlignHCenter
fill: 1
text: "colorize"
iconSize: Appearance.font.pixelSize.large
color: Appearance.colors.colOnLayer2
}
}
}
Loader {
active: Config.options.bar.utilButtons.showKeyboardToggle
visible: Config.options.bar.utilButtons.showKeyboardToggle
sourceComponent: CircleUtilButton {
Layout.alignment: Qt.AlignVCenter
onClicked: GlobalStates.oskOpen = !GlobalStates.oskOpen
MaterialSymbol {
horizontalAlignment: Qt.AlignHCenter
fill: 0
text: "keyboard"
iconSize: Appearance.font.pixelSize.large
color: Appearance.colors.colOnLayer2
}
}
}
Loader {
active: Config.options.bar.utilButtons.showMicToggle
visible: Config.options.bar.utilButtons.showMicToggle
sourceComponent: CircleUtilButton {
Layout.alignment: Qt.AlignVCenter
onClicked: Audio.toggleMicMute()
MaterialSymbol {
horizontalAlignment: Qt.AlignHCenter
fill: 0
text: Audio.source?.audio?.muted ? "mic_off" : "mic"
iconSize: Appearance.font.pixelSize.large
color: Appearance.colors.colOnLayer2
}
}
}
Loader {
active: Config.options.bar.utilButtons.showDarkModeToggle
visible: Config.options.bar.utilButtons.showDarkModeToggle
sourceComponent: CircleUtilButton {
Layout.alignment: Qt.AlignVCenter
onClicked: event => {
if (Appearance.m3colors.darkmode) {
Quickshell.execDetached(["bash", "-c", `${Directories.wallpaperSwitchScriptPath} --mode light --noswitch`])
} else {
Quickshell.execDetached(["bash", "-c", `${Directories.wallpaperSwitchScriptPath} --mode dark --noswitch`])
}
}
MaterialSymbol {
horizontalAlignment: Qt.AlignHCenter
fill: 0
text: Appearance.m3colors.darkmode ? "light_mode" : "dark_mode"
iconSize: Appearance.font.pixelSize.large
color: Appearance.colors.colOnLayer2
}
}
}
Loader {
active: Config.options.bar.utilButtons.showPerformanceProfileToggle
visible: Config.options.bar.utilButtons.showPerformanceProfileToggle
sourceComponent: CircleUtilButton {
Layout.alignment: Qt.AlignVCenter
onClicked: event => {
if (PowerProfiles.hasPerformanceProfile) {
switch(PowerProfiles.profile) {
case PowerProfile.PowerSaver: PowerProfiles.profile = PowerProfile.Balanced
break
case PowerProfile.Balanced: PowerProfiles.profile = PowerProfile.Performance
break
case PowerProfile.Performance: PowerProfiles.profile = PowerProfile.PowerSaver
break
}
} else {
PowerProfiles.profile = PowerProfiles.profile == PowerProfile.Balanced ? PowerProfile.PowerSaver : PowerProfile.Balanced
}
}
MaterialSymbol {
horizontalAlignment: Qt.AlignHCenter
fill: 0
text: switch(PowerProfiles.profile) {
case PowerProfile.PowerSaver: return "energy_savings_leaf"
case PowerProfile.Balanced: return "airwave"
case PowerProfile.Performance: return "local_fire_department"
}
iconSize: Appearance.font.pixelSize.large
color: Appearance.colors.colOnLayer2
}
}
}
}
}

View file

@ -0,0 +1,28 @@
ActiveWindow 1.0 ActiveWindow.qml
Bar 1.0 Bar.qml
BarContent 1.0 BarContent.qml
BarGroup 1.0 BarGroup.qml
BatteryIndicator 1.0 BatteryIndicator.qml
BatteryPopup 1.0 BatteryPopup.qml
CircleUtilButton 1.0 CircleUtilButton.qml
ClaudeUsageBar 1.0 ClaudeUsageBar.qml
ClockWidget 1.0 ClockWidget.qml
ClockWidgetPopup 1.0 ClockWidgetPopup.qml
HyprlandXkbIndicator 1.0 HyprlandXkbIndicator.qml
LeftSidebarButton 1.0 LeftSidebarButton.qml
Media 1.0 Media.qml
NotificationUnreadCount 1.0 NotificationUnreadCount.qml
PomodoroBarIndicator 1.0 PomodoroBarIndicator.qml
Resource 1.0 Resource.qml
Resources 1.0 Resources.qml
ResourcesPopup 1.0 ResourcesPopup.qml
ScrollHint 1.0 ScrollHint.qml
StyledPopup 1.0 StyledPopup.qml
StyledPopupHeaderRow 1.0 StyledPopupHeaderRow.qml
StyledPopupValueRow 1.0 StyledPopupValueRow.qml
SysTray 1.0 SysTray.qml
SysTrayItem 1.0 SysTrayItem.qml
SysTrayMenu 1.0 SysTrayMenu.qml
SysTrayMenuEntry 1.0 SysTrayMenuEntry.qml
UtilButtons 1.0 UtilButtons.qml
Workspaces 1.0 Workspaces.qml

View file

@ -0,0 +1,555 @@
// Pixel3Arch patch to ii's stock Dock.qml (2026-07-07).
//
// Problem: the dock (pinned, exclusiveZone claiming real screen space) and
// wvkbd (the on-screen keyboard, see OnScreenKeyboard.qml) both anchor to
// the bottom edge and both claim exclusive zones — their reservations
// stack instead of one giving way, so the keyboard/its focus-grab shield
// drift out of alignment with each other depending on what else is
// reserving space. Simplest real fix: the dock gets out of the way
// entirely while the keyboard is open, instead of trying to make the
// keyboard-side math account for wherever the dock happens to be.
//
// GlobalStates.oskOpen suppresses both reveal-when-idle and the pinned
// exclusive zone. 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
import qs.modules.common.widgets
import "." as DockLocal
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell.Io
import Quickshell.Hyprland
import Quickshell
import Quickshell.Widgets
import Quickshell.Wayland
import Quickshell.Hyprland
Scope { // Scope
id: root
property bool pinned: Config.options?.dock.pinnedOnStartup ?? false
// Dock visibility as one explicit state instead of overlapping booleans.
// HIDDEN - fully tucked below the edge
// SHOWN - visible without claiming exclusive space
// PINNED - visible AND reserving an exclusive zone
// OSK-open and previewPopup-hover are *inputs* to this, not states.
enum DockState { Hidden, Shown, Pinned }
// The normal dock pin is suppressed while the OSK is open. Fullscreen
// always takes precedence, so fullscreen remains genuinely edge-to-edge
// until the navigation rail explicitly reveals the dock.
property bool effectivePinned: root.pinned && !GlobalStates.oskOpen
property bool autoHide: Config.options?.dock.autoHide ?? false
property bool pointerAtDock: false
property bool pointerReveal: false
function notePointerAtDock(present) {
root.pointerAtDock = present;
if (present) {
hideTimer.stop();
revealTimer.restart();
} else {
revealTimer.stop();
hideTimer.restart();
}
}
Timer {
id: revealTimer
interval: Config.options?.dock.revealDelayMs ?? 120
onTriggered: root.pointerReveal = root.pointerAtDock
}
Timer {
id: hideTimer
interval: Config.options?.dock.hideDelayMs ?? 350
onTriggered: if (!root.pointerAtDock) root.pointerReveal = false
}
// 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; visibility: root.visibility }
// 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
// App mode: any fullscreen window on the focused monitor owns the
// display, so the dock hides. Two previous checks both had blind spots:
// (1) ws.toplevels scan via ext-foreign-toplevel-list used
// wayland?.fullscreen which is unreliable, and never saw
// standalone qs -p windows (souveraine-settings).
// (2) HyprlandData.activeWindow?.fullscreen === 2 only saw the
// focused window — missed fullscreen apps that lost focus to a
// layer-shell surface (the dock itself, notifications, OSK).
// HyprlandData.windowList (hyprctl clients -j) has ALL windows with
// their real fullscreen mode. Scan it for any fullscreen === 2 window
// on the focused monitor. Reactive: HyprlandData updates on every
// Hyprland event.
readonly property bool activeMonitorHasFullscreen: {
const focusedId = HyprlandData.monitors.find(m => m.focused)?.id;
if (focusedId === undefined) return false;
return HyprlandData.windowList.some(w => w.fullscreen === 2 && w.monitor === focusedId);
}
function computeDockState() {
// Multitasking owns the screen. The dock is *home's* furniture, and
// home is one of the cards — a dock over the strip is one destination's
// furniture drawn on top of the picker for all of them. Casey,
// 2026-08-16: *"remove the dock it should not be there."*
//
// First in the ladder, above the home check, because the gesture is
// most often made **from** home: `activeZone` is still 0 for the whole
// time the overview is up, so every rung below this returns PINNED.
//
// The peek counts. The destination draws from the first climbing pixel
// (`SystemGestureRail.peekWanted`), so the dock leaving as the cards
// arrive is one motion rather than two events.
if (GlobalStates.missionControlOpen || GlobalStates.overviewOpen
|| GlobalStates.missionPeek)
return Dock.DockState.Hidden;
// USB Hands owns the bottom of home while its trackpad is actually
// open. Arming the wire alone changes no furniture; opening the
// conditional controller makes the dock yield until it closes.
if (HidController.active)
return Dock.DockState.Hidden;
// A visible keyboard owns the bottom edge. This must precede the home
// zone's unconditional PINNED return below; otherwise home stacks the
// dock's exclusive zone under the OSK and charges the display twice.
// The deliberate pulse remains reachable, but is SHOWN rather than
// PINNED so it does not reserve another strip of screen.
if (GlobalStates.oskOpen)
return GlobalStates.dockRevealPulse
? Dock.DockState.Shown : Dock.DockState.Hidden;
// Zone one is home, and home has a dock. Casey, 2026-08-05: *"The dock
// should just always be on zone one."*
//
// Checked before everything below because on home it is not a reveal,
// a pulse, or a consequence of nothing being focused — it is furniture
// that is simply there, the way the widget space around it is. The
// whole ladder underneath decides when to show a dock that is normally
// absent; on home there is nothing to decide.
//
// The compositor is the authority on which zone is active — the shell
// asking `{"op":"workspaces"}` and believing its own copy is the
// second-decider shape — so this reads ViewtopControl's last answer and
// treats "unknown" as not-home rather than guessing.
if (ViewtopControl.activeZone === ViewtopControl.homeZone) {
if (GlobalStates.dockSuppressed)
return Dock.DockState.Hidden;
if (root.autoHide)
return (root.pointerReveal || root.previewShowing
|| GlobalStates.dockRevealed || GlobalStates.dockRevealPulse)
? Dock.DockState.Shown : Dock.DockState.Hidden;
return root.effectivePinned
? Dock.DockState.Pinned : Dock.DockState.Shown;
}
// And off home it is gone — including when `pinned` is set, which is
// the config that used to win. Casey, 2026-08-05: *"when I tap on an
// app the dock shouldn't be there anymore."* The positive check above
// is not enough on its own: `effectivePinned` sits further down the
// ladder and returned Pinned on every zone, so opening an app left the
// dock exactly where it was.
//
// `-1` is "not asked yet" and deliberately falls through rather than
// hiding: a compositor that has not answered must not take the dock
// away, or a slow first reply reads as the dock being broken.
if (ViewtopControl.activeZone >= 0)
return Dock.DockState.Hidden;
if (root.activeMonitorHasFullscreen)
return (GlobalStates.dockRevealed || GlobalStates.dockRevealPulse)
? Dock.DockState.Shown : Dock.DockState.Hidden;
// An explicit pulse outranks suppression and the OSK — it exists to
// glance at the dock while the keyboard is up.
if (GlobalStates.dockRevealPulse)
return Dock.DockState.Shown;
// NOT gated on `oskOpen` here, and that is the point.
//
// Suppressing the dock whenever `oskOpen` was true — above
// `effectivePinned`, so it applied in every state — hid the dock
// PERMANENTLY on the device. `oskOpen` is not "the keyboard is on
// screen": GlobalStates' own comment says squeekboard hides itself
// whenever input-method focus drops and a hold re-asserts it, and the
// journal shows exactly that — self-showed / self-hid every couple of
// seconds, ending in `Visible=true` with no keyboard in front of the
// user. Gating a persistent surface on a flag that flaps turns a
// cosmetic overlap into a dock nobody can reach.
//
// The real signal is the keyboard's exclusive zone, which the
// compositor already applies: an unpinned dock declares zone 0 and is
// placed above the keyboard for free, the same mechanism that fixed the
// pill. What remains is the PINNED case, where the dock reserves space
// of its own and the two reservations stack. That wants fixing where
// the zones are arbitrated, not by reading a D-Bus property the
// keyboard flaps at us.
//
// Rail swipe-down dismissed a visible dock; swipe up brings it back.
if (GlobalStates.dockSuppressed)
return Dock.DockState.Hidden;
if (root.effectivePinned)
return Dock.DockState.Pinned;
if (root.previewShowing)
return Dock.DockState.Shown;
// Empty desktop (nothing focused) reveals the dock, unless the OSK took
// the bottom edge. Kept here, where it was: on an empty desktop a
// spuriously-true `oskOpen` costs a reveal that would have been
// cosmetic anyway, which is a very different price from hiding a pinned
// dock the user relies on.
if (!GlobalStates.oskOpen && !ToplevelManager.activeToplevel?.activated)
return Dock.DockState.Shown;
return Dock.DockState.Hidden;
}
property int dockState: computeDockState()
// The one authored answer to "is the dock there?".
//
// `DockManifest` and `ShellModel` each recomputed this from GlobalStates
// alone, which cannot see the zone rule above — so both reported "hidden"
// while the dock sat pinned on home, and that is what the agent read.
readonly property string visibility: root.dockState === Dock.DockState.Pinned
? "pinned"
: root.dockState === Dock.DockState.Shown ? "shown" : "hidden"
// 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
// the dock through one IPC name. See services/DockManifest.qml and
// docs/tasks/souveraine-shell-ecosystem.md.
IpcHandler {
target: "dock"
function swipeUp(): void {
GlobalStates.dockSuppressed = false;
GlobalStates.dockRevealed = true;
}
function swipeDown(): void {
GlobalStates.dockRevealed = false;
GlobalStates.dockSuppressed = true;
}
function reveal(): void {
GlobalStates.dockSuppressed = false;
GlobalStates.dockRevealed = true;
}
// 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 +
// 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.
// 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 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.
const raw = Hyprland.activeToplevel?.address;
if (!raw) return;
// .address may or may not carry the 0x prefix; normalize to exactly
// one. Selector "address:0x..." is verified working on this fork.
const addr = raw.startsWith("0x") ? raw : "0x" + raw;
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.
//
// Returns are `string` (JSON), not `var`: quickshell marshals only
// string/int/bool/double/color across IPC and silently maps a `var`
// return to VOID (src/io/ipc.cpp ipcType()). Declared `: var`, these
// registered as `(): void` and returned nothing at all — the {ok,
// reason} contract never reached the caller. JSON-over-string is what
// actually crosses the socket.
function manifest(): string {
return JSON.stringify(dockManifest.manifest());
}
function pin(appId: string): string {
return JSON.stringify(dockManifest.pin(appId));
}
function unpin(appId: string): string {
return JSON.stringify(dockManifest.unpin(appId));
}
function addToStack(stackId: string, appId: string): string {
return JSON.stringify(dockManifest.addToStack(stackId, appId));
}
function removeFromStack(stackId: string, appId: string): string {
return JSON.stringify(dockManifest.removeFromStack(stackId, appId));
}
function renameStack(stackId: string, newName: string): string {
return JSON.stringify(dockManifest.renameStack(stackId, newName));
}
}
// Settings-surface stub (entry point b). souveraine-settings doesn't
// exist yet; the long-hold "App settings…" menu calls dockSettings.openApp
// here. For now it just pulses the dock and logs, so nothing errors and
// the future settings app has a stable IPC name to take over.
IpcHandler {
target: "dockSettings"
function openApp(appId: string): void {
console.log("[dockSettings] openApp stub for", appId,
"- souveraine-settings not yet installed");
GlobalStates.dockRevealed = true;
}
function open(): void {
console.log("[dockSettings] open stub - souveraine-settings not yet installed");
GlobalStates.dockRevealed = true;
}
}
// 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; dockVisibility: root.visibility }
IpcHandler {
target: "shell"
// JSON-over-string, not `var` — see the note on dock.manifest above.
// A `var` return marshals as VOID and silently drops the payload.
// surfaces() — registry list, one entry per meaningful surface with
// layer, gating state, config gate, and a live `active` flag.
function surfaces(): string {
return JSON.stringify(shellModel.surfaces());
}
// state() — the GlobalStates bits that matter for layer gating, plus
// the shell mode. Read-only snapshot.
function state(): string {
return JSON.stringify(shellModel.state());
}
}
Variants {
// For each monitor
model: Quickshell.screens
PanelWindow {
id: dockRoot
// Window
required property var modelData
screen: modelData
// The dock window maps only when it should paint. This is what
// actually hides it over a fullscreen app (a Hidden dockState
// unmounts the layer entirely) — the earlier `visible` was
// always-true and only `reveal` flipped, so the dock kept
// painting over fullscreen. hoverToReveal (mouse, off by default)
// keeps the strip alive for desktop pointer use.
property bool reveal: root.dockState !== Dock.DockState.Hidden
|| root.pointerReveal
// 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
// The rail draws nothing on home — `SystemGestureRail` takes the
// handle's opacity to 0 there, which is Casey's own call from
// 2026-08-05: *"above the dock it has no function."* The dock went
// on reserving the strip anyway, so home had 32 px of gap under the
// bar holding space for a pill that was never going to appear.
//
// Only the *drawing* collapses. The input reservation above stays
// exactly where it is, because the rail still takes both swipes on
// home and a dock that claimed those pixels would eat the gesture.
readonly property int railVisualHeight:
ViewtopControl.activeZone === ViewtopControl.homeZone
? 0 : gestureRailHeight
visible: !GlobalStates.screenLocked && (reveal || root.autoHide
|| Config.options?.dock.hoverToReveal)
anchors {
bottom: true
left: true
right: true
}
exclusiveZone: root.dockState === Dock.DockState.Pinned ? implicitHeight - (Appearance.sizes.hyprlandGapsOut) - (Appearance.sizes.elevationMargin - Appearance.sizes.hyprlandGapsOut) : 0
implicitWidth: dockBackground.implicitWidth
WlrLayershell.namespace: "quickshell:dock"
// Overlay, not Top: fullscreen windows render above the Top
// layer, and the whole point of the edge swipe is to reach the
// dock from a fullscreen app.
WlrLayershell.layer: WlrLayer.Overlay
color: "transparent"
// Content-driven: tall enough for one 64px dock button + the
// 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
// BUTTON's implicit height, NOT dockRow.implicitHeight: the
// separator's Layout margins inflate the row's implicit and
// made the bar overshoot (content then top-aligned with a dead
// band underneath).
implicitHeight: Math.max(Config.options?.dock.height ?? 70,
overviewButton.implicitHeight + 8 + gestureRailHeight)
+ Appearance.sizes.elevationMargin + Appearance.sizes.hyprlandGapsOut
mask: Region {
item: dockInputRegion
}
// Deliberately smaller than dockMouseArea. The full MouseArea is
// still useful for laying out and hovering dock content, while the
// layer-shell only advertises the bar itself as touchable.
Item {
id: dockInputRegion
anchors.horizontalCenter: parent.horizontalCenter
width: dockMouseArea.width
y: dockRoot.reveal
? dockRoot.height - dockRoot.railVisualHeight
- Appearance.sizes.hyprlandGapsOut - dockRoot.visualHeight
: dockRoot.height - (Config.options?.dock.hoverRegionHeight ?? 2)
height: dockRoot.reveal ? dockRoot.visualHeight
: (Config.options?.dock.hoverRegionHeight ?? 2)
}
readonly property real visualHeight:
Math.max(Config.options?.dock.height ?? 60,
overviewButton.implicitHeight + 8)
MouseArea {
id: dockMouseArea
height: parent.height
anchors {
top: parent.top
topMargin: dockRoot.reveal ? 0 : Config.options?.dock.hoverToReveal ? (dockRoot.implicitHeight - Config.options.dock.hoverRegionHeight) : (dockRoot.implicitHeight + 1)
horizontalCenter: parent.horizontalCenter
}
implicitWidth: dockHoverRegion.implicitWidth + Appearance.sizes.elevationMargin * 2
hoverEnabled: true
onContainsMouseChanged: root.notePointerAtDock(containsMouse)
Behavior on anchors.topMargin {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
Item {
id: dockHoverRegion
anchors.fill: parent
implicitWidth: dockBackground.implicitWidth
Item { // Wrapper for the dock background
id: dockBackground
anchors {
// Reserve the rail: bottom-anchor short of the
// window's bottom so the dock never covers it.
bottom: parent.bottom
bottomMargin: dockRoot.railVisualHeight
horizontalCenter: parent.horizontalCenter
}
Behavior on anchors.bottomMargin {
animation: Appearance.animation.elementMove.numberAnimation.createObject(this)
}
implicitWidth: dockRow.implicitWidth + 5 * 2
height: dockRoot.visualHeight + Appearance.sizes.elevationMargin
StyledRectangularShadow {
target: dockVisualBackground
}
Rectangle { // The real rectangle that is visible
id: dockVisualBackground
property real margin: Appearance.sizes.elevationMargin
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.bottomMargin: Appearance.sizes.hyprlandGapsOut
height: dockRoot.visualHeight
color: Appearance.colors.colLayer0
border.width: 1
border.color: Appearance.colors.colLayer0Border
radius: Appearance.rounding.large
}
RowLayout {
id: dockRow
// Anchored to the visible bar. Split top/bottom
// so the icons ride UP inside the bar instead of
// drooping out the bottom (uniform margins left
// them low; less top + more bottom lifts them).
anchors.fill: dockVisualBackground
anchors.topMargin: 0
anchors.bottomMargin: 8
// The background is built 2*padding wider than the
// row (dockBackground.implicitWidth); inset the row
// so that width actually becomes side padding —
// without it the first icon sat ON the rounded corner.
anchors.leftMargin: padding
anchors.rightMargin: padding
spacing: 3
property real padding: 5
DockApps {
id: dockApps
buttonPadding: dockRow.padding
// Keep the whole dock on-screen: the app list
// may take at most what's left after the fixed
// separator + overview button + paddings. Past
// that it scrolls horizontally.
maxWidth: dockRoot.width
- Appearance.sizes.elevationMargin * 2
- dockSeparator.implicitWidth
- overviewButton.implicitWidth
- dockRow.spacing * 2 - 5 * 2
onRequestDockShowChanged: root.previewShowing = requestDockShow
}
DockSeparator {
id: dockSeparator
}
DockButton {
id: overviewButton
Layout.fillHeight: true
onClicked: GlobalStates.overviewOpen = !GlobalStates.overviewOpen
topInset: Appearance.sizes.hyprlandGapsOut + dockRow.padding
bottomInset: Appearance.sizes.hyprlandGapsOut + dockRow.padding
contentItem: MaterialSymbol {
anchors.fill: parent
horizontalAlignment: Text.AlignHCenter
font.pixelSize: parent.width / 2
text: "apps"
color: Appearance.colors.colOnLayer0
}
}
}
}
}
}
}
}
}

View file

@ -0,0 +1,445 @@
import qs
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
import Qt5Compat.GraphicalEffects
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Widgets
DockButton {
id: root
property var appToplevel
property var appListRoot
property int modelIndex: -1 // set by the delegate Loader (parent.index)
property int lastFocused: -1
property real iconSize: Config.options?.dock.iconSize ?? 44
property real countDotWidth: 10
property real countDotHeight: 4
property bool appIsActive: appToplevel.toplevels.find(t => (t.activated == true)) !== undefined
readonly property bool isSeparator: appToplevel.appId === "SEPARATOR"
// Suffix-tolerant resolve: a bare heuristicLookup returns null for app_ids
// that carry an instance suffix (Firefox reports "firefox-default"), and a
// null entry makes tapping a pinned-but-not-running icon a silent no-op.
property var desktopEntry: AppSearch.resolveEntry(appToplevel.appId)
enabled: !isSeparator
implicitWidth: isSeparator ? 1 : implicitHeight - topInset - bottomInset
Connections {
target: DesktopEntries
function onApplicationsChanged() {
root.desktopEntry = AppSearch.resolveEntry(appToplevel.appId);
}
}
// --- Insertion gap indicator (drag-to-reorder) ----------------------
// A thin vertical line at the LEFT edge of this delegate when it is
// the current reorder target. Sits above the button content so it's
// always visible during a drag.
Rectangle {
visible: appListRoot.dragInsertIndex >= 0
&& root.modelIndex === appListRoot.dragInsertIndex
anchors.left: parent.left
anchors.leftMargin: -1 // straddle the ListView spacing
anchors.verticalCenter: parent.verticalCenter
width: 2
height: parent.height * 0.65
radius: 1
color: Appearance.colors.colPrimary
z: 10
opacity: visible ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 80 } }
}
// --- Drag-to-combine (Tier 1) --------------------------------------
// Pattern copied from this codebase's OverviewWidget.qml (drag a window
// onto a workspace): a MouseArea with drag.target moves a ghost Item;
// Drag.active/source/hotSpot are set imperatively on press; the release
// reads which DropArea the drag ended over (appListRoot.dragTargetAppId,
// set by the DropArea's onEntered) and commits. NOT Qt's DropArea.onDropped
// — the anchored-proxy version never moved, so no DropArea ever saw it.
// The ghost therefore carries NO anchors: Qt refuses to move an anchored
// drag.target, and an unmoving ghost generates no enter events — the
// exact bug the line above describes. Position is set on press instead.
// GNOME's dwell rule (500ms before a hover counts as "combine") gates the
// highlight so a quick brush-past doesn't read as an intended stack.
property bool formingStack: false // this icon is the current drop target, dwell fired
readonly property string dragAppId: appToplevel.appId
// The ghost that actually moves under the finger (real Drag source).
Item {
id: dragGhost
width: root.iconSize
height: root.iconSize
visible: dragMouse.dragging
Drag.hotSpot.x: width / 2
Drag.hotSpot.y: height / 2
z: 100
IconImage {
anchors.fill: parent
source: Quickshell.iconPath(AppSearch.guessIcon(root.appToplevel.appId), "image-missing")
opacity: 0.9
}
}
// Single interaction surface (mirrors OverviewWidget's dragArea owning
// all gestures): tap -> activate/launch, long-press -> menu, drag ->
// combine. Sits above the visual button; the button's own onClicked is
// routed here via root.activate() so nothing double-fires.
// Hold/drag arbitration follows VLC's DelegateTouchTapHandler rule: a
// long-press may open the menu, but the moment movement turns into a
// drag the menu is cancelled and the drag owns the gesture (Android
// home-screen semantics).
MouseArea {
id: dragMouse
anchors.fill: parent
enabled: !root.isSeparator
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
hoverEnabled: true
property bool dragging: false
drag.target: dragGhost
drag.threshold: 12
pressAndHoldInterval: 450
onPressed: (mouse) => {
// Start the (not yet visible) ghost centered under the finger.
// drag.target then moves it 1:1 with the pointer, so its center
// hotspot tracks the finger across sibling DropAreas.
dragGhost.x = mouse.x - dragGhost.width / 2
dragGhost.y = mouse.y - dragGhost.height / 2
}
drag.onActiveChanged: {
if (drag.active) {
dragging = true
dragGhost.Drag.source = root
dragGhost.Drag.active = true
// Structural dock edits (manifest pin/stack mutations) are
// refused while a drag is in flight — see DockManifest.
GlobalStates.dockDragInProgress = true
// Store source index for reorder computation.
appListRoot.dragSourceIndex = root.modelIndex
appListRoot.dragInsertIndex = -1
// Hold-then-move means drag, not menu: a menu that opened on
// the hold gets dismissed the moment real movement starts.
root.menuOpen = false
}
}
onPressAndHold: (mouse) => {
if (!dragging && mouse.button === Qt.LeftButton) root.menuOpen = true
}
onReleased: (mouse) => {
if (dragging) {
const target = appListRoot.dragTargetAppId
const targetStack = appListRoot.dragTargetStackId
dragGhost.Drag.active = false
dragging = false
GlobalStates.dockDragInProgress = false
if (target && target.toLowerCase() !== root.appToplevel.appId.toLowerCase()) {
// Dwell fired → combine into stack (existing path).
TaskbarApps.combineIntoStack(target, root.appToplevel.appId, targetStack)
} else if (appListRoot.dragInsertIndex >= 0
&& appListRoot.dragInsertIndex !== appListRoot.dragSourceIndex) {
// No dwell, valid insertion gap → reorder pinned app.
const stacksCount = TaskbarApps.stacksList().length
const targetPinnedIdx = appListRoot.dragInsertIndex - stacksCount
if (targetPinnedIdx >= 0) {
TaskbarApps.reorderPinned(root.appToplevel.appId, targetPinnedIdx)
}
}
appListRoot.dragTargetAppId = ""
appListRoot.dragTargetStackId = ""
appListRoot.dragInsertIndex = -1
appListRoot.dragSourceIndex = -1
return
}
if (root.menuOpen) return // long-press already handled it
if (mouse.button === Qt.MiddleButton) {
root.desktopEntry?.execute()
} else {
root.activate()
}
}
onCanceled: {
dragging = false
dragGhost.Drag.active = false
GlobalStates.dockDragInProgress = false
appListRoot.dragInsertIndex = -1
appListRoot.dragSourceIndex = -1
}
}
DropArea {
anchors.fill: parent
onEntered: (drag) => {
if (drag.source === root) return
dwellTimer.restart()
// --- Reorder: compute insertion gap position ---------------
// Only pinned apps (not stacks, not running apps) participate
// in reordering. The gap appears BEFORE the target if dragging
// left (source > target), AFTER if dragging right (source <
// target) — matching the physical intuition of sliding an icon
// into a new slot.
const srcIdx = appListRoot.dragSourceIndex
const tgtIdx = root.modelIndex
const stacksCount = TaskbarApps.stacksList().length
const pinnedCount = Config.options?.dock.pinnedApps?.length ?? 0
if (srcIdx >= stacksCount && srcIdx < stacksCount + pinnedCount
&& tgtIdx >= stacksCount && tgtIdx < stacksCount + pinnedCount
&& srcIdx !== tgtIdx
&& root.appToplevel.pinned && !root.appToplevel.isStack) {
appListRoot.dragInsertIndex = srcIdx < tgtIdx ? tgtIdx + 1 : tgtIdx
}
}
onExited: {
dwellTimer.stop()
root.formingStack = false
if (appListRoot.dragTargetAppId === root.appToplevel.appId) {
appListRoot.dragTargetAppId = ""
appListRoot.dragTargetStackId = ""
}
}
Timer {
id: dwellTimer
interval: Config.options?.dock.dragDwellMs ?? 500 // GNOME's dwell — intent, not accident
onTriggered: {
root.formingStack = true
appListRoot.dragTargetAppId = root.appToplevel.appId
appListRoot.dragTargetStackId = "" // plain app -> new stack
// Dwell = combine intent, not reorder — clear the gap.
appListRoot.dragInsertIndex = -1
}
}
}
// Pre-commit preview: a rounded container fades in behind the icon once
// the dwell fires, so you SEE the stack forming before releasing.
Rectangle {
anchors.fill: parent
anchors.margins: -2
z: -1
radius: Appearance.rounding.normal
visible: opacity > 0
opacity: root.formingStack ? 1 : 0
color: ColorUtils.transparentize(Appearance.colors.colPrimary, 0.55)
border.width: 1
border.color: Appearance.colors.colPrimary
Behavior on opacity { NumberAnimation { duration: 120 } }
scale: root.formingStack ? 1.08 : 1.0
Behavior on scale { NumberAnimation { duration: 120; easing.type: Easing.OutBack } }
}
Loader {
active: isSeparator
anchors {
fill: parent
topMargin: dockVisualBackground.margin + dockRow.padding + Appearance.rounding.normal
bottomMargin: dockVisualBackground.margin + dockRow.padding + Appearance.rounding.normal
}
sourceComponent: DockSeparator {}
}
Loader {
anchors.fill: parent
active: appToplevel.toplevels.length > 0
sourceComponent: MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.NoButton
onEntered: {
appListRoot.lastHoveredButton = root
appListRoot.buttonHovered = true
lastFocused = appToplevel.toplevels.length - 1
}
onExited: {
if (appListRoot.lastHoveredButton === root) {
appListRoot.buttonHovered = false
}
}
}
}
// Tap behaviour, called by the unified dragMouse handler above.
function activate() {
if (appToplevel.toplevels.length === 0) {
root.desktopEntry?.execute();
return;
}
// 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()
}
middleClickAction: () => {
root.desktopEntry?.execute();
}
altAction: () => {
TaskbarApps.togglePin(appToplevel.appId);
}
// Long-press -> per-app context menu (pin, stack membership, settings
// stub). Opened by dragMouse.onPressAndHold. This is settings-surface
// entry (b); "App settings…" fires the dockSettings IPC stub.
property bool menuOpen: false
property real menuCenterX: 0
onMenuOpenChanged: {
if (menuOpen && QsWindow)
menuCenterX = QsWindow.mapFromItem(root, root.width / 2, 0).x;
}
PopupWindow {
id: ctxMenu
visible: root.menuOpen || ctxContent.opacity > 0
color: "transparent"
anchor {
window: root.QsWindow?.window ?? null
adjustment: PopupAdjustment.None
gravity: Edges.Top | Edges.Right
edges: Edges.Top | Edges.Left
}
implicitWidth: root.QsWindow?.window?.width ?? 1
implicitHeight: ctxColumn.implicitHeight + 18
MouseArea {
anchors.fill: parent
enabled: root.menuOpen
onClicked: root.menuOpen = false // tap-away closes
}
Rectangle {
id: ctxContent
anchors.bottom: parent.bottom
x: root.menuCenterX - width / 2
width: 200
height: ctxColumn.implicitHeight + 12
radius: Appearance.rounding.normal
color: Appearance.m3colors.m3surfaceContainer
border.width: 1
border.color: Appearance.colors.colLayer0Border
opacity: root.menuOpen ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 100 } }
ColumnLayout {
id: ctxColumn
anchors.fill: parent
anchors.margins: 6
spacing: 2
component MenuItem: RippleButton {
Layout.fillWidth: true
implicitHeight: 34
property string label: ""
contentItem: StyledText {
anchors.fill: parent
anchors.leftMargin: 10
verticalAlignment: Text.AlignVCenter
text: parent.label
color: Appearance.m3colors.m3onSurface
font.pixelSize: Appearance.font.pixelSize.small
}
}
// Stack membership is drag-only now: drag onto an icon/stack
// to combine, drag a member off the arc to unstack. The menu
// keeps only what drag can't express.
MenuItem {
label: TaskbarApps.isPinned(root.appToplevel.appId) ? "Unpin" : "Pin to dock"
onClicked: { TaskbarApps.togglePin(root.appToplevel.appId); root.menuOpen = false }
}
MenuItem {
label: "App settings…"
onClicked: {
// Stub: hand off to the future souveraine-settings app.
Quickshell.execDetached(["qs", "-c", "ii", "ipc", "call",
"dockSettings", "openApp", root.appToplevel.appId]);
root.menuOpen = false;
}
}
}
}
}
contentItem: Loader {
active: !isSeparator
// The Loader stretches its loaded item to the Loader's own size, so
// width/height/centerIn declared ON the loaded item are overridden —
// that stretched the block to the content area and the top-anchored
// icon rode the top of the bar. The stretched item is a passthrough;
// the real fixed-size block centers INSIDE it (same structure as
// DockStack — keep them identical).
sourceComponent: Item {
Item {
anchors.centerIn: parent
// Center the icon+dots block. Reserve only HALF the dot strip
// below the icon: reserving the full strip pushed the block's
// center below the icon's center, so centerIn left extra gap
// above the icon (icons read a few px high). Half-reserve splits
// the difference so the glyph sits visually centered.
width: root.iconSize
height: root.iconSize + (root.countDotHeight + 2) / 2
Loader {
id: iconImageLoader
anchors {
left: parent.left
right: parent.right
top: parent.top
}
height: root.iconSize
active: !root.isSeparator
sourceComponent: IconImage {
source: Quickshell.iconPath(AppSearch.guessIcon(appToplevel.appId), "image-missing")
implicitSize: root.iconSize
}
}
Loader {
active: Config.options.dock.monochromeIcons
anchors.fill: iconImageLoader
sourceComponent: Item {
Desaturate {
id: desaturatedIcon
visible: false // There's already color overlay
anchors.fill: parent
source: iconImageLoader
desaturation: 0.8
}
ColorOverlay {
anchors.fill: desaturatedIcon
source: desaturatedIcon
color: ColorUtils.transparentize(Appearance.colors.colPrimary, 0.9)
}
}
}
RowLayout {
spacing: 3
anchors {
top: iconImageLoader.bottom
topMargin: 2
horizontalCenter: parent.horizontalCenter
}
Repeater {
model: Math.min(appToplevel.toplevels.length, 3)
delegate: Rectangle {
required property int index
radius: Appearance.rounding.full
implicitWidth: (appToplevel.toplevels.length <= 3) ?
root.countDotWidth : root.countDotHeight // Circles when too many
implicitHeight: root.countDotHeight
color: appIsActive ? Appearance.colors.colPrimary : ColorUtils.transparentize(Appearance.colors.colOnLayer0, 0.4)
}
}
}
}
}
}
}

View file

@ -0,0 +1,270 @@
pragma ComponentBehavior: Bound
import Qt5Compat.GraphicalEffects
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Widgets
import Quickshell.Wayland
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
Item {
id: root
property real maxWindowPreviewHeight: 200
property real maxWindowPreviewWidth: 300
property real windowControlsHeight: 30
property real buttonPadding: 5
property Item lastHoveredButton: null
property bool buttonHovered: false
property bool requestDockShow: previewPopup.show
// Drag-to-combine target, shared across all dock buttons/stacks. A
// DropArea's 500ms dwell sets these; the dragged button reads them on
// release to decide the combine. dragTargetStackId non-empty means the
// drop target is an existing stack (add into it); empty means a plain
// app (make a new stack).
property string dragTargetAppId: ""
property string dragTargetStackId: ""
// Cap from Dock.qml: the widest the app list may grow before the dock
// would outgrow the screen. Past it the ListView scrolls horizontally.
property real maxWidth: -1
// Drag-to-reorder state, shared across all DockAppButton delegates.
// dragSourceIndex: model index of the button currently being dragged.
// dragInsertIndex: model index BEFORE which the insertion gap shows.
// -1 means no valid reorder target. Set by DropArea.onEntered when
// the drag hovers a pinned app; cleared on release / cancel / dwell.
property int dragSourceIndex: -1
property int dragInsertIndex: -1
Layout.fillHeight: true
// No top-only margin: it shoved the whole icon list DOWN inside the bar
// with nothing balancing it — the actual cause of icons drooping below
// the dock. fillHeight + the dockRow centering handle vertical placement.
implicitWidth: maxWidth >= 0 ? Math.min(listView.contentWidth, maxWidth) : listView.contentWidth
Behavior on implicitWidth {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
function popupCenterXForButton(button) {
if (!button || !root.QsWindow)
return 0;
return root.QsWindow.mapFromItem(button, button.width / 2, 0).x;
}
StyledListView {
id: listView
spacing: 2
orientation: ListView.Horizontal
anchors.fill: parent
clip: true
// Only flickable when the list actually overflows, so touch drags on
// the buttons (drag-to-combine) aren't stolen while everything fits.
interactive: contentWidth > width
boundsBehavior: Flickable.StopAtBounds
model: ScriptModel {
objectProp: "appId"
values: TaskbarApps.apps
}
delegate: Loader {
required property var modelData
required property int index
// Fan-out stack entries render as DockStack; everything else
// (pinned apps, running windows, separators) as DockAppButton.
sourceComponent: modelData.isStack ? stackComp : appComp
Component {
id: appComp
DockAppButton {
appToplevel: modelData
appListRoot: root
modelIndex: parent.index
topInset: Appearance.sizes.hyprlandGapsOut + root.buttonPadding
bottomInset: Appearance.sizes.hyprlandGapsOut + root.buttonPadding
}
}
Component {
id: stackComp
DockStack {
appToplevel: modelData
appListRoot: root
topInset: Appearance.sizes.hyprlandGapsOut + root.buttonPadding
bottomInset: Appearance.sizes.hyprlandGapsOut + root.buttonPadding
}
}
}
}
PopupWindow {
id: previewPopup
property var appTopLevel: root.lastHoveredButton?.appToplevel
property bool shouldShow: (popupMouseArea.containsMouse || root.buttonHovered) && appTopLevel && appTopLevel.toplevels && appTopLevel.toplevels.length > 0
property bool show: false
property real cachedCenterX: 0
Connections {
target: root
function onLastHoveredButtonChanged() {
if (root.lastHoveredButton && root.QsWindow)
previewPopup.cachedCenterX = root.popupCenterXForButton(root.lastHoveredButton);
}
function onButtonHoveredChanged() {
if (root.buttonHovered && root.lastHoveredButton && root.QsWindow)
previewPopup.cachedCenterX = root.popupCenterXForButton(root.lastHoveredButton);
updateTimer.restart();
}
}
onShouldShowChanged: {
updateTimer.restart();
}
Timer {
id: updateTimer
interval: 100
onTriggered: {
previewPopup.show = previewPopup.shouldShow;
}
}
anchor {
window: root.QsWindow.window
adjustment: PopupAdjustment.None
gravity: Edges.Top | Edges.Right
edges: Edges.Top | Edges.Left
}
visible: popupBackground.opacity > 0
color: "transparent"
implicitWidth: root.QsWindow.window?.width ?? 1
implicitHeight: popupMouseArea.implicitHeight + root.windowControlsHeight + Appearance.sizes.elevationMargin * 2
MouseArea {
id: popupMouseArea
anchors.bottom: parent.bottom
implicitWidth: popupBackground.implicitWidth + Appearance.sizes.elevationMargin * 2
implicitHeight: root.maxWindowPreviewHeight + root.windowControlsHeight + Appearance.sizes.elevationMargin * 2
hoverEnabled: true
x: previewPopup.cachedCenterX - width / 2
StyledRectangularShadow {
target: popupBackground
opacity: previewPopup.show ? 1 : 0
visible: opacity > 0
Behavior on opacity {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
}
Rectangle {
id: popupBackground
property real padding: 5
opacity: previewPopup.show ? 1 : 0
visible: opacity > 0
Behavior on opacity {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
clip: true
color: Appearance.m3colors.m3surfaceContainer
radius: Appearance.rounding.normal
anchors.bottom: parent.bottom
anchors.bottomMargin: Appearance.sizes.elevationMargin
anchors.horizontalCenter: parent.horizontalCenter
implicitHeight: previewRowLayout.implicitHeight + padding * 2
implicitWidth: previewRowLayout.implicitWidth + padding * 2
Behavior on implicitWidth {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
Behavior on implicitHeight {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
RowLayout {
id: previewRowLayout
anchors.centerIn: parent
Repeater {
model: ScriptModel {
values: previewPopup.appTopLevel?.toplevels ?? []
}
RippleButton {
id: windowButton
Layout.fillHeight: true
required property var modelData
padding: 0
middleClickAction: () => {
windowButton.modelData?.close();
}
onClicked: {
windowButton.modelData?.activate();
}
contentItem: ColumnLayout {
implicitWidth: screencopyView.implicitWidth
implicitHeight: screencopyView.implicitHeight
ButtonGroup {
contentWidth: parent.width - anchors.margins * 2
StyledText {
Layout.margins: 5
Layout.fillWidth: true
font.pixelSize: Appearance.font.pixelSize.small
text: windowButton.modelData?.title
elide: Text.ElideRight
color: Appearance.m3colors.m3onSurface
}
GroupButton {
id: closeButton
colBackground: ColorUtils.transparentize(Appearance.colors.colSurfaceContainer)
baseWidth: root.windowControlsHeight
baseHeight: root.windowControlsHeight
buttonRadius: Appearance.rounding.full
contentItem: MaterialSymbol {
anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
text: "close"
iconSize: Appearance.font.pixelSize.normal
color: Appearance.m3colors.m3onSurface
}
onClicked: {
windowButton.modelData?.close();
}
}
}
Item {
Layout.fillWidth: true
Layout.fillHeight: true
implicitHeight: screencopyView.height
implicitWidth: screencopyView.width
ScreencopyView {
id: screencopyView
anchors.centerIn: parent
captureSource: windowButton.modelData
live: true
paintCursor: true
constraintSize: Qt.size(root.maxWindowPreviewWidth, root.maxWindowPreviewHeight)
layer.enabled: true
layer.effect: OpacityMask {
maskSource: Rectangle {
width: screencopyView.width
height: screencopyView.height
radius: Appearance.rounding.small
}
}
}
}
}
}
}
}
}
}
}
}

View file

@ -0,0 +1,19 @@
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Layouts
RippleButton {
Layout.fillHeight: true
// No top-only Layout.topMargin: it shoved every button DOWN with nothing
// balancing it, so icons drooped below the bar. The dockRow already
// centers the row in the visible bar; let fillHeight do the centering.
implicitWidth: implicitHeight - topInset - bottomInset
buttonRadius: Appearance.rounding.normal
// 56, not 44. The dock read small on a 540px-wide panel — Casey,
// 2026-08-07: "the whole dock is a bit small, it should scale a bit."
// This is the one number the row's height follows, so the icons and the
// stack box scale with it rather than each being tuned apart.
background.implicitHeight: Config.options?.dock.buttonSize ?? 56
}

View file

@ -0,0 +1,150 @@
// 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()
};
}
// The dock's computed visibility, bound by Dock.qml. Read, never derived:
// the ladder this used to keep could not see the home-zone rule and so
// answered "hidden" on the one zone where the dock is always furniture.
property string visibility: "hidden"
// Visible state as a stable string the agent can reason about. The osk and
// lock strings are the *reason* a hidden dock is hidden, not a second
// opinion about whether it is.
function dockState() {
if (GlobalStates.screenLocked) return "locked";
if (root.visibility === "hidden" && GlobalStates.oskOpen) return "suppressed-by-osk";
return root.visibility;
}
// --- 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 GlobalStates.screenLocked || root.visibility === "hidden";
}
}

View file

@ -0,0 +1,11 @@
import qs.modules.common
import QtQuick
import QtQuick.Layouts
Rectangle {
Layout.topMargin: Appearance.sizes.elevationMargin + dockRow.padding + Appearance.rounding.normal
Layout.bottomMargin: Appearance.sizes.hyprlandGapsOut + dockRow.padding + Appearance.rounding.normal
Layout.fillHeight: true
implicitWidth: 1
color: Appearance.colors.colOutlineVariant
}

View file

@ -0,0 +1,413 @@
// DockStack — macOS-Dock "Fan" mode for a stack of apps (2026-07-11).
//
// Collapsed: a single stacked-cards icon (the topmost member) with a count
// dot, like a pinned app. Tap or long-press to expand.
//
// Expanded: member icons arc UP off the dock along a polar curve. Angle
// sweeps ~90deg (straight up) to ~55deg across N members; radius grows per
// item so they don't overlap. Slide a thumb up the arc — the icon under the
// finger scales/highlights; release launches it. Release off the arc (or a
// plain tap on empty space) collapses without launching.
//
// The arc lives in its own PopupWindow (like previewPopup in DockApps.qml)
// so it can draw ABOVE the dock's own bounds. Icons animate x/y from the
// collapsed origin out to their arc slots, driven by one `expanded` bool.
pragma ComponentBehavior: Bound
import qs.services
import qs.modules.common
import qs.modules.common.functions
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Widgets
DockButton {
id: root
property var appToplevel // the STACK entry (isStack === true)
property var appListRoot
property real iconSize: Config.options?.dock.iconSize ?? 44
property real countDotWidth: 10
property real countDotHeight: 4
// Arc geometry.
readonly property real arcRadiusBase: 46 // first item's distance up
readonly property real arcRadiusStep: 30 // extra distance per item
readonly property real arcAngleStart: 90 // degrees, straight up
readonly property real arcAngleEnd: 55 // degrees, last item leans out
readonly property var members: appToplevel?.members ?? []
property bool expanded: false
// Index highlighted by the current drag, or -1 for none.
property int highlightedIndex: -1
// Polar slot for member i (0-based), as an offset from the collapsed
// icon centre. y is negative = upward. Single-member stacks go straight up.
function arcAngleFor(i) {
if (members.length <= 1) return root.arcAngleStart;
const t = i / (members.length - 1);
return root.arcAngleStart + t * (root.arcAngleEnd - root.arcAngleStart);
}
function arcRadiusFor(i) {
return root.arcRadiusBase + i * root.arcRadiusStep;
}
function slotX(i) {
const a = arcAngleFor(i) * Math.PI / 180;
return arcRadiusFor(i) * Math.cos(a);
}
function slotY(i) {
const a = arcAngleFor(i) * Math.PI / 180;
return -arcRadiusFor(i) * Math.sin(a);
}
function collapse() {
root.expanded = false;
root.highlightedIndex = -1;
}
// Last open window for a member appId, or null. The stack entry carries
// its members' toplevels (TaskbarApps routes them here instead of
// making standalone icons).
function runningToplevelFor(appId) {
const tls = root.appToplevel?.toplevels ?? [];
const low = (appId ?? "").toLowerCase();
let last = null;
for (let k = 0; k < tls.length; k++) {
if ((tls[k].appId ?? "").toLowerCase() === low) last = tls[k];
}
return last;
}
// Tap a member: focus its open window if it has one; launch otherwise.
function launchMember(i) {
if (i < 0 || i >= members.length) return;
const running = runningToplevelFor(members[i]);
if (running) {
running.activate();
return;
}
// Suffix-tolerant — see DockAppButton.desktopEntry.
const entry = AppSearch.resolveEntry(members[i]);
entry?.execute();
}
implicitWidth: implicitHeight - topInset - bottomInset
// --- Drop target: drag a loose app onto this stack to add it ---------
// Same dwell rule as DockAppButton (500ms of hover = intent). Sets the
// shared drag state with dragTargetStackId non-empty so the release in
// DockAppButton takes combineIntoStack's add-into-existing branch. The
// "STACK:" sentinel keeps dragTargetAppId from ever colliding with a
// plain appId.
property bool formingStack: false
DropArea {
anchors.fill: parent
onEntered: (drag) => {
dwellTimer.restart()
}
onExited: {
dwellTimer.stop()
root.formingStack = false
if (appListRoot.dragTargetStackId === root.appToplevel.appId) {
appListRoot.dragTargetAppId = ""
appListRoot.dragTargetStackId = ""
}
}
Timer {
id: dwellTimer
interval: Config.options?.dock.dragDwellMs ?? 500
onTriggered: {
root.formingStack = true
appListRoot.dragTargetAppId = "STACK:" + root.appToplevel.appId
appListRoot.dragTargetStackId = root.appToplevel.appId
}
}
}
// Pre-commit preview, mirrors DockAppButton: the stack lights up once
// the dwell fires so you SEE it will absorb the drop.
Rectangle {
anchors.fill: parent
anchors.margins: -2
z: -1
radius: Appearance.rounding.normal
visible: opacity > 0
opacity: root.formingStack ? 1 : 0
color: ColorUtils.transparentize(Appearance.colors.colPrimary, 0.55)
border.width: 1
border.color: Appearance.colors.colPrimary
Behavior on opacity { NumberAnimation { duration: 120 } }
scale: root.formingStack ? 1.08 : 1.0
Behavior on scale { NumberAnimation { duration: 120; easing.type: Easing.OutBack } }
}
// Collapsed icon: GNOME-folder style — a rounded plate holding a mini
// grid of the member icons (up to 4), so it reads as "a group of these
// apps" at a glance instead of a mystery blob. Count dots underneath.
contentItem: Item {
// Same block pattern as DockAppButton: icon+dots block centered as a
// unit, reserving half the dot strip, so the stack rides the bar
// exactly like a plain app icon instead of being placed by its own
// rules.
Item {
anchors.centerIn: parent
width: root.iconSize
height: root.iconSize + (root.countDotHeight + 2) / 2
Item {
id: collapsedStack
anchors {
left: parent.left
right: parent.right
top: parent.top
}
height: root.iconSize
Rectangle {
anchors.fill: parent
radius: Appearance.rounding.small
color: ColorUtils.transparentize(Appearance.m3colors.m3surfaceContainer, 0.15)
border.width: 1
border.color: ColorUtils.transparentize(Appearance.colors.colOnLayer0, 0.8)
}
Grid {
anchors.centerIn: parent
columns: 2
spacing: 3
Repeater {
model: Math.min(root.members.length, 4)
delegate: IconImage {
required property int index
// The members were 12px inside a 35px box — a
// stack you could not read at a glance, which is
// the whole job of the collapsed form. 11 was
// border, padding and grid spacing all taken off
// the icon; only the spacing genuinely has to be.
implicitSize: (root.iconSize - 7) / 2
source: Quickshell.iconPath(AppSearch.guessIcon(root.members[index] ?? ""), "image-missing")
}
}
}
}
// Count dot(s) under the icon — same pattern as DockAppButton.
RowLayout {
spacing: 3
anchors {
top: collapsedStack.bottom
topMargin: 2
horizontalCenter: parent.horizontalCenter
}
Repeater {
model: Math.min(root.members.length, 3)
delegate: Rectangle {
required property int index
radius: Appearance.rounding.full
implicitWidth: (root.members.length <= 3) ? root.countDotWidth : root.countDotHeight
implicitHeight: root.countDotHeight
color: (root.appToplevel?.toplevels?.length ?? 0) > 0
? Appearance.colors.colPrimary
: ColorUtils.transparentize(Appearance.colors.colOnLayer0, 0.4)
}
}
}
}
}
// Tap expands; tapping again (while expanded) collapses.
onClicked: {
if (root.expanded) root.collapse();
else root.expanded = true;
}
// Horizontal centre of this button, in its window's coordinates —
// recomputed when the popup opens (same trick as previewPopup).
property real cachedCenterX: 0
onExpandedChanged: {
if (expanded && QsWindow)
cachedCenterX = QsWindow.mapFromItem(root, root.width / 2, 0).x;
}
// The arc: a full-width PopupWindow anchored above the dock (same anchor
// pattern as previewPopup — gravity Top on the dock window). The arc
// MouseArea floats at cachedCenterX so it sits over this stack icon.
PopupWindow {
id: arcPopup
visible: root.expanded || arcContent.opacity > 0
color: "transparent"
// Reach = furthest member's radius + icon + margin.
readonly property real reach: root.arcRadiusFor(Math.max(root.members.length - 1, 0)) + root.iconSize + 24
anchor {
window: root.QsWindow?.window ?? null
adjustment: PopupAdjustment.None
gravity: Edges.Top | Edges.Right
edges: Edges.Top | Edges.Left
}
implicitWidth: root.QsWindow?.window?.width ?? 1
implicitHeight: reach
MouseArea {
id: arcArea
anchors.bottom: parent.bottom
implicitWidth: arcPopup.reach * 2
implicitHeight: arcPopup.reach
x: root.cachedCenterX - width / 2
enabled: root.expanded
hoverEnabled: false
// Collapsed-icon origin inside this MouseArea = bottom centre.
readonly property real originX: width / 2
readonly property real originY: height - root.iconSize / 2
function indexUnder(px, py) {
var best = -1;
var bestD = 44; // px pick radius
for (var i = 0; i < root.members.length; i++) {
const cx = originX + root.slotX(i);
const cy = originY + root.slotY(i);
const d = Math.hypot(px - cx, py - cy);
if (d < bestD) { bestD = d; best = i; }
}
return best;
}
// --- Member drag: reorder along the arc / drag out to unstack.
// Press-and-hold a member lifts it; sliding then moves it between
// slots (siblings part to make room — previewOrder is the live
// slot assignment). Release on a slot commits the new order;
// release off the arc entirely unstacks the member back to a
// standalone pinned app. Quick press-release still launches.
property int dragMemberIndex: -1 // members[] index being dragged
property var previewOrder: [] // member index per slot, during drag
property bool unstackPending: false
property real dragPX: 0
property real dragPY: 0
pressAndHoldInterval: 350
onPressAndHold: (mouse) => {
const i = indexUnder(mouse.x, mouse.y);
if (i < 0) return;
dragMemberIndex = i;
previewOrder = Array.from({length: root.members.length}, (_, k) => k);
dragPX = mouse.x; dragPY = mouse.y;
unstackPending = false;
root.highlightedIndex = -1;
}
onPositionChanged: (mouse) => {
if (dragMemberIndex >= 0) {
dragPX = mouse.x; dragPY = mouse.y;
const s = indexUnder(mouse.x, mouse.y);
unstackPending = (s < 0);
if (s >= 0) {
const cur = previewOrder.indexOf(dragMemberIndex);
if (s !== cur) {
var po = previewOrder.slice();
po.splice(cur, 1);
po.splice(s, 0, dragMemberIndex);
previewOrder = po;
}
}
return;
}
root.highlightedIndex = indexUnder(mouse.x, mouse.y);
}
onReleased: (mouse) => {
if (dragMemberIndex >= 0) {
const stackId = root.appToplevel.appId;
if (unstackPending) {
TaskbarApps.unstackMember(stackId, root.members[dragMemberIndex]);
} else if (previewOrder.some((m, s) => m !== s)) {
TaskbarApps.setStackOrder(stackId, previewOrder.map(k => root.members[k]));
}
dragMemberIndex = -1;
unstackPending = false;
return; // stay expanded — show the result
}
const i = indexUnder(mouse.x, mouse.y);
if (i >= 0) root.launchMember(i);
root.collapse();
}
onCanceled: {
dragMemberIndex = -1;
unstackPending = false;
root.collapse();
}
// A plain tap on empty popup space collapses.
onClicked: (mouse) => {
if (indexUnder(mouse.x, mouse.y) < 0) root.collapse();
}
Item {
id: arcContent
anchors.fill: parent
opacity: root.expanded ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 120 } }
Repeater {
model: root.members.length
delegate: Item {
id: memberIcon
required property int index
width: root.iconSize
height: root.iconSize
readonly property bool highlighted: root.highlightedIndex === index
readonly property bool beingDragged: arcArea.dragMemberIndex === index
// Slot this member occupies: its own index normally,
// its previewOrder position while a drag is live.
readonly property int slotIndex: {
if (arcArea.dragMemberIndex < 0) return index;
const s = arcArea.previewOrder.indexOf(index);
return s < 0 ? index : s;
}
// Animate from the collapsed origin out to the slot;
// a dragged member tracks the finger instead.
x: (root.expanded
? (beingDragged ? arcArea.dragPX
: arcArea.originX + root.slotX(slotIndex))
: arcArea.originX) - width / 2
y: (root.expanded
? (beingDragged ? arcArea.dragPY
: arcArea.originY + root.slotY(slotIndex))
: arcArea.originY) - height / 2
scale: (highlighted || beingDragged) ? 1.25 : 1.0
z: beingDragged ? 2 : (highlighted ? 1 : 0)
// Off-arc = release will unstack; telegraph it.
opacity: (beingDragged && arcArea.unstackPending) ? 0.55 : 1
Behavior on x { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } }
Behavior on y { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } }
Behavior on scale { NumberAnimation { duration: 90 } }
Rectangle {
anchors.fill: parent
anchors.margins: -4
radius: Appearance.rounding.normal
color: memberIcon.highlighted
? ColorUtils.transparentize(Appearance.colors.colPrimary, 0.6)
: "transparent"
}
IconImage {
anchors.fill: parent
source: Quickshell.iconPath(AppSearch.guessIcon(root.members[memberIcon.index] ?? ""), "image-missing")
}
// Running dot: this member has an open window.
Rectangle {
visible: root.runningToplevelFor(root.members[memberIcon.index]) !== null
anchors {
top: parent.bottom
topMargin: 1
horizontalCenter: parent.horizontalCenter
}
width: 8; height: 3
radius: Appearance.rounding.full
color: Appearance.colors.colPrimary
}
}
}
}
}
}
}

View file

@ -0,0 +1,7 @@
Dock 1.0 Dock.qml
DockAppButton 1.0 DockAppButton.qml
DockApps 1.0 DockApps.qml
DockButton 1.0 DockButton.qml
DockManifest 1.0 DockManifest.qml
DockSeparator 1.0 DockSeparator.qml
DockStack 1.0 DockStack.qml

View file

@ -0,0 +1,217 @@
// Souveraine patch to ii's stock Lock.qml.
//
// Selects the lock surface by config: lock.touchKeypad picks the
// TouchLockSurface (PIN pad for the phone's touch panel) instead of the
// stock keyboard-driven LockSurface. Everything else is unchanged stock ii.
pragma ComponentBehavior: Bound
import qs
import qs.services
import qs.modules.common
import qs.modules.common.functions
import qs.modules.common.panels.lock
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
LockScreen {
id: root
// Monitor name -> workspace id to restore on unlock (set when locking)
property var savedWorkspaces: ({})
// Session arbiter surface. Lives here beside the lock because the lock IS
// the session gate on this device — LockScreen already owns WlSessionLock,
// and the session verbs (suspend/poweroff/inhibit) are the other half of
// the same lifecycle. The logic lives in the Session singleton
// (modules/common/functions/Session.qml); this is only the IPC face.
//
// Every mutating method returns {ok, reason?} rather than throwing, so a
// refusal is information the agent learns from — same contract as dock.*,
// shell.* and apps.*. `lock` (target: "lock") stays as it was: it is the
// surface's own activate/focus pair, not the session lifecycle.
//
// `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
// maps a `var` return to VOID, discarding the value with no error. A
// method declared `: var` therefore looks correct in QML, registers as
// `(): void`, and silently returns nothing to the caller. JSON-over-string
// is the only way a structured {ok, reason} result actually crosses.
IpcHandler {
target: "session"
// Read-only projection: lock state (read through from the compositor,
// never a cached bool), idle inhibitors with their reasons, and what
// this machine can actually do.
function state(): string {
return JSON.stringify(Session.state());
}
function capabilities(): string {
return JSON.stringify(Session.caps());
}
function lock(): string {
return JSON.stringify(Session.lock());
}
// Refuses by design — unlocking is the credential gate.
function unlock(): string {
return JSON.stringify(Session.unlock());
}
function suspend(): string {
return JSON.stringify(Session.suspend());
}
function hibernate(): string {
return JSON.stringify(Session.hibernate());
}
function poweroff(): string {
return JSON.stringify(Session.poweroff());
}
function reboot(): string {
return JSON.stringify(Session.reboot());
}
function logout(): string {
return JSON.stringify(Session.logout());
}
// Reason is mandatory: an inhibitor nobody can explain is exactly the
// thing that leaves the phone awake in a pocket at 3am.
function inhibit(what: string, reason: string): string {
return JSON.stringify(Session.inhibit(what, reason));
}
function uninhibit(cookie: string): string {
return JSON.stringify(Session.uninhibit(cookie));
}
}
// Preview-only diagnostic ingress. The physical FPC1020 producer publishes
// its root-owned pulse record for LockContext to watch; it cannot use
// generic XF86WakeUp here because the touch controller emits that key too.
// This target lets us exercise the same visual-only path manually. It
// never unlocks, reveals Personal content, or mints step-up. Task 41 owns
// replacing this with an attested, source-specific producer.
IpcHandler {
target: "fingerprint"
function signal(): string {
return JSON.stringify(root.context.noteProvisionalFingerprintPulse());
}
}
Timer {
id: restoreTimer
interval: 150
repeat: false
onTriggered: {
var batch = ""
for (var j = 0; j < Quickshell.screens.length; ++j) {
var monName = Quickshell.screens[j].name
var wsId = root.savedWorkspaces[monName]
if (wsId !== undefined) {
batch += `hyprctl dispatch 'hl.dsp.focus({monitor="${monName}"})'; hyprctl dispatch 'hl.dsp.focus({workspace=${wsId}})';`
}
}
if (batch.length > 0) {
Quickshell.execDetached(["bash", "-c", batch])
}
}
}
// Which surface, decided WITHOUT waiting for the config file.
//
// `Config.options` is a JsonAdapter, so it answers with its QML defaults
// from the instant it exists — `touchKeypad` reads `false` until `onLoaded`
// flips `ready`. That is survivable at boot, where `initIfReady()` waits for
// `Config.ready` before requesting a lock, and fatal on a reload, where the
// lock is adopted at construction: the surface would bind to the DESKTOP
// keypad on the phone, and `WlSessionLock.surfaceComponent` cannot be
// changed while the lock is active — quickshell qCritical's and keeps the
// old one. The result is a lock screen that will not take your PIN, which
// is the worst of the three failures on this path because you cannot get
// back in.
//
// So the last known-good answer rides through the reload in-process, and
// Config takes over the moment it is genuinely loaded.
//
// `PersistentProperties` is in-process, so it covers a reload and not a
// cold start. sessiond takes the lock before the shell exists, so a shell
// started by greetd adopts a live lock at construction with `ready` still
// false — the fatal path above, reached without any reload. `SOUVERAINE_
// TOUCH_KEYPAD` is read from the environment, which is answerable at that
// instant; unset it reads exactly as before.
PersistentProperties {
id: lockPrefs
reloadableId: "souveraineLockPrefs"
property bool touchKeypad: Quickshell.env("SOUVERAINE_TOUCH_KEYPAD") === "1"
}
Connections {
target: Config
function onReadyChanged() {
if (Config.ready)
lockPrefs.touchKeypad = Config.options.lock.touchKeypad;
}
}
lockSurface: (Config.ready ? Config.options.lock.touchKeypad : lockPrefs.touchKeypad)
? touchSurfaceComponent
: desktopSurfaceComponent
property Component desktopSurfaceComponent: LockSurface {
context: root.context
}
property Component touchSurfaceComponent: TouchLockSurface {
context: root.context
}
// Single batch for lock and unlock so we don't race multiple hyprctl calls
Connections {
target: GlobalStates
function onScreenLockedChanged() {
if (GlobalStates.screenLocked) {
// Lock: save workspace per monitor and move all to temp workspace in one batch
var next = {}
var batch = "keyword animation workspaces,1,7,menu_decel,slidevert; "
for (var i = 0; i < Quickshell.screens.length; ++i) {
var mon = Quickshell.screens[i].name
var mData = HyprlandData.monitors.find(m => m.name === mon)
if (mData?.activeWorkspace == undefined) {
return;
}
var ws = (mData?.activeWorkspace?.id ?? 1)
next[mon] = ws
batch += `hyprctl dispatch 'hl.dsp.focus({monitor="${mon}"})'; hyprctl dispatch 'hl.dsp.focus({workspace=${2147483647 - ws}})';`
}
root.savedWorkspaces = next
Quickshell.execDetached(["bash", "-c", batch])
} else {
restoreTimer.start()
}
}
}
// Push everything down (visual only; workspace switch is in Connections above)
Variants {
model: Quickshell.screens
delegate: Scope {
required property ShellScreen modelData
property bool shouldPush: GlobalStates.screenLocked
property string targetMonitorName: modelData.name
property int verticalMovementDistance: modelData.height
property int horizontalSqueeze: modelData.width * 0.2
}
}
}

View file

@ -0,0 +1,485 @@
// Souveraine addition: touch-first lock surface for the phone.
//
// A PIN keypad instead of ii's keyboard-driven LockSurface — on the phone
// 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. 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
import QtQuick.Layouts
import qs
import qs.services
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
MouseArea {
id: root
required property LockContext context
readonly property bool requirePasswordToPower: Config.options.lock.security.requirePasswordToPower
readonly property bool allowPowerFromLock: Config.options.lock.security.allowPowerFromLock
readonly property int keySize: 96
readonly property int keySpacing: 18
// Two-stage lock: glance (ambient cards + swipe hint) and PIN. The pad
// is not always up — a swipe up (or any hardware key) reveals it, and it
// retreats after sitting idle with nothing typed. Credentials machinery
// is untouched; this is purely which stage is presented.
property bool pinRevealed: false
property real pressY: 0
// Live drag: the pad follows the finger during the swipe instead of
// snapping at a threshold. dragOffset is px of upward travel; release
// past commitDistance commits the reveal, anything less springs back.
property bool dragging: false
property real dragOffset: 0
readonly property real commitDistance: 120
// Lock wallpaper. The session-lock surface is transparent and nothing
// else paints behind this MouseArea, so the surface owns its own
// backdrop: the lock's pinned wallpaper when set, else the system
// wallpaper, else a plain dark field. The scrim keeps the glance text
// readable over any image.
Rectangle {
anchors.fill: parent
z: -3
color: "#0b0d10"
}
Image {
anchors.fill: parent
z: -2
source: {
const p = Config.options.lock.wallpaperPath
|| Config.options.background.wallpaperPath || "";
return p ? "file://" + p : "";
}
fillMode: Image.PreserveAspectCrop
asynchronous: true
visible: status === Image.Ready
}
Rectangle {
anchors.fill: parent
z: -1
color: "#000000"
opacity: 0.32
}
function forceFieldFocus() {
root.forceActiveFocus();
}
// Note: shouldReFocus does NOT reveal the pad — hypridle's after_sleep_cmd
// fires it on every wake to fix Hyprland's keyboard-focus loss, and wake
// must land on glance, not the keypad. Typing reveals via Keys below.
Connections {
target: root.context
function onShouldReFocus() {
forceFieldFocus();
}
}
Component.onCompleted: forceFieldFocus()
onPressed: mouse => {
root.pressY = mouse.y;
forceFieldFocus();
}
onPositionChanged: mouse => {
if (!root.pinRevealed) {
const d = root.pressY - mouse.y;
if (d > 8) {
root.dragging = true;
root.dragOffset = Math.max(0, d);
}
} else if (mouse.y - root.pressY > 80
&& root.context.currentText.length === 0
&& !root.context.unlockInProgress) {
root.pinRevealed = false;
}
}
onReleased: {
// Order matters: dragging must drop first so the settle (up on
// commit, back down on abort) animates from the finger's position.
const commit = !root.pinRevealed && root.dragOffset > root.commitDistance;
root.dragging = false;
if (commit) root.pinRevealed = true;
root.dragOffset = 0;
}
onCanceled: {
root.dragging = false;
root.dragOffset = 0;
}
// Retreat to glance when the pad sits unused and empty.
Timer {
interval: 25000
running: root.pinRevealed && root.context.currentText.length === 0
&& !root.context.unlockInProgress
onTriggered: root.pinRevealed = false
}
// 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;
}
// Hardware keyboard entry (USB-C keyboard); mirrors the desktop surface.
// Any key is also a reveal gesture, so typing works from glance.
focus: true
Keys.onPressed: event => {
root.pinRevealed = true;
root.context.resetClearTimer();
if (event.key === Qt.Key_Backspace) {
root.context.currentText = (event.modifiers & Qt.ControlModifier)
? "" : root.context.currentText.slice(0, -1);
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
if (root.context.currentText.length > 0) root.context.tryUnlock();
} else if (event.key === Qt.Key_Escape) {
root.context.currentText = "";
} else if (event.text.length === 1 && event.text >= " ") {
root.context.currentText += event.text;
}
}
// Glance-stage hint; tapping it is an alternative to the swipe.
ColumnLayout {
id: swipeHint
anchors {
horizontalCenter: parent.horizontalCenter
bottom: parent.bottom
bottomMargin: 64
}
z: 2
spacing: 2
visible: opacity > 0
opacity: root.pinRevealed ? 0
: 1 - Math.min(1, root.dragOffset / root.commitDistance)
Behavior on opacity {
enabled: !root.dragging
NumberAnimation { duration: 180 }
}
MaterialSymbol {
Layout.alignment: Qt.AlignHCenter
text: "keyboard_arrow_up"
iconSize: 34
color: Appearance.colors.colOnSurfaceVariant
}
StyledText {
Layout.alignment: Qt.AlignHCenter
text: Translation.tr("Swipe up to unlock")
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.normal
}
TapHandler {
onTapped: root.pinRevealed = true
}
Item {
id: fingerprintPreview
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: 18
visible: root.context.provisionalFingerprintEnabled
implicitWidth: 196
implicitHeight: 78
Rectangle {
anchors.fill: parent
radius: Appearance.rounding.normal
color: root.context.provisionalFingerprintConfirmed
? Appearance.colors.colPrimary
: "#2a000000"
border.width: root.context.provisionalFingerprintPulseSeen ? 2 : 1
border.color: root.context.provisionalFingerprintPulseSeen
? Appearance.colors.colPrimary : "#66ffffff"
}
Rectangle {
anchors.left: parent.left
anchors.bottom: parent.bottom
width: parent.width * root.context.provisionalFingerprintHoldProgress
height: 3
radius: 2
color: Appearance.colors.colPrimary
}
ColumnLayout {
anchors.centerIn: parent
spacing: 2
MaterialSymbol {
Layout.alignment: Qt.AlignHCenter
text: "fingerprint"
iconSize: 30
color: root.context.provisionalFingerprintConfirmed
? Appearance.colors.colOnPrimary : Appearance.colors.colOnLayer1
}
StyledText {
Layout.alignment: Qt.AlignHCenter
text: root.context.provisionalFingerprintConfirmed
? Translation.tr("Hold recorded — PIN still required")
: root.context.provisionalFingerprintPulseSeen
? Translation.tr("Sensor pulse received — hold to confirm")
: Translation.tr("Hold %1 seconds to test fingerprint wiring")
.arg(Math.round(root.context.provisionalFingerprintHoldMs / 1000))
color: root.context.provisionalFingerprintConfirmed
? Appearance.colors.colOnPrimary : Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
horizontalAlignment: Text.AlignHCenter
}
}
MouseArea {
anchors.fill: parent
enabled: root.context.provisionalFingerprintEnabled
preventStealing: true
pressAndHoldInterval: root.context.provisionalFingerprintHoldMs
onPressed: {
root.context.beginProvisionalFingerprintHold();
mouse.accepted = true;
}
onReleased: root.context.cancelProvisionalFingerprintHold()
onCanceled: root.context.cancelProvisionalFingerprintHold()
onPressAndHold: root.context.confirmProvisionalFingerprintHold()
}
}
}
ColumnLayout {
id: padColumn
anchors {
horizontalCenter: parent.horizontalCenter
bottom: parent.bottom
bottomMargin: 48
}
z: 2
spacing: 20
visible: opacity > 0
opacity: root.pinRevealed ? 1
: Math.min(1, root.dragOffset / root.commitDistance)
Behavior on opacity {
enabled: !root.dragging
NumberAnimation { duration: 200 }
}
// Slides with the finger during the drag; on release the Behavior
// takes over and settles it (fully up on commit, back down on abort).
transform: Translate {
y: root.pinRevealed ? 0
: Math.max(0, (padColumn.height + 48) - root.dragOffset)
Behavior on y {
enabled: !root.dragging
NumberAnimation {
duration: 260
easing.type: Easing.OutCubic
}
}
}
// Entered-PIN dots, with the empty-state hint behind them.
// In a ColumnLayout so ErrorShakeAnimation's Layout.leftMargin works.
Item {
id: dotsArea
Layout.alignment: Qt.AlignHCenter
implicitWidth: Math.max(dotsRow.width, hintText.width, 1)
implicitHeight: 36
opacity: root.context.unlockInProgress ? 0.5 : 1
Row {
id: dotsRow
anchors.centerIn: parent
spacing: 13
Repeater {
model: Math.min(root.context.currentText.length, 14)
Rectangle {
width: 13
height: 13
radius: 6.5
color: GlobalStates.screenUnlockFailed
? Appearance.colors.colError : Appearance.colors.colOnLayer1
}
}
}
StyledText {
id: hintText
anchors.centerIn: parent
visible: root.context.currentText.length === 0
text: GlobalStates.screenUnlockFailed
? Translation.tr("Incorrect PIN") : Translation.tr("Enter PIN")
color: GlobalStates.screenUnlockFailed
? Appearance.colors.colError : Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.normal
}
ErrorShakeAnimation {
id: wrongPinShakeAnim
target: dotsArea
}
Connections {
target: GlobalStates
function onScreenUnlockFailedChanged() {
if (GlobalStates.screenUnlockFailed) wrongPinShakeAnim.restart();
}
}
}
GridLayout {
Layout.alignment: Qt.AlignHCenter
columns: 3
columnSpacing: root.keySpacing
rowSpacing: root.keySpacing
Repeater {
model: ["1", "2", "3", "4", "5", "6", "7", "8", "9"]
KeypadButton {
id: digitKey
required property string modelData
onClicked: root.pressDigit(digitKey.modelData)
contentItem: StyledText {
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
text: digitKey.modelData
font.pixelSize: 30
color: Appearance.colors.colOnLayer1
}
}
}
KeypadButton {
enabled: root.context.currentText.length > 0 && !root.context.unlockInProgress
colBackground: "transparent"
onClicked: {
root.context.resetClearTimer();
root.context.currentText = root.context.currentText.slice(0, -1);
}
onPressAndHold: root.context.currentText = ""
contentItem: KeypadIcon {
text: "backspace"
color: Appearance.colors.colOnLayer1
}
}
KeypadButton {
onClicked: root.pressDigit("0")
contentItem: StyledText {
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
text: "0"
font.pixelSize: 30
color: Appearance.colors.colOnLayer1
}
}
KeypadButton {
id: confirmKey
enabled: root.context.currentText.length > 0 && !root.context.unlockInProgress
toggled: true
onClicked: root.context.tryUnlock()
contentItem: KeypadIcon {
text: "arrow_right_alt"
color: confirmKey.enabled ? Appearance.colors.colOnPrimary : Appearance.colors.colSubtext
}
}
}
// Utility row: power / battery / reboot, kept small and away from digits
RowLayout {
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: 6
spacing: 36
UtilityButton {
visible: root.allowPowerFromLock
iconName: "power_settings_new"
targetAction: LockContext.ActionEnum.Poweroff
}
RowLayout {
spacing: 5
visible: Battery.available
MaterialSymbol {
fill: 1
text: Battery.isCharging ? "bolt" : "battery_android_full"
iconSize: Appearance.font.pixelSize.huge
color: (Battery.isLow && !Battery.isCharging)
? Appearance.colors.colError : Appearance.colors.colOnSurfaceVariant
}
StyledText {
text: Math.round(Battery.percentage * 100) + "%"
color: Appearance.colors.colOnSurfaceVariant
}
}
UtilityButton {
visible: root.allowPowerFromLock
iconName: "restart_alt"
targetAction: LockContext.ActionEnum.Reboot
}
}
}
component KeypadButton: RippleButton {
implicitWidth: root.keySize
implicitHeight: root.keySize
buttonRadius: root.keySize / 2
enabled: !root.context.unlockInProgress
colBackground: ColorUtils.transparentize(Appearance.colors.colLayer1, 0.4)
}
component KeypadIcon: MaterialSymbol {
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
iconSize: 30
}
// Same semantics as the desktop surface's password-guarded power buttons:
// with requirePasswordToPower the action arms and the PIN confirms it,
// otherwise it fires immediately.
component UtilityButton: RippleButton {
id: utilBtn
required property string iconName
required property var targetAction
implicitWidth: 56
implicitHeight: 56
buttonRadius: 28
toggled: root.context.targetAction === utilBtn.targetAction
colBackground: "transparent"
onClicked: {
if (!root.requirePasswordToPower) {
root.context.unlocked(utilBtn.targetAction);
return;
}
if (root.context.targetAction === utilBtn.targetAction) {
root.context.resetTargetAction();
} else {
root.context.targetAction = utilBtn.targetAction;
root.context.shouldReFocus();
}
}
contentItem: KeypadIcon {
text: utilBtn.iconName
color: utilBtn.toggled ? Appearance.colors.colOnPrimary : Appearance.colors.colOnSurfaceVariant
}
}
}

View file

@ -0,0 +1,4 @@
Lock 1.0 Lock.qml
LockSurface 1.0 LockSurface.qml
PasswordChars 1.0 PasswordChars.qml
TouchLockSurface 1.0 TouchLockSurface.qml

View file

@ -0,0 +1,318 @@
// 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.
// Ask the bus first. If no owner exists, start the package-owned service;
// the owner monitor below pushes the pending visibility intent as soon as
// Squeekboard claims the name.
function showOsk() {
Quickshell.execDetached(["sh", "-c",
"busctl --user call sm.puri.OSK0 /sm/puri/OSK0 sm.puri.OSK0 " +
"SetVisible b true 2>/dev/null || " +
"systemctl --user start squeekboard.service >/dev/null 2>&1"])
}
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();
}
}
}
// squeekboard auto-shows/hides ITSELF on input-method focus (e.g. a
// sidebar taking or dropping a text field) without telling us. oskOpen
// then lies: the dock stays suppressed and the gesture rail floats at
// keyboard height over nothing until someone manually toggles. Mirror
// squeekboard's real Visible property back into oskOpen so there is
// exactly one truth. Setting oskOpen from here re-triggers SetVisible
// with the value squeekboard already has, which is a harmless no-op.
// A self-hide is squeekboard's opinion, not the user's. While a surface
// holds the keyboard (GlobalStates.oskHolds — polkit's password field is
// the first) that opinion is overridden and the keyboard comes straight
// back. Bounded: after this many re-asserts within one hold we stop and
// say so, rather than trading SetVisible calls with squeekboard forever.
property int maxReasserts: 5
property int reassertCount: 0
// An owner change is not a visibility event.
//
// sm.puri.OSK0 is a *name*, not a process. Boot and a crash-restart can
// both put a fresh Squeekboard process behind it.
// Each new process announces its own idea of Visible, and until now this
// monitor mirrored that into oskOpen — indistinguishable from the
// keyboard deciding to show itself. Measured on 2026-08-05: shell up at
// 03:03:38, `[osk] squeekboard self-showed` at 03:03:43, nine seconds
// after boot with nobody near the phone.
//
// A process that just started has no history. It cannot know whether the
// user wanted a keyboard. The shell does — oskOpen is the continuity of
// that intent across the keyboard's whole lifetime. So on an owner change
// we PUSH intent onto the new owner instead of PULLING state out of it,
// and we ignore its opening claim while our push is in flight.
//
// This is also why "the keyboard comes back on wake" needs no compositor
// change to stop hurting: wake perturbs the display connection, the OSK
// restarts, and it is the mirror — not the compositor and not the
// keyboard — that turns that restart into a keyboard on the user's
// screen.
property string oskOwner: ""
property int ownerSettleMs: 2000
// NOT a binding on Date.now() — QML bindings do not re-evaluate because
// time passed, so a `Date.now() - t < ms` property latches at creation
// and never clears. A timer is the only honest way to express "for a
// moment after".
property bool ownerSettling: false
Timer {
id: ownerSettleTimer
interval: root.ownerSettleMs
onTriggered: {
root.ownerSettling = false;
// Push once more on the way out. The first push can lose a race to
// Squeekboard self-showing after startup. Whoever speaks last
// during the settle window does not get to win; intent does.
root.assertIntent();
}
}
function assertIntent(): void {
if (GlobalStates.oskOpen) root.showOsk();
else root.hideOsk();
}
function adoptOwner(owner: string): void {
if (owner === root.oskOwner) return;
const previous = root.oskOwner;
root.oskOwner = owner;
root.ownerSettling = true;
ownerSettleTimer.restart();
if (owner === "") {
console.log("[osk] keyboard left the bus (was " + previous + ")");
return;
}
console.log("[osk] keyboard is now " + owner
+ (previous === "" ? "" : " (was " + previous + ")")
+ "; re-asserting oskOpen=" + GlobalStates.oskOpen);
// Push our intent, not theirs. A fresh keyboard claiming Visible=true
// when nobody asked for one gets closed here — and again when the
// settle window closes, in case it spoke after we did.
root.assertIntent();
}
Connections {
target: GlobalStates
function onOskHoldsChanged() {
if (GlobalStates.oskHolds.length > 0) root.reassertCount = 0;
}
}
Process {
id: oskVisMonitor
running: true
command: ["gdbus", "monitor", "--session",
"--dest", "sm.puri.OSK0", "--object-path", "/sm/puri/OSK0"]
stdout: SplitParser {
onRead: line => {
// gdbus prints the owner of --dest at startup and again on
// every NameOwnerChanged. These lines were being dropped by
// the 'Visible' filter below; they are the evidence we need.
// The name sm.puri.OSK0 is owned by :1.41
// The name sm.puri.OSK0 does not have an owner
const owned = line.match(/is owned by (\S+)/);
if (owned) {
root.adoptOwner(owned[1]);
return;
}
if (line.includes("does not have an owner")) {
root.adoptOwner("");
return;
}
if (!line.includes("'Visible'")) return;
const vis = line.includes("<true>");
// Still inside an owner change: this is the new process
// introducing itself, or the echo of the intent we just
// pushed at it. Either way it is not the user speaking.
if (root.ownerSettling) {
if (vis !== GlobalStates.oskOpen)
console.log("[osk] ignoring Visible=" + vis
+ " from freshly-arrived " + root.oskOwner
+ "; oskOpen=" + GlobalStates.oskOpen + " stands");
return;
}
if (!vis && GlobalStates.oskHolds.length > 0) {
if (root.reassertCount >= root.maxReasserts) {
console.log("[osk] squeekboard self-hid under hold ["
+ GlobalStates.oskHolds.join(",") + "] more than "
+ root.maxReasserts + " times; giving up the hold");
GlobalStates.oskDropHolds();
GlobalStates.oskOpen = false;
return;
}
root.reassertCount++;
console.log("[osk] squeekboard self-hid while held by ["
+ GlobalStates.oskHolds.join(",") + "]; re-asserting ("
+ root.reassertCount + "/" + root.maxReasserts + ")");
root.showOsk();
return;
}
if (GlobalStates.oskOpen !== vis) {
console.log("[osk] squeekboard self-" + (vis ? "showed" : "hid") + ", syncing oskOpen");
GlobalStates.oskOpen = vis;
}
}
}
}
// Every deliberate close — pill, gesture, shortcut, IPC — goes through
// here, because closing by hand is what drops a hold.
function userClose() {
GlobalStates.oskDropHolds();
GlobalStates.oskOpen = false;
}
// Unlocking must not leave a keyboard behind.
//
// Nothing asks for one — the lock module never touches `oskOpen`, and only
// polkit takes a hold. Squeekboard self-shows whenever input-method
// focus lands on them (GlobalStates' own note: "self-showed / self-hid
// every couple of seconds, ending in Visible=true with no keyboard in front
// of the user"), and the surfaces coming back at unlock are exactly such a
// focus change. So this is not a hold to release, it is a keyboard nobody
// requested — closed on the unlock edge, the same place the state machine
// treats as "the session is yours again".
//
// `userClose()`, not `oskOpen = false`: it drops holds too, so a stale
// polkit hold taken before the lock cannot re-assert the keyboard the
// instant this clears.
Connections {
target: GlobalStates
function onScreenLockSecureChanged() {
if (!GlobalStates.screenLockSecure && GlobalStates.oskOpen)
root.userClose();
}
}
IpcHandler {
target: "osk"
function toggle(): void {
if (GlobalStates.oskOpen) root.userClose();
else GlobalStates.oskOpen = true;
}
function close(): void {
root.userClose();
}
function open(): void {
GlobalStates.oskOpen = true;
}
// Which surfaces are holding the keyboard open, if any. The pill and
// the agent both ask "why won't this close"; this answers it.
function holds(): string {
return JSON.stringify(GlobalStates.oskHolds);
}
// 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: {
if (GlobalStates.oskOpen) root.userClose();
else GlobalStates.oskOpen = true;
}
}
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: {
root.userClose();
}
}
}

View file

@ -0,0 +1,3 @@
OnScreenKeyboard 1.0 OnScreenKeyboard.qml
OskContent 1.0 OskContent.qml
OskKey 1.0 OskKey.qml

View file

@ -0,0 +1,201 @@
// The phone app drawer: every desktop entry as a paged icon grid.
//
// TASK-14. The overview pane is stock ii's *desktop* overview — workspace
// thumbnails + search. On a 5.5" phone the workspace grid is dead weight, so
// the body becomes what a phone Home key opens: an app grid. This lives in its
// own file so Overview.qml only swaps the body widget and ii updates stay
// mergeable (the header comment in Overview.qml, "diff against upstream before
// re-applying"; the original stock body is `ii-base/modules/ii/overview/
// OverviewWidget.qml`).
//
// Deliberately NOT the workspace grid's sibling: the reference shell's
// overview is built on windows, and running windows have their own surface —
// WindowOverview, raised by mission control (the pill's second-stage swipe). An
// app drawer is for *finding* something, not for *switching* to what is
// running, so this grid has no notion of workspaces or of what is currently
// focused. It is the launcher half of the split; WindowOverview is the task
// half. Do not merge them: that conflation is what landed the running-cards on
// the Home page (the `overviewOpen || missionControlOpen` body in Overview.qml
// that this file replaces).
import qs
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import Quickshell
import Quickshell.Widgets
Item {
id: root
// --- Layout ----------------------------------------------------------
// 4x5 on the 540x1080 portrait. The reference shell's layout search
// collapses to a single column of full-width cards in portrait — that is
// the *task* surface. An app drawer is density, not drama, so this is a
// dense grid: 4 columns keeps a thumb travel across a page, 5 rows fills
// the 0.78-height body without needing the whole 1080. Columns, rows and
// icon size are user-settable in the settings app via
// Config.options.overview.appGrid (see Config.qml); the fallbacks match
// the phone's measured defaults. `property JsonObject` reads resolve
// against the active config file at load, not against Config.qml's base
// values — so these are fallbacks, not overrides.
readonly property int columns: Math.max(1, Math.min(10,
Config?.options?.overview?.appGrid?.columns ?? 4))
readonly property int rows: Math.max(1, Math.min(10,
Config?.options?.overview?.appGrid?.rows ?? 5))
readonly property int iconSize: Math.max(24, Math.min(96,
Config?.options?.overview?.appGrid?.iconSize ?? 44))
readonly property int perPage: columns * rows
readonly property real pageMargin: 14
readonly property real cellSpacing: 6
// --- Data ------------------------------------------------------------
// AppSearch.list is the canonical deduped DesktopEntry list (the same one
// the search backend reads), sorted here alphabetically. Paging is a
// property, not a ListModel, so a desktop-entry change recomputes cleanly.
readonly property var apps: {
const all = AppSearch.list.slice()
.filter(app => app?.name)
.sort((a, b) => (a.name || "").localeCompare(b.name || ""));
return all;
}
readonly property var pages: {
const result = [];
for (let i = 0; i < root.apps.length; i += root.perPage)
result.push(root.apps.slice(i, i + root.perPage));
return result;
}
readonly property int pageCount: root.pages.length
// How far open, 0..1 — the same single-progress idiom WindowOverview uses
// so this surface reads as arriving, not appearing. The overview's own
// gate; mission control drives its own surface.
property real progress: GlobalStates.overviewOpen ? 1 : 0
Behavior on progress {
NumberAnimation {
duration: 260
easing.type: Easing.OutCubic
}
}
transform: Translate { y: (1 - root.progress) * 24 }
opacity: root.progress
// Nothing installed worth showing. Kept distinct from a broken grid on
// purpose — the same silence-was-the-bug discipline WindowOverview's
// "No open windows" label exists for.
StyledText {
anchors.centerIn: parent
visible: root.apps.length === 0
text: qsTr("No apps")
opacity: 0.6
}
ListView {
id: list
anchors.fill: parent
// Room for the page dots below the grid.
anchors.bottomMargin: 22
// Paged horizontally, per TASK-14 ("paged"), matching the task
// surface's axis so the thumb does one learned motion for both.
orientation: ListView.Horizontal
snapMode: ListView.SnapOneItem
highlightRangeMode: ListView.StrictlyEnforceRange
preferredHighlightBegin: 0
preferredHighlightEnd: width
spacing: 0
clip: true
visible: root.apps.length > 0
model: root.pages
// The page the strip is on, live (not ListView.currentIndex, which
// only follows keyboard/programmatic selection); feeds the dots.
readonly property int pageIndex: Math.max(0, Math.min(root.pageCount - 1,
Math.round(list.contentX / list.width)))
delegate: Item {
id: page
required property var modelData
required property int index
width: list.width
height: list.height
readonly property real tileW: (width - root.pageMargin * 2
- root.cellSpacing * (root.columns - 1)) / root.columns
readonly property real tileH: (height - root.cellSpacing * (root.rows - 1)) / root.rows
Grid {
anchors.fill: parent
anchors.margins: root.pageMargin
columns: root.columns
rows: root.rows
columnSpacing: root.cellSpacing
rowSpacing: root.cellSpacing
Repeater {
model: page.modelData
delegate: RippleButton {
required property var modelData
implicitWidth: page.tileW
implicitHeight: page.tileH
buttonRadius: Appearance?.rounding?.normal ?? 12
// Launch closes the drawer; the tapped app takes the
// screen. Same close-before-launch order SearchItem
// uses — the entry must not execute into an overview
// still covering the monitor.
onClicked: {
GlobalStates.overviewOpen = false;
modelData.execute();
}
contentItem: Column {
anchors.fill: parent
anchors.topMargin: 12
spacing: 6
IconImage {
anchors.horizontalCenter: parent.horizontalCenter
source: Quickshell.iconPath(modelData.icon, "image-missing")
implicitWidth: root.iconSize
implicitHeight: root.iconSize
}
StyledText {
width: parent.width
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignTop
elide: Text.ElideRight
maximumLineCount: 1
text: modelData.name
font.pixelSize: Appearance?.font?.pixelSize?.small ?? 12
color: Appearance?.colors?.colOnLayer0 ?? "#fff"
opacity: 0.85
}
}
}
}
}
}
}
// Page dots. One per page, the current one in the accent; only shown when
// there is more than one page (a single dot that cannot move is noise).
Row {
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: 4
visible: root.pageCount > 1
spacing: 6
Repeater {
model: root.pageCount
delegate: Rectangle {
required property int index
width: 7
height: 7
radius: width / 2
color: index === list.pageIndex
? (Appearance?.colors?.colPrimary ?? "#a0c8ff")
: "#66ffffff"
Behavior on color { ColorAnimation { duration: 150 } }
}
}
}
}

View file

@ -0,0 +1,816 @@
// Pixel3Arch patch to ii's stock Overview.qml. History:
//
// 2026-07-07 — opened the search/overview with the on-screen keyboard
// (GlobalStates.oskOpen) and closed it with the keyboard too.
// 2026-08-05 — finesse pass (TASK-14): the keyboard no longer auto-raises
// with the drawer; it belongs to the search pane and is summoned by tapping
// it (see oskSummoner). The drawer gained a translucent theme sheet
// (panelBg), the dock is suppressed while it is open, and both bodies fill
// 0.78 of the panel height instead of 0.7.
//
// While the OSK is open, this panel does NOT register with
// GlobalFocusGrab.addDismissable — see the onOskOpenChanged handler below.
// Reason: GlobalFocusGrab's HyprlandFocusGrab (hyprland_focus_grab_v1, a
// real Wayland protocol) clears whenever a tap lands outside its
// whitelisted surfaces, and wvkbd (the real OSK — see OnScreenKeyboard.qml)
// is a separate process that CANNOT be added to that whitelist — Quickshell
// only exposes whitelisting its own in-process windows, and there's no
// hyprctl or other lever either. Two attempts at a "shield window" to fake
// wvkbd's inclusion both failed on real device testing (see
// OnScreenKeyboard.qml's history for what was tried and why). So instead of
// fighting the grab, this panel just stops being dismissable-by-outside-tap
// while the keyboard is up — it still closes normally via Escape, the
// explicit toggle, or picking a search result. Everything else in this file
// is unchanged stock ii — diff against upstream before re-applying this
// patch if ii updates.
import qs
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions as CF
import Qt.labs.synchronizer
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import Quickshell.Hyprland
import qs.modules.souveraine.navigation
Scope {
id: overviewScope
property bool dontAutoCancelSearch: false
// Whether panelWindow is currently registered with GlobalFocusGrab so an
// outside tap dismisses the drawer. Gated on the OSK (see onOskOpenChanged)
// and deliberately tracked rather than add/remove blind: adding the same
// window twice is the double-add race the registration comment below warns
// about, and re-opening the drawer while already registered would re-add.
property bool panelDismissableRegistered: false
// Mission control is on the glass — committed, or previewed under a dwelling
// thumb. Presentation gates read this; focus, dismissal and every commit
// path keep reading `missionControlOpen`, so a peek draws and decides
// nothing.
readonly property bool missionShown: GlobalStates.missionControlOpen || GlobalStates.missionPeek
PanelWindow {
id: panelWindow
property string searchingText: ""
readonly property HyprlandMonitor monitor: Hyprland.monitorFor(panelWindow.screen)
property bool monitorIsFocused: (Hyprland.focusedMonitor?.id == monitor?.id)
// Two ways in, one surface. The pill's second-stage swipe has set
// `missionControlOpen` since 2026-07, and TASK-14 records that NO
// SURFACE CONSUMES IT — the Auxo-like card surface it was meant to
// raise was never built. WindowOverview is that surface, so the state
// finally reaches something instead of being set and dropped.
//
// Mission control is the cards alone: no search, because it is a
// "switch to what is running" gesture, not a "find something" one.
visible: GlobalStates.overviewOpen || overviewScope.missionShown
WlrLayershell.namespace: "quickshell:overview"
// Overlay, not Top: the second-stage edge swipe must bring the
// overview up over a fullscreen app (fullscreen renders above Top).
WlrLayershell.layer: WlrLayer.Overlay
// Only the search half wants the keyboard. Mission control taking it
// would summon the OSK over the cards for a surface with no text field.
WlrLayershell.keyboardFocus: GlobalStates.overviewOpen ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
color: "transparent"
mask: Region {
// The window only exists where the drawer does: panelBg (which
// wraps the column with a breathing margin) is the drawn surface
// AND the input region. This replaced `columnLayout` when the
// panel sheet was added — the mask must cover what panelBg
// covers, or the sheet renders clipped and its margin is dead
// input space.
//
// Mission control covers the whole glass (the blurred home
// backdrop), so there it is the whole window or the cards take no
// taps at all.
// A peek maps this surface while the thumb is still down on the
// rail. Masking to a zero-size item makes it take no input at all,
// so the in-flight gesture stays the rail's and cannot be stolen by
// a full-glass region appearing mid-drag.
item: GlobalStates.missionControlOpen ? missionBackdrop
: GlobalStates.missionPeek ? noInput : panelBg
}
Item {
id: noInput
width: 0
height: 0
}
anchors {
top: true
bottom: true
left: true
right: true
}
Connections {
target: GlobalStates
function onOverviewOpenChanged() {
if (!GlobalStates.overviewOpen) {
searchWidget.disableExpandAnimation();
overviewScope.dontAutoCancelSearch = false;
GlobalFocusGrab.dismiss();
GlobalStates.oskOpen = false;
// Undo the drawer-time suppression (see below) so the dock
// returns to its pre-drawer posture: its own swipe-down
// state, or the empty-desktop reveal it had coming.
GlobalStates.dockSuppressed = false;
} else {
if (!overviewScope.dontAutoCancelSearch) {
searchWidget.cancelSearch();
}
// Keyboard-less on open — 2026-08-05: the OSK no longer
// auto-raises with the drawer. The keyboard belongs to the
// search pane: tapping it summons the OSK (see oskSummoner
// below), tapping anything else doesn't. The field still
// auto-focuses so the first tap has somewhere to go.
GlobalStates.oskOpen = false;
// No OSK: the drawer is dismissable by outside tap, and it
// must register right here — onOskOpenChanged only fires on
// a keyboard toggle, which no longer happens on open.
if (!overviewScope.panelDismissableRegistered) {
GlobalFocusGrab.addDismissable(panelWindow);
overviewScope.panelDismissableRegistered = true;
}
// The drawer takes the space, so the dock goes down while
// it is open — and suppression, not dockRevealed=false:
// the dock is pinned on the phone, and a pinned dock
// ignores dockRevealed entirely (Dock.qml computeDockState:
// only dockSuppressed beats effectivePinned). The overview
// button lives on the dock, which is why the drawer needs
// other doors in (pill swipe home / IPC). The close branch
// above restores the flag.
GlobalStates.dockSuppressed = true;
}
}
}
// Registering as dismissable is gated on the OSK's state, not
// just overviewOpen — see the file-header comment for why. The
// drawer registers itself in onOverviewOpenChanged (the OSK no
// longer flips on open); this handler only re-tracks when the OSK
// is toggled independently while the drawer stays open.
// One surface at a time. Home and the running-work view are opened by
// different gestures, but they are not modes to be stacked: a swipe to
// mission control while the drawer is up should replace it, not float
// the cards over the grid. Neither flag's writers enforce this, so the
// body's owner does.
Connections {
target: GlobalStates
function onOverviewOpenChanged() {
if (GlobalStates.overviewOpen)
GlobalStates.missionControlOpen = false;
}
function onMissionControlOpenChanged() {
if (GlobalStates.missionControlOpen)
GlobalStates.overviewOpen = false;
}
}
Connections {
target: GlobalStates
function onOskOpenChanged() {
if (!GlobalStates.overviewOpen) return;
if (GlobalStates.oskOpen) {
GlobalFocusGrab.removeDismissable(panelWindow);
overviewScope.panelDismissableRegistered = false;
} else if (!overviewScope.panelDismissableRegistered) {
GlobalFocusGrab.addDismissable(panelWindow);
overviewScope.panelDismissableRegistered = true;
}
}
}
Connections {
target: GlobalFocusGrab
function onDismissed() {
GlobalStates.overviewOpen = false;
}
}
implicitWidth: columnLayout.implicitWidth
implicitHeight: columnLayout.implicitHeight
function setSearchingText(text) {
searchWidget.setSearchingText(text);
searchWidget.focusFirstItem();
}
// Tap-to-dismiss for the dead space INSIDE the overview page: the
// window's input mask only covers the panel sheet (panelBg), and the
// grid's gaps (between tiles, around the grid) consume nothing —
// taps there used to be silently ignored, which read as the page
// being stuck. Declared before (= stacked below) the column, sized to
// the overview grid only, so the search bar's padding stays inert and
// every real control (workspaces, windows, search) wins the tap.
// While the OSK is up this is the ONLY outside-tap dismiss — the
// focus-grab route is deliberately disabled then (see header).
MouseArea {
enabled: (GlobalStates.overviewOpen || GlobalStates.missionControlOpen)
&& overviewLoader.active
x: columnLayout.x + overviewLoader.x
y: columnLayout.y + overviewLoader.y
width: overviewLoader.width
height: overviewLoader.height
// Closes whichever one is up. Mission control has no focus grab to
// fall back on — it takes no keyboard focus — so this is its only
// outside-tap dismissal, and leaving it out stranded the surface
// with no way back but the rail.
onClicked: {
GlobalStates.overviewOpen = false;
GlobalStates.missionControlOpen = false;
}
}
// The drawer's sheet. A solid, slightly translucent layer of the
// theme surface behind the search bar and grid — the "blur/look"
// finesse item. Deliberately NOT a real GaussianBlur: that needs
// Qt5Compat and dies on the phone's GLES-ish backend (2026-08-05),
// so this is the phone-safe substitute — an opaque-enough sheet that
// the wallpaper reads as a soft base behind it. Declared before the
// column (stacked below it) so nothing here can eat taps; it is
// input-transparent anyway.
// Multitasking sits on the home zone, blurred — not on the app you came
// from, and not on nothing. MultiEffect (Qt6), not GaussianBlur: that
// one needs Qt5Compat and dies on the phone's GLES path (2026-08-05).
// The sheet under it is the known-good look if the effect no-ops.
Item {
id: missionBackdrop
visible: overviewScope.missionShown
anchors.fill: parent
z: -1
// The backdrop arrives with the climb rather than at the end of it.
//
// This was a hard on/off gated on a 140 ms dwell, so an ordinary
// swipe shrank the real window over the live app and then the whole
// destination — blur, sheet, cards — appeared in one frame. That
// discontinuity is the "swipe to this swap is awkward" Casey has
// named repeatedly, and it is TASK-60's own acceptance: *"the
// scale-on-drag either continues into the view or is gone. Not
// both."*
//
// Same curve as the cards, from the same owner, so at the commit
// frame the backdrop is already opaque and the card's picture is
// already exactly under the real window — releasing the carry
// changes nothing on the glass. A fast flick to Home shows a faint
// wash rather than a hard flash, because `presence` recedes past
// the multitasking detent instead of latching on.
opacity: GlobalStates.missionControlOpen ? 1 : ZoneTransition.presence
Behavior on opacity {
enabled: GlobalStates.missionControlOpen || ZoneTransition.travel === 0
NumberAnimation {
duration: 260
easing.type: Easing.OutCubic
}
}
Image {
id: homeWall
anchors.fill: parent
source: Config.options.background.wallpaperPath ?? ""
fillMode: Image.PreserveAspectCrop
cache: true
asynchronous: true
visible: false
}
MultiEffect {
anchors.fill: parent
source: homeWall
visible: homeWall.status === Image.Ready
blurEnabled: true
blurMax: 64
blur: 1
saturation: -0.2
brightness: -0.25
}
Rectangle {
anchors.fill: parent
color: CF.ColorUtils.transparentize(Appearance?.colors?.colLayer0 ?? "#101010", 0.25)
}
// The backdrop is the input region while mission control is up, so
// it owes the way out. Without this a tap outside a card lands on
// the overlay and does nothing, which is a screen you cannot leave.
MouseArea {
anchors.fill: parent
onClicked: {
GlobalStates.missionControlOpen = false;
GlobalStates.overviewOpen = false;
}
}
}
Rectangle {
id: panelBg
visible: columnLayout.visible && !overviewScope.missionShown
anchors.fill: columnLayout
// The sheet breathes past the column so the drawer reads as a
// panel, not as widgets floating on the wallpaper. The mask
// follows this same box (see `mask` above).
anchors.leftMargin: -18
anchors.rightMargin: -18
anchors.topMargin: -6
anchors.bottomMargin: -14
radius: Appearance?.rounding?.large ?? 20
// colLayer1 at ~88% — opaque enough that wallpaper detail behind
// the grid is noise, translucent enough to still be "surface".
color: CF.ColorUtils.transparentize(Appearance?.colors?.colLayer1 ?? "#1a1a1a", 0.12)
border.width: 1
border.color: CF.ColorUtils.transparentize(Appearance?.colors?.colOnLayer1 ?? "#ffffff", 0.88)
}
Column {
id: columnLayout
// Both ways in. The panel, the mask and the loader were all moved
// to `overviewOpen || missionControlOpen` when mission control
// landed and this was left behind, so the second-stage pill swipe
// raised a panel whose entire contents were invisible — the cards
// were built, instantiated, and never shown.
visible: GlobalStates.overviewOpen || overviewScope.missionShown
anchors {
horizontalCenter: parent.horizontalCenter
top: parent.top
}
spacing: -8
Keys.onPressed: event => {
if (event.key === Qt.Key_Escape) {
GlobalStates.overviewOpen = false;
}
}
SearchWidget {
id: searchWidget
// Search belongs to the overview, not to mission control:
// "switch to what is running" has nothing to type into, and
// the panel deliberately takes no keyboard focus in that mode
// (see `keyboardFocus` above), so a field here would be one
// you could see and not use.
visible: GlobalStates.overviewOpen
anchors.horizontalCenter: parent.horizontalCenter
Synchronizer on searchingText {
property alias source: panelWindow.searchingText
}
}
Loader {
id: overviewLoader
anchors.horizontalCenter: parent.horizontalCenter
active: (GlobalStates.overviewOpen || overviewScope.missionShown)
&& (Config?.options.overview.enable ?? true)
// Two ways in, two bodies — TASK-14's split. The overview
// (Home) is the app drawer: search above (this file's
// SearchWidget) and AppGrid below. Mission control, raised by
// the pill's second-stage swipe, is the running-work view:
// WindowOverview's cards alone, deliberately no search and no
// keyboard (see `keyboardFocus` above). Previously this one
// body rendered the running cards for BOTH flags, which left
// Home showing what is running instead of what can run.
sourceComponent: overviewScope.missionShown
? windowOverviewComponent : appGridComponent
}
Component {
id: windowOverviewComponent
// WindowOverview, not OverviewWidget. The latter draws a grid
// of workspaces from HyprlandData; viewtop has neither, so it
// rendered an empty frame and read as the overview being
// broken. See WindowOverview.qml's header.
// ZoneOverview, not WindowOverview (TASK-60). The flat list
// of window cards drew the two halves of a split as unrelated
// things, and could not show a zone you were not on. A zone is
// the destination; its windows live inside its card.
ZoneOverview {
id: zoneOverview
// Tell the owner where this surface actually is, rather than
// letting it assume the screen. The panel respects exclusive
// zones, so the bar's 40 px makes it 1040 tall on a 1080
// screen and puts its origin 40 px down the output — and the
// compositor's carry targets are in output coordinates.
//
// The inset is what was lost to reservations, which are
// top-anchored for this surface. It should equal the `at.y`
// the compositor reports for any tiled window; if a future
// surface reserves along the bottom this becomes wrong, and
// the cross-check is how it would be caught.
function report() {
ZoneTransition.measuredAt(0,
(panelWindow.screen?.height ?? panelWindow.height) - panelWindow.height,
width, height);
}
onWidthChanged: zoneOverview.report()
onHeightChanged: zoneOverview.report()
Component.onCompleted: zoneOverview.report()
// The panel's width, NOT the column's.
//
// `overviewLoader.parent` is the Column, and a Column is
// as wide as its widest child. In the drawer the search
// widget supplies that width; mission control is the cards
// alone and has no search — so the only child left was
// this loader, whose width came from the column, whose
// width came from this loader. The cycle resolves to zero:
// measured `column.w: 0, item.w: 0` with `h: 842`.
//
// Full-height, zero-width cards draw nothing, so mission
// control was the blurred backdrop and no cards — while
// every other signal read healthy (zones 2, windows 1,
// subscribed true, loader active, item present, opacity 1,
// progress 1). Nothing was broken except one number.
//
// Full width is also what this surface wants: mission
// control covers the whole glass — its own mask is
// `missionBackdrop`, not the drawer's sheet.
width: panelWindow.width
height: panelWindow.height * 0.78
visible: (panelWindow.searchingText == "")
onActivated: zone => {
// The compositor moves the canvas; the shell only asks.
// Going to a zone is not "activate a toplevel" — that
// was the old model's verb and it could not express
// "this place, which happens to hold two windows".
//
// Through the end-target table, which is what makes the
// strand repro pass (TASK-60). This used to move the
// canvas and clear two flags by hand — three steps, none
// of which released a transform, and this tap never
// touches the rail where every release-the-pose path had
// been built. So btop came back as a small card in the
// middle of the screen. A tapped card is now the same
// machinery as a released pill: one destination, and
// arriving at it is what lets go.
ZoneTransition.commit(ZoneTransition.zoneTarget(zone));
}
onClosed: id => ViewtopControl.close(id)
}
}
Component {
id: appGridComponent
// The phone app drawer. TASK-14: a paged grid from the
// desktop-entry list, alphabetical; search stays exactly
// as-is on top. See AppGrid.qml's header.
AppGrid {
width: overviewLoader.parent.width
height: panelWindow.height * 0.78
visible: (panelWindow.searchingText == "")
}
}
}
// The search pane is now the keyboard's door (2026-08-05): the OSK
// no longer auto-raises with the drawer, it raises when this pane is
// TAPPED. This overlay sits above the search bar but outside its
// widget tree, so it can add behavior without forking SearchWidget:
// it re-focuses the field first (a tap on the grid may have left it)
// and only then opens the keyboard — the OSK's input redirection
// returns keys to whatever had focus before it opened, so focus must
// land BEFORE the keyboard does. The press propagates on afterwards,
// so the field still gets its normal tap.
MouseArea {
id: oskSummoner
enabled: GlobalStates.overviewOpen && !GlobalStates.oskOpen
visible: columnLayout.visible
// searchWidget is inside the column, not a sibling, so anchors
// cannot bind it — same idiom as the tap-to-dismiss MouseArea:
// geometry in columnLayout-local terms, offset by the column's
// own position within the panel.
x: columnLayout.x + searchWidget.x
y: columnLayout.y + searchWidget.y
width: searchWidget.width
height: searchWidget.height
acceptedButtons: Qt.LeftButton
propagateComposedEvents: true
// A tap, not a press.
//
// Summoning on `onPressed` meant any gesture that merely *began*
// over the search field raised the keyboard — including the swipe
// that scrolls the app grid, which starts at the top of the drawer
// more often than not. Casey, 2026-08-07: "keyboard still opens
// when I swipe on the app drawer."
//
// So the press only remembers where it landed and the release
// decides. The slop is deliberately generous: a thumb reaching the
// top of a 540px panel is not steady, and a few pixels of drift
// while tapping a text field is a tap.
property real pressX: 0
property real pressY: 0
readonly property real tapSlop: 12
onPressed: (mouse) => {
oskSummoner.pressX = mouse.x;
oskSummoner.pressY = mouse.y;
mouse.accepted = false;
}
onReleased: (mouse) => {
const moved = Math.hypot(mouse.x - oskSummoner.pressX,
mouse.y - oskSummoner.pressY);
if (moved <= oskSummoner.tapSlop && !GlobalStates.oskOpen) {
searchWidget.focusSearchInput();
GlobalStates.oskOpen = true;
}
mouse.accepted = false;
}
}
}
function toggleClipboard() {
if (GlobalStates.overviewOpen && overviewScope.dontAutoCancelSearch) {
GlobalStates.overviewOpen = false;
return;
}
overviewScope.dontAutoCancelSearch = true;
panelWindow.setSearchingText(Config.options.search.prefix.clipboard);
GlobalStates.overviewOpen = true;
}
function toggleEmojis() {
if (GlobalStates.overviewOpen && overviewScope.dontAutoCancelSearch) {
GlobalStates.overviewOpen = false;
return;
}
overviewScope.dontAutoCancelSearch = true;
panelWindow.setSearchingText(Config.options.search.prefix.emojis);
GlobalStates.overviewOpen = true;
}
// sessiond calls `ipc call overview toggle` for `Action::Overview` — the
// three-finger-tap binding (`device_state.rs`) and anything else that
// reaches for the overview by name. Nothing answered to `overview`: the
// only target here was `search`, so the executor shelled out to a handler
// that did not exist and the tap did nothing, silently. Two things were
// hiding that — the shipped sessiond has no `gesture` verb at all and
// refuses the op before it gets this far, so the second half of the break
// could not be seen from the device.
//
// This makes the existing binding reach the surface it already names. It
// does not decide what the tap *should* raise; that is TASK-55's Q1, and
// the answer there may retarget this without changing it.
IpcHandler {
target: "overview"
function toggle(): void {
GlobalStates.overviewOpen = !GlobalStates.overviewOpen;
}
function open(): void {
GlobalStates.overviewOpen = true;
}
function close(): void {
GlobalStates.overviewOpen = false;
}
// Multitasking. Raised only by the pill's first-stage swipe until now,
// which meant the running-work view was the one surface on the device
// that neither an agent nor a test could reach — and it is the one
// that has been reported broken.
function missionControl(): void {
GlobalStates.missionControlOpen = !GlobalStates.missionControlOpen;
}
// The multitasking gesture, without a finger.
//
// TASK-60's acceptance: *"The gesture is reachable as a verb. An agent
// can drive `shift` and pick an end target well enough to demo
// multitasking."* Casey, 2026-08-07 — there is a point where she is
// asked for a tour of the phone, *"and that does mean even these little
// gesture steps will be possible."*
//
// Three steps rather than one opaque call, because a tour narrates: she
// can hold the windows half-carried while she says what multitasking
// is, and then land them. `swipe` is the whole motion for when the
// narration is not the point.
//
// No contact is synthesized, so the compositor never sees input at all
// and the run cannot raise `observed_confidence` or feed the idle
// budget — the machine must not believe a human is present because she
// moved a window. Step-up stays human-only by the same absence:
// `input.rs:432` routes `Origin::Agent` to `Route::Withheld`, and this
// path does not go near it.
function carryBegin(): string {
return ZoneTransition.begin() ? "carrying"
: "nothing on this zone to carry";
}
function carryShift(shift: real): void {
ZoneTransition.progress(shift);
}
// `target` is an end target by name: home, overview, last_zone, or a
// zone number. Same four the pill commits, same table.
function carryCommit(target: string): void {
const n = parseInt(target, 10);
ZoneTransition.commit(isNaN(n) ? target : ZoneTransition.zoneTarget(n));
}
// Begin, travel, and land — the whole gesture in one call.
function swipe(target: string): void {
ZoneTransition.begin();
tour.target = target;
tour.step = 0;
tour.restart();
}
function carryState(): string {
return JSON.stringify({
inFlight: ZoneTransition.inFlight,
cardScale: ZoneTransition.cardScale,
card: {
x: ZoneTransition.cardX,
y: ZoneTransition.cardY,
w: ZoneTransition.cardWidth,
h: ZoneTransition.cardHeight
},
panel: { w: ZoneTransition.panelWidth, h: ZoneTransition.panelHeight },
// What the surface reported, beside what the panel actually is.
// The card rect is derived from these, so a card in the wrong
// place is diagnosed by reading them rather than by theorising —
// the same reason `state` exists at all (TASK-60: `item.w: 0`
// beside `column.w: 0` located the zero-width card in one step).
surface: {
left: ZoneTransition.surfaceLeft,
top: ZoneTransition.surfaceTop,
w: ZoneTransition.surfaceWidth,
h: ZoneTransition.surfaceHeight
},
panelWindow: { w: panelWindow.width, h: panelWindow.height },
loaderItem: overviewLoader.item
? { w: overviewLoader.item.width, h: overviewLoader.item.height }
: null
});
}
function state(): string {
return JSON.stringify({
overview: GlobalStates.overviewOpen,
missionControl: GlobalStates.missionControlOpen,
zones: ViewtopControl.zoneCount,
windows: ViewtopControl.windows.length,
subscribed: ViewtopControl.subscribed,
searchingText: panelWindow.searchingText,
loaderActive: overviewLoader.active,
loaderHasItem: overviewLoader.item !== null,
activeZone: ViewtopControl.activeZone,
item: overviewLoader.item ? {
w: overviewLoader.item.width,
h: overviewLoader.item.height,
opacity: overviewLoader.item.opacity,
visible: overviewLoader.item.visible,
zones: overviewLoader.item.zones ? overviewLoader.item.zones.length : -1,
progress: overviewLoader.item.progress ?? -1
} : null,
column: { w: columnLayout.width, h: columnLayout.height, visible: columnLayout.visible },
loaderPos: { x: overviewLoader.x, y: overviewLoader.y, w: overviewLoader.width, h: overviewLoader.height }
});
}
}
// The travel half of `overview swipe`. Sixteen steps over ~320 ms is the
// settle `ZoneOverview` already animates against, so an agent's swipe and a
// thumb's arrive at the same speed — a demo that moved at a different rate
// than the real gesture would be showing something the phone does not do.
Timer {
id: tour
property string target: "overview"
property int step: 0
readonly property int steps: 16
interval: 20
repeat: true
onTriggered: {
tour.step += 1;
if (tour.step >= tour.steps) {
tour.stop();
const n = parseInt(tour.target, 10);
ZoneTransition.commit(isNaN(n) ? tour.target
: ZoneTransition.zoneTarget(n));
ZoneTransition.rest();
return;
}
// Drives the clock, exactly as the rail does — so the carry, the
// backdrop and the cards all move on the demo for the same reason
// they move under a thumb. A tour that ran its own animation would
// be showing something the phone does not actually do.
//
// A detent of `steps` makes one step one unit of travel, so the
// demo lands precisely on the multitasking detent at the last step.
ZoneTransition.pullTo(tour.step, tour.steps);
}
}
IpcHandler {
target: "search"
function toggle() {
GlobalStates.overviewOpen = !GlobalStates.overviewOpen;
}
function workspacesToggle() {
GlobalStates.overviewOpen = !GlobalStates.overviewOpen;
}
function close() {
GlobalStates.overviewOpen = false;
}
function open() {
GlobalStates.overviewOpen = true;
}
// Device-side acceptance/debug surface: lets deploy checks exercise
// the exact query path the text field uses without synthesizing keys.
function setQuery(query: string): void {
overviewScope.dontAutoCancelSearch = true;
GlobalStates.overviewOpen = true;
panelWindow.setSearchingText(query);
}
function status(): string {
return JSON.stringify({
open: GlobalStates.overviewOpen,
query: LauncherSearch.query,
results: LauncherSearch.results.length,
desktopEntries: DesktopEntries.applications.values.length,
appSearchEntries: AppSearch.list.length,
widget: searchWidget.debugState()
});
}
function toggleReleaseInterrupt() {
GlobalStates.superReleaseMightTrigger = false;
}
function clipboardToggle() {
overviewScope.toggleClipboard();
}
}
GlobalShortcut {
name: "searchToggle"
description: "Toggles search on press"
onPressed: {
GlobalStates.overviewOpen = !GlobalStates.overviewOpen;
}
}
GlobalShortcut {
name: "overviewWorkspacesClose"
description: "Closes overview on press"
onPressed: {
GlobalStates.overviewOpen = false;
}
}
GlobalShortcut {
name: "overviewWorkspacesToggle"
description: "Toggles overview on press"
onPressed: {
GlobalStates.overviewOpen = !GlobalStates.overviewOpen;
}
}
GlobalShortcut {
name: "searchToggleRelease"
description: "Toggles search on release"
onPressed: {
GlobalStates.superReleaseMightTrigger = true;
}
onReleased: {
if (!GlobalStates.superReleaseMightTrigger) {
GlobalStates.superReleaseMightTrigger = true;
return;
}
GlobalStates.overviewOpen = !GlobalStates.overviewOpen;
}
}
GlobalShortcut {
name: "searchToggleReleaseInterrupt"
description: "Interrupts possibility of search being toggled on release. " + "This is necessary because GlobalShortcut.onReleased in quickshell triggers whether or not you press something else while holding the key. " + "To make sure this works consistently, use binditn = MODKEYS, catchall in an automatically triggered submap that includes everything."
onPressed: {
GlobalStates.superReleaseMightTrigger = false;
}
}
GlobalShortcut {
name: "overviewClipboardToggle"
description: "Toggle clipboard query on overview widget"
onPressed: {
overviewScope.toggleClipboard();
}
}
GlobalShortcut {
name: "overviewEmojiToggle"
description: "Toggle emoji query on overview widget"
onPressed: {
overviewScope.toggleEmojis();
}
}
}

View file

@ -0,0 +1,7 @@
AppGrid 1.0 AppGrid.qml
Overview 1.0 Overview.qml
OverviewWidget 1.0 OverviewWidget.qml
OverviewWindow 1.0 OverviewWindow.qml
SearchBar 1.0 SearchBar.qml
SearchItem 1.0 SearchItem.qml
SearchWidget 1.0 SearchWidget.qml

View file

@ -0,0 +1,53 @@
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.
// Laptop deploys (souveraine.phone == false) are unchanged — hardware
// keyboard handles it.
//
// This was a poke — `oskOpen = PolkitService.active` on the transition — and
// a poke is not enough for two reasons, both of which left a password field
// on screen with no way to type into it:
//
// 1. squeekboard hides itself when input-method focus drops, which this
// layer-shell surface does not reliably hold. The keyboard appeared and
// then left while the prompt was still waiting.
// 2. The transition is all a poke sees. pkexec run before Config.ready —
// an update on login, a boot-time authorization — has its prompt up
// before this file is even loaded, so no transition ever arrives.
//
// A hold fixes both: the keyboard is re-asserted for as long as the prompt
// is up (GlobalStates.oskHold), and onCompleted takes the hold for a prompt
// that was already waiting.
FullscreenPolkitWindow {
id: root
contentComponent: Component {
PolkitContent {}
}
readonly property bool phone: Config.options?.souveraine?.phone ?? false
function syncKeyboard() {
if (!root.phone) return;
if (PolkitService.active) GlobalStates.oskHold("polkit");
else GlobalStates.oskRelease("polkit");
}
Component.onCompleted: root.syncKeyboard()
Connections {
target: PolkitService
function onActiveChanged() {
root.syncKeyboard();
}
}
}

View file

@ -0,0 +1,193 @@
// Phone Polkit content with a first-class FPC1020 temporary factor.
//
// The PAM module owns acceptance. This surface only recognizes its prompt,
// waits for a pulse that came from blueline-fingerprintd (not generic wake
// input), and submits the blank PAM response after the visible confirmation
// interval. The normal PIN/password conversation stays intact as fallback.
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Widgets
import qs.services
import qs.modules.common
import qs.modules.common.widgets
Item {
id: root
readonly property bool usePasswordChars: !PolkitService.flow?.responseVisible ?? true
readonly property bool fpcPrompt:
FingerprintPreview.polkitEnabled
&& PolkitService.cleanPrompt === "Touch and hold the fingerprint reader"
Keys.onPressed: event => {
if (event.key === Qt.Key_Escape)
PolkitService.cancel();
}
function submitPassword() {
PolkitService.submit(inputField.text);
}
function usePinInstead() {
// An empty response tells pam_souveraine_fpc to decline. PAM then
// reaches the ordinary system-auth conversation, where this same
// window shows the normal password field.
PolkitService.submit("");
}
Connections {
target: PolkitService
function onInteractionAvailableChanged() {
if (!PolkitService.interactionAvailable || root.fpcPrompt)
return;
inputField.text = "";
inputField.forceActiveFocus();
}
}
Connections {
target: FingerprintPreview
function onPulseObserved() {
if (PolkitService.active && PolkitService.interactionAvailable && root.fpcPrompt)
FingerprintPreview.beginHold("polkit");
}
function onHoldConfirmed(purpose) {
if (purpose === "polkit" && root.fpcPrompt && PolkitService.interactionAvailable)
PolkitService.submit("");
}
}
Rectangle {
anchors.fill: parent
color: Appearance.colors.colScrim
opacity: 0
Component.onCompleted: opacity = 1
Behavior on opacity {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
}
WindowDialog {
anchors.centerIn: parent
backgroundWidth: 450
show: false
Component.onCompleted: show = true
MaterialSymbol {
Layout.alignment: Qt.AlignHCenter
iconSize: 26
text: root.fpcPrompt ? "fingerprint" : "security"
color: Appearance.colors.colSecondary
}
WindowDialogTitle {
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
text: Translation.tr("Authentication")
}
WindowDialogParagraph {
Layout.fillWidth: true
horizontalAlignment: Text.AlignLeft
text: PolkitService.cleanMessage
}
Item {
Layout.fillWidth: true
visible: root.fpcPrompt
implicitHeight: visible ? 104 : 0
Rectangle {
anchors.fill: parent
radius: Appearance.rounding.normal
color: FingerprintPreview.confirmedPurpose === "polkit"
? Appearance.colors.colPrimary : "#2a000000"
border.width: FingerprintPreview.pulseSeen ? 2 : 1
border.color: FingerprintPreview.pulseSeen
? Appearance.colors.colPrimary : "#66ffffff"
}
Rectangle {
anchors.left: parent.left
anchors.bottom: parent.bottom
width: parent.width * (FingerprintPreview.activePurpose === "polkit"
? FingerprintPreview.holdProgress : 0)
height: 3
radius: 2
color: Appearance.colors.colPrimary
}
ColumnLayout {
anchors.centerIn: parent
spacing: 4
MaterialSymbol {
Layout.alignment: Qt.AlignHCenter
text: "fingerprint"
iconSize: 32
color: FingerprintPreview.confirmedPurpose === "polkit"
? Appearance.colors.colOnPrimary : Appearance.colors.colOnLayer1
}
StyledText {
Layout.alignment: Qt.AlignHCenter
text: FingerprintPreview.activePurpose === "polkit"
? Translation.tr("Reader contact received — confirming")
: Translation.tr("Touch and hold the fingerprint reader")
color: FingerprintPreview.confirmedPurpose === "polkit"
? Appearance.colors.colOnPrimary : Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
}
StyledText {
Layout.alignment: Qt.AlignHCenter
visible: FingerprintPreview.activePurpose === "polkit"
text: Translation.tr("%1 seconds").arg(Math.round(FingerprintPreview.holdMs / 1000))
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
}
}
}
MaterialTextField {
id: inputField
Layout.fillWidth: true
visible: !root.fpcPrompt
focus: visible
enabled: PolkitService.interactionAvailable
placeholderText: PolkitService.cleanPrompt
echoMode: root.usePasswordChars ? TextInput.Password : TextInput.Normal
onAccepted: root.submitPassword()
Keys.onPressed: event => {
if (event.key === Qt.Key_Escape)
PolkitService.cancel();
}
}
WindowDialogButtonRow {
Layout.bottomMargin: 10
Item { Layout.fillWidth: true }
DialogButton {
buttonText: Translation.tr("Cancel")
onClicked: PolkitService.cancel()
}
DialogButton {
visible: root.fpcPrompt
enabled: PolkitService.interactionAvailable
buttonText: Translation.tr("Use PIN instead")
onClicked: root.usePinInstead()
}
DialogButton {
visible: !root.fpcPrompt
enabled: PolkitService.interactionAvailable
buttonText: Translation.tr("OK")
onClicked: root.submitPassword()
}
}
}
onFpcPromptChanged: {
if (!fpcPrompt)
FingerprintPreview.reset("polkit");
}
}

View file

@ -0,0 +1,2 @@
Polkit 1.0 Polkit.qml
PolkitContent 1.0 PolkitContent.qml

View file

@ -0,0 +1,220 @@
import qs
import qs.modules.common
import qs.modules.common.widgets
import qs.services
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import Quickshell.Hyprland
Scope {
id: screenCorners
readonly property Toplevel activeWindow: ToplevelManager.activeToplevel
function isLeftCorner(corner) {
return corner === RoundCorner.CornerEnum.TopLeft || corner === RoundCorner.CornerEnum.BottomLeft;
}
function openSidebarForCorner(corner) {
if (isLeftCorner(corner))
GlobalStates.sidebarLeftOpen = true;
else
GlobalStates.sidebarRightOpen = true;
}
function toggleSidebarForCorner(corner) {
if (isLeftCorner(corner))
GlobalStates.sidebarLeftOpen = !GlobalStates.sidebarLeftOpen;
else
GlobalStates.sidebarRightOpen = !GlobalStates.sidebarRightOpen;
}
component CornerPanelWindow: PanelWindow {
id: cornerPanelWindow
property var brightnessMonitor: Brightness.getMonitorForScreen(screen)
property bool fullscreen
property bool cornerTriggerActive: false
property bool registeredAsFocusGrabPersistent: false
visible: (Config.options.appearance.fakeScreenRounding === 1 || (Config.options.appearance.fakeScreenRounding === 2 && !fullscreen))
property var corner
function updateFocusGrabPersistence() {
const shouldRegister = visible && cornerTriggerActive;
if (registeredAsFocusGrabPersistent === shouldRegister)
return;
registeredAsFocusGrabPersistent = shouldRegister;
if (shouldRegister)
GlobalFocusGrab.addPersistent(cornerPanelWindow);
else
GlobalFocusGrab.removePersistent(cornerPanelWindow);
}
onVisibleChanged: updateFocusGrabPersistence()
Component.onCompleted: updateFocusGrabPersistence()
Component.onDestruction: {
if (registeredAsFocusGrabPersistent)
GlobalFocusGrab.removePersistent(cornerPanelWindow);
}
exclusionMode: ExclusionMode.Ignore
mask: Region {
item: sidebarCornerOpenInteractionLoader.active ? sidebarCornerOpenInteractionLoader : null
}
WlrLayershell.namespace: "quickshell:screenCorners"
WlrLayershell.layer: WlrLayer.Overlay
color: "transparent"
anchors {
top: cornerWidget.isTopLeft || cornerWidget.isTopRight
left: cornerWidget.isBottomLeft || cornerWidget.isTopLeft
bottom: cornerWidget.isBottomLeft || cornerWidget.isBottomRight
right: cornerWidget.isTopRight || cornerWidget.isBottomRight
}
margins {
right: (Config.options.interactions.deadPixelWorkaround.enable && cornerPanelWindow.anchors.right) * -1
bottom: (Config.options.interactions.deadPixelWorkaround.enable && cornerPanelWindow.anchors.bottom) * -1
}
implicitWidth: cornerWidget.implicitWidth
implicitHeight: cornerWidget.implicitHeight
RoundCorner {
id: cornerWidget
anchors.fill: parent
corner: cornerPanelWindow.corner
rightVisualMargin: (Config.options.interactions.deadPixelWorkaround.enable && cornerPanelWindow.anchors.right) * 1
bottomVisualMargin: (Config.options.interactions.deadPixelWorkaround.enable && cornerPanelWindow.anchors.bottom) * 1
implicitSize: Appearance.rounding.screenRounding
implicitHeight: Math.max(implicitSize, sidebarCornerOpenInteractionLoader.implicitHeight)
implicitWidth: Math.max(implicitSize, sidebarCornerOpenInteractionLoader.implicitWidth)
Loader {
id: sidebarCornerOpenInteractionLoader
active: {
if (!Config.options.sidebar.cornerOpen.enable) return false;
if (cornerPanelWindow.fullscreen) return false;
return (Config.options.sidebar.cornerOpen.bottom == cornerWidget.isBottom);
}
function syncFocusGrabPersistence() {
cornerPanelWindow.cornerTriggerActive = active;
cornerPanelWindow.updateFocusGrabPersistence();
}
Component.onCompleted: syncFocusGrabPersistence()
onActiveChanged: syncFocusGrabPersistence()
anchors {
top: (cornerWidget.isTopLeft || cornerWidget.isTopRight) ? parent.top : undefined
bottom: (cornerWidget.isBottomLeft || cornerWidget.isBottomRight) ? parent.bottom : undefined
left: (cornerWidget.isLeft) ? parent.left : undefined
right: (cornerWidget.isTopRight || cornerWidget.isBottomRight) ? parent.right : undefined
}
sourceComponent: FocusedScrollMouseArea {
id: mouseArea
implicitWidth: Config.options.sidebar.cornerOpen.cornerRegionWidth
implicitHeight: Config.options.sidebar.cornerOpen.cornerRegionHeight
hoverEnabled: true
onPositionChanged: {
if (!Config.options.sidebar.cornerOpen.clicklessCornerEnd) return;
const verticalOffset = Config.options.sidebar.cornerOpen.clicklessCornerVerticalOffset;
const correctX = (cornerWidget.isRight && mouseArea.mouseX >= mouseArea.width - 2) || (cornerWidget.isLeft && mouseArea.mouseX <= 2);
const correctY = (cornerWidget.isTop && mouseArea.mouseY > verticalOffset || cornerWidget.isBottom && mouseArea.mouseY < mouseArea.height - verticalOffset);
if (correctX && correctY)
screenCorners.openSidebarForCorner(cornerPanelWindow.corner);
}
onEntered: {
if (Config.options.sidebar.cornerOpen.clickless)
screenCorners.openSidebarForCorner(cornerPanelWindow.corner);
}
onPressed: {
screenCorners.toggleSidebarForCorner(cornerPanelWindow.corner);
}
onScrollDown: {
if (!Config.options.sidebar.cornerOpen.valueScroll)
return;
if (cornerWidget.isLeft)
Brightness.decreaseBrightness()
else {
const currentVolume = Audio.value;
const step = currentVolume < 0.1 ? 0.01 : 0.02 || 0.2;
Audio.sink.audio.volume -= step;
}
}
onScrollUp: {
if (!Config.options.sidebar.cornerOpen.valueScroll)
return;
if (cornerWidget.isLeft)
Brightness.increaseBrightness()
else {
const currentVolume = Audio.value;
const step = currentVolume < 0.1 ? 0.01 : 0.02 || 0.2;
Audio.sink.audio.volume = Math.min(1, Audio.sink.audio.volume + step);
}
}
onMovedAway: {
if (!Config.options.sidebar.cornerOpen.valueScroll)
return;
if (cornerWidget.isLeft)
GlobalStates.osdBrightnessOpen = false;
else
GlobalStates.osdVolumeOpen = false;
}
Loader {
active: Config.options.sidebar.cornerOpen.visualize
anchors.fill: parent
sourceComponent: Rectangle {
color: Appearance.colors.colPrimary
}
}
}
}
}
}
Variants {
model: Quickshell.screens
Scope {
id: monitorScope
required property var modelData
property HyprlandMonitor monitor: Hyprland.monitorFor(modelData)
// Hide when fullscreen
property list<HyprlandWorkspace> workspacesForMonitor: Hyprland.workspaces.values.filter(workspace => workspace.monitor && workspace.monitor.name == monitor.name)
property var activeWorkspaceWithFullscreen: workspacesForMonitor.filter(workspace => ((workspace.toplevels.values.filter(window => window.wayland?.fullscreen)[0] != undefined) && workspace.active))[0]
// The workspace scan uses ext-foreign-toplevel-list, which never
// sees standalone `qs -p` windows (souveraine-settings). Fall back
// to Hyprland's own IPC via HyprlandData (hyprctl activewindow -j);
// fullscreen === 2 is real fullscreen. The fallback is monitor-wide
// rather than per-monitor (activeWindow carries no monitor name we
// key on here) — correct on single-monitor phone duty.
property bool fullscreen: activeWorkspaceWithFullscreen != undefined
|| HyprlandData.activeWindow?.fullscreen === 2
CornerPanelWindow {
screen: modelData
corner: RoundCorner.CornerEnum.TopLeft
fullscreen: monitorScope.fullscreen
}
CornerPanelWindow {
screen: modelData
corner: RoundCorner.CornerEnum.TopRight
fullscreen: monitorScope.fullscreen
}
CornerPanelWindow {
screen: modelData
corner: RoundCorner.CornerEnum.BottomLeft
fullscreen: monitorScope.fullscreen
}
CornerPanelWindow {
screen: modelData
corner: RoundCorner.CornerEnum.BottomRight
fullscreen: monitorScope.fullscreen
}
}
}
}

View file

@ -0,0 +1 @@
ScreenCorners 1.0 ScreenCorners.qml

View file

@ -0,0 +1,351 @@
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
// Each compositor owns its focus fact. ViewTop reports the output carrying
// seat attention over its control socket; Hyprland supplies its native
// focused monitor. The first Wayland output is only the cold-start answer
// while neither compositor has replied yet.
readonly property var focusedScreen: {
const hyprlandScreen = Quickshell.screens.find(
screen => screen.name === Hyprland.focusedMonitor?.name
);
const viewtopScreen = Quickshell.screens.find(
screen => screen.name === ViewtopControl.activeOutputName
);
return hyprlandScreen ?? viewtopScreen ?? Quickshell.screens[0] ?? null;
}
Loader {
id: sessionLoader
active: GlobalStates.sessionOpen
onActiveChanged: {
if (sessionLoader.active) {
SessionWarnings.refresh();
ViewtopControl.refreshState();
}
}
Connections {
target: GlobalStates
function onScreenLockedChanged() {
if (GlobalStates.screenLocked) {
GlobalStates.sessionOpen = false;
}
}
}
sourceComponent: PanelWindow { // Session menu
id: sessionRoot
visible: sessionLoader.active
screen: root.focusedScreen
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
bottom: true
}
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

@ -0,0 +1,2 @@
SessionActionButton 1.0 SessionActionButton.qml
SessionScreen 1.0 SessionScreen.qml

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,291 @@
import qs
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import Quickshell.Io
import Quickshell
import Quickshell.Wayland
import Quickshell.Hyprland
Scope { // Scope
id: root
property bool detach: false
property bool pin: false
property Component contentComponent: SidebarLeftContent {}
property Item sidebarContent
function toggleDetach() {
root.detach = !root.detach;
}
Process { // Dodge cursor away, pin, move cursor back
id: pinWithFunnyHyprlandWorkaroundProc
property var hook: null
property int cursorX;
property int cursorY;
function doIt() {
command = ["hyprctl", "cursorpos"]
hook = (output) => {
cursorX = parseInt(output.split(",")[0]);
cursorY = parseInt(output.split(",")[1]);
doIt2();
}
running = true;
}
function doIt2(output) {
command = ["bash", "-c", "hyprctl dispatch 'hl.dsp.cursor.move({x=9999,y=9999})'"];
hook = () => {
doIt3();
}
running = true;
}
function doIt3(output) {
root.pin = !root.pin;
command = ["bash", "-c", `sleep 0.01; hyprctl dispatch 'hl.dsp.cursor.move({x=${cursorX},y=${cursorY}})'`];
hook = null
running = true;
}
stdout: StdioCollector {
onStreamFinished: {
pinWithFunnyHyprlandWorkaroundProc.hook(text);
}
}
}
function togglePin() {
if (!root.pin) pinWithFunnyHyprlandWorkaroundProc.doIt()
else root.pin = !root.pin;
}
Component.onCompleted: {
root.sidebarContent = contentComponent.createObject(null, {
"scopeRoot": root,
});
sidebarLoader.item.contentParent.children = [root.sidebarContent];
}
onDetachChanged: {
if (root.detach) {
GlobalFocusGrab.removeDismissable(sidebarLoader.item) // Remove sidebar from the focus grab system
sidebarContent.parent = null; // Detach content from sidebar
sidebarLoader.active = false; // Unload sidebar
detachedSidebarLoader.active = true; // Load detached window
detachedSidebarLoader.item.contentParent.children = [sidebarContent];
} else {
sidebarContent.parent = null; // Detach content from window
detachedSidebarLoader.active = false; // Unload detached window
sidebarLoader.active = true; // Load sidebar
sidebarLoader.item.contentParent.children = [sidebarContent];
}
}
Loader {
id: sidebarLoader
active: true
sourceComponent: PanelWindow { // Window
id: panelWindow
visible: GlobalStates.sidebarLeftOpen
property bool extend: false
property real sidebarWidth: panelWindow.extend ? Appearance.sizes.sidebarWidthExtended : Appearance.sizes.sidebarWidth
property var contentParent: sidebarLeftBackground
function hide() {
GlobalStates.sidebarLeftOpen = false
}
exclusionMode: ExclusionMode.Normal
exclusiveZone: root.pin ? sidebarWidth : 0
implicitWidth: Appearance.sizes.sidebarWidthExtended + Appearance.sizes.elevationMargin
WlrLayershell.namespace: "quickshell:sidebarLeft"
// Hyprland 0.49: OnDemand is Exclusive, Exclusive just breaks click-outside-to-close
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
color: "transparent"
anchors {
top: true
left: true
bottom: true
}
mask: Region {
item: sidebarLeftBackground
}
onVisibleChanged: {
if (visible && !GlobalStates.oskOpen) {
GlobalFocusGrab.addDismissable(panelWindow);
} else {
GlobalFocusGrab.removeDismissable(panelWindow);
}
}
Connections {
target: GlobalFocusGrab
function onDismissed() {
panelWindow.hide();
}
}
// OSK flee-fix: squeekboard is an external surface not in the
// HyprlandFocusGrab whitelist; typing on it clears the grab and
// hides this panel. While the OSK is open, leave the dismissable
// set so there is nothing to clear. Mirrors Overview.qml.
Connections {
target: GlobalStates
function onOskOpenChanged() {
if (!GlobalStates.sidebarLeftOpen) return;
if (GlobalStates.oskOpen) {
GlobalFocusGrab.removeDismissable(panelWindow);
} else {
GlobalFocusGrab.addDismissable(panelWindow);
}
}
}
// Content
StyledRectangularShadow {
target: sidebarLeftBackground
radius: sidebarLeftBackground.radius
}
Rectangle {
id: sidebarLeftBackground
anchors.top: parent.top
anchors.left: parent.left
anchors.topMargin: Appearance.sizes.hyprlandGapsOut
anchors.leftMargin: Appearance.sizes.hyprlandGapsOut
width: panelWindow.sidebarWidth - Appearance.sizes.hyprlandGapsOut - Appearance.sizes.elevationMargin
height: parent.height - Appearance.sizes.hyprlandGapsOut * 2
color: Appearance.colors.colLayer0
border.width: 1
border.color: Appearance.colors.colLayer0Border
radius: Appearance.rounding.screenRounding - Appearance.sizes.hyprlandGapsOut + 1
Behavior on width {
animation: Appearance.animation.elementMove.numberAnimation.createObject(this)
}
Keys.onPressed: (event) => {
if (event.key === Qt.Key_Escape) {
panelWindow.hide();
}
if (event.modifiers === Qt.ControlModifier) {
if (event.key === Qt.Key_O) {
panelWindow.extend = !panelWindow.extend;
} else if (event.key === Qt.Key_D) {
root.toggleDetach();
} else if (event.key === Qt.Key_P) {
root.togglePin();
}
event.accepted = true;
}
}
}
}
}
Loader {
id: detachedSidebarLoader
active: false
sourceComponent: FloatingWindow {
id: detachedSidebarRoot
property var contentParent: detachedSidebarBackground
color: "transparent"
visible: GlobalStates.sidebarLeftOpen
onVisibleChanged: {
if (!visible) GlobalStates.sidebarLeftOpen = false;
}
Rectangle {
id: detachedSidebarBackground
anchors.fill: parent
color: Appearance.colors.colLayer0
Keys.onPressed: (event) => {
if (event.modifiers === Qt.ControlModifier) {
if (event.key === Qt.Key_D) {
root.toggleDetach();
}
event.accepted = true;
}
}
}
}
}
// The keyboard leaves with the panel that summoned it.
//
// The drawer clears `oskOpen` on close and this did not, so the OSK
// outlived the surface holding the only text field it was serving — and
// with nothing focused there was no obvious way to put it away again.
// Casey, 2026-08-06: the keyboard "can't get it to disappear" on the AI
// side panel.
//
// On the state, not on a visibility handler: the panel's `visible` is a
// binding to this flag, so reacting to visibility would be reacting to
// our own effect (Phosh's rule, and SHELL-ECOSYSTEM's).
Connections {
target: GlobalStates
function onSidebarLeftOpenChanged() {
if (!GlobalStates.sidebarLeftOpen)
GlobalStates.oskOpen = false;
}
}
IpcHandler {
target: "sidebarLeft"
function toggle(): void {
GlobalStates.sidebarLeftOpen = !GlobalStates.sidebarLeftOpen
}
function close(): void {
GlobalStates.sidebarLeftOpen = false
}
function open(): void {
GlobalStates.sidebarLeftOpen = true
}
}
GlobalShortcut {
name: "sidebarLeftToggle"
description: "Toggles left sidebar on press"
onPressed: {
GlobalStates.sidebarLeftOpen = !GlobalStates.sidebarLeftOpen;
}
}
GlobalShortcut {
name: "sidebarLeftOpen"
description: "Opens left sidebar on press"
onPressed: {
GlobalStates.sidebarLeftOpen = true;
}
}
GlobalShortcut {
name: "sidebarLeftClose"
description: "Closes left sidebar on press"
onPressed: {
GlobalStates.sidebarLeftOpen = false;
}
}
GlobalShortcut {
name: "sidebarLeftToggleDetach"
description: "Detach left sidebar into a window/Attach it back"
onPressed: {
root.detach = !root.detach;
}
}
}

View file

@ -0,0 +1,9 @@
AiChat 1.0 AiChat.qml
Anime 1.0 Anime.qml
ApiCommandButton 1.0 ApiCommandButton.qml
ApiInputBoxIndicator 1.0 ApiInputBoxIndicator.qml
DescriptionBox 1.0 DescriptionBox.qml
ScrollToBottomButton 1.0 ScrollToBottomButton.qml
SidebarLeft 1.0 SidebarLeft.qml
SidebarLeftContent 1.0 SidebarLeftContent.qml
Translator 1.0 Translator.qml

View file

@ -0,0 +1,294 @@
pragma ComponentBehavior: Bound
import qs.modules.common
import qs.modules.common.widgets
import qs.services
import qs.modules.ii.sidebarRight.calendar
import qs.modules.ii.sidebarRight.todo
import qs.modules.ii.sidebarRight.pomodoro
import QtQuick
import QtQuick.Layouts
Rectangle {
id: root
radius: Appearance.rounding.normal
color: Appearance.colors.colLayer1
clip: true
implicitHeight: collapsed ? collapsedBottomWidgetGroupRow.implicitHeight : 430
property int selectedTab: Persistent.states.sidebar.bottomGroup.tab
property int previousIndex: -1
property bool collapsed: Persistent.states.sidebar.bottomGroup.collapsed
readonly property int collapsedHeight: Math.max(
Appearance.font.pixelSize.larger + 24,
Appearance.font.pixelSize.large + 24
)
property var tabs: [
{
"type": "calendar",
"name": Translation.tr("Calendar"),
"icon": "calendar_month",
"widget": "calendar/CalendarWidget.qml"
},
{
"type": "todo",
"name": Translation.tr("To Do"),
"icon": "done_outline",
"widget": "todo/TodoWidget.qml"
},
{
"type": "pomodoro",
"name": Translation.tr("Pomodoro"),
"icon": "search_activity",
"widget": "pomodoro/PomodoroWidget.qml"
},
{
"type": "stopwatch",
"name": Translation.tr("Stopwatch"),
"icon": "timer",
"widget": "pomodoro/StopwatchWidget.qml"
},
{
"type": "garden",
"name": Translation.tr("Garden"),
"icon": "hub",
"widget": "lens/LensWidget.qml"
},
]
Behavior on implicitHeight {
NumberAnimation {
duration: Appearance.animation.elementMove.duration
easing.type: Appearance.animation.elementMove.type
easing.bezierCurve: Appearance.animation.elementMove.bezierCurve
}
}
function setCollapsed(state) {
Persistent.states.sidebar.bottomGroup.collapsed = state;
if (collapsed) {
bottomWidgetGroupRow.opacity = 0;
} else {
collapsedBottomWidgetGroupRow.opacity = 0;
}
collapseCleanFadeTimer.start();
}
Connections {
target: Persistent.states.sidebar.bottomGroup
function onTabChanged() {
root.selectedTab = Persistent.states.sidebar.bottomGroup.tab;
}
}
Timer {
id: collapseCleanFadeTimer
interval: Appearance.animation.elementMove.duration / 2
repeat: false
onTriggered: {
if (collapsed)
collapsedBottomWidgetGroupRow.opacity = 1;
else
bottomWidgetGroupRow.opacity = 1;
}
}
Keys.onPressed: event => {
if ((event.key === Qt.Key_PageDown || event.key === Qt.Key_PageUp) && event.modifiers === Qt.ControlModifier) {
if (event.key === Qt.Key_PageDown) {
root.selectedTab = Math.min(root.selectedTab + 1, root.tabs.length - 1);
} else if (event.key === Qt.Key_PageUp) {
root.selectedTab = Math.max(root.selectedTab - 1, 0);
}
event.accepted = true;
}
}
RowLayout {
id: collapsedBottomWidgetGroupRow
opacity: collapsed ? 1 : 0
visible: opacity > 0
Behavior on opacity {
NumberAnimation {
id: collapsedBottomWidgetGroupRowFade
duration: Appearance.animation.elementMove.duration / 2
easing.type: Appearance.animation.elementMove.type
easing.bezierCurve: Appearance.animation.elementMove.bezierCurve
}
}
spacing: 15
CalendarHeaderButton {
Layout.margins: 10
Layout.rightMargin: 0
forceCircle: true
downAction: () => {
root.setCollapsed(false);
}
contentItem: MaterialSymbol {
text: "keyboard_arrow_up"
iconSize: Appearance.font.pixelSize.larger
horizontalAlignment: Text.AlignHCenter
color: Appearance.colors.colOnLayer1
}
}
StyledText {
property int remainingTasks: Todo.list.filter(task => !task.done).length
Layout.margins: 10
Layout.leftMargin: 0
text: Translation.tr("%1 • %2 tasks").arg(DateTime.collapsedCalendarFormat).arg(remainingTasks)
font.pixelSize: Appearance.font.pixelSize.large
color: Appearance.colors.colOnLayer1
}
}
RowLayout {
id: bottomWidgetGroupRow
opacity: collapsed ? 0 : 1
visible: opacity > 0
Behavior on opacity {
NumberAnimation {
id: bottomWidgetGroupRowFade
duration: Appearance.animation.elementMove.duration / 2
easing.type: Appearance.animation.elementMove.type
easing.bezierCurve: Appearance.animation.elementMove.bezierCurve
}
}
anchors.fill: parent
spacing: 20
Item {
Layout.fillHeight: true
Layout.fillWidth: false
Layout.leftMargin: 10
Layout.topMargin: 10
implicitWidth: tabBar.implicitWidth
NavigationRailTabArray {
id: tabBar
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: 5
currentIndex: root.selectedTab
expanded: false
Repeater {
model: root.tabs
NavigationRailButton {
required property int index
required property var modelData
showToggledHighlight: false
toggled: root.selectedTab == index
buttonText: modelData.name
buttonIcon: modelData.icon
onPressed: {
root.selectedTab = index;
Persistent.states.sidebar.bottomGroup.tab = index;
}
}
}
}
CalendarHeaderButton {
anchors.left: parent.left
anchors.top: parent.top
forceCircle: true
downAction: () => {
root.setCollapsed(true);
}
contentItem: MaterialSymbol {
text: "keyboard_arrow_down"
iconSize: Appearance.font.pixelSize.larger
horizontalAlignment: Text.AlignHCenter
color: Appearance.colors.colOnLayer1
}
}
}
Item {
Layout.fillWidth: true
Layout.fillHeight: true
Loader {
id: tabStack
anchors.fill: parent
anchors.bottomMargin: -anchors.topMargin
Component.onCompleted: {
tabStack.source = root.tabs[root.selectedTab].widget;
}
Connections {
target: root
function onSelectedTabChanged() {
if (root.selectedTab > root.previousIndex)
tabSwitchBehavior.animation.down = true;
else if (root.selectedTab < root.previousIndex)
tabSwitchBehavior.animation.down = false;
tabStack.source = root.tabs[root.selectedTab].widget;
}
}
Behavior on source {
id: tabSwitchBehavior
animation: TabSwitchAnim {
id: upAnim
down: true
}
}
}
}
}
component TabSwitchAnim: SequentialAnimation {
id: switchAnim
property bool down: false
ParallelAnimation {
PropertyAnimation {
target: tabStack
properties: "opacity"
to: 0
duration: Appearance.animation.elementMoveFast.duration
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve
}
PropertyAnimation {
target: tabStack.anchors
properties: "topMargin"
to: 10 * (switchAnim.down ? -1 : 1)
duration: Appearance.animation.elementMoveFast.duration
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve
}
}
PropertyAction {
target: tabStack
property: "source"
value: root.tabs[root.selectedTab].widget
}
ParallelAnimation {
PropertyAnimation {
target: tabStack.anchors
properties: "topMargin"
from: 10 * -(switchAnim.down ? -1 : 1)
to: 0
duration: Appearance.animation.elementMoveFast.duration
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.animation.elementMoveEnter.bezierCurve
}
PropertyAnimation {
target: tabStack
properties: "opacity"
to: 1
duration: Appearance.animation.elementMoveFast.duration
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.animation.elementMoveEnter.bezierCurve
}
}
ScriptAction {
script: {
root.previousIndex = root.selectedTab;
}
}
}
}

View file

@ -0,0 +1,18 @@
import qs.modules.common
import qs.modules.common.widgets
import qs.services
import qs.modules.ii.sidebarRight.notifications
import QtQuick
import QtQuick.Layouts
Rectangle {
id: root
clip: true
radius: Appearance.rounding.normal
color: Appearance.colors.colLayer1
NotificationList {
anchors.fill: parent
anchors.margins: 5
}
}

View file

@ -0,0 +1,152 @@
import qs
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Hyprland
import Quickshell.Services.UPower
Rectangle {
id: root
property var screen: root.QsWindow.window?.screen
property var brightnessMonitor: Brightness.getMonitorForScreen(screen)
implicitWidth: contentItem.implicitWidth + root.horizontalPadding * 2
implicitHeight: contentItem.implicitHeight + root.verticalPadding * 2
radius: Appearance.rounding.normal
color: Appearance.colors.colLayer1
property real verticalPadding: 4
property real horizontalPadding: 12
Column {
id: contentItem
anchors {
fill: parent
leftMargin: root.horizontalPadding
rightMargin: root.horizontalPadding
topMargin: root.verticalPadding
bottomMargin: root.verticalPadding
}
Loader {
anchors {
left: parent.left
right: parent.right
}
visible: active
active: Config.options.sidebar.quickSliders.showBrightness
sourceComponent: QuickSlider {
materialSymbol: "light_mode"
secondaryMaterialSymbol: "wb_twilight"
stopIndicatorValues: Hyprsunset.gamma !== 100 && root.brightnessMonitor?.brightness !== 0 ? [0.3 + root.brightnessMonitor?.brightness * 0.7] : []
value: Hyprsunset.gamma === 100? 0.3 + root.brightnessMonitor?.brightness * 0.7 : (Hyprsunset.gamma - Hyprsunset.gammaLowerLimit) / (100 - Hyprsunset.gammaLowerLimit) * 0.3
tooltipContent: Hyprsunset.gamma === 100 ? `${Math.round(root.brightnessMonitor?.brightness * 100)}%` : `${Translation.tr("Gamma")} ${Hyprsunset.gamma}%`
onMoved: {
if (value >= 0.3) {
// 0.3 - 1.0 brightness
root.brightnessMonitor.setBrightness((value - 0.3) / 0.7);
if (Hyprsunset.gamma !== 100) {
Hyprsunset.setGamma(100);
}
} else {
// 0 - 0.3 gamma
if (root.brightnessMonitor.brightness !== 0) {
root.brightnessMonitor.setBrightness(0);
}
Hyprsunset.setGamma((value / 0.3 * (100 - Hyprsunset.gammaLowerLimit) + Hyprsunset.gammaLowerLimit));
}
}
}
}
Loader {
anchors {
left: parent.left
right: parent.right
}
visible: active
// PipeWire may not have announced the default sink when ii starts.
// Do not create a slider that dereferences a null PwNode; this
// binding re-evaluates when the node appears.
active: Config.options.sidebar.quickSliders.showVolume && Audio.sink?.audio !== null && Audio.sink?.audio !== undefined
sourceComponent: QuickSlider {
materialSymbol: "volume_up"
value: Audio.sink.audio.volume
onMoved: {
Audio.sink.audio.volume = value
}
}
}
Loader {
anchors {
left: parent.left
right: parent.right
}
visible: active
// The microphone slider remains absent until ALSA exposes a
// capture source; this phone currently has none because mic
// transport is still all-zero (see PAF/audio.md).
active: Config.options.sidebar.quickSliders.showMic && Audio.source?.audio !== null && Audio.source?.audio !== undefined
sourceComponent: QuickSlider {
materialSymbol: "mic"
value: Audio.source.audio.volume
onMoved: {
Audio.source.audio.volume = value
}
}
}
}
component QuickSlider: StyledSlider {
id: quickSlider
required property string materialSymbol
property string secondaryMaterialSymbol
configuration: StyledSlider.Configuration.M
stopIndicatorValues: []
dividerValues: secondaryMaterialSymbol.length > 0 ? [secondaryIcon.iconLocation] : []
MaterialSymbol {
id: icon
property bool nearFull: quickSlider.value >= 0.9
anchors {
verticalCenter: quickSlider.verticalCenter
right: nearFull ? quickSlider.handle.right : quickSlider.right
rightMargin: nearFull ? 14 : 8
}
iconSize: 20
color: nearFull ? Appearance.colors.colOnPrimary : Appearance.colors.colOnSecondaryContainer
text: quickSlider.materialSymbol
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
Behavior on anchors.rightMargin {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
}
MaterialSymbol {
id: secondaryIcon
visible: secondaryMaterialSymbol.length > 0
property real iconLocation: 0.3
property bool nearIcon: iconLocation - quickSlider.value <= 0.1 && iconLocation - quickSlider.value > (quickSlider.handleWidth + 8 - 14) / quickSlider.effectiveDraggingWidth
anchors {
verticalCenter: quickSlider.verticalCenter
right: nearIcon ? quickSlider.handle.right : quickSlider.right
rightMargin: nearIcon ? 14 : (1 - iconLocation) * quickSlider.effectiveDraggingWidth + quickSlider.rightPadding + 8
}
iconSize: 20
color: quickSlider.value >= iconLocation - 0.1 ? Appearance.colors.colOnPrimary : Appearance.colors.colOnSecondaryContainer
text: secondaryMaterialSymbol
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
}
}
}

View file

@ -0,0 +1,126 @@
import qs
import qs.services
import qs.modules.common
import QtQuick
import Quickshell.Io
import Quickshell
import Quickshell.Wayland
import Quickshell.Hyprland
Scope {
id: root
property int sidebarWidth: Appearance.sizes.sidebarWidth
PanelWindow {
id: panelWindow
visible: GlobalStates.sidebarRightOpen
function hide() {
GlobalStates.sidebarRightOpen = false;
}
exclusiveZone: 0
implicitWidth: sidebarWidth
WlrLayershell.namespace: "quickshell:sidebarRight"
WlrLayershell.keyboardFocus: GlobalStates.sidebarRightOpen ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
color: "transparent"
anchors {
top: true
right: true
bottom: true
}
onVisibleChanged: {
if (visible && !GlobalStates.oskOpen) {
GlobalFocusGrab.addDismissable(panelWindow);
} else {
GlobalFocusGrab.removeDismissable(panelWindow);
}
}
Connections {
target: GlobalFocusGrab
function onDismissed() {
panelWindow.hide();
}
}
// OSK flee-fix: squeekboard is an external surface not in the
// HyprlandFocusGrab whitelist; typing on it clears the grab and
// hides this panel. While the OSK is open, leave the dismissable
// set so there is nothing to clear. Mirrors Overview.qml.
Connections {
target: GlobalStates
function onOskOpenChanged() {
if (!GlobalStates.sidebarRightOpen) return;
if (GlobalStates.oskOpen) {
GlobalFocusGrab.removeDismissable(panelWindow);
} else {
GlobalFocusGrab.addDismissable(panelWindow);
}
}
}
Loader {
id: sidebarContentLoader
active: GlobalStates.sidebarRightOpen || Config?.options.sidebar.keepRightSidebarLoaded
anchors {
fill: parent
margins: Appearance.sizes.hyprlandGapsOut
leftMargin: Appearance.sizes.elevationMargin
}
width: sidebarWidth - Appearance.sizes.hyprlandGapsOut - Appearance.sizes.elevationMargin
height: parent.height - Appearance.sizes.hyprlandGapsOut * 2
focus: GlobalStates.sidebarRightOpen
Keys.onPressed: event => {
if (event.key === Qt.Key_Escape) {
panelWindow.hide();
}
}
sourceComponent: SidebarRightContent {}
}
}
IpcHandler {
target: "sidebarRight"
function toggle(): void {
GlobalStates.sidebarRightOpen = !GlobalStates.sidebarRightOpen;
}
function close(): void {
GlobalStates.sidebarRightOpen = false;
}
function open(): void {
GlobalStates.sidebarRightOpen = true;
}
}
GlobalShortcut {
name: "sidebarRightToggle"
description: "Toggles right sidebar on press"
onPressed: {
GlobalStates.sidebarRightOpen = !GlobalStates.sidebarRightOpen;
}
}
GlobalShortcut {
name: "sidebarRightOpen"
description: "Opens right sidebar on press"
onPressed: {
GlobalStates.sidebarRightOpen = true;
}
}
GlobalShortcut {
name: "sidebarRightClose"
description: "Closes right sidebar on press"
onPressed: {
GlobalStates.sidebarRightOpen = false;
}
}
}

View file

@ -0,0 +1,329 @@
import qs
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Bluetooth
import Quickshell.Hyprland
import qs.modules.ii.sidebarRight.quickToggles
import qs.modules.ii.sidebarRight.quickToggles.classicStyle
import qs.modules.ii.sidebarRight.bluetoothDevices
import qs.modules.ii.sidebarRight.nightLight
import qs.modules.ii.sidebarRight.volumeMixer
import qs.modules.ii.sidebarRight.wifiNetworks
Item {
id: root
property int sidebarWidth: Appearance.sizes.sidebarWidth
property int sidebarPadding: 10
property bool showAudioOutputDialog: false
property bool showAudioInputDialog: false
property bool showBluetoothDialog: false
property bool showNightLightDialog: false
property bool showWifiDialog: false
property bool editMode: false
Connections {
target: GlobalStates
function onSidebarRightOpenChanged() {
if (!GlobalStates.sidebarRightOpen) {
root.showWifiDialog = false;
root.showBluetoothDialog = false;
root.showAudioOutputDialog = false;
root.showAudioInputDialog = false;
}
}
}
implicitHeight: sidebarRightBackground.implicitHeight
implicitWidth: sidebarRightBackground.implicitWidth
StyledRectangularShadow {
target: sidebarRightBackground
}
Rectangle {
id: sidebarRightBackground
anchors.fill: parent
implicitHeight: parent.height - Appearance.sizes.hyprlandGapsOut * 2
implicitWidth: sidebarWidth - Appearance.sizes.hyprlandGapsOut * 2
color: Appearance.colors.colLayer0
border.width: 1
border.color: Appearance.colors.colLayer0Border
radius: Appearance.rounding.screenRounding - Appearance.sizes.hyprlandGapsOut + 1
ColumnLayout {
anchors.fill: parent
anchors.margins: sidebarPadding
spacing: sidebarPadding
SystemButtonRow {
Layout.fillHeight: false
Layout.fillWidth: true
// Layout.margins: 10
Layout.topMargin: 5
Layout.bottomMargin: 0
}
Loader {
id: slidersLoader
Layout.fillWidth: true
visible: active
active: {
const configQuickSliders = Config.options.sidebar.quickSliders
if (!configQuickSliders.enable) return false
if (!configQuickSliders.showMic && !configQuickSliders.showVolume && !configQuickSliders.showBrightness) return false;
return true;
}
sourceComponent: QuickSliders {}
}
LoaderedQuickPanelImplementation {
styleName: "classic"
sourceComponent: ClassicQuickPanel {}
}
LoaderedQuickPanelImplementation {
styleName: "android"
sourceComponent: AndroidQuickPanel {
editMode: root.editMode
}
}
Item {
Layout.fillHeight: true
Layout.fillWidth: true
clip: true
CenterWidgetGroup {
id: centerGroup
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: bottomGroup.top
anchors.bottomMargin: root.sidebarPadding
opacity: bottomGroup.collapsed ? 1 : 0
visible: opacity > 0
Behavior on opacity {
NumberAnimation {
duration: Appearance.animation.elementMove.duration
easing.type: Appearance.animation.elementMove.type
easing.bezierCurve: Appearance.animation.elementMove.bezierCurve
}
}
}
BottomWidgetGroup {
id: bottomGroup
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: bottomGroup.collapsed ? bottomGroup.collapsedHeight : parent.height
Behavior on height {
NumberAnimation {
duration: Appearance.animation.elementMove.duration
easing.type: Appearance.animation.elementMove.type
easing.bezierCurve: Appearance.animation.elementMove.bezierCurve
}
}
}
}
}
}
ToggleDialog {
shownPropertyString: "showAudioOutputDialog"
dialog: VolumeDialog {
isSink: true
}
}
ToggleDialog {
shownPropertyString: "showAudioInputDialog"
dialog: VolumeDialog {
isSink: false
}
}
ToggleDialog {
shownPropertyString: "showBluetoothDialog"
dialog: BluetoothDialog {}
onShownChanged: {
if (!shown) {
Bluetooth.defaultAdapter.discovering = false;
} else {
Bluetooth.defaultAdapter.enabled = true;
Bluetooth.defaultAdapter.discovering = true;
}
}
}
ToggleDialog {
shownPropertyString: "showNightLightDialog"
dialog: NightLightDialog {}
}
ToggleDialog {
shownPropertyString: "showWifiDialog"
dialog: WifiDialog {}
onShownChanged: {
if (!shown) return;
Network.enableWifi();
Network.rescanWifi();
}
}
component ToggleDialog: Loader {
id: toggleDialogLoader
required property string shownPropertyString
property alias dialog: toggleDialogLoader.sourceComponent
readonly property bool shown: root[shownPropertyString]
anchors.fill: parent
onShownChanged: if (shown) toggleDialogLoader.active = true;
active: shown
onActiveChanged: {
if (active) {
item.show = true;
item.forceActiveFocus();
}
}
Connections {
target: toggleDialogLoader.item
function onDismiss() {
toggleDialogLoader.item.show = false
root[toggleDialogLoader.shownPropertyString] = false;
}
function onVisibleChanged() {
if (!toggleDialogLoader.item.visible && !root[toggleDialogLoader.shownPropertyString]) toggleDialogLoader.active = false;
}
}
}
component LoaderedQuickPanelImplementation: Loader {
id: quickPanelImplLoader
required property string styleName
Layout.alignment: item ? item.Layout.alignment : Qt.AlignHCenter
Layout.fillWidth: item ? item.Layout.fillWidth : false
visible: active
active: Config.options.sidebar.quickToggles.style === styleName
Connections {
target: quickPanelImplLoader.item
function onOpenAudioOutputDialog() {
root.showAudioOutputDialog = true;
}
function onOpenAudioInputDialog() {
root.showAudioInputDialog = true;
}
function onOpenBluetoothDialog() {
root.showBluetoothDialog = true;
}
function onOpenNightLightDialog() {
root.showNightLightDialog = true;
}
function onOpenWifiDialog() {
root.showWifiDialog = true;
}
}
}
component SystemButtonRow: Item {
implicitHeight: Math.max(uptimeContainer.implicitHeight, systemButtonsRow.implicitHeight)
Rectangle {
id: uptimeContainer
anchors {
top: parent.top
bottom: parent.bottom
left: parent.left
}
color: Appearance.colors.colLayer1
radius: height / 2
implicitWidth: uptimeRow.implicitWidth + 24
implicitHeight: uptimeRow.implicitHeight + 8
Row {
id: uptimeRow
anchors.centerIn: parent
spacing: 8
CustomIcon {
id: distroIcon
anchors.verticalCenter: parent.verticalCenter
width: 25
height: 25
source: SystemInfo.distroIcon
colorize: true
color: Appearance.colors.colOnLayer0
}
StyledText {
anchors.verticalCenter: parent.verticalCenter
font.pixelSize: Appearance.font.pixelSize.normal
color: Appearance.colors.colOnLayer0
text: Translation.tr("Up %1").arg(DateTime.uptime)
textFormat: Text.MarkdownText
}
}
}
ButtonGroup {
id: systemButtonsRow
anchors {
top: parent.top
bottom: parent.bottom
right: parent.right
}
color: Appearance.colors.colLayer1
padding: 4
QuickToggleButton {
toggled: root.editMode
visible: Config.options.sidebar.quickToggles.style === "android"
buttonIcon: "edit"
onClicked: root.editMode = !root.editMode
StyledToolTip {
text: Translation.tr("Edit quick toggles") + (root.editMode ? Translation.tr("\nLMB to enable/disable\nRMB to toggle size\nScroll to swap position") : "")
}
}
QuickToggleButton {
toggled: false
buttonIcon: "restart_alt"
onClicked: {
Quickshell.execDetached(["hyprctl", "reload"])
Quickshell.reload(true);
}
StyledToolTip {
text: Translation.tr("Reload Hyprland & Quickshell")
}
}
QuickToggleButton {
toggled: false
buttonIcon: "settings"
onClicked: {
GlobalStates.sidebarRightOpen = false;
Quickshell.execDetached(["qs", "-p", root.settingsQmlPath]);
}
StyledToolTip {
text: Translation.tr("Settings")
}
}
QuickToggleButton {
toggled: false
buttonIcon: "power_settings_new"
onClicked: {
GlobalStates.sessionOpen = true;
}
StyledToolTip {
text: Translation.tr("Session")
}
}
}
}
}

View file

@ -0,0 +1,34 @@
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Layouts
RippleButton {
id: button
property string day
property int isToday
property bool bold
Layout.fillWidth: false
Layout.fillHeight: false
implicitWidth: 38;
implicitHeight: 38;
toggled: (isToday == 1)
buttonRadius: Appearance.rounding.small
contentItem: StyledText {
anchors.fill: parent
text: day
horizontalAlignment: Text.AlignHCenter
font.weight: bold ? Font.DemiBold : Font.Normal
color: (isToday == 1) ? Appearance.m3colors.m3onPrimary :
(isToday == 0) ? Appearance.colors.colOnLayer1 :
Appearance.colors.colOutlineVariant
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
}
}

View file

@ -0,0 +1,36 @@
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
RippleButton {
id: button
property string buttonText: ""
property string tooltipText: ""
property bool forceCircle: false
implicitHeight: 30
implicitWidth: forceCircle ? implicitHeight : (contentItem.implicitWidth + 10 * 2)
Behavior on implicitWidth {
SmoothedAnimation {
velocity: Appearance.animation.elementMove.velocity
}
}
background.anchors.fill: button
buttonRadius: Appearance.rounding.full
colBackground: Appearance.colors.colLayer2
colBackgroundHover: Appearance.colors.colLayer2Hover
colRipple: Appearance.colors.colLayer2Active
contentItem: StyledText {
text: buttonText
horizontalAlignment: Text.AlignHCenter
font.pixelSize: Appearance.font.pixelSize.larger
color: Appearance.colors.colOnLayer1
}
StyledToolTip {
text: tooltipText
extraVisibleCondition: tooltipText.length > 0
}
}

View file

@ -0,0 +1,122 @@
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import "calendar_layout.js" as CalendarLayout
import QtQuick
import QtQuick.Layouts
Item {
// Layout.topMargin: 10
anchors.topMargin: 10
property int monthShift: 0
property var viewingDate: CalendarLayout.getDateInXMonthsTime(monthShift)
property var calendarLayout: CalendarLayout.getCalendarLayout(viewingDate, monthShift === 0)
width: calendarColumn.width
implicitHeight: calendarColumn.height + 10 * 2
Keys.onPressed: (event) => {
if ((event.key === Qt.Key_PageDown || event.key === Qt.Key_PageUp)
&& event.modifiers === Qt.NoModifier) {
if (event.key === Qt.Key_PageDown) {
monthShift++;
} else if (event.key === Qt.Key_PageUp) {
monthShift--;
}
event.accepted = true;
}
}
MouseArea {
anchors.fill: parent
onWheel: (event) => {
if (event.angleDelta.y > 0) {
monthShift--;
} else if (event.angleDelta.y < 0) {
monthShift++;
}
}
}
ColumnLayout {
id: calendarColumn
anchors.centerIn: parent
spacing: 5
// Calendar header
RowLayout {
Layout.fillWidth: true
spacing: 5
CalendarHeaderButton {
clip: true
buttonText: `${monthShift != 0 ? "• " : ""}${viewingDate.toLocaleDateString(Qt.locale(), "MMMM yyyy")}`
tooltipText: (monthShift === 0) ? "" : Translation.tr("Jump to current month")
downAction: () => {
monthShift = 0;
}
}
Item {
Layout.fillWidth: true
Layout.fillHeight: false
}
CalendarHeaderButton {
forceCircle: true
downAction: () => {
monthShift--;
}
contentItem: MaterialSymbol {
text: "chevron_left"
iconSize: Appearance.font.pixelSize.larger
horizontalAlignment: Text.AlignHCenter
color: Appearance.colors.colOnLayer1
}
}
CalendarHeaderButton {
forceCircle: true
downAction: () => {
monthShift++;
}
contentItem: MaterialSymbol {
text: "chevron_right"
iconSize: Appearance.font.pixelSize.larger
horizontalAlignment: Text.AlignHCenter
color: Appearance.colors.colOnLayer1
}
}
}
// Week days row
RowLayout {
id: weekDaysRow
Layout.alignment: Qt.AlignHCenter
Layout.fillHeight: false
spacing: 5
Repeater {
model: CalendarLayout.weekDays
delegate: CalendarDayButton {
day: Translation.tr(modelData.day)
isToday: modelData.today
bold: true
enabled: false
}
}
}
// Real week rows
Repeater {
id: calendarRows
// model: calendarLayout
model: 6
delegate: RowLayout {
Layout.alignment: Qt.AlignHCenter
Layout.fillHeight: false
spacing: 5
Repeater {
model: Array(7).fill(modelData)
delegate: CalendarDayButton {
day: calendarLayout[modelData][index].day
isToday: calendarLayout[modelData][index].today
}
}
}
}
}
}

View file

@ -0,0 +1,113 @@
const weekDays = [ // MONDAY IS THE FIRST DAY OF THE WEEK :HESRIGHTYOUKNOW:
{ day: 'Mo', today: 0 },
{ day: 'Tu', today: 0 },
{ day: 'We', today: 0 },
{ day: 'Th', today: 0 },
{ day: 'Fr', today: 0 },
{ day: 'Sa', today: 0 },
{ day: 'Su', today: 0 },
]
function checkLeapYear(year) {
return (
year % 400 == 0 ||
(year % 4 == 0 && year % 100 != 0));
}
function getMonthDays(month, year) {
const leapYear = checkLeapYear(year);
if ((month <= 7 && month % 2 == 1) || (month >= 8 && month % 2 == 0)) return 31;
if (month == 2 && leapYear) return 29;
if (month == 2 && !leapYear) return 28;
return 30;
}
function getNextMonthDays(month, year) {
const leapYear = checkLeapYear(year);
if (month == 1 && leapYear) return 29;
if (month == 1 && !leapYear) return 28;
if (month == 12) return 31;
if ((month <= 7 && month % 2 == 1) || (month >= 8 && month % 2 == 0)) return 30;
return 31;
}
function getPrevMonthDays(month, year) {
const leapYear = checkLeapYear(year);
if (month == 3 && leapYear) return 29;
if (month == 3 && !leapYear) return 28;
if (month == 1) return 31;
if ((month <= 7 && month % 2 == 1) || (month >= 8 && month % 2 == 0)) return 30;
return 31;
}
function getDateInXMonthsTime(x) {
var currentDate = new Date(); // Get the current date
if (x == 0) return currentDate; // If x is 0, return the current date
var targetMonth = currentDate.getMonth() + x; // Calculate the target month
var targetYear = currentDate.getFullYear(); // Get the current year
// Adjust the year and month if necessary
targetYear += Math.floor(targetMonth / 12);
targetMonth = (targetMonth % 12 + 12) % 12;
// Create a new date object with the target year and month
var targetDate = new Date(targetYear, targetMonth, 1);
// Set the day to the last day of the month to get the desired date
// targetDate.setDate(0);
return targetDate;
}
function getCalendarLayout(dateObject, highlight) {
if (!dateObject) dateObject = new Date();
const weekday = (dateObject.getDay() + 6) % 7; // MONDAY IS THE FIRST DAY OF THE WEEK
const day = dateObject.getDate();
const month = dateObject.getMonth() + 1;
const year = dateObject.getFullYear();
const weekdayOfMonthFirst = (weekday + 35 - (day - 1)) % 7;
const daysInMonth = getMonthDays(month, year);
const daysInNextMonth = getNextMonthDays(month, year);
const daysInPrevMonth = getPrevMonthDays(month, year);
// Fill
var monthDiff = (weekdayOfMonthFirst == 0 ? 0 : -1);
var toFill, dim;
if (weekdayOfMonthFirst == 0) {
toFill = 1;
dim = daysInMonth;
}
else {
toFill = (daysInPrevMonth - (weekdayOfMonthFirst - 1));
dim = daysInPrevMonth;
}
var calendar = [...Array(6)].map(() => Array(7));
var i = 0, j = 0;
while (i < 6 && j < 7) {
calendar[i][j] = {
"day": toFill,
"today": ((toFill == day && monthDiff == 0 && highlight) ? 1 : (
monthDiff == 0 ? 0 : -1
))
};
// Increment
toFill++;
if (toFill > dim) { // Next month?
monthDiff++;
if (monthDiff == 0)
dim = daysInMonth;
else if (monthDiff == 1)
dim = daysInNextMonth;
toFill = 1;
}
// Next tile
j++;
if (j == 7) {
j = 0;
i++;
}
}
return calendar;
}

View file

@ -0,0 +1,3 @@
CalendarDayButton 1.0 CalendarDayButton.qml
CalendarHeaderButton 1.0 CalendarHeaderButton.qml
CalendarWidget 1.0 CalendarWidget.qml

View file

@ -0,0 +1,281 @@
// The garden's micro-view — the vault card in the right panel.
//
// The micro view is a window into the lens window, not a second editor:
// it shows the freshest notes, the sync truth, a daily note entry point
// and a quick capture — and every tap either opens the garden at that
// note or plants one in it. The full app is the lens window itself; this
// is the pulse.
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Layouts
Item {
id: root
implicitHeight: column.implicitHeight
property var shown: Lens.joined ? Lens.notes.slice(0, 5) : []
function dailyRel() {
return new Date().toISOString().slice(0, 10) + ".md";
}
function openDaily() {
if (!Lens.joined) {
Lens.join();
return;
}
const rel = root.dailyRel();
const exists = Lens.notes.some(n => n.rel === rel);
if (exists)
Lens.openNote(rel);
else
Lens.createNote(rel.replace(/\.md$/, ""));
}
function relTime(mtime) {
const s = Math.max(0, Date.now() / 1000 - mtime);
if (s < 60) return Translation.tr("just now");
if (s < 3600) return Translation.tr("%1m ago").arg(Math.floor(s / 60));
if (s < 86400) return Translation.tr("%1h ago").arg(Math.floor(s / 3600));
if (s < 604800) return Translation.tr("%1d ago").arg(Math.floor(s / 86400));
return new Date(mtime * 1000).toLocaleDateString(Qt.locale(), "dd MMM");
}
onVisibleChanged: {
if (root.visible && Lens.joined)
Lens.requestState();
}
ColumnLayout {
id: column
anchors.fill: parent
spacing: 4
// ── header ──
RowLayout {
Layout.fillWidth: true
spacing: 6
MaterialSymbol {
text: "hub"
iconSize: Appearance.font.pixelSize.larger
color: Appearance.colors.colPrimary
}
ColumnLayout {
Layout.fillWidth: true
spacing: 0
StyledText {
font.pixelSize: Appearance.font.pixelSize.larger
color: Appearance.colors.colOnLayer1
text: Translation.tr("Garden")
}
StyledText {
visible: Lens.joined && Lens.head.length > 0
font.pixelSize: Appearance.font.pixelSize.small
color: Appearance.colors.colOnLayer1Inactive
text: Lens.branch + " @" + Lens.head
}
}
Rectangle {
id: syncPill
Layout.alignment: Qt.AlignVCenter
radius: 8
color: Appearance.colors.colLayer0
implicitHeight: pillText.implicitHeight + 6
implicitWidth: pillText.implicitWidth + 14
Row {
anchors.centerIn: parent
spacing: 5
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 7
height: 7
radius: width / 2
color: root.pillColor
}
StyledText {
id: pillText
font.pixelSize: Appearance.font.pixelSize.small
color: Appearance.colors.colOnLayer0
text: Lens.stateText
}
}
}
}
Rectangle {
Layout.fillWidth: true
Layout.topMargin: 2
height: 1
color: Appearance.colors.colLayer0Border
}
// ── the daily note ──
Item {
Layout.fillWidth: true
Layout.topMargin: 2
implicitHeight: dailyRow.implicitHeight
RowLayout {
id: dailyRow
anchors.fill: parent
spacing: 6
MaterialSymbol {
text: "today"
iconSize: Appearance.font.pixelSize.normal
color: Appearance.colors.colOnLayer1Inactive
}
StyledText {
Layout.fillWidth: true
font.pixelSize: Appearance.font.pixelSize.normal
color: Appearance.colors.colOnLayer1
text: Translation.tr("Today's note")
}
StyledText {
font.pixelSize: Appearance.font.pixelSize.small
color: Appearance.colors.colPrimary
text: Translation.tr("open")
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.openDaily()
}
}
// ── fresh notes ──
Repeater {
model: root.shown
delegate: Item {
required property var modelData
required property int index
Layout.fillWidth: true
implicitHeight: noteRow.implicitHeight
RowLayout {
id: noteRow
anchors.fill: parent
spacing: 6
Rectangle {
Layout.alignment: Qt.AlignVCenter
width: 4
height: 4
radius: width / 2
color: index === 0 ? Appearance.colors.colPrimary : Appearance.colors.colOnLayer1Inactive
}
StyledText {
Layout.fillWidth: true
elide: Text.ElideRight
font.pixelSize: Appearance.font.pixelSize.normal
color: Appearance.colors.colOnLayer1
text: modelData.title
}
StyledText {
font.pixelSize: Appearance.font.pixelSize.small
color: Appearance.colors.colOnLayer1Inactive
text: root.relTime(modelData.mtime)
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: Lens.openNote(modelData.rel)
}
}
}
// ── cold card ──
Item {
Layout.fillWidth: true
visible: !Lens.joined
implicitHeight: coldRow.implicitHeight
RowLayout {
id: coldRow
anchors.fill: parent
spacing: 6
MaterialSymbol {
text: "crop_free"
iconSize: Appearance.font.pixelSize.normal
color: Appearance.colors.colOnLayer1Inactive
}
StyledText {
Layout.fillWidth: true
font.pixelSize: Appearance.font.pixelSize.normal
color: Appearance.colors.colOnLayer1Inactive
text: Translation.tr("The garden is closed")
}
StyledText {
font.pixelSize: Appearance.font.pixelSize.normal
color: Appearance.colors.colPrimary
text: Translation.tr("Open")
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: Lens.join()
}
}
// ── push the truth ──
Item {
Layout.fillWidth: true
visible: Lens.joined && (Lens.syncState === "ahead" || Lens.syncState === "diverged" || Lens.syncState === "dirty")
implicitHeight: pushRow.implicitHeight
RowLayout {
id: pushRow
anchors.fill: parent
spacing: 6
MaterialSymbol {
text: "upload"
iconSize: Appearance.font.pixelSize.normal
color: Appearance.colors.colPrimary
}
StyledText {
Layout.fillWidth: true
font.pixelSize: Appearance.font.pixelSize.normal
color: Appearance.colors.colOnLayer1
text: Translation.tr("The garden is ahead — push it")
}
StyledText {
font.pixelSize: Appearance.font.pixelSize.normal
color: Appearance.colors.colPrimary
text: Translation.tr("Push")
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: Lens.syncNow()
}
}
}
// Pill color: one dot, one meaning.
readonly property color pillColor: {
if (!Lens.joined) return Appearance.colors.colOnLayer1Inactive;
switch (Lens.syncState) {
case "clean": return Appearance.colors.colPrimary;
case "dirty": return Appearance.m3colors.m3error;
case "ahead":
case "diverged": return Appearance.m3colors.m3tertiary;
case "behind": return Appearance.m3colors.m3error;
default: return Appearance.colors.colOnLayer1Inactive;
}
}
}

View file

@ -0,0 +1,71 @@
import qs.modules.common
import qs.modules.common.widgets
import qs.services
import Qt5Compat.GraphicalEffects
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
Item {
id: root
NotificationListView { // Scrollable window
id: listview
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: statusRow.top
anchors.bottomMargin: 5
clip: true
layer.enabled: true
layer.effect: OpacityMask {
maskSource: Rectangle {
width: listview.width
height: listview.height
radius: Appearance.rounding.normal
}
}
popup: false
}
// Placeholder when list is empty
PagePlaceholder {
shown: Notifications.list.length === 0
icon: "notifications_active"
description: Translation.tr("Nothing")
shape: MaterialShape.Shape.Ghostish
descriptionHorizontalAlignment: Text.AlignHCenter
}
ButtonGroup {
id: statusRow
anchors {
left: parent.left
right: parent.right
bottom: parent.bottom
}
NotificationStatusButton {
Layout.fillWidth: false
buttonIcon: "notifications_paused"
toggled: Notifications.silent
onClicked: () => {
Notifications.silent = !Notifications.silent;
}
}
NotificationStatusButton {
enabled: false
Layout.fillWidth: true
buttonText: Translation.tr("%1 notifications").arg(Notifications.list.length)
}
NotificationStatusButton {
Layout.fillWidth: false
buttonIcon: "delete_sweep"
onClicked: () => {
Notifications.discardAllNotifications()
}
}
}
}

View file

@ -0,0 +1,46 @@
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Layouts
GroupButton {
id: button
property string buttonIcon: ""
property string buttonText: ""
baseHeight: 36
baseWidth: content.implicitWidth + 46
clickedWidth: baseWidth + 6
buttonRadius: baseHeight / 2
buttonRadiusPressed: Appearance.rounding.small
colBackground: Appearance.colors.colLayer2
colBackgroundHover: Appearance.colors.colLayer2Hover
colBackgroundActive: Appearance.colors.colLayer2Active
property color colText: toggled ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer1
contentItem: Item {
id: content
anchors.fill: parent
implicitWidth: contentRowLayout.implicitWidth
implicitHeight: contentRowLayout.implicitHeight
RowLayout {
id: contentRowLayout
anchors.centerIn: parent
spacing: 5
MaterialSymbol {
visible: buttonIcon !== ""
text: buttonIcon
iconSize: Appearance.font.pixelSize.huge
color: button.colText
}
StyledText {
visible: buttonText !== ""
text: buttonText
font.pixelSize: Appearance.font.pixelSize.small
color: button.colText
}
}
}
}

View file

@ -0,0 +1,2 @@
NotificationList 1.0 NotificationList.qml
NotificationStatusButton 1.0 NotificationStatusButton.qml

View file

@ -0,0 +1,368 @@
import qs
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Layouts
StyledFlickable {
id: root
clip: true
contentWidth: width
contentHeight: settingsColumn.implicitHeight
function setMinutes(optionName, minutes) {
Config.options.time.pomodoro[optionName] = Math.max(1, minutes) * 60;
if (!TimerService.pomodoroRunning) {
TimerService.resetPomodoro();
}
}
function minutes(optionName) {
return Math.round(Config.options.time.pomodoro[optionName] / 60);
}
function applyPreset(focus, shortBreak, longBreak, cycles) {
Config.options.time.pomodoro.focus = focus * 60;
Config.options.time.pomodoro.breakTime = shortBreak * 60;
Config.options.time.pomodoro.longBreak = longBreak * 60;
Config.options.time.pomodoro.cyclesBeforeLongBreak = cycles;
if (!TimerService.pomodoroRunning) {
TimerService.resetPomodoro();
}
}
function setCycles(cycles) {
Config.options.time.pomodoro.cyclesBeforeLongBreak = cycles;
if (!TimerService.pomodoroRunning) {
TimerService.resetPomodoro();
}
}
ColumnLayout {
id: settingsColumn
width: root.width
spacing: 10
Rectangle {
Layout.fillWidth: true
Layout.leftMargin: 4
Layout.rightMargin: 12
implicitHeight: 108
radius: Appearance.rounding.normal
color: Appearance.colors.colLayer2
ColumnLayout {
anchors.fill: parent
anchors.margins: 12
spacing: 8
RowLayout {
Layout.fillWidth: true
spacing: 10
Rectangle {
Layout.alignment: Qt.AlignVCenter
implicitWidth: 42
implicitHeight: 42
radius: Appearance.rounding.full
color: Appearance.colors.colSecondaryContainer
MaterialSymbol {
anchors.centerIn: parent
text: "search_activity"
iconSize: Appearance.font.pixelSize.hugeass
color: Appearance.colors.colOnSecondaryContainer
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 1
StyledText {
text: Translation.tr("Focus profile")
font.pixelSize: Appearance.font.pixelSize.normal
font.weight: Font.Medium
color: Appearance.colors.colOnLayer2
}
StyledText {
text: `${root.minutes("focus")} / ${root.minutes("breakTime")} / ${root.minutes("longBreak")} min • ${Config.options.time.pomodoro.cyclesBeforeLongBreak} cycles`
font.pixelSize: Appearance.font.pixelSize.smaller
color: Appearance.colors.colSubtext
}
}
}
RowLayout {
Layout.fillWidth: true
spacing: 8
ProfileChip {
iconName: "search_activity"
label: `${root.minutes("focus")}m`
color: Appearance.colors.colSecondaryContainer
textColor: Appearance.colors.colOnSecondaryContainer
}
ProfileChip {
iconName: "coffee"
label: `${root.minutes("breakTime")}m`
color: Appearance.colors.colTertiaryContainer
textColor: Appearance.colors.colOnTertiaryContainer
}
ProfileChip {
iconName: "spa"
label: `${root.minutes("longBreak")}m`
color: Appearance.colors.colLayer1
textColor: Appearance.colors.colOnLayer1
}
Item { Layout.fillWidth: true }
RowLayout {
spacing: 3
Repeater {
model: Config.options.time.pomodoro.cyclesBeforeLongBreak
Rectangle {
implicitWidth: 7
implicitHeight: 7
radius: Appearance.rounding.full
color: Appearance.colors.colOnLayer2
opacity: 0.45
}
}
}
}
}
}
ContentSection {
icon: "timer"
title: Translation.tr("Pomodoro")
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: 8
Layout.rightMargin: 8
spacing: 6
uniformCellSizes: true
PresetButton {
label: "25/5"
onClicked: root.applyPreset(25, 5, 15, 4)
}
PresetButton {
label: "50/10"
onClicked: root.applyPreset(50, 10, 25, 4)
}
PresetButton {
label: "15/5"
onClicked: root.applyPreset(15, 5, 15, 4)
}
}
PomodoroSpinRow {
iconName: "search_activity"
label: Translation.tr("Focus")
suffix: Translation.tr("min")
from: 1
to: 180
stepSize: 5
value: root.minutes("focus")
onValueModified: value => root.setMinutes("focus", value)
}
PomodoroSpinRow {
iconName: "coffee"
label: Translation.tr("Break")
suffix: Translation.tr("min")
from: 1
to: 60
stepSize: 1
value: root.minutes("breakTime")
onValueModified: value => root.setMinutes("breakTime", value)
}
PomodoroSpinRow {
iconName: "spa"
label: Translation.tr("Long break")
suffix: Translation.tr("min")
from: 1
to: 120
stepSize: 5
value: root.minutes("longBreak")
onValueModified: value => root.setMinutes("longBreak", value)
}
PomodoroSpinRow {
iconName: "repeat"
label: Translation.tr("Cycles")
suffix: ""
from: 1
to: 12
stepSize: 1
value: Config.options.time.pomodoro.cyclesBeforeLongBreak
onValueModified: value => root.setCycles(value)
}
}
ContentSection {
icon: "notifications"
title: Translation.tr("Alerts")
ConfigRow {
uniform: true
ConfigSwitch {
buttonIcon: "notifications"
text: Translation.tr("Notifications")
checked: Config.options.time.pomodoro.notifications
onCheckedChanged: Config.options.time.pomodoro.notifications = checked
}
ConfigSwitch {
buttonIcon: "notification_sound"
text: Translation.tr("Sound")
checked: Config.options.sounds.pomodoro
onCheckedChanged: Config.options.sounds.pomodoro = checked
}
}
RippleButton {
Layout.fillWidth: true
implicitHeight: 40
buttonRadius: Appearance.rounding.full
colBackground: Appearance.colors.colLayer2
colBackgroundHover: Appearance.colors.colLayer2Hover
colRipple: Appearance.colors.colLayer2Active
onClicked: Audio.playSystemSound("alarm-clock-elapsed")
contentItem: RowLayout {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: 8
MaterialSymbol {
text: "play_circle"
iconSize: Appearance.font.pixelSize.larger
color: Appearance.colors.colOnLayer2
}
StyledText {
Layout.fillWidth: true
text: Translation.tr("Test sound")
color: Appearance.colors.colOnLayer2
}
}
}
}
Item {
Layout.fillWidth: true
implicitHeight: 4
}
}
component PresetButton: RippleButton {
id: preset
property string label
Layout.fillWidth: true
implicitHeight: 34
buttonRadius: Appearance.rounding.full
colBackground: Appearance.colors.colLayer2
colBackgroundHover: Appearance.colors.colLayer2Hover
colRipple: Appearance.colors.colLayer2Active
contentItem: StyledText {
anchors.centerIn: parent
text: preset.label
horizontalAlignment: Text.AlignHCenter
font.family: Appearance.font.family.monospace
font.weight: Font.DemiBold
color: Appearance.colors.colOnLayer2
}
}
component ProfileChip: Rectangle {
property string iconName
property string label
property color textColor
Layout.fillWidth: true
implicitHeight: 28
radius: Appearance.rounding.full
RowLayout {
anchors.centerIn: parent
spacing: 4
MaterialSymbol {
text: parent.parent.iconName
iconSize: 14
color: parent.parent.textColor
}
StyledText {
text: parent.parent.label
font.family: Appearance.font.family.monospace
font.pixelSize: Appearance.font.pixelSize.smaller
font.weight: Font.DemiBold
color: parent.parent.textColor
}
}
}
component PomodoroSpinRow: RowLayout {
id: row
property string iconName
property string label
property string suffix
property alias value: spinBox.value
property alias from: spinBox.from
property alias to: spinBox.to
property alias stepSize: spinBox.stepSize
signal valueModified(int value)
Layout.fillWidth: true
Layout.leftMargin: 8
Layout.rightMargin: 8
spacing: 10
MaterialSymbol {
text: row.iconName
iconSize: Appearance.font.pixelSize.larger
color: Appearance.colors.colOnSecondaryContainer
}
StyledText {
Layout.fillWidth: true
text: row.label
color: Appearance.colors.colOnSecondaryContainer
}
StyledSpinBox {
id: spinBox
Layout.preferredWidth: 96
stepSize: 1
onValueModified: row.valueModified(value)
}
StyledText {
Layout.preferredWidth: 24
text: row.suffix
color: Appearance.colors.colSubtext
}
}
}

View file

@ -0,0 +1,187 @@
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import Qt5Compat.GraphicalEffects
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
Item {
id: root
implicitHeight: contentColumn.implicitHeight
implicitWidth: contentColumn.implicitWidth
readonly property color stateColor: TimerService.pomodoroBreak ? Appearance.colors.colTertiaryContainer : Appearance.colors.colSecondaryContainer
readonly property color stateTextColor: TimerService.pomodoroBreak ? Appearance.colors.colOnTertiaryContainer : Appearance.colors.colOnSecondaryContainer
readonly property string stateLabel: TimerService.pomodoroLongBreak ? Translation.tr("Long break") : TimerService.pomodoroBreak ? Translation.tr("Break") : Translation.tr("Focus")
readonly property int cyclePosition: TimerService.pomodoroCycle + 1
ColumnLayout {
id: contentColumn
anchors.fill: parent
spacing: 10
// The Pomodoro timer circle
CircularProgress {
Layout.alignment: Qt.AlignHCenter
lineWidth: 10
value: {
return TimerService.pomodoroSecondsLeft / TimerService.pomodoroLapDuration;
}
implicitSize: 200
colPrimary: root.stateTextColor
colSecondary: root.stateColor
enableAnimation: true
ColumnLayout {
anchors.centerIn: parent
spacing: 2
StyledText {
Layout.alignment: Qt.AlignHCenter
Layout.preferredWidth: 150
horizontalAlignment: Text.AlignHCenter
text: {
let minutes = Math.floor(TimerService.pomodoroSecondsLeft / 60).toString().padStart(2, '0');
let seconds = Math.floor(TimerService.pomodoroSecondsLeft % 60).toString().padStart(2, '0');
return `${minutes}:${seconds}`;
}
font.family: Appearance.font.family.monospace
font.pixelSize: 40
font.weight: Font.DemiBold
color: Appearance.m3colors.m3onSurface
}
StyledText {
Layout.alignment: Qt.AlignHCenter
text: root.stateLabel
font.pixelSize: Appearance.font.pixelSize.normal
color: Appearance.colors.colSubtext
}
}
Rectangle {
radius: Appearance.rounding.full
color: root.stateColor
anchors {
right: parent.right
bottom: parent.bottom
}
implicitWidth: 58
implicitHeight: 36
RowLayout {
anchors.centerIn: parent
spacing: 3
MaterialSymbol {
text: "repeat"
iconSize: 14
color: root.stateTextColor
}
StyledText {
id: cycleText
font.family: Appearance.font.family.monospace
font.weight: Font.DemiBold
color: root.stateTextColor
text: `${root.cyclePosition}/${TimerService.cyclesBeforeLongBreak}`
}
}
}
}
Rectangle {
Layout.alignment: Qt.AlignHCenter
Layout.preferredWidth: 210
Layout.preferredHeight: 30
radius: Appearance.rounding.full
color: Appearance.colors.colLayer2
RowLayout {
anchors.centerIn: parent
spacing: 6
MaterialSymbol {
text: TimerService.pomodoroBreak ? "coffee" : "search_activity"
iconSize: Appearance.font.pixelSize.larger
color: root.stateTextColor
}
StyledText {
text: root.stateLabel
font.pixelSize: Appearance.font.pixelSize.normal
color: Appearance.colors.colOnLayer2
}
}
}
RowLayout {
Layout.alignment: Qt.AlignHCenter
spacing: 5
Repeater {
model: TimerService.cyclesBeforeLongBreak
Rectangle {
required property int index
implicitWidth: index === TimerService.pomodoroCycle ? 18 : 8
implicitHeight: 8
radius: Appearance.rounding.full
color: index <= TimerService.pomodoroCycle ? root.stateTextColor : Appearance.colors.colLayer2
Behavior on implicitWidth {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
}
}
}
// The Start/Stop and Reset buttons
RowLayout {
Layout.alignment: Qt.AlignHCenter
spacing: 10
RippleButton {
buttonRadius: Appearance.rounding.full
contentItem: StyledText {
anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
text: TimerService.pomodoroRunning ? Translation.tr("Pause") : (TimerService.pomodoroSecondsLeft === TimerService.focusTime) ? Translation.tr("Start") : Translation.tr("Resume")
color: TimerService.pomodoroRunning ? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnPrimary
}
implicitHeight: 38
implicitWidth: 96
font.pixelSize: Appearance.font.pixelSize.larger
onClicked: TimerService.togglePomodoro()
colBackground: TimerService.pomodoroRunning ? Appearance.colors.colSecondaryContainer : Appearance.colors.colPrimary
colBackgroundHover: TimerService.pomodoroRunning ? Appearance.colors.colSecondaryContainer : Appearance.colors.colPrimary
}
RippleButton {
buttonRadius: Appearance.rounding.full
implicitHeight: 38
implicitWidth: 96
onClicked: TimerService.resetPomodoro()
enabled: (TimerService.pomodoroSecondsLeft < TimerService.pomodoroLapDuration) || TimerService.pomodoroCycle > 0 || TimerService.pomodoroBreak
font.pixelSize: Appearance.font.pixelSize.larger
colBackground: Appearance.colors.colErrorContainer
colBackgroundHover: Appearance.colors.colErrorContainerHover
colRipple: Appearance.colors.colErrorContainerActive
contentItem: StyledText {
anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
text: Translation.tr("Reset")
color: Appearance.colors.colOnErrorContainer
}
}
}
}
}

View file

@ -0,0 +1,68 @@
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
Item {
id: root
property var tabButtonList: [
{"name": Translation.tr("Timer"), "icon": "search_activity"},
{"name": Translation.tr("Customize"), "icon": "tune"}
]
// Pomodoro keybinds
Keys.onPressed: (event) => {
if ((event.key === Qt.Key_PageDown || event.key === Qt.Key_PageUp) && event.modifiers === Qt.NoModifier) { // Switch tabs
if (event.key === Qt.Key_PageDown) {
tabBar.incrementCurrentIndex();
} else if (event.key === Qt.Key_PageUp) {
tabBar.decrementCurrentIndex();
}
event.accepted = true
} else if (event.key === Qt.Key_Space || event.key === Qt.Key_S) { // Pause/resume with Space or S
if (tabBar.currentIndex === 0) {
TimerService.togglePomodoro()
}
event.accepted = true
} else if (event.key === Qt.Key_R) { // Reset with R
if (tabBar.currentIndex === 0) {
TimerService.resetPomodoro()
}
event.accepted = true
}
}
ColumnLayout {
anchors.fill: parent
spacing: 0
SecondaryTabBar {
id: tabBar
currentIndex: swipeView.currentIndex
Repeater {
model: root.tabButtonList
delegate: SecondaryTabButton {
buttonText: modelData.name
buttonIcon: modelData.icon
}
}
}
SwipeView {
id: swipeView
Layout.topMargin: 10
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 10
clip: true
currentIndex: tabBar.currentIndex
// Tabs
PomodoroTimer {}
PomodoroSettings {}
}
}
}

View file

@ -0,0 +1,207 @@
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import Qt5Compat.GraphicalEffects
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
Item {
id: stopwatchTab
Layout.fillWidth: true
Layout.fillHeight: true
Item {
anchors {
fill: parent
topMargin: 8
leftMargin: 16
rightMargin: 16
}
RowLayout { // Elapsed
id: elapsedIndicator
anchors {
top: undefined
verticalCenter: parent.verticalCenter
left: controlButtons.left
leftMargin: 6
}
states: State {
name: "hasLaps"
when: TimerService.stopwatchLaps.length > 0
AnchorChanges {
target: elapsedIndicator
anchors.top: parent.top
anchors.verticalCenter: undefined
anchors.left: controlButtons.left
}
}
transitions: Transition {
AnchorAnimation {
duration: Appearance.animation.elementMoveFast.duration
easing.type: Appearance.animation.elementMoveFast.type
easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve
}
}
spacing: 0
StyledText {
// Layout.preferredWidth: elapsedIndicator.width * 0.6 // Prevent shakiness
font.pixelSize: 40
color: Appearance.m3colors.m3onSurface
text: {
let totalSeconds = Math.floor(TimerService.stopwatchTime) / 100
let minutes = Math.floor(totalSeconds / 60).toString().padStart(2, '0')
let seconds = Math.floor(totalSeconds % 60).toString().padStart(2, '0')
return `${minutes}:${seconds}`
}
}
StyledText {
Layout.fillWidth: true
font.pixelSize: 40
color: Appearance.colors.colSubtext
text: {
return `:<sub>${(Math.floor(TimerService.stopwatchTime) % 100).toString().padStart(2, '0')}</sub>`
}
}
}
// Laps
StyledListView {
id: lapsList
anchors {
top: elapsedIndicator.bottom
bottom: controlButtons.top
left: parent.left
right: parent.right
topMargin: 16
bottomMargin: 16
}
spacing: 4
clip: true
popin: true
model: ScriptModel {
values: TimerService.stopwatchLaps.map((v, i, arr) => arr[arr.length - 1 - i])
}
delegate: Rectangle {
id: lapItem
required property int index
required property var modelData
property var horizontalPadding: 10
property var verticalPadding: 6
width: lapsList.width
implicitHeight: lapRow.implicitHeight + verticalPadding * 2
implicitWidth: lapRow.implicitWidth + horizontalPadding * 2
color: Appearance.colors.colLayer2
radius: Appearance.rounding.small
RowLayout {
id: lapRow
anchors {
fill: parent
leftMargin: lapItem.horizontalPadding
rightMargin: lapItem.horizontalPadding
topMargin: lapItem.verticalPadding
bottomMargin: lapItem.verticalPadding
}
StyledText {
font.pixelSize: Appearance.font.pixelSize.small
color: Appearance.colors.colSubtext
text: `${TimerService.stopwatchLaps.length - lapItem.index}.`
}
StyledText {
font.pixelSize: Appearance.font.pixelSize.small
text: {
const lapTime = lapItem.modelData
const _10ms = (Math.floor(lapTime) % 100).toString().padStart(2, '0')
const totalSeconds = Math.floor(lapTime) / 100
const minutes = Math.floor(totalSeconds / 60).toString().padStart(2, '0')
const seconds = Math.floor(totalSeconds % 60).toString().padStart(2, '0')
return `${minutes}:${seconds}.${_10ms}`
}
}
Item { Layout.fillWidth: true }
StyledText {
font.pixelSize: Appearance.font.pixelSize.smaller
color: Appearance.colors.colPrimary
text: {
const originalIndex = TimerService.stopwatchLaps.length - lapItem.index - 1
const lastTime = originalIndex > 0 ? TimerService.stopwatchLaps[originalIndex - 1] : 0
const lapTime = lapItem.modelData - lastTime
const _10ms = (Math.floor(lapTime) % 100).toString().padStart(2, '0')
const totalSeconds = Math.floor(lapTime) / 100
const minutes = Math.floor(totalSeconds / 60).toString().padStart(2, '0')
const seconds = Math.floor(totalSeconds % 60).toString().padStart(2, '0')
return `+${minutes == "00" ? "" : minutes + ":"}${seconds}.${_10ms}`
}
}
}
}
}
RowLayout {
id: controlButtons
anchors {
horizontalCenter: parent.horizontalCenter
bottom: parent.bottom
bottomMargin: 6
}
spacing: 4
RippleButton {
Layout.preferredHeight: 35
Layout.preferredWidth: 90
font.pixelSize: Appearance.font.pixelSize.larger
onClicked: {
TimerService.toggleStopwatch()
}
colBackground: TimerService.stopwatchRunning ? Appearance.colors.colSecondaryContainer : Appearance.colors.colPrimary
colBackgroundHover: TimerService.stopwatchRunning ? Appearance.colors.colSecondaryContainerHover : Appearance.colors.colPrimaryHover
colRipple: TimerService.stopwatchRunning ? Appearance.colors.colSecondaryContainerActive : Appearance.colors.colPrimaryActive
contentItem: StyledText {
horizontalAlignment: Text.AlignHCenter
color: TimerService.stopwatchRunning ? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnPrimary
text: TimerService.stopwatchRunning ? Translation.tr("Pause") : TimerService.stopwatchTime === 0 ? Translation.tr("Start") : Translation.tr("Resume")
}
}
RippleButton {
implicitHeight: 35
implicitWidth: 90
font.pixelSize: Appearance.font.pixelSize.larger
onClicked: {
if (TimerService.stopwatchRunning)
TimerService.stopwatchRecordLap()
else
TimerService.stopwatchReset()
}
enabled: TimerService.stopwatchTime > 0 || Persistent.states.timer.stopwatch.laps.length > 0
colBackground: TimerService.stopwatchRunning ? Appearance.colors.colLayer2 : Appearance.colors.colErrorContainer
colBackgroundHover: TimerService.stopwatchRunning ? Appearance.colors.colLayer2Hover : Appearance.colors.colErrorContainerHover
colRipple: TimerService.stopwatchRunning ? Appearance.colors.colLayer2Active : Appearance.colors.colErrorContainerActive
contentItem: StyledText {
horizontalAlignment: Text.AlignHCenter
text: TimerService.stopwatchRunning ? Translation.tr("Lap") : Translation.tr("Reset")
color: TimerService.stopwatchRunning ? Appearance.colors.colOnLayer2 : Appearance.colors.colOnErrorContainer
}
}
}
}
}

View file

@ -0,0 +1,63 @@
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Layouts
Item {
id: root
Keys.onPressed: (event) => {
if (event.key === Qt.Key_Space || event.key === Qt.Key_S) {
TimerService.toggleStopwatch();
event.accepted = true;
} else if (event.key === Qt.Key_R) {
TimerService.stopwatchReset();
event.accepted = true;
} else if (event.key === Qt.Key_L) {
TimerService.stopwatchRecordLap();
event.accepted = true;
}
}
ColumnLayout {
anchors.fill: parent
spacing: 8
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: 4
Layout.rightMargin: 14
spacing: 8
MaterialSymbol {
text: "timer"
iconSize: Appearance.font.pixelSize.hugeass
color: Appearance.colors.colOnSecondaryContainer
}
ColumnLayout {
Layout.fillWidth: true
spacing: 0
StyledText {
text: Translation.tr("Stopwatch")
font.pixelSize: Appearance.font.pixelSize.larger
font.weight: Font.Medium
color: Appearance.colors.colOnLayer1
}
StyledText {
text: TimerService.stopwatchRunning ? Translation.tr("Running") : Translation.tr("Ready")
font.pixelSize: Appearance.font.pixelSize.smaller
color: Appearance.colors.colSubtext
}
}
}
Stopwatch {
Layout.fillWidth: true
Layout.fillHeight: true
}
}
}

View file

@ -0,0 +1,5 @@
PomodoroSettings 1.0 PomodoroSettings.qml
PomodoroTimer 1.0 PomodoroTimer.qml
PomodoroWidget 1.0 PomodoroWidget.qml
Stopwatch 1.0 Stopwatch.qml
StopwatchWidget 1.0 StopwatchWidget.qml

View file

@ -0,0 +1,5 @@
BottomWidgetGroup 1.0 BottomWidgetGroup.qml
CenterWidgetGroup 1.0 CenterWidgetGroup.qml
QuickSliders 1.0 QuickSliders.qml
SidebarRight 1.0 SidebarRight.qml
SidebarRightContent 1.0 SidebarRightContent.qml

View file

@ -0,0 +1,141 @@
import qs.modules.common
import qs.modules.common.widgets
import qs.services
import Qt5Compat.GraphicalEffects
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
Item {
id: root
required property var taskList
property string emptyPlaceholderIcon
property string emptyPlaceholderText
property int todoListItemSpacing: 5
property int todoListItemPadding: 8
property int listBottomPadding: 80
StyledListView {
id: listView
anchors.fill: parent
spacing: root.todoListItemSpacing
animateAppearance: false
model: ScriptModel {
values: root.taskList
}
delegate: Item {
id: todoItem
required property var modelData
property bool pendingDoneToggle: false
property bool pendingDelete: false
property bool enableHeightAnimation: false
implicitHeight: todoItemRectangle.implicitHeight
width: ListView.view.width
clip: true
Behavior on implicitHeight {
enabled: enableHeightAnimation
NumberAnimation {
duration: Appearance.animation.elementMoveFast.duration
easing.type: Appearance.animation.elementMoveFast.type
easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve
}
}
Rectangle {
id: todoItemRectangle
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
implicitHeight: todoContentRowLayout.implicitHeight
color: Appearance.colors.colLayer2
radius: Appearance.rounding.small
ColumnLayout {
id: todoContentRowLayout
anchors.left: parent.left
anchors.right: parent.right
StyledText {
id: todoContentText
Layout.fillWidth: true // Needed for wrapping
Layout.leftMargin: 10
Layout.rightMargin: 10
Layout.topMargin: todoListItemPadding
text: todoItem.modelData.content
wrapMode: Text.Wrap
}
RowLayout {
Layout.leftMargin: 10
Layout.rightMargin: 10
Layout.bottomMargin: todoListItemPadding
Item {
Layout.fillWidth: true
}
TodoItemActionButton {
Layout.fillWidth: false
onClicked: {
if (!todoItem.modelData.done)
Todo.markDone(todoItem.modelData.originalIndex);
else
Todo.markUnfinished(todoItem.modelData.originalIndex);
}
contentItem: MaterialSymbol {
anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
text: todoItem.modelData.done ? "remove_done" : "check"
iconSize: Appearance.font.pixelSize.larger
color: Appearance.colors.colOnLayer1
}
}
TodoItemActionButton {
Layout.fillWidth: false
onClicked: {
Todo.deleteItem(todoItem.modelData.originalIndex);
}
contentItem: MaterialSymbol {
anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
text: "delete_forever"
iconSize: Appearance.font.pixelSize.larger
color: Appearance.colors.colOnLayer1
}
}
}
}
}
}
}
Item {
// Placeholder when list is empty
visible: opacity > 0
opacity: taskList.length === 0 ? 1 : 0
anchors.fill: parent
Behavior on opacity {
animation: Appearance.animation.elementMove.numberAnimation.createObject(this)
}
ColumnLayout {
anchors.centerIn: parent
spacing: 5
MaterialSymbol {
Layout.alignment: Qt.AlignHCenter
iconSize: 55
color: Appearance.m3colors.m3outline
text: emptyPlaceholderIcon
}
StyledText {
Layout.alignment: Qt.AlignHCenter
font.pixelSize: Appearance.font.pixelSize.normal
color: Appearance.m3colors.m3outline
horizontalAlignment: Text.AlignHCenter
text: emptyPlaceholderText
}
}
}
}

View file

@ -0,0 +1,32 @@
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
RippleButton {
id: button
property string buttonText: ""
property string tooltipText: ""
implicitHeight: 30
implicitWidth: implicitHeight
Behavior on implicitWidth {
SmoothedAnimation {
velocity: Appearance.animation.elementMove.velocity
}
}
buttonRadius: Appearance.rounding.small
contentItem: StyledText {
text: buttonText
horizontalAlignment: Text.AlignHCenter
font.pixelSize: Appearance.font.pixelSize.larger
color: Appearance.colors.colOnLayer1
}
StyledToolTip {
text: tooltipText
extraVisibleCondition: tooltipText.length > 0
}
}

View file

@ -0,0 +1,219 @@
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
Item {
id: root
property var tabButtonList: [{"icon": "checklist", "name": Translation.tr("Unfinished")}, {"name": Translation.tr("Done"), "icon": "check_circle"}]
property bool showAddDialog: false
property int dialogMargins: 20
property int fabSize: 48
property int fabMargins: 14
Keys.onPressed: (event) => {
if ((event.key === Qt.Key_PageDown || event.key === Qt.Key_PageUp) && event.modifiers === Qt.NoModifier) {
if (event.key === Qt.Key_PageDown) {
tabBar.incrementCurrentIndex();
} else if (event.key === Qt.Key_PageUp) {
tabBar.decrementCurrentIndex();
}
event.accepted = true;
}
// Open add dialog on "N" (any modifiers)
else if (event.key === Qt.Key_N) {
root.showAddDialog = true
event.accepted = true;
}
// Close dialog on Esc if open
else if (event.key === Qt.Key_Escape && root.showAddDialog) {
root.showAddDialog = false
event.accepted = true;
}
}
ColumnLayout {
anchors.fill: parent
spacing: 0
SecondaryTabBar {
id: tabBar
currentIndex: swipeView.currentIndex
Repeater {
model: root.tabButtonList
delegate: SecondaryTabButton {
buttonText: modelData.name
buttonIcon: modelData.icon
}
}
}
SwipeView {
id: swipeView
Layout.topMargin: 10
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 10
clip: true
currentIndex: tabBar.currentIndex
// To Do tab
TaskList {
listBottomPadding: root.fabSize + root.fabMargins * 2
emptyPlaceholderIcon: "check_circle"
emptyPlaceholderText: Translation.tr("Nothing here!")
taskList: Todo.list
.map(function(item, i) { return Object.assign({}, item, {originalIndex: i}); })
.filter(function(item) { return !item.done; })
}
TaskList {
listBottomPadding: root.fabSize + root.fabMargins * 2
emptyPlaceholderIcon: "checklist"
emptyPlaceholderText: Translation.tr("Finished tasks will go here")
taskList: Todo.list
.map(function(item, i) { return Object.assign({}, item, {originalIndex: i}); })
.filter(function(item) { return item.done; })
}
}
}
// + FAB
StyledRectangularShadow {
target: fabButton
radius: fabButton.buttonRadius
blur: 0.6 * Appearance.sizes.elevationMargin
}
FloatingActionButton {
id: fabButton
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.rightMargin: root.fabMargins
anchors.bottomMargin: root.fabMargins
onClicked: root.showAddDialog = true
iconText: "add"
}
Item {
anchors.fill: parent
z: 9999
visible: opacity > 0
opacity: root.showAddDialog ? 1 : 0
Behavior on opacity {
NumberAnimation {
duration: Appearance.animation.elementMoveFast.duration
easing.type: Appearance.animation.elementMoveFast.type
easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve
}
}
onVisibleChanged: {
if (!visible) {
todoInput.text = ""
fabButton.focus = true
}
}
Rectangle { // Scrim
anchors.fill: parent
radius: Appearance.rounding.small
color: Appearance.colors.colScrim
MouseArea {
hoverEnabled: true
anchors.fill: parent
preventStealing: true
propagateComposedEvents: false
}
}
Rectangle { // The dialog
id: dialog
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: root.dialogMargins
implicitHeight: dialogColumnLayout.implicitHeight
color: Appearance.m3colors.m3surfaceContainerHigh
radius: Appearance.rounding.normal
function addTask() {
if (todoInput.text.length > 0) {
Todo.addTask(todoInput.text)
todoInput.text = ""
root.showAddDialog = false
tabBar.setCurrentIndex(0) // Show unfinished tasks
}
}
ColumnLayout {
id: dialogColumnLayout
anchors.fill: parent
spacing: 16
StyledText {
Layout.topMargin: 16
Layout.leftMargin: 16
Layout.rightMargin: 16
Layout.alignment: Qt.AlignLeft
color: Appearance.m3colors.m3onSurface
font.pixelSize: Appearance.font.pixelSize.larger
text: Translation.tr("Add task")
}
TextField {
id: todoInput
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
padding: 10
color: activeFocus ? Appearance.m3colors.m3onSurface : Appearance.m3colors.m3onSurfaceVariant
renderType: Text.NativeRendering
selectedTextColor: Appearance.m3colors.m3onSecondaryContainer
selectionColor: Appearance.colors.colSecondaryContainer
placeholderText: Translation.tr("Task description")
placeholderTextColor: Appearance.m3colors.m3outline
focus: root.showAddDialog
onAccepted: dialog.addTask()
background: Rectangle {
anchors.fill: parent
radius: Appearance.rounding.verysmall
border.width: 2
border.color: todoInput.activeFocus ? Appearance.colors.colPrimary : Appearance.m3colors.m3outline
color: "transparent"
}
cursorDelegate: Rectangle {
width: 1
color: todoInput.activeFocus ? Appearance.colors.colPrimary : "transparent"
radius: 1
}
}
RowLayout {
Layout.bottomMargin: 16
Layout.leftMargin: 16
Layout.rightMargin: 16
Layout.alignment: Qt.AlignRight
spacing: 5
DialogButton {
buttonText: Translation.tr("Cancel")
onClicked: root.showAddDialog = false
}
DialogButton {
buttonText: Translation.tr("Add")
enabled: todoInput.text.length > 0
onClicked: dialog.addTask()
}
}
}
}
}
}

View file

@ -0,0 +1,3 @@
TaskList 1.0 TaskList.qml
TodoItemActionButton 1.0 TodoItemActionButton.qml
TodoWidget 1.0 TodoWidget.qml

View file

@ -0,0 +1,84 @@
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Layouts
// Native PulseAudio publishes the Pixel 3 board endpoints directly: playback
// on hw:0,0 and UCM handset capture on hw:0,1. Present both without depending
// on PipeWire's node model.
ColumnLayout {
id: root
required property bool isSink
readonly property bool outputAvailable: Audio.ready && Audio.sink?.name.length > 0
readonly property bool inputAvailable: Audio.sourceReady && Audio.source?.name.length > 0
readonly property var currentNode: root.isSink ? Audio.sink : Audio.source
spacing: 16
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: deviceRow.implicitHeight + 24
radius: Appearance.rounding.small
color: Appearance.colors.colLayer2
RowLayout {
id: deviceRow
anchors.fill: parent
anchors.margins: 12
spacing: 12
MaterialSymbol {
text: root.isSink ? "speaker" : "mic"
iconSize: Appearance.font.pixelSize.hugeass
color: Appearance.colors.colOnLayer2
}
ColumnLayout {
Layout.fillWidth: true
spacing: 2
StyledText {
Layout.fillWidth: true
elide: Text.ElideRight
font.pixelSize: Appearance.font.pixelSize.normal
color: Appearance.colors.colOnLayer2
text: root.isSink
? (root.outputAvailable ? Audio.friendlyDeviceName(Audio.sink) : Translation.tr("Connecting to PulseAudio…"))
: (root.inputAvailable ? Audio.friendlyDeviceName(Audio.source) : Translation.tr("Connecting to PulseAudio…"))
}
StyledText {
Layout.fillWidth: true
wrapMode: Text.Wrap
font.pixelSize: Appearance.font.pixelSize.smaller
color: Appearance.m3colors.m3outline
text: root.isSink
? Translation.tr("Default output • Pixel 3 internal stereo speakers")
: (Audio.micActive
? Translation.tr("Default input • recording in progress")
: Translation.tr("Default input • Pixel 3 handset microphone"))
}
}
}
}
StyledSlider {
Layout.fillWidth: true
visible: root.isSink ? root.outputAvailable : root.inputAvailable
value: root.currentNode?.audio?.volume ?? 0
onMoved: root.currentNode.audio.volume = value
configuration: StyledSlider.Configuration.M
}
StyledText {
Layout.fillWidth: true
visible: root.isSink ? root.outputAvailable : root.inputAvailable
horizontalAlignment: Text.AlignHCenter
color: Appearance.colors.colSubtext
text: root.currentNode?.audio?.muted
? Translation.tr("Muted")
: `${Math.round((root.currentNode?.audio?.volume ?? 0) * 100)}%`
}
Item { Layout.fillHeight: true }
}

View file

@ -0,0 +1,4 @@
AudioDeviceSelectorButton 1.0 AudioDeviceSelectorButton.qml
VolumeDialog 1.0 VolumeDialog.qml
VolumeDialogContent 1.0 VolumeDialogContent.qml
VolumeMixerEntry 1.0 VolumeMixerEntry.qml