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