Watch
1
0
Fork
You've already forked souveraine
0

publish: the public projection begins here

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

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

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

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

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

View file

@ -0,0 +1,933 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Io
import qs.modules.common.functions
Singleton {
id: root
property string filePath: Directories.shellConfigPath
property alias options: configOptionsJsonAdapter
property bool ready: false
property int readWriteDelay: 50 // milliseconds
property bool blockWrites: false
// Tri-state option: "auto" (or anything unrecognized) defers to the
// caller's default, "on"/"off" force it. Booleans are honored so old
// configs written before the string form keep working.
function tristate(value, fallback) {
if (value === "on" || value === true) return true;
if (value === "off" || value === false) return false;
return fallback;
}
function setNestedValue(nestedKey, value) {
let keys = nestedKey.split(".");
let obj = root.options;
let parents = [obj];
// Traverse and collect parent objects
for (let i = 0; i < keys.length - 1; ++i) {
if (!obj[keys[i]] || typeof obj[keys[i]] !== "object") {
obj[keys[i]] = {};
}
obj = obj[keys[i]];
parents.push(obj);
}
// Convert value to correct type using JSON.parse when safe
let convertedValue = value;
if (typeof value === "string") {
let trimmed = value.trim();
if (trimmed === "true" || trimmed === "false" || !isNaN(Number(trimmed))) {
try {
convertedValue = JSON.parse(trimmed);
} catch (e) {
convertedValue = value;
}
}
}
obj[keys[keys.length - 1]] = convertedValue;
}
Timer {
id: fileReloadTimer
interval: root.readWriteDelay
repeat: false
onTriggered: {
configFileView.reload()
}
}
Timer {
id: fileWriteTimer
interval: root.readWriteDelay
repeat: false
onTriggered: {
configFileView.writeAdapter()
}
}
FileView {
id: configFileView
path: root.filePath
watchChanges: true
blockWrites: root.blockWrites
onFileChanged: fileReloadTimer.restart()
onAdapterUpdated: fileWriteTimer.restart()
onLoaded: root.ready = true
onLoadFailed: error => {
if (error == FileViewError.FileNotFound) {
writeAdapter();
}
}
JsonAdapter {
id: configOptionsJsonAdapter
property string panelFamily: "ii" // "ii", "waffle"
// Souveraine form-factor gate. Flip to true on a phone deploy:
// gates the Phone settings category and (later) the single-column
// layout. Laptop deploys stay false and see the stock page set.
property JsonObject souveraine: JsonObject {
property bool phone: false
}
property JsonObject policies: JsonObject {
property int ai: 1 // 0: No | 1: Yes | 2: Local
property int weeb: 1 // 0: No | 1: Open | 2: Closet
}
property JsonObject ai: JsonObject {
property string systemPrompt: "## Style\n- Use casual tone, don't be formal!\n- Always be brief and to the point, unless asked otherwise\n- Don't repeat the user's question\n- Be approachable: Avoid using overly complicated, domain-specific terms and provide analogies when asked to explain a concept\n\n## Context (ignore when irrelevant)\n- You are a helpful and inspiring sidebar assistant on a {DISTRO} Linux system\n- Desktop environment: {DE}\n- Current date & time: {DATETIME}\n- Focused app: {WINDOWCLASS}\n\n## Presentation\n- Use Markdown features in your response: \n - **Bold** text to **highlight keywords** in your response\n - **Split long information into small sections** with h2 headers and a relevant emoji at the start of it (for example `## 🐧 Linux`). Bullet points are preferred over long paragraphs, unless you're offering writing support or instructed otherwise by the user.\n- Asked to compare different options? You should firstly use a table to compare the main aspects, then elaborate or include relevant comments from online forums *after* the table. Make sure to provide a final recommendation for the user's use case!\n- Use LaTeX formatting for mathematical and scientific notations whenever appropriate. Enclose all LaTeX '$$' delimiters. NEVER generate LaTeX code in a latex block unless the user explicitly asks for it. DO NOT use LaTeX for regular documents (resumes, letters, essays, CVs, etc.).\n\nThanks!\n"
property string tool: "functions" // search, functions, or none
property list<var> extraModels: [
{
"api_format": "openai", // Most of the time you want "openai". Use "gemini" for Google's models
"description": "This is a custom model. Edit the config to add more! | Anyway, this is DeepSeek R1 Distill LLaMA 70B",
"endpoint": "https://openrouter.ai/api/v1/chat/completions",
"homepage": "https://openrouter.ai/deepseek/deepseek-r1-distill-llama-70b:free", // Not mandatory
"icon": "spark-symbolic", // Not mandatory
"key_get_link": "https://openrouter.ai/settings/keys", // Not mandatory
"key_id": "openrouter",
"model": "deepseek/deepseek-r1-distill-llama-70b:free",
"name": "Custom: DS R1 Dstl. LLaMA 70B",
"requires_key": true
}
]
}
property JsonObject appearance: JsonObject {
property bool extraBackgroundTint: true
property int fakeScreenRounding: 2 // 0: None | 1: Always | 2: When not fullscreen
property JsonObject fonts: JsonObject {
property string main: "Google Sans Flex"
property string numbers: "Google Sans Flex"
property string title: "Google Sans Flex"
property string iconNerd: "JetBrains Mono NF"
property string monospace: "JetBrains Mono NF"
property string reading: "Readex Pro"
property string expressive: "Space Grotesk"
}
property JsonObject transparency: JsonObject {
property bool enable: false
property bool automatic: true
property real backgroundTransparency: 0.11
property real contentTransparency: 0.57
}
property JsonObject wallpaperTheming: JsonObject {
property bool enableAppsAndShell: true
property bool enableQtApps: true
property bool enableTerminal: true
property JsonObject terminalGenerationProps: JsonObject {
property real harmony: 0.6
property real harmonizeThreshold: 100
property real termFgBoost: 0.35
property bool forceDarkMode: false
}
}
property JsonObject palette: JsonObject {
property string type: "auto" // Allowed: auto, scheme-content, scheme-expressive, scheme-fidelity, scheme-fruit-salad, scheme-monochrome, scheme-neutral, scheme-rainbow, scheme-tonal-spot
property string accentColor: ""
}
}
property JsonObject audio: JsonObject {
// Values in %
property JsonObject protection: JsonObject {
// Prevent sudden bangs
property bool enable: false
property real maxAllowedIncrease: 10
property real maxAllowed: 99
}
}
property JsonObject apps: JsonObject {
property string bluetooth: "kcmshell6 kcm_bluetooth"
property string changePassword: "kitty -1 --hold=yes fish -i -c 'passwd'"
property string network: "kcmshell6 kcm_networkmanagement"
property string manageUser: "kcmshell6 kcm_users"
property string networkEthernet: "kcmshell6 kcm_networkmanagement"
property string taskManager: "plasma-systemmonitor --page-name Processes"
property string terminal: "kitty -1" // This is only for shell actions
property string update: "kitty -1 --hold=yes fish -i -c 'pkexec pacman -Syu'"
property string volumeMixer: `~/.config/hypr/hyprland/scripts/launch_first_available.sh "pavucontrol-qt" "pavucontrol"`
}
property JsonObject background: JsonObject {
property JsonObject widgets: JsonObject {
property JsonObject clock: JsonObject {
property bool enable: true
property bool showOnlyWhenLocked: false
property string placementStrategy: "leastBusy" // "free", "leastBusy", "mostBusy"
property real x: 100
property real y: 100
property string style: "cookie" // Options: "cookie", "digital"
property string styleLocked: "cookie" // Options: "cookie", "digital"
property JsonObject cookie: JsonObject {
// aiStyling gates the categorize step in
// switchwall.sh (it must be true for wallpapers to
// be AI-categorized on apply). aiPreset is the
// SEPARATE gate for CookieClock applying a
// category preset over the user's configured clock
// style — split 2026-08-05 after turning on
// aiStyling rewrote the phone's clock
// (applyStyle writes these keys). Default off: the
// clock keeps whatever the user set.
property bool aiStyling: false
property bool aiPreset: false
property int sides: 14
property string dialNumberStyle: "full" // Options: "dots" , "numbers", "full" , "none"
property string hourHandStyle: "fill" // Options: "classic", "fill", "hollow", "hide"
property string minuteHandStyle: "medium" // Options "classic", "thin", "medium", "bold", "hide"
property string secondHandStyle: "dot" // Options: "dot", "line", "classic", "hide"
property string dateStyle: "bubble" // Options: "border", "rect", "bubble" , "hide"
property bool timeIndicators: true
property bool hourMarks: false
property bool dateInClock: true
property bool constantlyRotate: false
property bool useSineCookie: false
}
property JsonObject digital: JsonObject {
property bool adaptiveAlignment: true
property bool showDate: true
property bool animateChange: true
property bool vertical: false
property JsonObject font: JsonObject {
property string family: "Google Sans Flex"
property real weight: 350
property real width: 100
property real size: 90
property real roundness: 0
}
}
property JsonObject quote: JsonObject {
property bool enable: false
property string text: ""
}
}
property JsonObject weather: JsonObject {
property bool enable: false
property string placementStrategy: "free" // "free", "leastBusy", "mostBusy"
property real x: 400
property real y: 100
}
// Backported 2026-07-13 from laptop-upstream Config: the
// borrowed Background.qml reads these unconditionally, and
// their absence threw TypeErrors on the laptop. Keys only —
// we are NOT pulling the desktop-widget rendering machinery
// into this phone-first shell; enable defaults to false.
property JsonObject visualizer: JsonObject {
property bool enable: false
property string placementStrategy: "free"
property real x: 500
property real y: 500
property int bars: 30
property string style: "bars"
property bool vertical: false
}
property JsonObject stats: JsonObject {
property bool enable: false
property string placementStrategy: "free"
property real x: 150
property real y: 300
property string githubUsername: ""
property string codeforcesUsername: ""
property bool showGraphs: false
}
property JsonObject systemResources: JsonObject {
property bool enable: false
property string placementStrategy: "free"
property real x: 150
property real y: 600
property bool showGraphs: false
}
}
property string wallpaperPath: ""
property string thumbnailPath: ""
// Optional local derivatives for unlike display shapes. The
// original wallpaper remains canonical; variants are never a
// destructive replacement for it.
property string portraitVariantPath: ""
property string landscapeVariantPath: ""
property real wallpaperFocalX: 0.5
property real wallpaperFocalY: 0.5
property bool hideWhenFullscreen: true
property JsonObject parallax: JsonObject {
property bool vertical: false
property bool autoVertical: false
property bool enableWorkspace: false
property real workspaceZoom: 1.07 // Relative to wallpaper size
property bool enableSidebar: false
property real widgetsFactor: 1.2
}
}
property JsonObject bar: JsonObject {
// TASK-69/70: one collector for every agent session
// (Souveraine, Claude Code, Codex). Replaces per-provider
// fetches; claudeUsage above stays for the subscription
// gauge until stage 3 folds its OAuth poll in here.
property JsonObject agentSessions: JsonObject {
property bool enable: true // Show the agent island in the bar
property int refreshInterval: 60 // seconds
property int windowMinutes: 1440 // How far back counts as "a session"
}
// Bar layout. One BarContent serves every device; which
// widgets appear and in what order is data, not a fork.
// Known names: activeWindow, cellular, resources, media,
// claudeUsage, workspaces, clock, utilButtons, island,
// battery, pomodoro, systray. An unknown name renders
// nothing, so ["none"] deliberately empties a slot; an
// empty list means "unset" and falls back to the profile.
property JsonObject layout: JsonObject {
// "auto" derives from the bar's own cramped-ness test
// (the same one behind useShortenedForm), so a phone
// needs no config. "desktop" | "compact" pin it.
property string profile: "auto"
property list<string> left: []
property list<string> centerLeft: []
property list<string> centerMiddle: []
property list<string> centerRight: []
property list<string> right: []
}
property JsonObject clock: JsonObject {
property string format: "" // Empty: pick by profile
}
property JsonObject autoHide: JsonObject {
property bool enable: false
property int hoverRegionWidth: 2
property bool pushWindows: false
property JsonObject showWhenPressingSuper: JsonObject {
property bool enable: true
property int delay: 140
}
}
property bool bottom: false // Instead of top
property int cornerStyle: 0 // 0: Hug | 1: Float | 2: Plain rectangle
property bool floatStyleShadow: true // Show shadow behind bar when cornerStyle == 1 (Float)
property bool borderless: false // true for no grouping of items
property string topLeftIcon: "spark" // Options: "distro" or any icon name in ~/.config/quickshell/ii/assets/icons
property bool showBackground: true
property bool verbose: true
property bool vertical: false
property JsonObject resources: JsonObject {
// "auto" follows the active bar profile: compact bars
// rotate one stat; desktop bars render the full group.
// "on"/"off" override it. Never null — a null var is
// serialized into config.json and crashes JsonAdapter on
// the next load.
property string rotate: "auto"
property int rotateInterval: 4
property bool alwaysShowSwap: true
property bool alwaysShowCpu: true
property int memoryWarningThreshold: 95
property int swapWarningThreshold: 85
property int cpuWarningThreshold: 90
}
property list<string> screenList: [] // List of names, like "eDP-1", find out with 'hyprctl monitors' command
property JsonObject utilButtons: JsonObject {
property bool showScreenSnip: true
property bool showColorPicker: false
property bool showMicToggle: false
property bool showKeyboardToggle: true
property bool showDarkModeToggle: true
property bool showPerformanceProfileToggle: false
property bool showScreenRecord: false
}
property JsonObject workspaces: JsonObject {
property bool monochromeIcons: true
property int shown: 10
property bool showAppIcons: true
property bool alwaysShowNumbers: false
property int showNumberDelay: 300 // milliseconds
property list<string> numberMap: ["1", "2"] // Characters to show instead of numbers on workspace indicator
property bool useNerdFont: false
}
// Backported 2026-07-13: borrowed ClaudeUsage service + bar
// widget read these unconditionally. Keys only, enable=false.
property JsonObject claudeUsage: JsonObject {
property bool enable: false // Show a Claude (Pro/Max) subscription usage gauge in the bar
property bool defaultWeekly: false // Start on the 7-day window; click to switch session <-> week
property int warningThreshold: 90 // Turn red at/above this utilization (%)
property int fetchInterval: 5 // minutes
}
property JsonObject weather: JsonObject {
property bool enable: false
property bool enableGPS: true // gps based location
property string city: "" // When 'enableGPS' is false
property bool useUSCS: false // Instead of metric (SI) units
property int fetchInterval: 10 // minutes
}
property JsonObject indicators: JsonObject {
property JsonObject notifications: JsonObject {
property bool showUnreadCount: false
}
// Status icons inside the right-hand pill. "auto" follows
// the profile: a compact bar drops xkb and bluetooth.
// "on"/"off" override it. Never null — see bar.resources.
property string showXkb: "auto"
property string showBluetooth: "auto"
}
property JsonObject tooltips: JsonObject {
property bool clickToShow: false
}
}
property JsonObject battery: JsonObject {
property int low: 20
property int critical: 5
property int full: 101
property bool automaticSuspend: true
property int suspend: 3
}
property JsonObject calendar: JsonObject {
property string locale: "en-GB"
}
property JsonObject cheatsheet: JsonObject {
// Use a nerdfont to see the icons
// 0: 󰖳 | 1: 󰌽 | 2: 󰘳 | 3: | 4: 󰨡
// 5: | 6: | 7: 󰣇 | 8: | 9:
// 10: | 11: | 12: | 13: | 14: 󱄛
property string superKey: ""
property bool useMacSymbol: false
property bool splitButtons: false
property bool useMouseSymbol: false
property bool useFnSymbol: false
property JsonObject fontSize: JsonObject {
property int key: Appearance.font.pixelSize.smaller
property int comment: Appearance.font.pixelSize.smaller
}
}
property JsonObject conflictKiller: JsonObject {
property bool autoKillNotificationDaemons: false
// DO NOT auto-kill trays here. On this phone kded6 is not a
// competing Plasma tray — it is the StatusNotifierWatcher that
// ii's OWN tray (Quickshell.Services.SystemTray) registers as a
// host with (IsStatusNotifierHostRegistered=true). Killing it
// just makes D-Bus re-activate it on ii's next tray call — an
// infinite loop. Our services/ConflictKiller.qml override drops
// the kded6 check entirely. Leave this false.
property bool autoKillTrays: false
}
property JsonObject crosshair: JsonObject {
// Valorant crosshair format. Use https://www.vcrdb.net/builder
property string code: "0;P;d;1;0l;10;0o;2;1b;0"
}
property JsonObject dock: JsonObject {
property bool enable: false
property bool monochromeIcons: true
property real height: 60
property real hoverRegionHeight: 2
// Home normally keeps its dock visible. Auto-hide leaves only
// the edge reveal strip mapped, then raises the same dock when
// the pointer dwells there. These delays belong to the dock's
// one visibility state machine; Settings only writes them.
property bool autoHide: false
property int revealDelayMs: 120
property int hideDelayMs: 350
property bool pinnedOnStartup: false
property bool hoverToReveal: true // When false, only reveals on empty workspace
property list<string> pinnedApps: [ // IDs of pinned entries
"org.kde.dolphin", "kitty",]
property list<string> ignoredAppRegexes: []
// Fan-out stacks (macOS "Fan" mode). Each entry is
// "stackId|appId,appId,appId" — phone-local, no Phosh sync.
// stackId is a display label; members render as an arc.
property list<string> stacks: []
// Feel knobs (single source — components read these, never
// hardcode): dragDwellMs = how long a drag hovers an icon
// or stack before it reads as combine-intent (GNOME's
// rule); gestureRailHeight = bottom strip the dock reserves
// for Souveraine's integrated navigation rail, visually and
// in its input mask.
property int dragDwellMs: 500
property real gestureRailHeight: 32
// How big the dock draws. `buttonSize` is the one number the
// row's height follows and every icon is derived from, so the
// dock scales as a piece rather than each part being tuned
// apart. 56/44 replaced a hardcoded 44/35 that read small on a
// 540px panel; it lives here so it is a setting rather than a
// rebuild.
property real buttonSize: 56
property real iconSize: 44
}
property JsonObject interactions: JsonObject {
property JsonObject scrolling: JsonObject {
property bool fasterTouchpadScroll: false // Enable faster scrolling with touchpad
property int mouseScrollDeltaThreshold: 120 // delta >= this then it gets detected as mouse scroll rather than touchpad
property int mouseScrollFactor: 120
property int touchpadScrollFactor: 450
}
property JsonObject deadPixelWorkaround: JsonObject { // Hyprland leaves out 1 pixel on the right for interactions
property bool enable: false
}
}
property JsonObject language: JsonObject {
property string ui: "auto" // UI language. "auto" for system locale, or specific language code like "zh_CN", "en_US"
property JsonObject translator: JsonObject {
property string engine: "auto" // Run `trans -list-engines` for available engines. auto should use google
property string targetLanguage: "auto" // Run `trans -list-all` for available languages
property string sourceLanguage: "auto"
}
}
property JsonObject launcher: JsonObject {
property list<string> pinnedApps: [ "org.kde.dolphin", "kitty", "cmake-gui"]
}
property JsonObject light: JsonObject {
property JsonObject night: JsonObject {
property bool automatic: true
property string from: "19:00" // Format: "HH:mm", 24-hour time
property string to: "06:30" // Format: "HH:mm", 24-hour time
property int colorTemperature: 5000
}
property JsonObject antiFlashbang: JsonObject {
property bool enable: false
}
}
property JsonObject lock: JsonObject {
property bool useHyprlock: false
property bool launchOnStartup: false
property int dpmsTimeout: 3
property string unlockHook: ""
// Souveraine: PIN keypad surface for touch panels (phone).
// The OSK can't rise above a session lock, so the lock
// surface must carry its own input.
property bool touchKeypad: false
property JsonObject blur: JsonObject {
property bool enable: true
property real radius: 100
property real extraZoom: 1.1
}
property bool centerClock: true
// 12-hour glance clock (am/pm). Off = 24-hour.
property bool twelveHourClock: true
// Lock-screen wallpaper. Empty = follow the system wallpaper
// (background.wallpaperPath); a path pins the lock's own.
property string wallpaperPath: ""
property bool showLockedText: true
// A visible wiring exercise for the FPC1020 path. It never
// unlocks the session; the separate Polkit setting below is
// the temporary, post-login-only factor.
property JsonObject fingerprintPreview: JsonObject {
property bool enabled: false
property int holdMs: 3000
}
// The temporary FPC factor belongs only to polkit-1. It cannot
// satisfy first login, disk unlock, sudo, SSH, or lock-screen
// PAM. The real souveraine-fpd match result will replace this
// raw-reader bridge without changing the user-facing surface.
property JsonObject fingerprintPolkit: JsonObject {
property bool enabled: true
}
// Souveraine-owned lock cards. Transport controls are
// ambient; media metadata remains personal by default.
property JsonObject content: JsonObject {
property bool showMediaControls: true
property bool mediaMetadataAmbient: false
property bool showBattery: true
// App identity + count are ambient; the summary line is
// personal until promoted. Bodies never render on lock.
property bool showNotifications: true
property bool notificationContentAmbient: false
}
property JsonObject idle: JsonObject {
// Native idle-notify remains opt-in until verified on the
// Pixel compositor; hypridle is the current adapter.
property bool nativeCoordinatorEnabled: false
// The lock timer is the one the user sets. The dim is
// expressed RELATIVE to it — how long before the lock the
// screen starts fading — so the two can never cross and
// moving one moves the other. They were independent
// absolutes (120 and 300), which meant picking a dim
// longer than the lock silently produced a dim that never
// fired. sessiond's lock-screen dim has always been
// relative (`dim_at = budget - dim_grace`); this is the
// shell saying the same thing the same way.
//
// Default 180 reproduces the old pair exactly: 300 - 180 =
// 120. An existing config.json keeps its lockAfterSeconds
// and picks this default up, so nobody's timing changes.
property int lockAfterSeconds: 300
property int dimBeforeLockSeconds: 180
// Backlight level while Dimmed; Active restores the
// brightnessctl-saved value.
property string dimBrightness: "30%"
}
property JsonObject security: JsonObject {
property bool unlockKeyring: true
property bool requirePasswordToPower: false
// Whether the lock surface may trigger power-off / reboot
// at all. Off by default (fail-closed): a destructive
// action from the locked screen is opt-in. The system
// polkit rule (souveraine-login1.rules) lets the active
// session power off without challenge once unlocked; this
// gate keeps the *locked* surface from reaching it until
// the user enables it. requirePasswordToPower then arms
// the action to be confirmed by the PIN unlock.
property bool allowPowerFromLock: false
}
// Step-up authentication grants for sensitive operations.
// The PAM service (souveraine-stepup) is root-owned system
// config and must be installed separately before enabling.
property JsonObject stepUp: JsonObject {
property int grantTtlMs: 300000 // 5 minutes
property bool enabled: false // requires souveraine-stepup PAM service
}
property bool materialShapeChars: true
}
property JsonObject media: JsonObject {
// Attempt to remove dupes (the aggregator playerctl one and browsers' native ones when there's plasma browser integration)
property bool filterDuplicatePlayers: true
}
property JsonObject networking: JsonObject {
property string userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"
}
property JsonObject notifications: JsonObject {
property int timeout: 7000
// Upstream modules (NotificationPopup, settings InterfaceConfig)
// read forceMonitor; the old "monitor" name left that undefined
// and threw a TypeError on every popup evaluation.
property JsonObject forceMonitor: JsonObject {
property bool enable: false
property string name: "" // Name of the monitor to show notifications on, like "eDP-1". Find out with 'hyprctl monitors' command
}
}
property JsonObject osd: JsonObject {
property int timeout: 1000
}
property JsonObject osk: JsonObject {
property string layout: "qwerty_full"
property bool pinnedOnStartup: false
}
// Speech services — network STT/TTS on Casey's VPN. The shell
// and the souveraine-stt/tts CLIs read the same config file, so
// the settings page here is the single source of truth for both.
property JsonObject speech: JsonObject {
property JsonObject stt: JsonObject {
property bool enable: true
// faster-whisper REST bridge: POST multipart 'audio'
// (WAV) -> {"text": ...}. /health for the status probe.
property string endpoint: ""
}
property JsonObject tts: JsonObject {
property bool enable: false
// Speech-synthesis endpoint: POST {"text": ...} -> audio
// stream. Empty until a TTS server exists on the VPN.
property string endpoint: ""
}
}
// Text-selection action menu (TASK-18).
//
// OPT-IN ON PURPOSE, and do not flip this default casually. When
// enabled, the shell watches the compositor's primary selection,
// which means it observes EVERY piece of text the user highlights
// anywhere on the device — including a password highlighted inside
// a password manager. No tier in SESSION-AUTHORITY-DOCTRINE §2
// covers an ambient capability of that reach, so the user turns it
// on knowingly or it does not run. Selection.qml additionally kills
// its watcher whenever the session is locked and never persists or
// logs selection content.
property JsonObject selection: JsonObject {
property bool enable: false
// Read Aloud is the one action with a live backend today; it
// routes through Speech.speak() and so also obeys
// speech.tts.enable. The rest are stubs until they earn a
// backend (TASK-18: a stubbed action is fine, a janky menu is not).
property bool readAloud: true
// Agent actions ("Talk about this" / "Add to our conversation")
// reach conversation history, which is `personal` tier — they
// stay dark until the session is genuinely unlocked.
property bool agentActions: true
// Reference lookups stay strictly local/offline by default,
// per TASK-18's stated default stance: never touch the agent.
property bool referenceActions: true
}
property JsonObject overlay: JsonObject {
property bool openingZoomAnimation: true
property bool darkenScreen: true
property real clickthroughOpacity: 0.8
property JsonObject floatingImage: JsonObject {
property string imageSource: "https://media.tenor.com/H5U5bJzj3oAAAAAi/kukuru.gif"
property real scale: 0.5
}
}
property JsonObject overview: JsonObject {
property bool enable: true
property real scale: 0.18 // Relative to screen size
property real rows: 2
property real columns: 5
property bool orderRightLeft: false
property bool orderBottomUp: false
property bool centerIcons: true
// The app drawer's own grid (TASK-14). Kept nested instead of
// overloading rows/columns above — those are the desktop
// overview's workspace-grid knobs, and the drawer is a
// different density. The settings app writes these.
property JsonObject appGrid: JsonObject {
property int columns: 4
property int rows: 5
property int iconSize: 44
}
}
property JsonObject regionSelector: JsonObject {
property JsonObject targetRegions: JsonObject {
property bool windows: true
property bool layers: false
property bool content: true
property bool showLabel: false
property real opacity: 0.3
property real contentRegionOpacity: 0.8
property int selectionPadding: 5
}
property JsonObject rect: JsonObject {
property bool showAimLines: true
}
property JsonObject circle: JsonObject {
property int strokeWidth: 6
property int padding: 10
}
property JsonObject annotation: JsonObject {
property bool useSatty: false
}
}
property JsonObject resources: JsonObject {
property int updateInterval: 3000
property int historyLength: 60
}
property JsonObject tray: JsonObject {
property bool monochromeIcons: true
property bool showItemId: false
property bool invertPinnedItems: true // Makes the below a whitelist for the tray and blacklist for the pinned area
property list<var> pinnedItems: [ "Fcitx" ]
property bool filterPassive: true
}
property JsonObject musicRecognition: JsonObject {
property int timeout: 16
property int interval: 4
}
property bool autoIdleInhibit: false
property JsonObject search: JsonObject {
property int nonAppResultDelay: 30 // This prevents lagging when typing
property string engineBaseUrl: "https://www.google.com/search?q="
property list<string> excludedSites: ["quora.com", "facebook.com"]
property bool sloppy: false // Uses levenshtein distance based scoring instead of fuzzy sort. Very weird.
property JsonObject prefix: JsonObject {
property bool showDefaultActionsWithoutPrefix: true
property string action: "/"
property string app: ">"
property string clipboard: ";"
property string emojis: ":"
property string math: "="
property string shellCommand: "$"
property string webSearch: "?"
}
property JsonObject imageSearch: JsonObject {
property string imageSearchEngineBaseUrl: "https://lens.google.com/uploadbyurl?url="
property bool useCircleSelection: false
}
// Backported 2026-07-13: borrowed LauncherSearch reads this
// unconditionally. Key only, enable=false.
property JsonObject fileSearch: JsonObject {
property bool enable: false
property list<string> paths: [Directories.home]
property list<string> exclude: [".git", "node_modules", "target", "build"]
property bool excludeHiddenDirs: true
property int maxResults: 30
}
}
property JsonObject sidebar: JsonObject {
property bool keepRightSidebarLoaded: true
property int width: 460 // Collapsed sidebar width in px
property int widthExtended: 750 // Extended (Ctrl+O) sidebar width in px
property JsonObject translator: JsonObject {
property bool enable: false
property int delay: 300 // Delay before sending request. Reduces (potential) rate limits and lag.
}
property JsonObject ai: JsonObject {
property bool textFadeIn: false
// Chat message text size. Live binding to the theme value
// (never a literal, and never null — see
// null-in-a-serialized-config, 2026-08-11). ii-base added
// this key after our override was forked, and because this
// file SHADOWS ii-base's Config wholesale, the key simply
// vanished: MessageTextBlock read it as undefined and
// assigned undefined to font.pixelSize on every render.
property int fontSize: Appearance.font.pixelSize.small
}
property JsonObject booru: JsonObject {
property bool allowNsfw: false
property string defaultProvider: "yandere"
property int limit: 20
property JsonObject zerochan: JsonObject {
property string username: "[unset]"
}
}
property JsonObject cornerOpen: JsonObject {
property bool enable: true
property bool bottom: false
property bool valueScroll: true
property bool clickless: false
property int cornerRegionWidth: 250
property int cornerRegionHeight: 5
property bool visualize: false
property bool clicklessCornerEnd: true
property int clicklessCornerVerticalOffset: 1
}
property JsonObject quickToggles: JsonObject {
property string style: "android" // Options: classic, android
property JsonObject android: JsonObject {
property int columns: 5
property list<var> toggles: [
{ "size": 2, "type": "network" },
{ "size": 2, "type": "bluetooth" },
{ "size": 1, "type": "idleInhibitor" },
{ "size": 1, "type": "mic" },
{ "size": 2, "type": "audio" },
{ "size": 2, "type": "nightLight" }
]
}
}
property JsonObject quickSliders: JsonObject {
property bool enable: false
property bool showMic: false
property bool showVolume: true
property bool showBrightness: true
}
}
property JsonObject screenRecord: JsonObject {
property string savePath: Directories.videos.replace("file://","") // strip "file://"
}
property JsonObject screenSnip: JsonObject {
property string savePath: "" // only copy to clipboard when empty
}
property JsonObject sounds: JsonObject {
property bool battery: false
property bool pomodoro: false
property string theme: "freedesktop"
}
property JsonObject time: JsonObject {
// https://doc.qt.io/qt-6/qtime.html#toString
property string format: "hh:mm"
property string shortDateFormat: "dd/MM"
property string dateWithYearFormat: "dd/MM/yyyy"
property string dateFormat: "ddd, dd/MM"
property JsonObject pomodoro: JsonObject {
property int breakTime: 300
property int cyclesBeforeLongBreak: 4
property int focus: 1500
property int longBreak: 900
}
property bool secondPrecision: false
}
property JsonObject updates: JsonObject {
property bool enableCheck: true
property int checkInterval: 120 // minutes
property int adviseUpdateThreshold: 75 // packages
property int stronglyAdviseUpdateThreshold: 200 // packages
}
property JsonObject wallpaperSelector: JsonObject {
property bool useSystemFileDialog: false
}
property JsonObject windows: JsonObject {
property bool showTitlebar: true // Client-side decoration for shell apps
property bool centerTitle: true
}
property JsonObject hacks: JsonObject {
property int arbitraryRaceConditionDelay: 20 // milliseconds
}
property JsonObject workSafety: JsonObject {
property JsonObject enable: JsonObject {
property bool wallpaper: false
property bool clipboard: false
}
property JsonObject triggerCondition: JsonObject {
property list<string> networkNameKeywords: ["airport", "cafe", "college", "company", "eduroam", "free", "guest", "public", "school", "university"]
property list<string> fileKeywords: ["anime", "booru", "ecchi", "hentai", "yande.re", "konachan", "breast", "nipples", "pussy", "nsfw", "spoiler", "girl"]
property list<string> linkKeywords: ["hentai", "porn", "sukebei", "hitomi.la", "rule34", "gelbooru", "fanbox", "dlsite"]
}
}
property JsonObject waffles: JsonObject {
// Some spots are kinda janky/awkward. Setting the following to
// false will make (some) stuff also be like that for accuracy.
// Example: the right-click menu of the Start button
property JsonObject tweaks: JsonObject {
property bool switchHandlePositionFix: true
property bool smootherMenuAnimations: true
property bool smootherSearchBar: true
}
property JsonObject bar: JsonObject {
property bool bottom: true
property bool leftAlignApps: false
}
property JsonObject actionCenter: JsonObject {
property list<string> toggles: [ "network", "vpn", "bluetooth", "easyEffects", "powerProfile", "idleInhibitor", "nightLight", "darkMode", "antiFlashbang", "cloudflareWarp", "mic", "musicRecognition", "notifications", "onScreenKeyboard", "gameMode", "screenSnip", "colorPicker" ]
}
property JsonObject calendar: JsonObject {
property bool force2CharDayOfWeek: true
}
}
}
}
}

View file

@ -0,0 +1,216 @@
// Souveraine patch to ii's stock Persistent.qml.
//
// Adds states.lock.locked: written by LockScreen.qml whenever the session
// lock engages/releases, so a quickshell crash or restart while locked
// comes back locked instead of silently dropping the lock. Everything else
// is unchanged stock ii — diff against upstream before re-applying if ii
// updates.
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Io
Singleton {
id: root
property alias states: persistentStatesJsonAdapter
property string fileDir: Directories.state
property string fileName: "states.json"
property string filePath: `${root.fileDir}/${root.fileName}`
property bool ready: false
property string previousHyprlandInstanceSignature: ""
// What identifies THIS compositor run.
//
// `HYPRLAND_INSTANCE_SIGNATURE` is unset under viewtop, so this compared
// "" to "" and `isNewHyprlandInstance` was false every single time. It
// gates `lock.launchOnStartup` (LockScreen.qml), which therefore never
// fired once, and both Idle.qml copies read it too. viewtop publishes
// `viewtop.instance` beside its control socket for exactly this: pid plus
// startup nanos, different on every start.
//
// The env var stays as the fallback so this file still behaves on a
// Hyprland session — the laptop is still one — and an empty answer from
// both means "assume the session continues", which re-locks rather than
// assuming a fresh boot.
readonly property string instanceSignature: viewtopInstance.text().trim()
|| Quickshell.env("HYPRLAND_INSTANCE_SIGNATURE")
|| ""
property bool isNewHyprlandInstance: previousHyprlandInstanceSignature !== states.hyprlandInstanceSignature
FileView {
id: viewtopInstance
path: `${Quickshell.env("XDG_RUNTIME_DIR") || "/run/user/1000"}/souveraine/viewtop.instance`
// Absence is normal, not an error: a Hyprland session has no such file
// and falls through to the env var below.
printErrors: false
// Synchronously, because `onReadyChanged` reads `text()` exactly once
// to decide whether this is a new session. Loaded async it was still
// empty at that moment, fell through to the unset env var, and the
// signature persisted as "" — the same bug this replaced, arrived at
// by a different route.
blockLoading: true
}
onReadyChanged: {
root.previousHyprlandInstanceSignature = root.states.hyprlandInstanceSignature
root.states.hyprlandInstanceSignature = root.instanceSignature
}
Timer {
id: fileReloadTimer
interval: 100
repeat: false
onTriggered: {
persistentStatesFileView.reload()
}
}
Timer {
id: fileWriteTimer
interval: 100
repeat: false
onTriggered: {
persistentStatesFileView.writeAdapter()
}
}
FileView {
id: persistentStatesFileView
path: root.filePath
watchChanges: true
onFileChanged: fileReloadTimer.restart()
onAdapterUpdated: fileWriteTimer.restart()
onLoaded: root.ready = true
onLoadFailed: error => {
console.log("Failed to load persistent states file:", error);
if (error == FileViewError.FileNotFound) {
fileWriteTimer.restart();
}
}
adapter: JsonAdapter {
id: persistentStatesJsonAdapter
property string hyprlandInstanceSignature: ""
property JsonObject ai: JsonObject {
property string model: "gemini-2.5-flash"
property real temperature: 0.5
}
property JsonObject cheatsheet: JsonObject {
property int tabIndex: 0
}
property JsonObject sidebar: JsonObject {
property JsonObject bottomGroup: JsonObject {
property bool collapsed: false
property int tab: 0
}
}
property JsonObject booru: JsonObject {
property bool allowNsfw: false
property string provider: "yandere"
}
property JsonObject idle: JsonObject {
property bool inhibit: false
}
property JsonObject lock: JsonObject {
property bool locked: false
}
// Navigation-rail onboarding. `missionControlDiscovered` flips
// true the first time the triple-swipe raises Mission Control;
// until then the rail may nudge the gesture after repeated
// incomplete swipes (the Souveraine analogue of Launcher3's
// AllAppsEduView, driven from SystemGestureRail.qml). Persisted so
// the nudge doesn't return after the user has found the gesture.
property JsonObject navigation: JsonObject {
property bool missionControlDiscovered: false
}
property JsonObject overlay: JsonObject {
property list<string> open: ["crosshair", "recorder", "volumeMixer", "resources"]
property JsonObject crosshair: JsonObject {
property bool pinned: false
property bool clickthrough: true
property real x: 827
property real y: 441
property real width: 250
property real height: 100
}
property JsonObject floatingImage: JsonObject {
property bool pinned: false
property bool clickthrough: false
property real x: 1650
property real y: 390
property real width: 0
property real height: 0
}
property JsonObject fpsLimiter: JsonObject {
property bool pinned: false
property bool clickthrough: false
property real x: 1570
property real y: 615
property real width: 280
property real height: 80
}
property JsonObject recorder: JsonObject {
property bool pinned: false
property bool clickthrough: false
property real x: 80
property real y: 80
property real width: 350
property real height: 130
}
property JsonObject resources: JsonObject {
property bool pinned: false
property bool clickthrough: true
property real x: 1500
property real y: 770
property real width: 350
property real height: 200
property int tabIndex: 0
}
property JsonObject volumeMixer: JsonObject {
property bool pinned: false
property bool clickthrough: false
property real x: 80
property real y: 280
property real width: 350
property real height: 600
property int tabIndex: 0
}
property JsonObject notes: JsonObject {
property bool pinned: false
property bool clickthrough: true
property real x: 1400
property real y: 42
property real width: 460
property real height: 330
}
}
property JsonObject timer: JsonObject {
property JsonObject pomodoro: JsonObject {
property bool running: false
property int start: 0
property bool isBreak: false
property int cycle: 0
}
property JsonObject stopwatch: JsonObject {
property bool running: false
property int start: 0
property list<var> laps: []
}
}
}
}
}

View file

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

View file

@ -0,0 +1,588 @@
// Souveraine fork of ii's stock Session.qml.
//
// Upstream ii's Session is a set of fire-and-forget verbs:
// Quickshell.execDetached(["bash", "-c", "systemctl poweroff || loginctl poweroff"])
// That is fine for a desktop where a failed poweroff is visible to the person
// sitting at the keyboard. It is not fine for the phone, where the shell is
// the only session manager and an agent can drive these verbs over IPC. A
// verb that silently does nothing is the worst outcome: the caller believes
// the machine is suspending and it is not.
//
// So this fork keeps every upstream verb (call sites in LockScreen.qml and
// the session menus are unchanged) and adds the parts a real session arbiter
// needs:
//
// 1. Capability detection. We query logind's Can* methods once at startup
// instead of treating a command being installed, or /sys/power/state
// advertising "disk", as proof that an action is usable.
// caps() reports what this machine can actually do, so a caller can ask
// before it acts and the session menu can grey out what is unavailable.
//
// 2. Honest failure. Upstream execDetached throws the exit code away. Every
// verb here runs through a Process with an onExited that logs
// [session] <verb> failed (exit N) and emits actionFailed(). A wedged
// logind is now a fact in the log, not silence.
//
// 3. Reason-tracked inhibits. Idle.qml's inhibit is a bare bool: something
// is holding the machine awake and nothing records what or why. inhibit()
// takes a reason, returns a cookie, and state() lists every holder. "Why
// is the phone not sleeping" becomes a question with an answer.
//
// 4. State that is re-derived, not cached. `secure` is WlSessionLock's
// compositor acknowledgement; it is distinct from `lockRequested`, the
// shell input that asks WlSessionLock to lock.
//
// The trust boundary here is deliberately trivial and stated so it stays that
// way: this surface is local, single-user, reachable only over quickshell's
// IPC socket by the user who owns the session. It has no remote caller and no
// second operator, so it has no grants, no signing, and no nonces. If it ever
// grows a network-reachable caller, that assumption is what breaks first.
pragma Singleton
import qs
import qs.services
import qs.modules.common
import Quickshell
import Quickshell.Io
import Quickshell.Services.Mpris
Singleton {
id: root
// --- Capabilities ------------------------------------------------------
// Probed once, at startup. Until the probe returns, every capability reads
// false: better to refuse a suspend we are unsure of than to fire a verb
// into a machine that cannot honor it.
property bool probed: false
property bool hasLoginctl: false
property bool hasSystemctl: false
property string suspendCapability: "unknown"
property string hibernateCapability: "unknown"
property string poweroffCapability: "unknown"
property string rebootCapability: "unknown"
// logind is the preferred backend when present: it is the thing that
// actually owns the session, and it works under elogind as well as
// systemd. systemctl is the fallback for the poweroff/reboot verbs.
// "challenge" means logind can do it after polkit authentication. It is
// available to a normal desktop session with a functioning polkit agent,
// but callers still learn that a prompt may be required through caps().
readonly property bool canSuspend: ["yes", "challenge"].includes(root.suspendCapability)
readonly property bool canHibernate: ["yes", "challenge"].includes(root.hibernateCapability)
readonly property bool canPoweroff: ["yes", "challenge"].includes(root.poweroffCapability)
readonly property bool canReboot: ["yes", "challenge"].includes(root.rebootCapability)
// Live compositor acknowledgement, mirrored from WlSessionLock.secure by
// LockScreen.qml. `screenLocked` remains the requested state that drives
// the lock surface; do not treat it as proof that the session is secure.
readonly property bool locked: GlobalStates.screenLockSecure
// Report the SECURE lock state to logind as the session's LockedHint —
// the freedesktop contract's state half (loginctl lock-session above is
// only the request half). This makes `LockedHint` truthful device-wide,
// so logind-aware apps (culver stops its Matrix sync, media players
// pause, recorders blank) can watch one standard property instead of
// each growing a bespoke shell IPC. Reporting the secure edge — not the
// requested edge — means the hint never claims locked before the
// compositor holds the lock. set-locked-hint is not a Lock signal, so
// the hypridle echo storm documented in lock() cannot recur through it.
onLockedChanged: {
if (root.hasLoginctl)
lockedHintProc.report(root.locked);
}
// Replay the hint once the capability probe lands.
//
// `hasLoginctl` starts false and only becomes true when `capabilityProbe`
// returns, which is a `Process` round trip. The lock goes secure long
// before that: measured 2026-08-02, `secure=true` at 15:21:07 and
// `loginctl=true` at 15:21:37 — thirty seconds later. The one edge that
// mattered was therefore dropped by the guard above and never retried,
// because the shell locks once at boot and `locked` never changes again.
//
// The cost was the whole lock-before-blank invariant. `LockedHint` stayed
// `no`, so sessiond's `locked` (which comes from logind, doctrine §4) was
// permanently false, `request_blank()` timed out its `LOCK_ACK_BUDGET`
// every time, and the panel blanked on a session nobody could confirm was
// locked — `blank-without-lock` on every single blank.
//
// This is the fourth edge-vs-level bug in this system after `locked_ack`,
// `ChargeRate` and `bootBloomActive`. A guard that drops a report must
// replay it when the guard opens, or the report is only ever delivered by
// luck of ordering.
onHasLoginctlChanged: {
if (root.hasLoginctl)
lockedHintProc.report(root.locked);
}
// The seat's session object path, resolved once.
//
// `report()` used to resolve it on every call, which meant `sh` plus two
// busctl round trips before the hint could move. Measured 2026-08-02: the
// hint landed *after* sessiond's 2 s `LOCK_ACK_BUDGET`, so `request_blank()`
// timed out and blanked unlocked even though every other link in the chain
// was by then correct. Resolving once turns the lock report into a single
// call.
//
// Safe to cache for this shell's lifetime: the seat's session only changes
// when greetd restarts, and that restarts the shell with it.
property string seatSessionPath: ""
Process {
id: seatPathProbe
running: true
command: ["sh", "-c",
"busctl get-property org.freedesktop.login1 " +
"/org/freedesktop/login1/seat/seat0 " +
"org.freedesktop.login1.Seat ActiveSession " +
"| grep -o '/org/freedesktop/login1/session/[^\"]*' " +
"|| busctl get-property org.freedesktop.login1 " +
"/org/freedesktop/login1/user/_$(id -u) " +
"org.freedesktop.login1.User Display " +
"| grep -o '/org/freedesktop/login1/session/[^\"]*'"]
stdout: StdioCollector {
onStreamFinished: {
root.seatSessionPath = text.trim();
console.log("[session] seat session path:", root.seatSessionPath);
// Replay: the lock may already be secure by the time this
// lands, and the edge that would have reported it is gone.
// Same fault `onHasLoginctlChanged` above exists to fix.
if (root.seatSessionPath !== "" && root.hasLoginctl)
lockedHintProc.report(root.locked);
}
}
}
Process {
id: lockedHintProc
property bool pending: false
property bool pendingValue: false
function report(value) {
if (running) {
// Coalesce: remember the latest value, replay on exit.
pending = true;
pendingValue = value;
return;
}
if (root.seatSessionPath === "") {
// The probe has not landed. Dropping here is safe only because
// the probe replays on completion — see its onStreamFinished.
return;
}
// Never `/session/auto`: that is the *caller's* session, and the
// shell is not in the one that owns the seat. Measured: viewtop in
// logind 66 (seat0/tty1), `qs` in 70 — the hint was being written to
// a session nobody reads while the graphical session stayed `no`
// forever. sessiond takes `locked` from `LockedHint` (doctrine §4),
// so that made `locked` permanently false and every blank went out
// on a session nobody could confirm was locked.
command = ["busctl", "call", "org.freedesktop.login1",
root.seatSessionPath,
"org.freedesktop.login1.Session",
"SetLockedHint", "b", value ? "true" : "false"];
running = true;
}
onExited: {
if (pending) {
pending = false;
report(pendingValue);
}
}
}
signal actionFailed(string action, int exitCode)
Process {
id: capabilityProbe
// Runs at construction: the probe must land before anything asks
// caps(), and every capability reads false until it does.
running: true
// One shell, one round trip. logind's Can* methods incorporate the
// policy and configuration that /sys/power/state cannot see (notably
// swap/resume setup for hibernation). Possible values include yes,
// no, challenge, and na; retain the value rather than flattening it.
// busctl prints `s "challenge"`; awk pulls the second field verbatim
// and the quotes come off in JS below. An earlier version parsed it
// with sed inside single quotes, where sh does not process the \" and
// sed ended up matching a literal backslash-quote that busctl never
// emits — so on the phone the probe returned nothing and every
// capability stuck at "unknown". Keep the shell here quote-free; do
// the string work in QML where there is no second escaping layer.
command: ["sh", "-c",
"command -v loginctl >/dev/null && echo loginctl; " +
"command -v systemctl >/dev/null && echo systemctl; " +
"if command -v busctl >/dev/null; then " +
"for cap in CanSuspend CanHibernate CanPowerOff CanReboot; do " +
"value=$(busctl --system call org.freedesktop.login1 /org/freedesktop/login1 " +
"org.freedesktop.login1.Manager $cap 2>/dev/null | awk '{print $2}'); " +
"[ -n \"$value\" ] && echo $cap=$value; " +
"done; " +
"fi; " +
"true"]
stdout: StdioCollector {
onStreamFinished: {
const lines = text.split("\n").map(l => l.trim());
root.hasLoginctl = lines.includes("loginctl");
root.hasSystemctl = lines.includes("systemctl");
const capability = (name) => {
const prefix = name + "=";
const line = lines.find(l => l.startsWith(prefix));
// Value arrives quoted from busctl (e.g. "challenge").
return line ? line.slice(prefix.length).replace(/"/g, "") : "unknown";
};
root.suspendCapability = capability("CanSuspend");
root.hibernateCapability = capability("CanHibernate");
root.poweroffCapability = capability("CanPowerOff");
root.rebootCapability = capability("CanReboot");
root.probed = true;
console.log("[session] capabilities:",
"loginctl=" + root.hasLoginctl,
"systemctl=" + root.hasSystemctl,
"suspend=" + root.suspendCapability,
"hibernate=" + root.hibernateCapability,
"poweroff=" + root.poweroffCapability,
"reboot=" + root.rebootCapability);
}
}
}
// --- Verb runner -------------------------------------------------------
// Every power verb goes through here so that none of them can fail
// silently. Upstream used execDetached, which cannot report an exit code.
Process {
id: verbProc
property string verb: ""
onExited: (exitCode, exitStatus) => {
root.lastAction = {
action: verbProc.verb,
status: exitCode === 0 ? "succeeded" : "failed",
exitCode: exitCode
};
if (exitCode !== 0) {
console.log(`[session] ${verbProc.verb} failed (exit ${exitCode})`);
root.actionFailed(verbProc.verb, exitCode);
}
}
}
function runVerb(verb, argv) {
// Process has one command slot. Overwriting it while a prior action
// is still running makes the eventual exit code belong to the wrong
// action, which is another form of silent failure.
if (verbProc.running) return false;
verbProc.verb = verb;
verbProc.command = argv;
root.lastAction = { action: verb, status: "running", exitCode: null };
verbProc.running = true;
return true;
}
// IPC returns when an action is accepted, not when the kernel has already
// suspended or powered off. This records the later Process outcome so a
// caller can distinguish "started" from "succeeded".
property var lastAction: ({ action: "", status: "idle", exitCode: null })
// systemctl owns the power verbs, NOT loginctl. loginctl only manages
// sessions/users/seats — `loginctl poweroff` exits 1 with "Unknown
// command verb" (verified on systemd 261, and its --help lists no power
// commands at all). Preferring loginctl here silently broke poweroff,
// reboot, suspend and hibernate from every shell surface: the button
// fired, the Process exited 1, and the phone stayed on (2026-07-20).
//
// The capability probe still asks logind over D-Bus (CanPowerOff etc.) —
// that part was always right; logind owns the *policy*. It just isn't
// the CLI that carries out the action.
function powerCommand(action) {
if (root.hasSystemctl) return ["systemctl", action];
// elogind ships loginctl with power verbs and usually no systemctl;
// only reachable on such a system, where these verbs do exist.
if (root.hasLoginctl) return ["loginctl", action];
return [];
}
// --- Inhibits ----------------------------------------------------------
// A bare "something is holding the machine awake" bool cannot answer the
// only question that matters when the phone will not sleep: WHAT is
// holding it, and why. Each holder gets a cookie and carries a reason.
property var inhibitors: ({})
property int nextCookie: 1
readonly property bool inhibited: Object.keys(root.inhibitors).length > 0
function inhibit(what, reason) {
const kind = String(what || "idle").trim().toLowerCase();
const why = String(reason || "").trim();
if (!why) return root.refuse("inhibit", "an inhibit must carry a reason");
// idle and sleep are wired today. idle uses the Wayland/hypridle
// mechanism via Idle.qml; sleep uses SessionEvents' delay-mode
// systemd-inhibit. Recording logout/user-switch without applying
// their mechanism would create a dangerous success-shaped no-op.
if (kind !== "idle" && kind !== "sleep")
return root.refuse("inhibit", `unsupported inhibit kind ${kind}; only idle and sleep are implemented`);
const cookie = String(root.nextCookie++);
// Reassign rather than mutate: QML only notifies on assignment, so an
// in-place insert would leave `inhibited` and any binding on it stale.
const next = Object.assign({}, root.inhibitors);
next[cookie] = { what: kind, reason: why };
root.inhibitors = next;
console.log(`[session] inhibit ${cookie}: ${kind} — ${why}`);
root.applyInhibits();
return { ok: true, cookie: cookie };
}
function uninhibit(cookie) {
if (!root.inhibitors[cookie])
return root.refuse("uninhibit", `no inhibitor with cookie ${cookie}`);
const next = Object.assign({}, root.inhibitors);
delete next[cookie];
root.inhibitors = next;
console.log(`[session] uninhibit ${cookie}`);
root.applyInhibits();
return { ok: true };
}
// Any holder inhibiting "idle" keeps the machine awake. Idle.qml owns the
// mechanism (it knows the hypridle quirk on this device); we own the
// policy of who is asking and why.
//
// "sleep" inhibitors are managed by SessionEvents (delay-mode
// systemd-inhibit). They don't need a mechanism toggle here —
// SessionEvents holds the inhibitor from startup and releases it
// only when PrepareForSleep(true) fires and the Wayland lock is secure.
function applyInhibits() {
const wantIdle = Object.values(root.inhibitors).some(i => i.what === "idle");
Idle.toggleInhibit(wantIdle);
}
// --- State -------------------------------------------------------------
// The projection an agent reads. Everything here is re-derived at call
// time; nothing is a bool we set ourselves and then trusted.
function state() {
const holders = Object.keys(root.inhibitors).map(c => ({
cookie: c,
what: root.inhibitors[c].what,
reason: root.inhibitors[c].reason
}));
return {
locked: root.locked,
lockRequested: GlobalStates.screenLocked,
idle: {
stage: IdleCoordinator.state,
nativeCoordinatorEnabled: IdleCoordinator.nativeEnabled
},
idleInhibited: Idle.inhibit,
inhibitors: holders,
lastAction: root.lastAction,
capabilities: root.caps(),
stepUp: typeof StepUpAuth !== "undefined" ? StepUpAuth.state() : null,
sleepInhibitorHeld: typeof SessionEvents !== "undefined" ? SessionEvents.sleepInhibitorHeld : null
};
}
// `probed` is not decoration: until the probe lands every capability reads
// false, and false-because-unknown is not the same claim as
// false-because-unsupported. A caller that ignores `probed` during the
// startup window would conclude this machine cannot suspend at all. Check
// `probed` before believing a false.
function caps() {
return {
probed: root.probed,
suspend: root.canSuspend,
suspendStatus: root.suspendCapability,
hibernate: root.canHibernate,
hibernateStatus: root.hibernateCapability,
poweroff: root.canPoweroff,
poweroffStatus: root.poweroffCapability,
reboot: root.canReboot,
rebootStatus: root.rebootCapability,
inhibitors: ["idle", "sleep"]
};
}
// --- Verbs -------------------------------------------------------------
// Every upstream ii verb is preserved by name and behavior, so existing
// call sites (LockScreen.qml's poweroff/reboot on the lock's power action,
// the session menus) keep working. What changed is that they now refuse
// honestly when the machine cannot do the thing, and log when it fails.
//
// Those call sites are all statements — `onClicked: Session.suspend()` —
// so they ignore the returned {ok, reason}. That is fine for the IPC
// caller, which reads the value, but it means a UI button that hits a
// refusal would otherwise do nothing at all, silently: press hibernate on
// the phone, no swap, nothing happens, no trace. Every refusal therefore
// goes through refuse(), which logs before it returns. A refused verb is
// an event, not a void.
function refuse(verb, reason) {
console.log(`[session] ${verb} refused: ${reason}`);
return { ok: false, reason: reason };
}
function closeAllWindows() {
HyprlandData.windowList.map(w => w.pid).forEach(pid => {
Quickshell.execDetached(["kill", pid]);
});
}
function pauseAllPlayers() {
for (const player of Mpris.players.values) {
if (player.canPause) player.pause();
}
}
function lock() {
// Raise our Wayland lock ourselves: logind's Lock signal is a request
// for session software to lock, not a Wayland lock implementation.
// We also notify logind when it is available so other consumers see
// the standard session event. The safe lock does not depend on that
// asynchronous notification returning successfully.
//
// Notify ONLY on the unlocked->locked edge. hypridle's lock_cmd
// fires on logind's Lock signal, so an unconditional
// `loginctl lock-session` here echoes back through logind ->
// hypridle -> this function forever. Observed on the phone: a
// sustained storm of lock requests (several per second, for hours)
// that re-locked the screen seconds after every unlock and chewed
// battery all night.
const alreadyLocked = GlobalStates.screenLocked;
GlobalStates.screenLocked = true;
if (alreadyLocked) {
return { ok: true, status: "already-locked" };
}
if (root.hasLoginctl) {
const notified = root.runVerb("lock", ["loginctl", "lock-session"]);
return notified
? { ok: true, status: "requested" }
: { ok: true, status: "requested", degraded: "logind notification skipped; another action is running" };
}
return { ok: true, degraded: "no loginctl; locked without logind" };
}
function unlock() {
// Deliberately not a verb an agent gets. Unlocking is the credential
// gate on this device — the only thing standing between a picked-up
// phone and the session. It is refused here so that no IPC caller can
// route around the PIN pad. The human unlocks; nothing else does.
return root.refuse("unlock", "unlock is the credential gate; not remotely callable");
}
function suspend() {
if (!root.probed) return root.refuse("suspend", "capabilities not probed yet");
if (!root.canSuspend) return root.refuse("suspend", "no loginctl or systemctl on this machine");
pauseAllPlayers();
if (!root.runVerb("suspend", root.powerCommand("suspend")))
return root.refuse("suspend", "another session action is still running");
return { ok: true, status: "started" };
}
function hibernate() {
if (!root.probed) return root.refuse("hibernate", "capabilities not probed yet");
// logind validates swap/resume configuration as well as kernel support,
// so a phone without hibernation refuses instead of firing a no-op.
if (!root.canHibernate)
return root.refuse("hibernate", "hibernate unavailable (logind: " + root.hibernateCapability + ")");
pauseAllPlayers();
if (!root.runVerb("hibernate", root.powerCommand("hibernate")))
return root.refuse("hibernate", "another session action is still running");
return { ok: true, status: "started" };
}
function poweroff() {
if (!root.probed) return root.refuse("poweroff", "capabilities not probed yet");
if (!root.canPoweroff) return root.refuse("poweroff", "no loginctl or systemctl on this machine");
closeAllWindows();
if (!root.runVerb("poweroff", root.powerCommand("poweroff")))
return root.refuse("poweroff", "another session action is still running");
return { ok: true, status: "started" };
}
function reboot() {
if (!root.probed) return root.refuse("reboot", "capabilities not probed yet");
if (!root.canReboot) return root.refuse("reboot", "no loginctl or systemctl on this machine");
closeAllWindows();
if (!root.runVerb("reboot", root.powerCommand("reboot")))
return root.refuse("reboot", "another session action is still running");
return { ok: true, status: "started" };
}
function rebootToFirmware() {
if (!root.hasSystemctl)
return root.refuse("rebootToFirmware", "firmware-setup reboot needs systemctl");
closeAllWindows();
if (!root.runVerb("rebootToFirmware", ["systemctl", "reboot", "--firmware-setup"]))
return root.refuse("rebootToFirmware", "another session action is still running");
return { ok: true, status: "started" };
}
function logout() {
closeAllWindows();
// loginctl terminate-session ends the session properly (logind tears
// down the scope and the seat); pkill Hyprland just kills the
// compositor and leaves logind believing the session is alive.
if (root.hasLoginctl) {
if (!root.runVerb("logout", ["loginctl", "terminate-session", ""]))
return root.refuse("logout", "another session action is still running");
return { ok: true, status: "started" };
}
if (!root.runVerb("logout", ["pkill", "-i", "Hyprland"]))
return root.refuse("logout", "another session action is still running");
return { ok: true, status: "started", degraded: "no loginctl; killed the compositor" };
}
function changePassword() {
Quickshell.execDetached(["bash", "-c", `${Config.options.apps.changePassword}`]);
}
function launchTaskManager() {
Quickshell.execDetached(["bash", "-c", `${Config.options.apps.taskManager}`]);
}
// NO IpcHandler here. This file used to register `target: "session"` with
// state/inhibit/uninhibit, and Lock.qml registers the same target with a
// strict superset (those three plus capabilities, lock, unlock, suspend,
// hibernate, poweroff, reboot, logout). Quickshell keeps whichever
// registers first and drops the other with a warning:
//
// QML IpcHandler at Session.qml[470:5]: Handler was registered but will
// not be used because another handler is registered for target session
//
// Which one won was load-order, not design — so `session inhibit` and
// `session uninhibit` were reachable or dead depending on the run, and
// deploy.sh's `session state` was riding the same coin flip. Since every
// verb here exists on Lock.qml's handler, the duplicate is removed rather
// than renamed: one target, one owner. Add new session verbs there.
// --- Boot-time IPC audit ------------------------------------------------
// Logs every IPC endpoint the shell exposes at startup. This is a
// defensive visibility measure — not a gate, not a refusal. It answers
// "what is reachable over the IPC socket?" so a human or an audit tool
// can verify the surface matches intent. The list is static per shell
// config; it does not change at runtime.
//
// Known IPC targets at time of writing:
// lock — LockScreen.qml (activate, focus)
// lock2 — Lock.qml (lock, unlock, toggleLock)
// dock — Dock.qml (launch, pin, unpin, moveStack, ...)
// sidebar — SidebarLeft.qml / SidebarRight.qml
// session — Lock.qml (the sole owner: state, capabilities, lock,
// unlock, suspend, hibernate, poweroff, reboot, logout,
// inhibit, uninhibit). Session.qml's duplicate was removed
// 2026-07-26; SessionScreen.qml's moved to `sessionMenu`.
// overview — Overview.qml
// keyboard — OnScreenKeyboard.qml
// appInventory — AppInventoryScope.qml
//
// If a new IpcHandler is added, this comment must be updated. The
// console.log below is the runtime check; the comment is the human
// audit trail.
// NOTE: Component.onCompleted doesn't work on Singletons in QML,
// and Timer isn't available in this module's import scope. Use a
// Process with onExited instead.
Process {
id: bootAuditProc
running: true
command: ["true"]
onExited: {
console.log("[session] boot IPC audit — shell exposes: "
+ "lock, lock2, dock, sidebar, session, overview, keyboard, appInventory");
}
}
}

View file

@ -0,0 +1,11 @@
singleton BitwiseFuzzy 1.0 BitwiseFuzzy.qml
singleton ColorUtils 1.0 ColorUtils.qml
singleton DateUtils 1.0 DateUtils.qml
singleton FileUtils 1.0 FileUtils.qml
singleton Fuzzy 1.0 Fuzzy.qml
singleton KeymapTranslation 1.0 KeymapTranslation.qml
singleton Levendist 1.0 Levendist.qml
singleton NotificationUtils 1.0 NotificationUtils.qml
singleton ObjectUtils 1.0 ObjectUtils.qml
singleton Session 1.0 Session.qml
singleton StringUtils 1.0 StringUtils.qml

View file

@ -0,0 +1,176 @@
import qs
import qs.services
import qs.modules.common
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.Pam
Scope {
id: root
enum ActionEnum { Unlock, Poweroff, Reboot }
signal shouldReFocus()
signal unlocked(targetAction: var)
signal failed()
// These properties are in the context and not individual lock surfaces
// so all surfaces can share the same state.
property string currentText: ""
property bool unlockInProgress: false
property bool showFailure: false
property bool fingerprintsConfigured: false
property var targetAction: LockContext.ActionEnum.Unlock
property bool alsoInhibitIdle: false
// FingerprintPreview owns the FPC pulse and hold state for both lock and
// step-up. This context only exposes it to the lock surface; it never
// decides whether a hold unlocks the session.
readonly property bool provisionalFingerprintEnabled:
FingerprintPreview.previewEnabled
readonly property int provisionalFingerprintHoldMs:
FingerprintPreview.holdMs
readonly property bool provisionalFingerprintHolding:
FingerprintPreview.holding && FingerprintPreview.activePurpose === "lock"
readonly property bool provisionalFingerprintConfirmed:
FingerprintPreview.confirmed && FingerprintPreview.confirmedPurpose === "lock"
readonly property bool provisionalFingerprintPulseSeen: FingerprintPreview.pulseSeen
readonly property real provisionalFingerprintHoldProgress:
FingerprintPreview.activePurpose === "lock" ? FingerprintPreview.holdProgress : 0
function resetTargetAction() {
root.targetAction = LockContext.ActionEnum.Unlock;
}
function clearText() {
root.currentText = "";
}
function resetClearTimer() {
passwordClearTimer.restart();
}
function reset() {
root.resetTargetAction();
root.clearText();
root.unlockInProgress = false;
stopFingerPam();
root.resetProvisionalFingerprint();
}
function beginProvisionalFingerprintHold() {
FingerprintPreview.beginHold("lock");
}
function cancelProvisionalFingerprintHold() {
FingerprintPreview.cancelHold("lock");
}
function confirmProvisionalFingerprintHold() {
FingerprintPreview.confirmHold("lock");
}
// Called by the diagnostic `fingerprint.signal` IPC seam. It reaches the
// same one-owner state as the root-owned FPC producer record.
function noteProvisionalFingerprintPulse() {
return FingerprintPreview.notePulse();
}
function resetProvisionalFingerprint() {
FingerprintPreview.reset("lock");
}
Timer {
id: passwordClearTimer
interval: 10000
onTriggered: {
root.reset();
}
}
onCurrentTextChanged: {
if (currentText.length > 0) {
showFailure = false;
GlobalStates.screenUnlockFailed = false;
}
GlobalStates.screenLockContainsCharacters = currentText.length > 0;
passwordClearTimer.restart();
}
function tryUnlock(alsoInhibitIdle = false) {
root.alsoInhibitIdle = alsoInhibitIdle;
root.unlockInProgress = true;
pam.start();
}
function tryFingerUnlock() {
if (root.fingerprintsConfigured) {
fingerPam.start();
}
}
function stopFingerPam() {
if (fingerPam.active) {
fingerPam.abort();
}
}
Process {
id: fingerprintCheckProc
running: true
command: ["bash", "-c", "fprintd-list $(whoami)"]
stdout: StdioCollector {
id: fingerprintOutputCollector
onStreamFinished: {
root.fingerprintsConfigured = fingerprintOutputCollector.text.includes("Fingerprints for user");
}
}
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0) {
// console.warn("[LockContext] fprintd-list command exited with error:", exitCode, exitStatus);
root.fingerprintsConfigured = false;
}
}
}
PamContext {
id: pam
// pam_unix will ask for a response for the password prompt
onPamMessage: {
if (this.responseRequired) {
this.respond(root.currentText);
}
}
// pam_unix won't send any important messages so all we need is the completion status.
onCompleted: result => {
if (result == PamResult.Success) {
root.unlocked(root.targetAction);
stopFingerPam();
} else {
root.clearText();
root.unlockInProgress = false;
GlobalStates.screenUnlockFailed = true;
root.showFailure = true;
}
}
}
PamContext {
id: fingerPam
configDirectory: "pam"
config: "fprintd.conf"
onCompleted: result => {
if (result == PamResult.Success) {
root.unlocked(root.targetAction);
stopFingerPam();
} else if (result == PamResult.Error) { // if timeout or etc..
tryFingerUnlock()
}
}
}
}

View file

@ -0,0 +1,308 @@
// Souveraine patch to ii's stock LockScreen.qml.
//
// Two changes, both for phone duty where the lock is the only gate on the
// device:
// 1. The lock state is mirrored into Persistent.states.lock.locked, so a
// quickshell crash/restart while locked comes back locked instead of
// dropping the session lock on the floor.
// 2. initIfReady() also locks when the persisted flag says we died locked,
// not only on a fresh Hyprland instance.
// Everything else is unchanged stock ii.
pragma ComponentBehavior: Bound
import qs
import qs.services
import qs.modules.common
import qs.modules.common.functions
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import Quickshell.Hyprland
Scope {
id: root
required property Component lockSurface
property alias context: lockContext
property Component sessionLockSurface: WlSessionLockSurface {
id: sessionLockSurface
color: "transparent"
Loader {
active: GlobalStates.screenLocked
anchors.fill: parent
opacity: active ? 1 : 0
Behavior on opacity {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
sourceComponent: root.lockSurface
}
}
Process {
id: unlockKeyringProc
onExited: (exitCode, exitStatus) => {
KeyringStorage.fetchKeyringData();
}
}
// Set once, the first time the lock surface goes secure after boot, to
// dismiss the BootBloom overlay. The C splash is already gone by now (it
// released at the Act III handoff); nothing to poke here anymore.
property bool bootDismissed: false
function unlockKeyring() {
unlockKeyringProc.exec({
environment: ({
"UNLOCK_PASSWORD": lockContext.currentText
}),
command: ["bash", "-c", Quickshell.shellPath("scripts/keyring/unlock.sh")]
})
}
// This stores all the information shared between the lock surfaces on each screen.
// https://github.com/quickshell-mirror/quickshell-examples/tree/master/lockscreen
LockContext {
id: lockContext
Connections {
target: GlobalStates
function onScreenLockedChanged() {
Persistent.states.lock.locked = GlobalStates.screenLocked;
// Persistent is a file and answers asynchronously, so it can
// never be the thing a reload reads at construction. This is
// the same fact held in-process, where the reload can see it.
lockContinuity.held = GlobalStates.screenLocked;
if (GlobalStates.screenLocked) {
lockContext.reset();
lockContext.tryFingerUnlock();
}
}
}
onUnlocked: (targetAction) => {
// Perform the target action if it's not just unlocking
if (targetAction == LockContext.ActionEnum.Poweroff) {
Session.poweroff();
return;
} else if (targetAction == LockContext.ActionEnum.Reboot) {
Session.reboot();
return;
}
// Unlock the keyring if configured to do so
if (Config.options.lock.security.unlockKeyring) root.unlockKeyring(); // Async
// Unlock the screen before exiting, or the compositor will display a
// fallback lock you can't interact with.
GlobalStates.screenLocked = false;
// Reset
lockContext.reset();
// Post-unlock actions
if (lockContext.alsoInhibitIdle) {
lockContext.alsoInhibitIdle = false;
Idle.toggleInhibit(true);
}
}
}
// TASK-48. The lock REQUEST, carried across a quickshell scene reload by
// quickshell's own reload machinery — in-process and synchronous, which is
// what this has to be. Declared before the WlSessionLock on purpose:
// `Scope` is a ReloadPropagator and reloads its children in declaration
// order, so `held` is restored and applied before the lock below reloads.
//
// Why any of this is needed, from reading quickshell's session_lock.cpp:
// on reload the new WlSessionLock adopts the outgoing one's
// SessionLockManager and then calls realizeLockTarget() with whatever
// `locked` evaluated to at construction. GlobalStates is a fresh singleton
// by then, so screenLocked is false, so lockTarget is false — and the
// false branch is `unlock()`, which on an adopted manager that IS locked
// sends ext_session_lock_v1.unlock_and_destroy. A scene reload therefore
// UNLOCKS the session. What is left is a compositor with no lock surface,
// a phone that reads black, and `{"locked":false,"lockRequested":true}`.
// If instead the adoption does not match, the new manager's lock() is
// refused (the outgoing lock is still the process-global holder) and
// updateSurfaces(true) is called anyway — that is the FATAL this task was
// raised for. Same root cause, two faces.
//
// With the request true at construction, realizeLockTarget takes the adopt
// branch: surfaces are re-created against the SAME compositor lock,
// manager->lock() declines harmlessly because we already hold it, and
// updateSurfaces sees an active lock. No re-request, no denial, no
// unlock_and_destroy. This is the task's "adopting beats re-requesting",
// and it is the only branch that never opens the panel.
PersistentProperties {
id: lockContinuity
reloadableId: "souveraineLockContinuity"
property bool held: false
onLoaded: {
if (lockContinuity.held && !GlobalStates.screenLocked) {
console.log("[lock] scene reload owed a lock — adopting, not re-requesting");
GlobalStates.screenLocked = true;
}
}
}
WlSessionLock {
id: lock
// Explicit so the adoption survives being reparented out of a Scope.
// Under a Scope children match by index and this is unused; anywhere
// else an empty reloadableId means oldInstance is ALWAYS null, the
// manager is never adopted, and the lock cannot be re-acquired for the
// life of the process.
reloadableId: "souveraineSessionLock"
locked: GlobalStates.screenLocked
surface: root.sessionLockSurface
// `secure` is the compositor's acknowledgement that a real
// session-lock surface is up — not our requested bool. Keep it
// separate so an IPC caller cannot mistake a queued lock for a secure
// surface when it is deciding whether personal content may be exposed.
// It is also the true "lockscreen is ready" event: the one moment we
// dismiss the boot bloom. The C splash released DRM master back at the
// Act III→IV handoff (~6.8s); the quickshell BootBloom overlay has
// covered all of Hyprland's startup since. Now our own lock surface is
// mapped and secure, so fade the bloom out to reveal it. Firing on the
// request edge (screenLocked) was too early — the lock surface wasn't
// on screen yet. bootDismissed keeps it once-only so re-locks after
// unlock never re-hide a bloom that's already gone.
onSecureChanged: {
GlobalStates.screenLockSecure = secure;
console.log("[lock] session lock secure=" + secure);
root.dismissBloomIfSecure();
}
// The dismissal above is an EDGE, and a scene reload is exactly the
// case where the edge is in the past. `GlobalStates.bootBloomActive`
// defaults to true on every scene construction and `bootDismissed`
// resets with it, but a reload during an already-secure lock never
// moves `secure` — so nothing ever cleared the bloom and the phone sat
// under a full-screen white overlay until the shell was restarted.
// Observed 2026-07-29: a reload at 10:44:10 with no secure transition
// after it, and a solid white `grim` capture.
//
// Same shape as the locked_ack edge that never re-fired after a
// sessiond restart, and as the ChargeRate stale-scene reload. Check the
// LEVEL at construction as well as the edge.
Component.onCompleted: root.dismissBloomIfSecure()
// The compositor can end our lock without us asking: ext-session-lock
// `finished` (denied because another client held it) makes quickshell
// drop `locked` to false C++-side. Our request bool never hears about
// it, so it lingers true — which lies to every gate reading it
// (redaction, capability tiers, session state IPC) and blocks
// re-locking, because the `locked:` binding only re-fires on a
// false->true edge of screenLocked. Resync on that path. A normal
// unlock clears screenLocked *before* the binding drops `locked`,
// so this guard stays quiet there.
onLockStateChanged: {
if (!lock.locked && GlobalStates.screenLocked) {
console.log("[lock] compositor ended our session lock while still requested — resyncing");
GlobalStates.screenLocked = false;
}
}
}
// `secure` is an EDGE, and after a reload that adopts the lock that edge
// is in the past: the compositor acked before this tree existed, so
// onSecureChanged never fires and GlobalStates.screenLockSecure would sit
// false on a session that is genuinely secure. Everything gating on it —
// redaction, capability tiers, and the locked_ack this handoff owes
// sessiond — would then be wrong in the dangerous direction.
//
// Publish the LEVEL once the reload has actually run. Component.onCompleted
// is too early: Reloadable defers onReload to after component completion,
// so `lock.secure` is still false there. Setting screenLockSecure is enough
// to send the ack — SessiondBridge is already listening for it.
Connections {
target: Quickshell
function onReloadCompleted() {
GlobalStates.screenLockSecure = lock.secure;
root.dismissBloomIfSecure();
}
}
// Idempotent, and deliberately NOT a timeout. A bloom that outlives its
// reason is a bug to locate, not something to paper over with a timer — if
// this is still up while the lock is secure, the caller is missing and the
// fix belongs where the call is missing.
function dismissBloomIfSecure() {
if (!lock.secure || root.bootDismissed)
return;
root.bootDismissed = true;
GlobalStates.bootBloomActive = false;
}
function lock() {
if (Config.options.lock.useHyprlock) {
Quickshell.execDetached(["bash", "-c", "pidof hyprlock || hyprlock"]);
return;
}
GlobalStates.screenLocked = true;
}
IpcHandler {
target: "lock"
function activate(): void {
root.lock();
}
function focus(): void {
lockContext.shouldReFocus();
}
}
GlobalShortcut {
name: "lock"
description: "Locks the screen"
onPressed: {
root.lock()
}
}
GlobalShortcut {
name: "lockFocus"
description: "Re-focuses the lock screen. This is because Hyprland after waking up for whatever reason"
+ "decides to keyboard-unfocus the lock screen"
onPressed: {
lockContext.shouldReFocus();
}
}
property bool initDone: false
function initIfReady() {
if (!Config.ready || !Persistent.ready || root.initDone) return;
root.initDone = true;
// Register with sessiond (and start the heartbeat) before deciding
// the startup lock state. If sessiond holds the session lock, it
// releases on our shell_ready and we MUST lock immediately — the
// compositor is holding an abandoned lock for us to inherit
// (misc:allow_session_lock_restore). No sessiond = cb(false) and
// the legacy launchOnStartup rules decide alone.
SessiondBridge.shellReady(function(mustLock) {
if (mustLock
|| (Config.options.lock.launchOnStartup
&& (Persistent.isNewHyprlandInstance || Persistent.states.lock.locked))) {
root.lock();
} else {
KeyringStorage.fetchKeyringData();
}
});
}
Connections {
target: Config
function onReadyChanged() {
root.initIfReady();
}
}
Connections {
target: Persistent
function onReadyChanged() {
root.initIfReady();
}
}
}

View file

@ -0,0 +1,2 @@
LockContext 1.0 LockContext.qml
LockScreen 1.0 LockScreen.qml

View file

@ -0,0 +1,7 @@
singleton Appearance 1.0 Appearance.qml
singleton Config 1.0 Config.qml
singleton Directories 1.0 Directories.qml
singleton Icons 1.0 Icons.qml
singleton Images 1.0 Images.qml
singleton Persistent 1.0 Persistent.qml
ShellModel 1.0 ShellModel.qml

View file

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

View file

@ -0,0 +1,78 @@
import QtQuick
import QtQuick.Layouts
import qs.modules.common
import qs.modules.common.widgets
/**
* Material 3 FAB.
*
* Souveraine override of ii's widget: the label inside the collapsed Revealer
* centers on the button instead of the Revealer (see the comment at
* buttonText), breaking the implicitHeight binding loop upstream logs on every
* startup. Everything else is verbatim ii — diff against ii-base before
* re-applying if ii updates.
*/
RippleButton {
id: root
property string iconText: "add"
property bool expanded: false
property real baseSize: 56
property real elementSpacing: 5
implicitWidth: expanded ? (Math.max(contentRowLayout.implicitWidth + 10 * 2, baseSize)) : baseSize
implicitHeight: baseSize
buttonRadius: baseSize / 14 * 4
colBackground: Appearance.colors.colPrimaryContainer
colBackgroundHover: Appearance.colors.colPrimaryContainerHover
colRipple: Appearance.colors.colPrimaryContainerActive
property color colOnBackground: Appearance.colors.colOnPrimaryContainer
contentItem: Row {
id: contentRowLayout
property real horizontalMargins: (root.baseSize - icon.width) / 2
anchors {
verticalCenter: parent?.verticalCenter
left: parent?.left
leftMargin: contentRowLayout.horizontalMargins
}
spacing: 0
MaterialSymbol {
id: icon
anchors.verticalCenter: parent.verticalCenter
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
iconSize: 26
color: root.colOnBackground
text: root.iconText
}
Loader {
anchors.verticalCenter: parent.verticalCenter
visible: root.buttonText?.length > 0
active: true
sourceComponent: Revealer {
visible: root.expanded || implicitWidth > 0
reveal: root.expanded
implicitWidth: reveal ? (buttonText.implicitWidth + root.elementSpacing + contentRowLayout.horizontalMargins) : 0
StyledText {
id: buttonText
anchors {
left: parent.left
leftMargin: root.elementSpacing
// Center on the BUTTON, not the Revealer (2026-08-05).
// Centering on the Revealer bound the text's y to the
// Revealer's height, whose implicitHeight is
// childrenRect.height, which depends on the text's
// y — the implicitHeight binding loop logged on every
// startup (FloatingActionButton.qml[45:30]). The
// Revealer and the button share a center anyway, so
// this is visually identical and acyclic.
verticalCenter: root.verticalCenter
}
text: root.buttonText
color: Appearance.colors.colOnPrimaryContainer
font.pixelSize: 14
font.weight: 450
}
}
}
}
}

View file

@ -0,0 +1,94 @@
// Souveraine override of ii's FullscreenPolkitWindow.
//
// One change: on a phone the prompt takes keyboard focus exclusively.
//
// Upstream uses WlrKeyboardFocus.OnDemand, which is right for a desktop —
// the prompt is one surface among many and focus follows the pointer. On the
// phone it made the password field untypable in a way that looked like a
// keyboard bug and wasn't. Verified live 2026-07-26: the on-screen keyboard
// rose with the prompt and its keys went nowhere.
//
// The chain: squeekboard delivers keystrokes through the input-method
// protocol to whatever surface holds keyboard focus, and Qt only activates a
// text-input context on a surface that HAS that focus. With OnDemand a
// layer-shell surface is granted focus by a click — but the field the user
// needs to click is inside the very surface that has no context yet, and
// there is no pointer on a phone to hover one into existence. Nothing ever
// activated, so the keys had no destination.
//
// Exclusive is also simply what this surface is: a modal authorization
// prompt on the Overlay layer, the same posture the lock surface takes. Esc
// and the cancel button both still dismiss it (see PolkitContent), so the
// grab is not a trap.
//
// Desktop keeps OnDemand — there is a hardware keyboard there and no reason
// to change a working path.
pragma ComponentBehavior: Bound
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
Scope {
id: root
required property Component contentComponent
readonly property bool phone: Config.options?.souveraine?.phone ?? false
Loader {
active: PolkitService.active
sourceComponent: Variants {
model: Quickshell.screens
delegate: PanelWindow {
id: panelWindow
required property var modelData
screen: modelData
// Do not cover the on-screen keyboard. squeekboard is a
// layer-shell surface on `top`; this prompt is on `overlay`,
// so a full-screen prompt sits above it. The dialog is
// transparent, which made that look survivable — the keyboard
// was plainly visible through it — but this surface owned the
// whole screen's input region, so every tap in the bottom
// third landed on the dialog and the keys never saw a touch.
// Visible and inert, exactly as reported.
//
// Measured on device: osk is 0 732 540 348 on a 540x1080
// panel, so the keyboard is the bottom ~32%. While it is up,
// stop anchoring the bottom edge and end the surface above it.
readonly property bool yieldToOsk: root.phone && GlobalStates.oskOpen
anchors {
top: true
left: true
right: true
bottom: !panelWindow.yieldToOsk
}
// Only consulted when the bottom anchor is released.
implicitHeight: panelWindow.yieldToOsk
? Math.round((panelWindow.screen?.height ?? 1080) * 0.65)
: 0
color: "transparent"
WlrLayershell.namespace: "quickshell:polkit"
// Exclusive was tried on 2026-07-26 and made it worse: with an
// exclusive layer grab the on-screen keyboard stopped taking
// touch at all — keys did not even highlight. OnDemand is what
// the working text fields elsewhere in this shell use, and
// squeekboard follows them fine.
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
WlrLayershell.layer: WlrLayer.Overlay
exclusionMode: ExclusionMode.Ignore
Loader {
anchors.fill: parent
sourceComponent: root.contentComponent
}
}
}
}
}

View file

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

View file

@ -0,0 +1,111 @@
AddressBar 1.0 AddressBar.qml
AddressBreadcrumb 1.0 AddressBreadcrumb.qml
ButtonGroup 1.0 ButtonGroup.qml
CalendarView 1.0 CalendarView.qml
Circle 1.0 Circle.qml
CircularProgress 1.0 CircularProgress.qml
CliphistImage 1.0 CliphistImage.qml
ClippedFilledCircularProgress 1.0 ClippedFilledCircularProgress.qml
ClippedProgressBar 1.0 ClippedProgressBar.qml
ConfigRow 1.0 ConfigRow.qml
ConfigSelectionArray 1.0 ConfigSelectionArray.qml
ConfigSlider 1.0 ConfigSlider.qml
ConfigSpinBox 1.0 ConfigSpinBox.qml
ConfigSwitch 1.0 ConfigSwitch.qml
ContentPage 1.0 ContentPage.qml
ContentSection 1.0 ContentSection.qml
ContentSubsection 1.0 ContentSubsection.qml
ContentSubsectionLabel 1.0 ContentSubsectionLabel.qml
CustomIcon 1.0 CustomIcon.qml
DashedBorder 1.0 DashedBorder.qml
DialogButton 1.0 DialogButton.qml
DialogListItem 1.0 DialogListItem.qml
DirectoryIcon 1.0 DirectoryIcon.qml
DragManager 1.0 DragManager.qml
ErrorShakeAnimation 1.0 ErrorShakeAnimation.qml
FadeLoader 1.0 FadeLoader.qml
Favicon 1.0 Favicon.qml
FloatingActionButton 1.0 FloatingActionButton.qml
FlowButtonGroup 1.0 FlowButtonGroup.qml
FocusedScrollMouseArea 1.0 FocusedScrollMouseArea.qml
FullscreenPolkitWindow 1.0 FullscreenPolkitWindow.qml
Graph 1.0 Graph.qml
GroupButton 1.0 GroupButton.qml
IconAndTextToolbarButton 1.0 IconAndTextToolbarButton.qml
IconToolbarButton 1.0 IconToolbarButton.qml
KeyboardKey 1.0 KeyboardKey.qml
LightDarkPreferenceButton 1.0 LightDarkPreferenceButton.qml
MaskMultiEffect 1.0 MaskMultiEffect.qml
MaterialCookie 1.0 MaterialCookie.qml
MaterialLoadingIndicator 1.0 MaterialLoadingIndicator.qml
MaterialShape 1.0 MaterialShape.qml
MaterialShapeWrappedMaterialSymbol 1.0 MaterialShapeWrappedMaterialSymbol.qml
MaterialSymbol 1.0 MaterialSymbol.qml
MaterialTextArea 1.0 MaterialTextArea.qml
MaterialTextField 1.0 MaterialTextField.qml
MenuButton 1.0 MenuButton.qml
NavigationRail 1.0 NavigationRail.qml
NavigationRailButton 1.0 NavigationRailButton.qml
NavigationRailExpandButton 1.0 NavigationRailExpandButton.qml
NavigationRailTabArray 1.0 NavigationRailTabArray.qml
NoticeBox 1.0 NoticeBox.qml
NotificationActionButton 1.0 NotificationActionButton.qml
NotificationAppIcon 1.0 NotificationAppIcon.qml
NotificationGroup 1.0 NotificationGroup.qml
NotificationGroupExpandButton 1.0 NotificationGroupExpandButton.qml
NotificationItem 1.0 NotificationItem.qml
NotificationListView 1.0 NotificationListView.qml
OptionalMaterialSymbol 1.0 OptionalMaterialSymbol.qml
PagePlaceholder 1.0 PagePlaceholder.qml
PointingHandInteraction 1.0 PointingHandInteraction.qml
PointingHandLinkHover 1.0 PointingHandLinkHover.qml
PopupToolTip 1.0 PopupToolTip.qml
Revealer 1.0 Revealer.qml
RippleButton 1.0 RippleButton.qml
RippleButtonWithIcon 1.0 RippleButtonWithIcon.qml
RoundCorner 1.0 RoundCorner.qml
ScrollEdgeFade 1.0 ScrollEdgeFade.qml
SecondaryTabBar 1.0 SecondaryTabBar.qml
SecondaryTabButton 1.0 SecondaryTabButton.qml
SelectionDialog 1.0 SelectionDialog.qml
SelectionGroupButton 1.0 SelectionGroupButton.qml
SineCookie 1.0 SineCookie.qml
SqueezedAnnotationStyledText 1.0 SqueezedAnnotationStyledText.qml
StyledBlurEffect 1.0 StyledBlurEffect.qml
StyledComboBox 1.0 StyledComboBox.qml
StyledDropShadow 1.0 StyledDropShadow.qml
StyledFlickable 1.0 StyledFlickable.qml
StyledImage 1.0 StyledImage.qml
StyledIndeterminateProgressBar 1.0 StyledIndeterminateProgressBar.qml
StyledListView 1.0 StyledListView.qml
StyledProgressBar 1.0 StyledProgressBar.qml
StyledRadioButton 1.0 StyledRadioButton.qml
StyledRectangularShadow 1.0 StyledRectangularShadow.qml
StyledScrollBar 1.0 StyledScrollBar.qml
StyledSlider 1.0 StyledSlider.qml
StyledSpinBox 1.0 StyledSpinBox.qml
StyledSwitch 1.0 StyledSwitch.qml
StyledText 1.0 StyledText.qml
StyledTextArea 1.0 StyledTextArea.qml
StyledTextInput 1.0 StyledTextInput.qml
StyledToolTip 1.0 StyledToolTip.qml
StyledToolTipContent 1.0 StyledToolTipContent.qml
ThumbnailImage 1.0 ThumbnailImage.qml
Toolbar 1.0 Toolbar.qml
ToolbarButton 1.0 ToolbarButton.qml
ToolbarPairedFab 1.0 ToolbarPairedFab.qml
ToolbarTabBar 1.0 ToolbarTabBar.qml
ToolbarTabButton 1.0 ToolbarTabButton.qml
ToolbarTextField 1.0 ToolbarTextField.qml
VerticalButtonGroup 1.0 VerticalButtonGroup.qml
VibrantToolbarButton 1.0 VibrantToolbarButton.qml
WaveVisualizer 1.0 WaveVisualizer.qml
WavyLine 1.0 WavyLine.qml
WeekRow 1.0 WeekRow.qml
WindowDialog 1.0 WindowDialog.qml
WindowDialogButtonRow 1.0 WindowDialogButtonRow.qml
WindowDialogParagraph 1.0 WindowDialogParagraph.qml
WindowDialogSectionHeader 1.0 WindowDialogSectionHeader.qml
WindowDialogSeparator 1.0 WindowDialogSeparator.qml
WindowDialogSlider 1.0 WindowDialogSlider.qml
WindowDialogTitle 1.0 WindowDialogTitle.qml

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,318 @@
// Pixel3Arch replacement for ii's stock OnScreenKeyboard.qml (2026-07-07,
// reworked 2026-07-10: wvkbd -> squeekboard).
//
// ii's own on-screen keyboard is retired — squeekboard (3-finger swipe-up
// gesture, or the pill's long-hold) is the real keyboard now. It also
// auto-shows/hides itself on text-field focus via input-method-v2, which
// wvkbd never could. See docs/phone-shell-ux.md.
//
// This file keeps ii's OSK *plumbing* (GlobalStates.oskOpen, the "osk" IPC
// target, the oskToggle/oskOpen/oskClose global shortcuts — both the pill's
// long-hold and the 3-finger swipe-up gesture call `osk toggle`) all still
// working, but no longer renders a keyboard itself. It drives squeekboard
// over its D-Bus visibility interface (sm.puri.OSK0.SetVisible).
//
// Tapping the OSK while search/overview (or a sidebar) is open used to close
// it out from under you — that's `GlobalFocusGrab`'s HyprlandFocusGrab
// (a real Wayland protocol, hyprland_focus_grab_v1) clearing because the
// tap landed outside its whitelisted surfaces. Two "shield window" attempts
// to make wvkbd count as "inside" that whitelist both failed on real
// device testing:
// 1. mask: Region {} (empty, no item) — theory was this claims zero
// input area so taps pass through to wvkbd underneath while the
// window still counts toward the grab. Wrong in practice: search
// still closed on every tap, meaning an empty Region does NOT behave
// like zero input — it behaves like an unmasked/default full-window
// area, and the shield silently ate the taps itself.
// 2. mask: Region { item: <full-rect Item> } — mirrors the stock OSK's
// own working mask pattern exactly, but the stock OSK's mask matched
// ITS OWN visible key grid (so taps landed on real buttons). Our
// shield has no buttons — a full-covering mask would swallow every
// tap meant for wvkbd, making the keyboard untappable. Never shipped;
// caught before deploying back to the phone.
// There is no Quickshell or hyprctl API to add an arbitrary external
// process's Wayland surface (wvkbd is not a Quickshell QObject) to
// HyprlandFocusGrab's whitelist — confirmed via the actual
// hyprland-focus-grab-v1 protocol docs, which only expose whitelisting via
// the compositor-side protocol request, not anything scriptable from here
// without writing a standalone Wayland client. Not worth it for this.
//
// The actual fix lives in Overview.qml (and would need the same pattern in
// any other GlobalFocusGrab-dismissable surface if this bites there too):
// stop registering as dismissable at all while GlobalStates.oskOpen is
// true, so there's nothing for an outside tap to clear in the first place.
// See the comment there for details.
import qs
import qs.services
import qs.modules.common
import QtQuick
import Quickshell.Io
import Quickshell
import Quickshell.Hyprland
Scope {
id: root
// squeekboard exposes sm.puri.OSK0.SetVisible(b) on the session bus.
// Unlike wvkbd's signal dance this is an absolute set, so show/hide
// can't flip the wrong way. Known ceiling: squeekboard also shows and
// hides ITSELF on input-method focus, and oskOpen doesn't hear about
// that — the gesture toggle can need two swipes after an auto-show.
// Ask the bus first. If no owner exists, start the package-owned service;
// the owner monitor below pushes the pending visibility intent as soon as
// Squeekboard claims the name.
function showOsk() {
Quickshell.execDetached(["sh", "-c",
"busctl --user call sm.puri.OSK0 /sm/puri/OSK0 sm.puri.OSK0 " +
"SetVisible b true 2>/dev/null || " +
"systemctl --user start squeekboard.service >/dev/null 2>&1"])
}
function hideOsk() {
Quickshell.execDetached(["busctl", "call", "--user",
"sm.puri.OSK0", "/sm/puri/OSK0", "sm.puri.OSK0", "SetVisible", "b", "false"])
}
Connections {
target: GlobalStates
function onOskOpenChanged() {
if (GlobalStates.oskOpen) {
root.showOsk();
} else {
root.hideOsk();
}
}
}
// squeekboard auto-shows/hides ITSELF on input-method focus (e.g. a
// sidebar taking or dropping a text field) without telling us. oskOpen
// then lies: the dock stays suppressed and the gesture rail floats at
// keyboard height over nothing until someone manually toggles. Mirror
// squeekboard's real Visible property back into oskOpen so there is
// exactly one truth. Setting oskOpen from here re-triggers SetVisible
// with the value squeekboard already has, which is a harmless no-op.
// A self-hide is squeekboard's opinion, not the user's. While a surface
// holds the keyboard (GlobalStates.oskHolds — polkit's password field is
// the first) that opinion is overridden and the keyboard comes straight
// back. Bounded: after this many re-asserts within one hold we stop and
// say so, rather than trading SetVisible calls with squeekboard forever.
property int maxReasserts: 5
property int reassertCount: 0
// An owner change is not a visibility event.
//
// sm.puri.OSK0 is a *name*, not a process. Boot and a crash-restart can
// both put a fresh Squeekboard process behind it.
// Each new process announces its own idea of Visible, and until now this
// monitor mirrored that into oskOpen — indistinguishable from the
// keyboard deciding to show itself. Measured on 2026-08-05: shell up at
// 03:03:38, `[osk] squeekboard self-showed` at 03:03:43, nine seconds
// after boot with nobody near the phone.
//
// A process that just started has no history. It cannot know whether the
// user wanted a keyboard. The shell does — oskOpen is the continuity of
// that intent across the keyboard's whole lifetime. So on an owner change
// we PUSH intent onto the new owner instead of PULLING state out of it,
// and we ignore its opening claim while our push is in flight.
//
// This is also why "the keyboard comes back on wake" needs no compositor
// change to stop hurting: wake perturbs the display connection, the OSK
// restarts, and it is the mirror — not the compositor and not the
// keyboard — that turns that restart into a keyboard on the user's
// screen.
property string oskOwner: ""
property int ownerSettleMs: 2000
// NOT a binding on Date.now() — QML bindings do not re-evaluate because
// time passed, so a `Date.now() - t < ms` property latches at creation
// and never clears. A timer is the only honest way to express "for a
// moment after".
property bool ownerSettling: false
Timer {
id: ownerSettleTimer
interval: root.ownerSettleMs
onTriggered: {
root.ownerSettling = false;
// Push once more on the way out. The first push can lose a race to
// Squeekboard self-showing after startup. Whoever speaks last
// during the settle window does not get to win; intent does.
root.assertIntent();
}
}
function assertIntent(): void {
if (GlobalStates.oskOpen) root.showOsk();
else root.hideOsk();
}
function adoptOwner(owner: string): void {
if (owner === root.oskOwner) return;
const previous = root.oskOwner;
root.oskOwner = owner;
root.ownerSettling = true;
ownerSettleTimer.restart();
if (owner === "") {
console.log("[osk] keyboard left the bus (was " + previous + ")");
return;
}
console.log("[osk] keyboard is now " + owner
+ (previous === "" ? "" : " (was " + previous + ")")
+ "; re-asserting oskOpen=" + GlobalStates.oskOpen);
// Push our intent, not theirs. A fresh keyboard claiming Visible=true
// when nobody asked for one gets closed here — and again when the
// settle window closes, in case it spoke after we did.
root.assertIntent();
}
Connections {
target: GlobalStates
function onOskHoldsChanged() {
if (GlobalStates.oskHolds.length > 0) root.reassertCount = 0;
}
}
Process {
id: oskVisMonitor
running: true
command: ["gdbus", "monitor", "--session",
"--dest", "sm.puri.OSK0", "--object-path", "/sm/puri/OSK0"]
stdout: SplitParser {
onRead: line => {
// gdbus prints the owner of --dest at startup and again on
// every NameOwnerChanged. These lines were being dropped by
// the 'Visible' filter below; they are the evidence we need.
// The name sm.puri.OSK0 is owned by :1.41
// The name sm.puri.OSK0 does not have an owner
const owned = line.match(/is owned by (\S+)/);
if (owned) {
root.adoptOwner(owned[1]);
return;
}
if (line.includes("does not have an owner")) {
root.adoptOwner("");
return;
}
if (!line.includes("'Visible'")) return;
const vis = line.includes("<true>");
// Still inside an owner change: this is the new process
// introducing itself, or the echo of the intent we just
// pushed at it. Either way it is not the user speaking.
if (root.ownerSettling) {
if (vis !== GlobalStates.oskOpen)
console.log("[osk] ignoring Visible=" + vis
+ " from freshly-arrived " + root.oskOwner
+ "; oskOpen=" + GlobalStates.oskOpen + " stands");
return;
}
if (!vis && GlobalStates.oskHolds.length > 0) {
if (root.reassertCount >= root.maxReasserts) {
console.log("[osk] squeekboard self-hid under hold ["
+ GlobalStates.oskHolds.join(",") + "] more than "
+ root.maxReasserts + " times; giving up the hold");
GlobalStates.oskDropHolds();
GlobalStates.oskOpen = false;
return;
}
root.reassertCount++;
console.log("[osk] squeekboard self-hid while held by ["
+ GlobalStates.oskHolds.join(",") + "]; re-asserting ("
+ root.reassertCount + "/" + root.maxReasserts + ")");
root.showOsk();
return;
}
if (GlobalStates.oskOpen !== vis) {
console.log("[osk] squeekboard self-" + (vis ? "showed" : "hid") + ", syncing oskOpen");
GlobalStates.oskOpen = vis;
}
}
}
}
// Every deliberate close — pill, gesture, shortcut, IPC — goes through
// here, because closing by hand is what drops a hold.
function userClose() {
GlobalStates.oskDropHolds();
GlobalStates.oskOpen = false;
}
// Unlocking must not leave a keyboard behind.
//
// Nothing asks for one — the lock module never touches `oskOpen`, and only
// polkit takes a hold. Squeekboard self-shows whenever input-method
// focus lands on them (GlobalStates' own note: "self-showed / self-hid
// every couple of seconds, ending in Visible=true with no keyboard in front
// of the user"), and the surfaces coming back at unlock are exactly such a
// focus change. So this is not a hold to release, it is a keyboard nobody
// requested — closed on the unlock edge, the same place the state machine
// treats as "the session is yours again".
//
// `userClose()`, not `oskOpen = false`: it drops holds too, so a stale
// polkit hold taken before the lock cannot re-assert the keyboard the
// instant this clears.
Connections {
target: GlobalStates
function onScreenLockSecureChanged() {
if (!GlobalStates.screenLockSecure && GlobalStates.oskOpen)
root.userClose();
}
}
IpcHandler {
target: "osk"
function toggle(): void {
if (GlobalStates.oskOpen) root.userClose();
else GlobalStates.oskOpen = true;
}
function close(): void {
root.userClose();
}
function open(): void {
GlobalStates.oskOpen = true;
}
// Which surfaces are holding the keyboard open, if any. The pill and
// the agent both ask "why won't this close"; this answers it.
function holds(): string {
return JSON.stringify(GlobalStates.oskHolds);
}
// Double-tapping the pill while the keyboard is open calls this —
// pops the (suppressed, see Dock.qml) dock back up for a few
// seconds without having to close the keyboard first.
function pulseDock(): void {
GlobalStates.pulseDockReveal();
}
}
GlobalShortcut {
name: "oskToggle"
description: "Toggles on screen keyboard on press"
onPressed: {
if (GlobalStates.oskOpen) root.userClose();
else GlobalStates.oskOpen = true;
}
}
GlobalShortcut {
name: "oskOpen"
description: "Opens on screen keyboard on press"
onPressed: {
GlobalStates.oskOpen = true;
}
}
GlobalShortcut {
name: "oskClose"
description: "Closes on screen keyboard on press"
onPressed: {
root.userClose();
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,53 @@
import qs
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
import QtQuick
import Quickshell
import Quickshell.Wayland
// Souveraine: on a phone deploy, surface the on-screen keyboard when a polkit
// prompt appears. The polkit window is a wlr-layer-shell Overlay and the OSK
// is a separate layer-shell surface (squeekboard), so the OSK won't auto-rise
// to it. Since both run under this shell, we drive the OSK explicitly.
// Laptop deploys (souveraine.phone == false) are unchanged — hardware
// keyboard handles it.
//
// This was a poke — `oskOpen = PolkitService.active` on the transition — and
// a poke is not enough for two reasons, both of which left a password field
// on screen with no way to type into it:
//
// 1. squeekboard hides itself when input-method focus drops, which this
// layer-shell surface does not reliably hold. The keyboard appeared and
// then left while the prompt was still waiting.
// 2. The transition is all a poke sees. pkexec run before Config.ready —
// an update on login, a boot-time authorization — has its prompt up
// before this file is even loaded, so no transition ever arrives.
//
// A hold fixes both: the keyboard is re-asserted for as long as the prompt
// is up (GlobalStates.oskHold), and onCompleted takes the hold for a prompt
// that was already waiting.
FullscreenPolkitWindow {
id: root
contentComponent: Component {
PolkitContent {}
}
readonly property bool phone: Config.options?.souveraine?.phone ?? false
function syncKeyboard() {
if (!root.phone) return;
if (PolkitService.active) GlobalStates.oskHold("polkit");
else GlobalStates.oskRelease("polkit");
}
Component.onCompleted: root.syncKeyboard()
Connections {
target: PolkitService
function onActiveChanged() {
root.syncKeyboard();
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,254 @@
import QtQuick
import QtQuick.Layouts
import qs.services
import qs.modules.common
import qs.modules.common.widgets
// Device — Souveraine form-factor and (later) per-device overrides.
// Principle: pages are views over Config.options / the owning daemon;
// nothing app-private. This page holds the knobs that describe WHAT this
// device is, so behaviors elsewhere gate on config, not hardcoded checks.
ContentPage {
forceWidth: true
ContentSection {
icon: "smartphone"
title: Translation.tr("Form factor")
ConfigSwitch {
buttonIcon: "smartphone"
text: Translation.tr("Phone mode")
checked: Config.options.souveraine.phone
onCheckedChanged: {
Config.options.souveraine.phone = checked;
}
StyledToolTip {
text: Translation.tr("Gates phone behaviors: OSK rises for polkit prompts, phone-only pages, single-column layouts. Laptop deploys leave this off.")
}
}
}
// ── Proprioception ───────────────────────────────────────────────────
// TASK-08(f) / TASK-19: the state machine computes its state, its
// evidence, its confidence and its per-source health, and until now none
// of it reached a screen. `forensic.jsonl` knew; the device could not tell
// you. A body that cannot feel itself is the thing this OS is not
// supposed to be.
//
// READOUT ONLY, deliberately. TASK-19: the confidence gates are computed,
// logged and never branched on, so a control over them "would be lying" —
// showing a threshold slider nothing consults breaks this page's own rule
// against success-shaped switches. Observations can be shown honestly
// today; controls wait on TASK-08(g).
property bool _watchingEvidence: false
function _startWatching() {
if (_watchingEvidence) return;
_watchingEvidence = true;
DeviceEvidence.watch();
}
function _stopWatching() {
if (!_watchingEvidence) return;
_watchingEvidence = false;
DeviceEvidence.unwatch();
}
Component.onCompleted: _startWatching()
Component.onDestruction: _stopWatching()
ContentSection {
icon: "monitor_heart"
title: Translation.tr("Device state")
// The laptop has no sessiond. Say so, rather than rendering zeroes
// that look like a healthy reading (§10: "no evidence" and "evidence
// says nothing is happening" must not be the same state).
StyledText {
Layout.fillWidth: true
visible: !DeviceEvidence.available
text: Translation.tr("sessiond is not answering on this device — no state to report.")
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
wrapMode: Text.WordWrap
}
Repeater {
model: DeviceEvidence.available ? [
{ k: Translation.tr("State"), v: DeviceEvidence.state.device_state ?? "—" },
{ k: Translation.tr("Lock phase"), v: DeviceEvidence.state.phase ?? "—" },
{ k: Translation.tr("Locked"), v: (DeviceEvidence.state.locked ?? false) ? Translation.tr("yes") : Translation.tr("no") },
{ k: Translation.tr("Panel"), v: (DeviceEvidence.state.panel_on ?? false) ? Translation.tr("on") : Translation.tr("off") },
{ k: Translation.tr("Dimmed"), v: (DeviceEvidence.state.dimmed ?? false) ? Translation.tr("yes") : Translation.tr("no") },
{ k: Translation.tr("Display active"), v: (DeviceEvidence.state.display_active ?? false) ? Translation.tr("yes") : Translation.tr("no") },
{ k: Translation.tr("Idle"), v: (DeviceEvidence.state.idle_secs ?? 0) + "s" },
{ k: Translation.tr("Observed"), v: (DeviceEvidence.state.observed ?? false) ? Translation.tr("yes") : Translation.tr("no") },
{ k: Translation.tr("Confidence"), v: Number(DeviceEvidence.state.observed_confidence ?? 0).toFixed(2) },
{ k: Translation.tr("Wake suppressed"), v: (DeviceEvidence.state.suppress_dpms_wake ?? false) ? Translation.tr("yes") : Translation.tr("no") },
{ k: Translation.tr("Shell alive"), v: (DeviceEvidence.state.shell_alive ?? false) ? Translation.tr("yes") : Translation.tr("no") }
] : []
delegate: RowLayout {
required property var modelData
Layout.fillWidth: true
spacing: 8
StyledText {
Layout.fillWidth: true
text: modelData.k
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
}
StyledText {
text: String(modelData.v)
color: Appearance.colors.colOnLayer1
font.pixelSize: Appearance.font.pixelSize.smaller
}
}
}
}
ContentSection {
icon: "sensors"
title: Translation.tr("Evidence sources")
// The flag §10 was built for. It rides every forensic snapshot and had
// nowhere to appear: the SLPI outage on 2026-07-25 killed every sensor
// for four hours and exited status 0, so the crash reporter
// structurally could not help.
StyledText {
Layout.fillWidth: true
visible: DeviceEvidence.available && (DeviceEvidence.state.sensors_degraded ?? false)
text: Translation.tr("A source reported and then went silent. Readings below are not trustworthy.")
color: Appearance.colors.colError
font.pixelSize: Appearance.font.pixelSize.smaller
wrapMode: Text.WordWrap
}
StyledText {
Layout.fillWidth: true
visible: DeviceEvidence.available
text: Translation.tr("live = reporting · unknown = never heard from (no reporter wired) · down = spoke, then stopped")
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
wrapMode: Text.WordWrap
}
Repeater {
model: {
if (!DeviceEvidence.available) return [];
const health = DeviceEvidence.state.sensor_health ?? {};
const fresh = DeviceEvidence.state.evidence_fresh ?? {};
return Object.keys(health).map(name => ({
name: name,
health: health[name],
fresh: fresh[name] === true
}));
}
delegate: RowLayout {
required property var modelData
Layout.fillWidth: true
spacing: 8
MaterialSymbol {
iconSize: Appearance.font.pixelSize.normal
text: modelData.health === "live" ? "sensors"
: modelData.health === "down" ? "sensors_off"
: "help"
color: modelData.health === "down" ? Appearance.colors.colError
: modelData.health === "live" ? Appearance.colors.colOnLayer1
: Appearance.colors.colSubtext
}
StyledText {
Layout.fillWidth: true
text: modelData.name
color: Appearance.colors.colOnLayer1
font.pixelSize: Appearance.font.pixelSize.smaller
}
StyledText {
// "unknown" is not a failure — accel, light and touch have
// no reporter on this device and correctly sit there
// forever. Only a source that spoke and then stopped failed.
text: modelData.health + (modelData.fresh ? Translation.tr(" · fresh") : "")
color: modelData.health === "down" ? Appearance.colors.colError
: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
}
}
}
}
ContentSection {
icon: "history"
title: Translation.tr("Recent decisions")
StyledText {
Layout.fillWidth: true
text: Translation.tr("What the machine last decided, and what it decided it from. The same entries the forensic trail hash-chains.")
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
wrapMode: Text.WordWrap
}
Repeater {
// Newest first, by seq — independent of the order the buffer
// happens to return.
model: (DeviceEvidence.recentDecisions ?? []).slice()
.sort((a, b) => (b.seq ?? 0) - (a.seq ?? 0))
.slice(0, 12)
delegate: ColumnLayout {
required property var modelData
Layout.fillWidth: true
spacing: 1
RowLayout {
Layout.fillWidth: true
spacing: 6
StyledText {
text: {
// `event` is a tagged union (decision,
// state-transition, sensor-input, error-*). Take
// whichever key it carries rather than assuming.
const ev = modelData.event ?? {};
const kind = Object.keys(ev)[0] ?? "event";
const body = ev[kind] ?? {};
return body.decision ?? body.to ?? kind;
}
color: Appearance.colors.colOnLayer1
font.pixelSize: Appearance.font.pixelSize.smaller
font.bold: true
}
Item { Layout.fillWidth: true }
StyledText {
text: "#" + (modelData.seq ?? "?")
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
}
}
StyledText {
Layout.fillWidth: true
visible: (modelData.reason ?? "").length > 0
text: modelData.reason ?? ""
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
wrapMode: Text.WordWrap
}
}
}
}
ContentSection {
icon: "tune"
title: Translation.tr("Device overrides")
StyledText {
Layout.fillWidth: true
text: Translation.tr("Per-device profile overrides (panel size, sensor set, feel presets) land here as the framework grows. One config tree, many devices.")
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
wrapMode: Text.WordWrap
}
}
}

View file

@ -0,0 +1,71 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.services
import qs.modules.common
import qs.modules.common.widgets
ContentPage {
id: page
forceWidth: true
readonly property var monitor: Brightness.monitors.length > 0
? Brightness.monitors[0] : null
ContentSection {
icon: "brightness_6"
title: Translation.tr("Brightness")
RowLayout {
Layout.fillWidth: true
spacing: 12
MaterialSymbol {
text: "brightness_low"
iconSize: 22
}
StyledSlider {
Layout.fillWidth: true
enabled: page.monitor ? page.monitor.ready : false
value: page.monitor ? page.monitor.brightness : 0
from: 0.01
to: 1
onMoved: {
if (page.monitor) page.monitor.setBrightness(value);
}
}
StyledText {
text: page.monitor && page.monitor.ready
? `${Math.round(page.monitor.brightness * 100)}%` : "—"
font.family: Appearance.font.family.numbers
}
}
ConfigSwitch {
buttonIcon: "flash_off"
text: Translation.tr("Anti-flashbang dimming")
checked: Config.options.light.antiFlashbang.enable
onCheckedChanged: Config.options.light.antiFlashbang.enable = checked
StyledToolTip {
text: Translation.tr("Temporarily softens large brightness jumps when content changes.")
}
}
}
ContentSection {
icon: "monitor"
title: Translation.tr("Built-in display")
StyledText {
Layout.fillWidth: true
text: Quickshell.screens.length > 0
? Translation.tr("%1 · %2 × %3 logical pixels")
.arg(Quickshell.screens[0].name)
.arg(Quickshell.screens[0].width)
.arg(Quickshell.screens[0].height)
: Translation.tr("No display reported")
color: Appearance.colors.colSubtext
wrapMode: Text.WordWrap
}
}
}

View file

@ -0,0 +1,331 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Widgets
import qs.services
import qs.modules.common
import qs.modules.common.widgets
// Dock — every configurable the dock components read. Feel knobs are the
// single source (components read Config, never hardcode); stacks and pins
// are managed by drag on the dock itself — an editor lands here later.
ContentPage {
forceWidth: true
ContentSection {
icon: "dock_to_bottom"
title: Translation.tr("Behavior")
ConfigSwitch {
buttonIcon: "dock_to_bottom"
text: Translation.tr("Enable dock")
checked: Config.options.dock.enable
onCheckedChanged: {
Config.options.dock.enable = checked;
}
StyledToolTip {
text: Translation.tr("The dock surface itself. This settings app runs standalone, so it can always re-enable it.")
}
}
ConfigSwitch {
buttonIcon: "bottom_panel_open"
text: Translation.tr("Auto-hide on Home")
checked: Config.options.dock.autoHide
onCheckedChanged: {
Config.options.dock.autoHide = checked;
}
StyledToolTip {
text: Translation.tr("Hide the Home dock until the pointer reaches the bottom edge.")
}
}
ConfigSwitch {
buttonIcon: "push_pin"
text: Translation.tr("Reserve screen space")
checked: Config.options.dock.pinnedOnStartup
enabled: !Config.options.dock.autoHide
onCheckedChanged: {
Config.options.dock.pinnedOnStartup = checked;
}
StyledToolTip {
text: Translation.tr("Keep normal windows above the visible dock instead of allowing them behind it.")
}
}
ConfigSwitch {
buttonIcon: "swipe_up"
text: Translation.tr("Reveal from other zones")
checked: Config.options.dock.hoverToReveal
onCheckedChanged: {
Config.options.dock.hoverToReveal = checked;
}
StyledToolTip {
text: Translation.tr("Keep a bottom-edge reveal strip available when the dock is not Home furniture.")
}
}
ConfigSpinBox {
icon: "hourglass_top"
text: Translation.tr("Reveal delay (ms)")
value: Config.options.dock.revealDelayMs
from: 0
to: 1000
stepSize: 25
onValueChanged: {
Config.options.dock.revealDelayMs = value;
}
}
ConfigSpinBox {
icon: "hourglass_bottom"
text: Translation.tr("Hide delay (ms)")
value: Config.options.dock.hideDelayMs
from: 0
to: 2000
stepSize: 25
onValueChanged: {
Config.options.dock.hideDelayMs = value;
}
}
ConfigSpinBox {
icon: "timer"
text: Translation.tr("Drag dwell (ms)")
value: Config.options.dock.dragDwellMs
from: 100
to: 2000
stepSize: 50
onValueChanged: {
Config.options.dock.dragDwellMs = value;
}
StyledToolTip {
text: Translation.tr("How long a drag hovers an icon or stack before it reads as combine-intent.")
}
}
}
ContentSection {
icon: "palette"
title: Translation.tr("Appearance")
ConfigSwitch {
buttonIcon: "filter_b_and_w"
text: Translation.tr("Monochrome icons")
checked: Config.options.dock.monochromeIcons
onCheckedChanged: {
Config.options.dock.monochromeIcons = checked;
}
}
// The dock's own scale. Two numbers, because the button is the row's
// height and the icon is what you actually see — sizing one from the
// other would mean either cramped icons in a tall row or icons
// overflowing a short one, depending on which way the ratio was fixed.
ConfigSpinBox {
icon: "aspect_ratio"
text: Translation.tr("Button size (px)")
value: Config.options.dock.buttonSize
from: 36
to: 88
stepSize: 2
onValueChanged: {
Config.options.dock.buttonSize = value;
}
}
ConfigSpinBox {
icon: "apps"
text: Translation.tr("Icon size (px)")
value: Config.options.dock.iconSize
from: 24
to: 72
stepSize: 2
onValueChanged: {
Config.options.dock.iconSize = value;
}
}
ConfigSpinBox {
icon: "height"
text: Translation.tr("Dock height (px)")
value: Config.options.dock.height
from: 40
to: 120
stepSize: 2
onValueChanged: {
Config.options.dock.height = value;
}
}
ConfigSpinBox {
icon: "expand"
text: Translation.tr("Reveal region height (px)")
value: Config.options.dock.hoverRegionHeight
from: 1
to: 20
stepSize: 1
onValueChanged: {
Config.options.dock.hoverRegionHeight = value;
}
StyledToolTip {
text: Translation.tr("Height of the invisible bottom strip that triggers hover-reveal.")
}
}
}
ContentSection {
icon: "push_pin"
title: Translation.tr("Pinned apps")
StyledText {
Layout.fillWidth: true
text: Translation.tr("Drag on the dock is the primary way to manage these (drag onto an icon to stack, drag a member off the arc to split). This list is the fallback editor.")
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
wrapMode: Text.WordWrap
}
// One row per pinned app. Model binds the raw config list so writes
// (from here, the dock, or the agent) re-render immediately.
Repeater {
model: Config.options.dock.pinnedApps
delegate: RowLayout {
required property string modelData
Layout.fillWidth: true
spacing: 8
IconImage {
implicitSize: 24
source: Quickshell.iconPath(AppSearch.guessIcon(modelData), "image-missing")
}
StyledText {
Layout.fillWidth: true
text: modelData
elide: Text.ElideMiddle
color: Appearance.m3colors.m3onSurface
font.pixelSize: Appearance.font.pixelSize.small
}
RippleButton {
implicitWidth: 32
implicitHeight: 32
onClicked: TaskbarApps.togglePin(modelData)
contentItem: MaterialSymbol {
anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
text: "close"
iconSize: Appearance.font.pixelSize.normal
color: Appearance.m3colors.m3onSurface
}
StyledToolTip { text: Translation.tr("Unpin") }
}
}
}
StyledText {
visible: (Config.options.dock.pinnedApps?.length ?? 0) === 0
text: Translation.tr("Nothing pinned. Long-press a running app on the dock to pin it.")
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
}
}
ContentSection {
icon: "stacks"
title: Translation.tr("Stacks")
// One block per stack: editable name, then member rows. All edits go
// through TaskbarApps so the rules (id never changes, empty stacks
// dissolve, unstacked members re-pin) live in one place.
Repeater {
model: Config.options.dock.stacks
delegate: ColumnLayout {
id: stackBlock
required property string modelData
readonly property var stack: TaskbarApps.parseStack(modelData)
Layout.fillWidth: true
spacing: 2
RowLayout {
Layout.fillWidth: true
spacing: 8
MaterialSymbol {
text: "stacks"
iconSize: Appearance.font.pixelSize.large
color: Appearance.m3colors.m3onSurface
}
MaterialTextField {
Layout.fillWidth: true
text: stackBlock.stack.name
placeholderText: Translation.tr("Stack name")
onEditingFinished: {
if (text.length > 0 && text !== stackBlock.stack.name)
TaskbarApps.renameStack(stackBlock.stack.id, text);
}
}
RippleButton {
implicitWidth: 32
implicitHeight: 32
onClicked: {
// Dissolve: every member goes back to a pin.
for (const m of stackBlock.stack.members.slice())
TaskbarApps.unstackMember(stackBlock.stack.id, m);
}
contentItem: MaterialSymbol {
anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
text: "delete"
iconSize: Appearance.font.pixelSize.normal
color: Appearance.m3colors.m3onSurface
}
StyledToolTip { text: Translation.tr("Dissolve stack (members become pins)") }
}
}
Repeater {
model: stackBlock.stack.members
delegate: RowLayout {
required property string modelData
Layout.fillWidth: true
Layout.leftMargin: 28
spacing: 8
IconImage {
implicitSize: 22
source: Quickshell.iconPath(AppSearch.guessIcon(modelData), "image-missing")
}
StyledText {
Layout.fillWidth: true
text: modelData
elide: Text.ElideMiddle
color: Appearance.m3colors.m3onSurface
font.pixelSize: Appearance.font.pixelSize.small
}
RippleButton {
implicitWidth: 32
implicitHeight: 32
onClicked: TaskbarApps.unstackMember(stackBlock.stack.id, modelData)
contentItem: MaterialSymbol {
anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
text: "remove"
iconSize: Appearance.font.pixelSize.normal
color: Appearance.m3colors.m3onSurface
}
StyledToolTip { text: Translation.tr("Unstack (back to a pin)") }
}
}
}
}
}
StyledText {
visible: (Config.options.dock.stacks?.length ?? 0) === 0
text: Translation.tr("No stacks yet. Drag one dock icon onto another and hold until it highlights.")
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
}
}
}

View file

@ -0,0 +1,346 @@
import QtQuick
import QtQuick.Layouts
import qs.services
import qs.modules.common
import qs.modules.common.widgets
// Idle & sleep — the staged idle projection, exposed honestly.
//
// The governing idea is REFERENCE-EXTRACTION.md's "idle is a transition graph,
// not a timer": the page shows the live stage the shell is actually in, not
// just three timeout knobs pretending idle is linear.
//
// Two honesty constraints drive the layout, and both come straight from the
// extraction's build order (truth before visuals):
//
// 1. Two authorities, two sections, never blended. The shell's own
// IdleMonitors decide when a session IN USE dims and locks; sessiond's
// device state machine decides how long a LOCKED panel may burn. The
// second set is read live from the daemon (SessiondPolicy) rather than
// from a config file, because the daemon is what actuates them —
// TASK-19's "no success-shaped switches".
//
// hypridle no longer owns screen-off: its idle listeners were deleted
// 2026-07-25 once sessiond got real actuators, because "lock, then off"
// held only while its 300s lock happened to precede its 600s blank.
//
// 2. Settings is a window in the authoritative shell process, so this page
// reads the idle/session singletons directly. It must not spawn `qs ipc`
// children, which can become accidental shell instances when display
// selection is ambiguous.
ContentPage {
id: page
forceWidth: true
// --- Live stage readout ----------------------------------------------
// 0 Active · 1 Dimmed · 2 Lock requested · 3 Lock secure ·
// 4 Suspending · 5 Asleep · 6 Waking — the IdleCoordinator.State
// enum, read directly from the shell-owned coordinator.
readonly property int liveStage: IdleCoordinator.state
readonly property bool liveNative: IdleCoordinator.nativeEnabled
readonly property bool probeOk: true
readonly property bool sleepInhibitorHeld: SessionEvents.sleepInhibitorHeld
readonly property bool stepUpEnabled: StepUpAuth.grantTtlMs > 0
// Seconds on the wire, human words on screen. "Never" is 0, which is what
// the daemon already means by it.
readonly property var blankPresets: [
{ displayName: Translation.tr("15s"), icon: "timer", value: 15 },
{ displayName: Translation.tr("30s"), icon: "timer", value: 30 },
{ displayName: Translation.tr("1 min"), icon: "timer", value: 60 },
{ displayName: Translation.tr("5 min"), icon: "timer", value: 300 },
{ displayName: Translation.tr("Never"), icon: "timer_off", value: 0 }
]
readonly property var idlePresets: [
{ displayName: Translation.tr("30s"), icon: "timer", value: 30 },
{ displayName: Translation.tr("1 min"), icon: "timer", value: 60 },
{ displayName: Translation.tr("2 min"), icon: "timer", value: 120 },
{ displayName: Translation.tr("5 min"), icon: "timer", value: 300 },
{ displayName: Translation.tr("10 min"), icon: "timer", value: 600 }
]
// ONE dim setting, for both authorities.
//
// This used to be two: a session dim (15s–5min, before the lock) and a
// separate lock-screen dim grace (5–20s, before the blank). They are two
// daemons, but they are not two questions — the user is answering "how
// much warning do I get before the screen goes away", once. Splitting it
// made the page describe our architecture instead of their screen.
//
// Written to both, unchanged: the shell's dimBeforeLockSeconds and
// sessiond's dim_grace_secs.
readonly property var dimPresets: [
{ displayName: Translation.tr("5s"), icon: "brightness_medium", value: 5 },
{ displayName: Translation.tr("10s"), icon: "brightness_medium", value: 10 },
{ displayName: Translation.tr("30s"), icon: "brightness_medium", value: 30 },
{ displayName: Translation.tr("1 min"), icon: "brightness_medium", value: 60 },
{ displayName: Translation.tr("3 min"), icon: "brightness_medium", value: 180 }
]
// Write the chosen value through, unchanged, to both authorities.
//
// An earlier version clamped the lock-screen grace to `blank - 1` so a
// long dim would still "fit". That was invented policy nobody asked for
// and it inverted the setting: a 30 s dim against a 15 s blank became a
// 14 s grace, which starts the dim one second after you stop touching the
// phone. A setting that silently means something else is worse than one
// that does not apply.
function applyDim(v) {
Config.options.lock.idle.dimBeforeLockSeconds = v;
if (SessiondPolicy.available)
SessiondPolicy.apply({ dim_grace_secs: v });
}
// "2 min" reads better than "120 s" in a sentence about when things happen.
function clockText(secs) {
if (secs >= 60 && secs % 60 === 0)
return Translation.tr("%1 min").arg(secs / 60);
if (secs >= 60)
return Translation.tr("%1 min %2 s").arg(Math.floor(secs / 60)).arg(secs % 60);
return Translation.tr("%1 s").arg(secs);
}
readonly property var stageNames: [
Translation.tr("Active"),
Translation.tr("Dimmed"),
Translation.tr("Lock requested"),
Translation.tr("Lock secure"),
Translation.tr("Suspending"),
Translation.tr("Asleep"),
Translation.tr("Waking")
]
function stageLabel(s) {
return (s >= 0 && s < stageNames.length) ? stageNames[s] : Translation.tr("unknown");
}
ContentSection {
icon: "motion_sensor_active"
title: Translation.tr("Current state")
RowLayout {
Layout.fillWidth: true
spacing: 12
StyledText {
text: page.probeOk
? Translation.tr("Idle stage: %1").arg(page.stageLabel(page.liveStage))
: Translation.tr("Idle stage: (shell not reachable)")
font.pixelSize: Appearance.font.pixelSize.normal
}
}
StyledText {
Layout.fillWidth: true
wrapMode: Text.WordWrap
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
text: page.liveNative
? Translation.tr("The native coordinator is driving idle transitions. Dim and lock fire on the timers below.")
: Translation.tr("The native coordinator is off, so the session timers below do not run. Nothing else locks on idle now that hypridle's listeners are gone — turn it on, or the phone only locks when you lock it.")
}
RowLayout {
Layout.fillWidth: true
spacing: 12
StyledText {
text: page.sleepInhibitorHeld
? Translation.tr("Sleep inhibitor: held (suspend blocked until lock is secure)")
: Translation.tr("Sleep inhibitor: released (suspend may proceed)")
font.pixelSize: Appearance.font.pixelSize.normal
color: page.sleepInhibitorHeld
? Appearance.colors.colOnLayer1
: Appearance.colors.colSubtext
}
}
}
ContentSection {
icon: "experiment"
title: Translation.tr("Native idle coordinator")
ConfigSwitch {
buttonIcon: "science"
text: Translation.tr("Enable native coordinator (experimental)")
checked: Config.options.lock.idle.nativeCoordinatorEnabled
onCheckedChanged: {
Config.options.lock.idle.nativeCoordinatorEnabled = checked;
}
StyledToolTip {
text: Translation.tr("Use Wayland idle-notify to drive the dim/lock timers. Unverified on the Pixel 3 compositor build — leave off unless you are testing it. When off, hypridle handles idle and screen-off.")
}
}
}
ContentSection {
icon: "timer"
title: Translation.tr("Session timers")
// Config keys, effective only while the native coordinator is on.
// Presets rather than a seconds spinner, same reasoning as below.
ContentSubsectionLabel {
text: Translation.tr("Lock the session after")
}
ConfigSelectionArray {
currentValue: Config.options.lock.idle.lockAfterSeconds
onSelected: v => Config.options.lock.idle.lockAfterSeconds = v
options: page.idlePresets
}
StyledText {
Layout.fillWidth: true
visible: !Config.options.lock.idle.nativeCoordinatorEnabled
wrapMode: Text.WordWrap
color: Appearance.colors.colError
font.pixelSize: Appearance.font.pixelSize.smaller
text: Translation.tr("The native coordinator is off, so these do not run.")
}
}
// --- Brightness dimming, one question ---------------------------------
// Deliberately not split by authority. Two daemons own the actuation, and
// the page used to say so by giving each its own dim control — which made
// the user answer the same question twice and left them to work out that
// the two interact. One control, written to both. See applyDim().
ContentSection {
icon: "brightness_medium"
title: Translation.tr("Brightness dimming")
ConfigSwitch {
buttonIcon: "brightness_low"
text: Translation.tr("Dim before the screen goes away")
checked: SessiondPolicy.dimWarning
enabled: SessiondPolicy.available
onCheckedChanged: {
if (SessiondPolicy.available && checked !== SessiondPolicy.dimWarning)
SessiondPolicy.apply({ dim_warning: checked });
}
}
ContentSubsectionLabel {
text: Translation.tr("Start dimming this long before")
}
ConfigSelectionArray {
currentValue: Config.options.lock.idle.dimBeforeLockSeconds
onSelected: v => page.applyDim(v)
options: page.dimPresets
}
// Say what it actually does, on both surfaces, in one sentence each.
// A relationship the user has to compute in their head is one they
// will get wrong.
StyledText {
Layout.fillWidth: true
wrapMode: Text.WordWrap
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
text: {
const lock = Config.options.lock.idle.lockAfterSeconds;
const grace = Config.options.lock.idle.dimBeforeLockSeconds;
const dimAt = Math.max(1, Math.min(lock - 1, lock - grace));
return Translation.tr("In use: dims at %1, locks at %2.")
.arg(page.clockText(dimAt))
.arg(page.clockText(lock));
}
}
StyledText {
Layout.fillWidth: true
visible: SessiondPolicy.available
wrapMode: Text.WordWrap
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
text: {
const blank = SessiondPolicy.lockBlankAfterSecs;
if (blank === 0)
return Translation.tr("On the lock screen: never blanks, so nothing dims.");
const g = SessiondPolicy.dimGraceSecs;
return Translation.tr("On the lock screen: dims %1 before blanking at %2.")
.arg(page.clockText(g))
.arg(page.clockText(blank));
}
}
}
// --- Lock screen timers, owned by sessiond ----------------------------
// A separate section because these are a different authority. The two
// above are the shell's own IdleMonitors deciding when to dim and lock a
// session in use; these are the device state machine deciding how long a
// LOCKED, lit panel may burn before it goes dark. That machine has its own
// clock and its own actuators, so its numbers must come from it — which is
// what SessiondPolicy is for, and what this page did not do until
// 2026-07-25 (SetPolicy had zero callers; the daemon ran on its built-in
// 15 s while this page showed whatever was in the JSON file).
Component.onCompleted: SessiondPolicy.refresh()
ContentSection {
icon: "phonelink_lock"
title: Translation.tr("Lock screen (device authority)")
StyledText {
Layout.fillWidth: true
wrapMode: Text.WordWrap
color: SessiondPolicy.available
? Appearance.colors.colSubtext
: Appearance.colors.colError
font.pixelSize: Appearance.font.pixelSize.smaller
text: SessiondPolicy.available
? Translation.tr("Read live from souveraine-sessiond. Changes here go straight to the daemon that blanks the panel.")
: Translation.tr("sessiond is not answering — these values are NOT authoritative. %1").arg(SessiondPolicy.lastError)
}
// Presets, not a seconds spinner. The policy struct's own comment
// names the shape — "iOS's Auto-Lock: a user-chosen timeout with a
// visible dim shortly before it, and a 'never' option for the
// desk-clock case" — and nobody reasons about a lock screen in
// 5-second increments. Values are still seconds on the wire; the
// daemon's vocabulary does not change because the UI got legible.
// One blank timeout, not two.
//
// The daemon still has a separate held-vs-resting budget, and it is
// still the right idea — a phone in your hand should not blank on the
// same schedule as one face-up on a desk. It is not a *setting*
// though. Asking the user to pick two numbers made them responsible
// for arbitrating a guess the accelerometer was making on their
// behalf, and the machine already has a better vocabulary for that:
// §4's confidence arithmetic. Held-ness belongs there, as an
// adjustment to one budget, not as a second budget on this page.
//
// Until that lands, both fields get the same value, so the behaviour
// is uniform and predictable rather than silently forking on a sensor
// reading nothing surfaces.
ContentSubsectionLabel {
text: Translation.tr("Blank after")
}
ConfigSelectionArray {
currentValue: SessiondPolicy.lockBlankAfterSecs
onSelected: v => SessiondPolicy.apply({
lock_blank_after_secs: v,
lock_blank_after_held_secs: v
})
options: page.blankPresets
}
// Only claim the ordering guarantee when the daemon actually reports
// the field. An older sessiond has no lock_ack_budget_secs, and
// rendering that absence as "waits 0s" would be the page inventing a
// number — the same class of lie as the timers this section replaced.
StyledText {
Layout.fillWidth: true
visible: SessiondPolicy.available && SessiondPolicy.lockAckBudgetSecs > 0
wrapMode: Text.WordWrap
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
text: Translation.tr("The screen never goes dark on an unlocked session. sessiond locks first and waits %1s for the compositor to acknowledge; if it cannot, it blanks anyway and records a security error rather than pretending the session locked.")
.arg(SessiondPolicy.lockAckBudgetSecs)
}
StyledText {
Layout.fillWidth: true
visible: SessiondPolicy.available && SessiondPolicy.lockAckBudgetSecs === 0
wrapMode: Text.WordWrap
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
text: Translation.tr("This sessiond predates the lock-before-blank ordering. Update the souveraine package to get it.")
}
}
}

View file

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

View file

@ -0,0 +1,320 @@
import QtQuick
import QtQuick.Layouts
import Qt.labs.folderlistmodel
import Quickshell
import qs.services
import qs.modules.common
import qs.modules.common.widgets
// Lock screen — behavior + appearance. Pure Config bindings: every control
// writes an option that already exists in Config.qml's adapter, so
// persistence + hot-apply come for free.
ContentPage {
forceWidth: true
ContentSection {
icon: "lock"
title: Translation.tr("Behavior")
ConfigSwitch {
buttonIcon: "pin"
text: Translation.tr("Touch keypad (phone)")
checked: Config.options.lock.touchKeypad
onCheckedChanged: {
Config.options.lock.touchKeypad = checked;
}
StyledToolTip {
text: Translation.tr("Show the on-lock PIN keypad. The on-screen keyboard can't rise above a session lock, so the lock surface carries its own input.")
}
}
ConfigSwitch {
buttonIcon: "rocket_launch"
text: Translation.tr("Launch lock on startup")
checked: Config.options.lock.launchOnStartup
onCheckedChanged: {
Config.options.lock.launchOnStartup = checked;
}
StyledToolTip {
text: Translation.tr("Start the session locked so a PIN is required before the shell is exposed.")
}
}
ConfigSwitch {
buttonIcon: "key_off"
text: Translation.tr("Require password to power off")
checked: Config.options.lock.security.requirePasswordToPower
onCheckedChanged: {
Config.options.lock.security.requirePasswordToPower = checked;
}
StyledToolTip {
text: Translation.tr("Guard the power menu behind the lock so the device can't be silenced without a PIN.")
}
}
ConfigSwitch {
buttonIcon: "power_settings_new"
text: Translation.tr("Allow power off / reboot from lock screen")
checked: Config.options.lock.security.allowPowerFromLock
onCheckedChanged: {
Config.options.lock.security.allowPowerFromLock = checked;
}
StyledToolTip {
text: Translation.tr("Show the power and reboot buttons on the lock screen. Off by default — a destructive action from the locked surface is opt-in. The buttons still respect “require password to power off” above.")
}
}
ConfigSwitch {
buttonIcon: "vpn_key"
text: Translation.tr("Unlock keyring on PIN unlock")
checked: Config.options.lock.security.unlockKeyring
onCheckedChanged: {
Config.options.lock.security.unlockKeyring = checked;
}
StyledToolTip {
text: Translation.tr("Feed the PIN to the keyring so stored secrets unlock together with the session.")
}
}
}
ContentSection {
icon: "palette"
title: Translation.tr("Appearance")
ConfigSwitch {
buttonIcon: "format_align_center"
text: Translation.tr("Center the clock")
checked: Config.options.lock.centerClock
onCheckedChanged: {
Config.options.lock.centerClock = checked;
}
}
ConfigSwitch {
buttonIcon: "schedule"
text: Translation.tr("12-hour clock (am/pm)")
checked: Config.options.lock.twelveHourClock
onCheckedChanged: {
Config.options.lock.twelveHourClock = checked;
}
}
ConfigSwitch {
buttonIcon: "text_fields"
text: Translation.tr("Show locked text")
checked: Config.options.lock.showLockedText
onCheckedChanged: {
Config.options.lock.showLockedText = checked;
}
}
ConfigSwitch {
buttonIcon: "blur_on"
text: Translation.tr("Blur background")
checked: Config.options.lock.blur.enable
onCheckedChanged: {
Config.options.lock.blur.enable = checked;
}
}
ConfigSwitch {
buttonIcon: "category"
text: Translation.tr("Material shapes for PIN dots")
checked: Config.options.lock.materialShapeChars
onCheckedChanged: {
Config.options.lock.materialShapeChars = checked;
}
}
}
ContentSection {
id: lockWallSection
icon: "wallpaper"
title: Translation.tr("Lock screen wallpaper")
readonly property string wallpaperDir: Quickshell.env("HOME") + "/Pictures/Wallpapers"
ConfigSwitch {
buttonIcon: "sync"
text: Translation.tr("Follow system wallpaper")
checked: !Config.options.lock.wallpaperPath
onCheckedChanged: {
if (checked) Config.options.lock.wallpaperPath = "";
}
StyledToolTip {
text: Translation.tr("The lock screen shows the same wallpaper as the shell. Pick an image below to pin the lock screen's own.")
}
}
GridView {
id: lockWallGrid
Layout.fillWidth: true
readonly property int cols: 3
cellWidth: Math.floor(width / cols)
cellHeight: Math.floor(cellWidth * 2)
implicitHeight: Math.ceil(lockWallModel.count / cols) * cellHeight
interactive: false
clip: true
model: FolderListModel {
id: lockWallModel
folder: "file://" + lockWallSection.wallpaperDir
nameFilters: ["*.jpg", "*.jpeg", "*.png", "*.webp"]
showDirs: false
}
delegate: Item {
required property string filePath
width: lockWallGrid.cellWidth
height: lockWallGrid.cellHeight
Rectangle {
anchors.fill: parent
anchors.margins: 5
radius: Appearance.rounding.small
color: Appearance.colors.colLayer1
border.width: Config.options.lock.wallpaperPath === filePath ? 3 : 0
border.color: Appearance.colors.colPrimary
clip: true
Image {
anchors.fill: parent
anchors.margins: 3
source: "file://" + filePath
fillMode: Image.PreserveAspectCrop
asynchronous: true
sourceSize.width: 240
}
MouseArea {
anchors.fill: parent
onClicked: Config.options.lock.wallpaperPath = filePath
}
}
}
}
}
ContentSection {
icon: "visibility"
title: Translation.tr("Lock screen content")
ConfigSwitch {
buttonIcon: "music_note"
text: Translation.tr("Show media controls")
checked: Config.options.lock.content.showMediaControls
onCheckedChanged: Config.options.lock.content.showMediaControls = checked
StyledToolTip {
text: Translation.tr("Shows previous, play/pause, and next. Track details remain private unless enabled below.")
}
}
ConfigSwitch {
buttonIcon: "visibility"
text: Translation.tr("Show media title and artist")
checked: Config.options.lock.content.mediaMetadataAmbient
enabled: Config.options.lock.content.showMediaControls
onCheckedChanged: Config.options.lock.content.mediaMetadataAmbient = checked
StyledToolTip {
text: Translation.tr("Treats current media metadata as ambient. Leave off to keep it hidden until unlock.")
}
}
ConfigSwitch {
buttonIcon: "battery_android_full"
text: Translation.tr("Show battery on lock screen")
checked: Config.options.lock.content.showBattery
onCheckedChanged: Config.options.lock.content.showBattery = checked
}
ConfigSwitch {
buttonIcon: "notifications"
text: Translation.tr("Show notifications while locked")
checked: Config.options.lock.content.showNotifications
onCheckedChanged: Config.options.lock.content.showNotifications = checked
StyledToolTip {
text: Translation.tr("Shows which apps had notifications arrive and how many. Content stays private unless enabled below.")
}
}
ConfigSwitch {
buttonIcon: "visibility"
text: Translation.tr("Show notification summaries")
checked: Config.options.lock.content.notificationContentAmbient
enabled: Config.options.lock.content.showNotifications
onCheckedChanged: Config.options.lock.content.notificationContentAmbient = checked
StyledToolTip {
text: Translation.tr("Treats the notification title line as ambient. Bodies never show on the lock screen.")
}
}
}
ContentSection {
icon: "key"
title: Translation.tr("Step-up authentication")
ConfigSwitch {
buttonIcon: "shield_lock"
text: Translation.tr("Enable step-up authentication")
checked: Config.options.lock.stepUp.enabled
onCheckedChanged: Config.options.lock.stepUp.enabled = checked
StyledToolTip {
text: Translation.tr("Require re-authentication for sensitive operations (send, delete, payment). Requires the souveraine-stepup PAM service to be installed on the system.")
}
}
ConfigSpinBox {
icon: "timer"
text: Translation.tr("Grant validity (seconds)")
value: Config.options.lock.stepUp.grantTtlMs / 1000
from: 30
to: 3600
stepSize: 30
enabled: Config.options.lock.stepUp.enabled
onValueChanged: Config.options.lock.stepUp.grantTtlMs = value * 1000
StyledToolTip {
text: Translation.tr("How long a step-up grant remains valid after authentication. The user can perform sensitive operations within this window without re-authenticating.")
}
}
}
ContentSection {
icon: "fingerprint"
title: Translation.tr("Fingerprint integration")
ConfigSwitch {
buttonIcon: "fingerprint"
text: Translation.tr("Show fingerprint wiring preview")
checked: Config.options.lock.fingerprintPreview.enabled
onCheckedChanged: Config.options.lock.fingerprintPreview.enabled = checked
StyledToolTip {
text: Translation.tr("Shows a three-second hold test on the lock screen. It never unlocks the device; post-login Polkit is configured separately below.")
}
}
ConfigSpinBox {
icon: "timer"
text: Translation.tr("Preview hold (seconds)")
value: Config.options.lock.fingerprintPreview.holdMs / 1000
from: 1
to: 10
stepSize: 1
enabled: Config.options.lock.fingerprintPreview.enabled
onValueChanged: Config.options.lock.fingerprintPreview.holdMs = value * 1000
StyledToolTip {
text: Translation.tr("This controls the visible lock-screen exercise only. It does not change the temporary post-login Polkit factor.")
}
}
ConfigSwitch {
buttonIcon: "admin_panel_settings"
text: Translation.tr("Allow temporary FPC confirmation for Polkit")
checked: Config.options.lock.fingerprintPolkit.enabled
onCheckedChanged: Config.options.lock.fingerprintPolkit.enabled = checked
StyledToolTip {
text: Translation.tr("After PIN login, user-facing Polkit prompts may accept a fresh reader assertion after the visible confirmation interval. It never unlocks the session or replaces first-login PIN.")
}
}
}
}

View file

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

View file

@ -0,0 +1,199 @@
import QtQuick
import QtQuick.Layouts
import Quickshell.Bluetooth
import qs.services
import qs.modules.common
import qs.modules.common.widgets
// First-party connectivity page. The controls are direct views over the
// resident Network/Cellular/Bluetooth services; no settings-only state is
// allowed to pretend a radio changed when its owning service did not.
ContentPage {
id: page
forceWidth: true
function wifiIcon(strength) {
return strength > 80 ? "signal_wifi_4_bar"
: strength > 60 ? "network_wifi_3_bar"
: strength > 40 ? "network_wifi_2_bar"
: strength > 20 ? "network_wifi_1_bar"
: "signal_wifi_0_bar";
}
ContentSection {
icon: "wifi"
title: Translation.tr("Wi-Fi")
ConfigSwitch {
buttonIcon: Network.materialSymbol
text: Network.wifiEnabled
? Translation.tr("Wi-Fi on") : Translation.tr("Wi-Fi off")
checked: Network.wifiEnabled
onCheckedChanged: {
if (checked !== Network.wifiEnabled) Network.enableWifi(checked);
}
}
RowLayout {
Layout.fillWidth: true
spacing: 10
StyledText {
Layout.fillWidth: true
text: Network.active
? Translation.tr("Connected to %1").arg(Network.active.ssid)
: Translation.tr("Not connected")
color: Appearance.colors.colSubtext
elide: Text.ElideRight
}
RippleButton {
implicitWidth: 44
implicitHeight: 44
enabled: Network.wifiEnabled && !Network.wifiScanning
buttonRadius: Appearance.rounding.full
onClicked: Network.rescanWifi()
contentItem: MaterialSymbol {
anchors.centerIn: parent
text: Network.wifiScanning ? "progress_activity" : "refresh"
iconSize: 21
}
StyledToolTip { text: Translation.tr("Scan for networks") }
}
}
Repeater {
model: Network.wifiEnabled ? Network.friendlyWifiNetworks : []
delegate: DialogListItem {
id: networkRow
required property var modelData
Layout.fillWidth: true
active: modelData.active
buttonRadius: Appearance.rounding.normal
onClicked: {
if (modelData.active) Network.disconnectWifiNetwork();
else Network.connectToWifiNetwork(modelData);
}
contentItem: ColumnLayout {
anchors {
fill: parent
leftMargin: networkRow.horizontalPadding
rightMargin: networkRow.horizontalPadding
topMargin: networkRow.verticalPadding
bottomMargin: networkRow.verticalPadding
}
spacing: 8
RowLayout {
Layout.fillWidth: true
spacing: 10
MaterialSymbol {
text: page.wifiIcon(networkRow.modelData.strength)
iconSize: 22
}
StyledText {
Layout.fillWidth: true
text: networkRow.modelData.ssid
textFormat: Text.PlainText
elide: Text.ElideRight
}
MaterialSymbol {
text: networkRow.modelData.active ? "check"
: networkRow.modelData.isSecure ? "lock" : ""
iconSize: 20
}
}
MaterialTextField {
Layout.fillWidth: true
visible: networkRow.modelData.askingPassword
placeholderText: Translation.tr("Network password")
echoMode: TextInput.Password
inputMethodHints: Qt.ImhSensitiveData
onAccepted: {
Network.changePassword(networkRow.modelData, text);
text = "";
}
}
}
}
}
}
ContentSection {
icon: Cellular.materialSymbol
title: Translation.tr("Mobile network")
RowLayout {
Layout.fillWidth: true
spacing: 12
ColumnLayout {
Layout.fillWidth: true
spacing: 2
StyledText {
Layout.fillWidth: true
text: Cellular.available
? (Cellular.operatorName || Translation.tr("Mobile network"))
: Translation.tr("No modem detected")
elide: Text.ElideRight
}
StyledText {
Layout.fillWidth: true
text: Cellular.available
? Translation.tr("%1 · %2% signal%3")
.arg(Cellular.accessTech || Cellular.state)
.arg(Cellular.signalQuality)
.arg(Cellular.roaming ? Translation.tr(" · roaming") : "")
: Translation.tr("ModemManager has not exposed a modem")
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
wrapMode: Text.WordWrap
}
}
RippleButton {
implicitWidth: 44
implicitHeight: 44
buttonRadius: Appearance.rounding.full
onClicked: Cellular.update()
contentItem: MaterialSymbol {
anchors.centerIn: parent
text: "refresh"
iconSize: 21
}
}
}
}
ContentSection {
icon: "bluetooth"
title: Translation.tr("Bluetooth")
ConfigSwitch {
buttonIcon: BluetoothStatus.connected ? "bluetooth_connected" : "bluetooth"
text: BluetoothStatus.available
? Translation.tr("Bluetooth") : Translation.tr("Bluetooth unavailable")
enabled: BluetoothStatus.available
checked: BluetoothStatus.enabled
onCheckedChanged: {
if (Bluetooth.defaultAdapter && checked !== Bluetooth.defaultAdapter.enabled)
Bluetooth.defaultAdapter.enabled = checked;
}
}
StyledText {
Layout.fillWidth: true
text: !BluetoothStatus.available
? Translation.tr("BlueZ has not exposed an adapter; no success-shaped toggle is shown.")
: BluetoothStatus.activeDeviceCount > 0
? Translation.tr("%1 connected device(s)").arg(BluetoothStatus.activeDeviceCount)
: Translation.tr("No connected devices")
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
wrapMode: Text.WordWrap
}
}
}

View file

@ -0,0 +1,80 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Widgets
import qs.services
import qs.modules.common
import qs.modules.common.widgets
// Home screen (app drawer) — the TASK-14 split's launcher half. The drawer
// reads its grid straight from Config (see AppGrid.qml), so these knobs are
// the single source; the grid re-lays-out on the next drawer open. Rows and
// columns at the phone's 540x1080: 4 columns keeps a thumb travel across a
// page, 5 rows fills the panel without scrolling. The old desktop overview's
// rows/columns are untouched — this page owns Config.options.overview.appGrid.
ContentPage {
forceWidth: true
ContentSection {
icon: "grid_view"
title: Translation.tr("App drawer")
ConfigSwitch {
buttonIcon: "apps"
text: Translation.tr("Enable drawer")
checked: Config.options.overview.enable
onCheckedChanged: {
Config.options.overview.enable = checked;
}
StyledToolTip {
text: Translation.tr("The Home surface: search on top, the app grid below. Off, the pill's swipe-home does nothing.")
}
}
ConfigSpinBox {
icon: "view_column"
text: Translation.tr("Columns")
value: Config.options.overview.appGrid.columns
from: 1
to: 10
stepSize: 1
onValueChanged: {
Config.options.overview.appGrid.columns = value;
}
StyledToolTip {
text: Translation.tr("Icons per row. More columns = denser grid and smaller tiles.")
}
}
ConfigSpinBox {
icon: "view_agenda"
text: Translation.tr("Rows")
value: Config.options.overview.appGrid.rows
from: 1
to: 10
stepSize: 1
onValueChanged: {
Config.options.overview.appGrid.rows = value;
}
StyledToolTip {
text: Translation.tr("Icon rows per page. More rows fills the screen; fewer leaves room for the page dots.")
}
}
ConfigSpinBox {
icon: "photo_size_select_large"
text: Translation.tr("Icon size (px)")
value: Config.options.overview.appGrid.iconSize
from: 24
to: 96
stepSize: 4
onValueChanged: {
Config.options.overview.appGrid.iconSize = value;
}
StyledToolTip {
text: Translation.tr("The icon glyph itself; labels always scale to the tile.")
}
}
}
}

View file

@ -0,0 +1,93 @@
import QtQuick
import QtQuick.Layouts
import qs.services
import qs.modules.common
import qs.modules.common.widgets
ContentPage {
forceWidth: true
ContentSection {
icon: "volume_up"
title: Translation.tr("Output")
StyledText {
Layout.fillWidth: true
text: Audio.sink ? Audio.friendlyDeviceName(Audio.sink)
: Translation.tr("No audio output")
color: Appearance.colors.colSubtext
elide: Text.ElideRight
}
RowLayout {
Layout.fillWidth: true
spacing: 12
RippleButton {
implicitWidth: 44
implicitHeight: 44
enabled: Audio.sink ? Audio.sink.ready : false
buttonRadius: Appearance.rounding.full
onClicked: Audio.toggleMute()
contentItem: MaterialSymbol {
anchors.centerIn: parent
text: Audio.sink && Audio.sink.audio.muted ? "volume_off" : "volume_up"
iconSize: 22
}
}
StyledSlider {
Layout.fillWidth: true
enabled: Audio.sink ? Audio.sink.ready : false
value: Audio.sink ? Audio.sink.audio.volume : 0
from: 0
to: 1
onMoved: {
if (Audio.sink) Audio.sink.audio.volume = value;
}
}
}
}
ContentSection {
icon: "mic"
title: Translation.tr("Microphone")
StyledText {
Layout.fillWidth: true
text: Audio.source ? Audio.friendlyDeviceName(Audio.source)
: Translation.tr("No microphone")
color: Appearance.colors.colSubtext
elide: Text.ElideRight
}
RowLayout {
Layout.fillWidth: true
spacing: 12
RippleButton {
implicitWidth: 44
implicitHeight: 44
enabled: Audio.source ? Audio.source.ready : false
buttonRadius: Appearance.rounding.full
onClicked: Audio.toggleMicMute()
contentItem: MaterialSymbol {
anchors.centerIn: parent
text: Audio.source && Audio.source.audio.muted ? "mic_off" : "mic"
iconSize: 22
}
StyledToolTip {
text: Audio.source && Audio.source.audio.muted
? Translation.tr("Unmute microphone") : Translation.tr("Mute microphone")
}
}
StyledSlider {
Layout.fillWidth: true
enabled: Audio.source ? Audio.source.ready : false
value: Audio.source ? Audio.source.audio.volume : 0
from: 0
to: 1
onMoved: {
if (Audio.source) Audio.source.audio.volume = value;
}
}
}
}
}

View file

@ -0,0 +1,135 @@
import QtQuick
import QtQuick.Layouts
import Quickshell.Io
import qs.services
import qs.modules.common
import qs.modules.common.widgets
// Speech services — dictation (STT) and speech synthesis (TTS).
// The endpoints live in Config so the souveraine-stt CLI (and the
// keyboard's mic key that shells out to it) read exactly what's set
// here. The probe hits the STT /health route so "it's configured" and
// "it's answering" are visibly different states.
ContentPage {
forceWidth: true
ContentSection {
icon: "mic"
title: Translation.tr("Dictation (speech to text)")
ConfigSwitch {
buttonIcon: "record_voice_over"
text: Translation.tr("Enable dictation")
checked: Config.options.speech.stt.enable
onCheckedChanged: {
Config.options.speech.stt.enable = checked;
}
StyledToolTip {
text: Translation.tr("Voice input through the transcription server. The keyboard's mic key and the souveraine-stt command both use this.")
}
}
MaterialTextField {
Layout.fillWidth: true
text: Config.options.speech.stt.endpoint
placeholderText: Translation.tr("Transcription endpoint (http://host:port/transcribe)")
onEditingFinished: {
if (text !== Config.options.speech.stt.endpoint) {
Config.options.speech.stt.endpoint = text;
healthProbe.refresh();
}
}
}
RowLayout {
spacing: 8
StyledText {
text: healthProbe.statusText
color: healthProbe.healthy ? Appearance.colors.colOnLayer1
: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
}
RippleButton {
implicitWidth: 32
implicitHeight: 32
buttonRadius: Appearance.rounding.full
onClicked: healthProbe.refresh()
contentItem: MaterialSymbol {
anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
text: "refresh"
iconSize: 18
}
StyledToolTip {
text: Translation.tr("Check the server")
}
}
}
}
ContentSection {
icon: "text_to_speech"
title: Translation.tr("Speech synthesis (text to speech)")
ConfigSwitch {
buttonIcon: "campaign"
text: Translation.tr("Enable speech output")
checked: Config.options.speech.tts.enable
onCheckedChanged: {
Config.options.speech.tts.enable = checked;
}
StyledToolTip {
text: Translation.tr("Spoken responses through the synthesis server. Off until a TTS endpoint exists.")
}
}
MaterialTextField {
Layout.fillWidth: true
text: Config.options.speech.tts.endpoint
placeholderText: Translation.tr("Synthesis endpoint (empty = none yet)")
onEditingFinished: {
if (text !== Config.options.speech.tts.endpoint)
Config.options.speech.tts.endpoint = text;
}
}
}
// STT /health probe. Derives the health URL from the transcribe
// endpoint (…/transcribe -> …/health) rather than storing a second URL.
QtObject {
id: healthProbe
property bool healthy: false
property string statusText: Translation.tr("Checking server…")
function refresh() {
statusText = Translation.tr("Checking server…");
healthy = false;
probeProc.running = false;
probeProc.running = true;
}
}
Process {
id: probeProc
running: true
command: ["curl", "-s", "--max-time", "5",
Config.options.speech.stt.endpoint.replace(/\/[^\/]*$/, "/health")]
stdout: StdioCollector {
onStreamFinished: {
try {
const h = JSON.parse(text);
healthProbe.healthy = h.status === "ok";
healthProbe.statusText = healthProbe.healthy
? Translation.tr("Server up · %1 on %2").arg(h.model ?? "?").arg(h.device ?? "?")
: Translation.tr("Server answered but not ok");
} catch (e) {
healthProbe.healthy = false;
healthProbe.statusText = Translation.tr("Server unreachable");
}
}
}
}
}

View file

@ -0,0 +1,278 @@
import QtQuick
import QtQuick.Layouts
import Qt.labs.folderlistmodel
import Quickshell
import qs.services
import qs.modules.common
import qs.modules.common.widgets
// Wallpaper — pick from ~/Pictures/Wallpapers inside the settings app.
// Settings is a window in the main shell process, so applying calls the
// shell-owned Wallpapers singleton directly. Never spawn a nested `qs ipc`
// process from this page.
ContentPage {
forceWidth: true
readonly property string homeDir: Quickshell.env("HOME")
property string currentDir: homeDir + "/Pictures/Wallpapers"
property var quickDirs: [
{ name: "Wallpapers", path: homeDir + "/Pictures/Wallpapers", icon: "wallpaper" },
{ name: "Pictures", path: homeDir + "/Pictures", icon: "image" },
{ name: "Downloads", path: homeDir + "/Downloads", icon: "download" },
{ name: "Home", path: homeDir, icon: "home" }
]
ContentSection {
icon: "wallpaper"
title: Translation.tr("Wallpaper")
StyledText {
Layout.fillWidth: true
text: Translation.tr("Current: %1").arg(
(Config.options.background.wallpaperPath || Translation.tr("none")).split("/").pop())
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
elide: Text.ElideMiddle
}
// Content filter — which wallhaven categories the random pick may
// include. Persisted to config.json where the download script reads it.
StyledText {
Layout.fillWidth: true
text: Translation.tr("Allowed content")
color: Appearance.colors.colOnLayer1
font.pixelSize: Appearance.font.pixelSize.small
}
Flow {
Layout.fillWidth: true
spacing: 6
Repeater {
model: [
{ key: "sfw", label: Translation.tr("SFW") },
{ key: "sketchy", label: Translation.tr("Sketchy") },
{ key: "nsfw", label: Translation.tr("NSFW") }
]
RippleButton {
id: purityChip
required property var modelData
property bool on: modelData.key === "sfw" ? WallpaperDownload.puritySfw
: modelData.key === "sketchy" ? WallpaperDownload.puritySketchy
: WallpaperDownload.purityNsfw
padding: 8
buttonRadius: Appearance.rounding.small
toggled: on
colBackgroundToggled: Appearance.colors.colSecondaryContainer
colBackgroundToggledHover: Appearance.colors.colSecondaryContainerHover
colRippleToggled: Appearance.colors.colSecondaryContainerActive
onClicked: {
if (modelData.key === "sfw")
WallpaperDownload.puritySfw = !WallpaperDownload.puritySfw;
else if (modelData.key === "sketchy")
WallpaperDownload.puritySketchy = !WallpaperDownload.puritySketchy;
else
WallpaperDownload.purityNsfw = !WallpaperDownload.purityNsfw;
WallpaperDownload.savePurity();
}
contentItem: RowLayout {
spacing: 4
MaterialSymbol {
iconSize: 16
text: purityChip.on ? "check" : "add"
color: purityChip.on ? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnLayer1
}
StyledText {
font.pixelSize: Appearance.font.pixelSize.small
text: modelData.label
color: purityChip.on ? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnLayer1
}
}
}
}
}
// Download a fresh random wallpaper from wallhaven. Downloads land in
// ~/Pictures/Wallpapers (each a distinct wallhaven_<id> file), then
// apply through the shell like any picked image.
RippleButton {
Layout.fillWidth: true
padding: 10
buttonRadius: Appearance.rounding.small
enabled: !WallpaperDownload.downloading
onClicked: WallpaperDownload.download()
contentItem: RowLayout {
spacing: 8
MaterialSymbol {
iconSize: 20
text: WallpaperDownload.downloading ? "hourglass_top" : "cloud_download"
color: Appearance.colors.colOnLayer1
}
StyledText {
Layout.fillWidth: true
font.pixelSize: Appearance.font.pixelSize.normal
text: WallpaperDownload.downloading
? Translation.tr("Downloading…")
: Translation.tr("Download random wallpaper")
color: Appearance.colors.colOnLayer1
}
}
}
StyledText {
visible: WallpaperDownload.lastError.length > 0
Layout.fillWidth: true
text: WallpaperDownload.lastError
color: Appearance.colors.colError
font.pixelSize: Appearance.font.pixelSize.smaller
wrapMode: Text.WordWrap
}
// Quick directory buttons
Flow {
Layout.fillWidth: true
spacing: 6
Repeater {
model: quickDirs
RippleButton {
required property var modelData
property bool isCurrent: currentDir === modelData.path
padding: 8
buttonRadius: Appearance.rounding.small
toggled: isCurrent
colBackgroundToggled: Appearance.colors.colSecondaryContainer
colBackgroundToggledHover: Appearance.colors.colSecondaryContainerHover
colRippleToggled: Appearance.colors.colSecondaryContainerActive
onClicked: {
currentDir = modelData.path;
}
contentItem: RowLayout {
spacing: 4
MaterialSymbol {
iconSize: 16
text: modelData.icon
color: isCurrent ? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnLayer1
}
StyledText {
font.pixelSize: Appearance.font.pixelSize.small
text: modelData.name
color: isCurrent ? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnLayer1
}
}
}
}
}
// Current path display
StyledText {
Layout.fillWidth: true
text: currentDir.replace(homeDir, "~")
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
elide: Text.ElideMiddle
}
GridView {
id: grid
Layout.fillWidth: true
// Rows of ~3 across the phone width; height fits the model.
readonly property int cols: 3
cellWidth: Math.floor(width / cols)
cellHeight: Math.floor(cellWidth * 2) // portrait-ish tiles
implicitHeight: Math.ceil(folderModel.count / cols) * cellHeight
interactive: false // the page scrolls, not the grid
clip: true
model: FolderListModel {
id: folderModel
folder: "file://" + currentDir
nameFilters: ["*.jpg", "*.jpeg", "*.png", "*.webp", "*.avif"]
showDirs: true
showDotAndDotDot: false
showOnlyReadable: true
sortField: FolderListModel.Name
}
delegate: Item {
required property string filePath
required property string fileName
required property bool fileIsDir
width: grid.cellWidth
height: grid.cellHeight
Rectangle {
anchors.fill: parent
anchors.margins: 5
radius: Appearance.rounding.small
color: Appearance.colors.colLayer1
border.width: (!fileIsDir && Config.options.background.wallpaperPath === filePath) ? 3 : 0
border.color: Appearance.colors.colPrimary
clip: true
Image {
anchors.fill: parent
anchors.margins: 3
source: fileIsDir ? "" : "file://" + filePath
fillMode: Image.PreserveAspectCrop
asynchronous: true
sourceSize.width: 240
visible: !fileIsDir
}
// Directory indicator
Column {
anchors.centerIn: parent
visible: fileIsDir
spacing: 4
MaterialSymbol {
anchors.horizontalCenter: parent.horizontalCenter
iconSize: 32
text: "folder"
color: Appearance.colors.colPrimary
}
StyledText {
anchors.horizontalCenter: parent.horizontalCenter
font.pixelSize: Appearance.font.pixelSize.smaller
text: fileName
color: Appearance.colors.colOnLayer1
elide: Text.ElideRight
width: grid.cellWidth - 16
horizontalAlignment: Text.AlignHCenter
}
}
MouseArea {
anchors.fill: parent
onClicked: {
if (fileIsDir) {
currentDir = filePath;
} else {
// Shell process owns selection + theming.
Wallpapers.apply(filePath);
Config.options.background.wallpaperPath = filePath;
}
}
}
}
}
}
StyledText {
visible: folderModel.count === 0
Layout.fillWidth: true
text: Translation.tr("No images in %1").arg(currentDir.replace(homeDir, "~"))
color: Appearance.colors.colSubtext
wrapMode: Text.WordWrap
}
}
}

View file

@ -0,0 +1,21 @@
About 1.0 About.qml
AdvancedConfig 1.0 AdvancedConfig.qml
BackgroundConfig 1.0 BackgroundConfig.qml
BarConfig 1.0 BarConfig.qml
DeviceConfig 1.0 DeviceConfig.qml
DisplayConfig 1.0 DisplayConfig.qml
DockConfig 1.0 DockConfig.qml
GeneralConfig 1.0 GeneralConfig.qml
IdleConfig 1.0 IdleConfig.qml
InterfaceConfig 1.0 InterfaceConfig.qml
KeyboardConfig 1.0 KeyboardConfig.qml
LockConfig 1.0 LockConfig.qml
NavigationConfig 1.0 NavigationConfig.qml
NetworkConfig 1.0 NetworkConfig.qml
OverviewConfig 1.0 OverviewConfig.qml
QuickConfig 1.0 QuickConfig.qml
ServicesConfig 1.0 ServicesConfig.qml
SettingsHome 1.0 SettingsHome.qml
SpeechConfig 1.0 SpeechConfig.qml
SoundConfig 1.0 SoundConfig.qml
WallpaperConfig 1.0 WallpaperConfig.qml

View file

@ -0,0 +1,404 @@
pragma ComponentBehavior: Bound
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
import qs.modules.ii.sidebarLeft.aiChat
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
/*
* One message, drawn from typed segments.
*
* Owned Souveraine surface (TASK-72 step 2). This replaces the vendor
* snapshot's AiMessage as the agent-surface delegate.
*
* The structural point: it consumes the segment list, never a flattened
* markdown blob. `services/Ai.qml` stays the data boundary — it preserves wire
* segment type and call identity and does not choose pixels; this file chooses
* pixels and reads no state.
*
* Deliberately reused from the vendor snapshot: MessageTextBlock and
* MessageCodeBlock. Those are markdown renderers, not agent vocabulary — the
* ii-base rule is that no further *agent-surface feature* lands in the vendor
* tree, not that we owe ourselves a second markdown engine. Reasoning and tool
* calls are ours because those are the parts that are about her.
*
* Known parity gaps against the vendor delegate, stated rather than hidden:
* - in-place message editing (Ctrl+S save) is not carried over;
* - the attached-file indicator is not drawn yet — it belongs with the image
* intake half of TASK-72, which has no picker yet either.
*/
Rectangle {
id: root
property int messageIndex
property var messageData
property var messageInputField
property real messagePadding: 7
property real contentSpacing: 3
property bool renderMarkdown: true
property bool enableMouseSelection: false
// Typed segments are the contract. The markdown splitter is the fallback
// for legacy/provider messages that never carried segments at all.
readonly property var messageBlocks: {
const segments = root.messageData ? root.messageData.segments : [];
return (segments && segments.length > 0)
? segments
: StringUtils.splitMarkdownBlocks(root.messageData?.content);
}
readonly property bool isAssistant: (root.messageData?.role ?? "") === "assistant"
readonly property bool done: root.messageData?.done ?? false
// Delete is armed rather than immediate — see the control row.
property bool deleteArmed: false
onMessageIndexChanged: root.deleteArmed = false
readonly property bool isSpeakingThis: Speech.speaking
&& Ai.speakingMessageIndex === root.messageIndex
// What `copy` puts on the clipboard: everything the message actually says.
readonly property string plainText: root.messageData?.content ?? ""
// What `speak` sends to TTS. NOT the same thing, deliberately.
//
// The vendor spoke messageData.content verbatim, which on a segmented
// message means the synthesizer reads reasoning and tool payloads aloud.
// Typed segments let us say what a voice should say: prose only. Code is
// excluded because reading a diff aloud is noise, not speech; `think` and
// `tool` are excluded because they are not addressed to anyone.
//
// Falls back to the whole content when a legacy message carries no
// segments, which is the pre-existing behaviour rather than silence.
readonly property string spokenText: {
const segments = root.messageData?.segments;
if (!segments || segments.length < 1) return root.plainText;
return segments
.filter(s => (s?.kind ?? "text") === "text")
.map(s => s?.text ?? "")
.join("\n\n")
.trim();
}
anchors.left: parent?.left
anchors.right: parent?.right
// While streaming, never let the bubble shrink: markdown reflow transiently
// collapses height and jolts the auto-scroll. Floor it until done.
readonly property real naturalHeight: columnLayout.implicitHeight + root.messagePadding * 2
property real streamingHeightFloor: 0
implicitHeight: !root.done ? Math.max(naturalHeight, streamingHeightFloor) : naturalHeight
onNaturalHeightChanged: if (!root.done) streamingHeightFloor = Math.max(streamingHeightFloor, naturalHeight)
onMessageDataChanged: streamingHeightFloor = 0
radius: Appearance.rounding.normal
color: Appearance.colors.colLayer1
ColumnLayout {
id: columnLayout
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: root.messagePadding
spacing: root.contentSpacing
Rectangle { // Header
Layout.fillWidth: true
implicitHeight: headerRow.implicitHeight + 8
radius: Appearance.rounding.small
color: Appearance.colors.colSecondaryContainer
RowLayout {
id: headerRow
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 10
anchors.rightMargin: 10
spacing: 8
MaterialSymbol {
text: root.isAssistant ? "auto_awesome" : "person"
iconSize: Appearance.font.pixelSize.normal
color: Appearance.colors.colOnSecondaryContainer
}
StyledText {
Layout.fillWidth: true
text: root.messageData?.name ?? (root.isAssistant ? Translation.tr("Souveraine") : Translation.tr("You"))
font.pixelSize: Appearance.font.pixelSize.small
color: Appearance.colors.colOnSecondaryContainer
elide: Text.ElideRight
}
// ── Controls ──────────────────────────────────────────────
// Deliberately NOT carried over from the vendor row:
//
// regenerate — the conversation is forward-only. Ai.qml's
// regenerate() is already a no-op that returns advice.
// A button that only ever explains it does nothing is
// worse than no button. Audio re-synthesis is the real
// verb, and it lives on `replay` below.
// edit — there is no in-place edit. The vendor's saved to
// a local array the server never sees, so the message
// you read back was not the message the agent holds.
//
// `delete` is kept but gated, because it is a *view filter*
// wearing a delete icon: removeMessage() splices two local
// arrays and the server transcript is untouched. /resume
// brings it straight back.
ButtonGroup {
id: controlRow
spacing: 5
visible: !root.deleteArmed
AiMessageControlButton {
id: speakButton
// stop icon only while THIS message is the speaker
buttonIcon: root.isSpeakingThis ? "stop_circle" : "volume_up"
visible: Speech.enabled && root.isAssistant
enabled: visible
onClicked: {
if (root.isSpeakingThis) {
Speech.stop()
} else {
Ai.speakingMessageIndex = root.messageIndex
Speech.speak(StringUtils.ttsClean(root.spokenText))
}
}
StyledToolTip {
text: root.isSpeakingThis ? Translation.tr("Stop") : Translation.tr("Speak")
}
}
AiMessageControlButton {
id: respeakButton
buttonIcon: "replay"
visible: Speech.enabled && root.isAssistant
enabled: visible
onClicked: {
// Re-synthesize when the previous synth came out
// wrong. Does NOT re-run the agent.
//
// The legacy path below cannot actually do this:
// speak() opens with a cache check on the text,
// and re-synthesis is by definition the same
// text — so it replays the identical broken file.
// Speech.resynthesize() is the honest verb
// (cancel live job, bypass cache, re-request);
// until it lands we degrade rather than lie.
Ai.speakingMessageIndex = root.messageIndex
const text = StringUtils.ttsClean(root.spokenText)
if (typeof Speech.resynthesize === "function") {
Speech.resynthesize(text)
} else {
Speech.stop()
Speech.speak(text)
}
}
StyledToolTip {
text: Translation.tr("Re-synthesize audio")
}
}
AiMessageControlButton {
id: copyButton
buttonIcon: activated ? "inventory" : "content_copy"
onClicked: {
Quickshell.clipboardText = root.plainText
copyButton.activated = true
copyIconTimer.restart()
}
Timer {
id: copyIconTimer
interval: 1500
repeat: false
onTriggered: copyButton.activated = false
}
StyledToolTip {
text: Translation.tr("Copy")
}
}
AiMessageControlButton {
id: toggleMarkdownButton
activated: !root.renderMarkdown
buttonIcon: "code"
onClicked: root.renderMarkdown = !root.renderMarkdown
StyledToolTip {
text: Translation.tr("View Markdown source")
}
}
AiMessageControlButton {
id: deleteButton
buttonIcon: "close"
onClicked: root.deleteArmed = true
StyledToolTip {
text: Translation.tr("Hide from view")
}
}
}
// Armed state replaces the row in place rather than opening a
// modal: this panel is a layer-shell surface and a grabbing
// popup here fights the compositor for focus. The words are
// the point, not the chrome.
RowLayout {
id: deleteConfirmRow
visible: root.deleteArmed
spacing: 6
MaterialSymbol {
text: "visibility_off"
iconSize: Appearance.font.pixelSize.normal
color: Appearance.colors.colOnSecondaryContainer
}
StyledText {
text: Translation.tr("Hides it here only — the transcript keeps it.")
font.pixelSize: Appearance.font.pixelSize.smaller
color: Appearance.colors.colOnSecondaryContainer
elide: Text.ElideRight
}
DialogButton {
buttonText: Translation.tr("Cancel")
onClicked: root.deleteArmed = false
}
DialogButton {
buttonText: Translation.tr("Hide")
onClicked: {
root.deleteArmed = false
Ai.removeMessage(root.messageIndex)
}
}
}
}
}
Item { // Waiting, before any segment has arrived
Layout.fillWidth: true
implicitHeight: waitingLoader.shown ? waitingLoader.implicitHeight : 0
visible: implicitHeight > 0
Behavior on implicitHeight {
animation: Appearance.animation.elementMove.numberAnimation.createObject(this)
}
FadeLoader {
id: waitingLoader
anchors.centerIn: parent
shown: (root.messageBlocks.length < 1) && !root.done
sourceComponent: MaterialLoadingIndicator {
loading: true
}
}
}
Repeater {
model: ScriptModel {
values: root.messageBlocks
}
delegate: DelegateChooser {
role: "type"
DelegateChoice {
roleValue: "tool"
ToolCard {
required property var modelData
segment: modelData
}
}
DelegateChoice {
roleValue: "think"
ThinkingCard {
required property var modelData
segmentContent: modelData.content ?? ""
renderMarkdown: root.renderMarkdown
enableMouseSelection: root.enableMouseSelection
done: root.done
completed: modelData.completed ?? root.done
}
}
DelegateChoice {
roleValue: "code"
MessageCodeBlock {
required property var modelData
renderMarkdown: root.renderMarkdown
enableMouseSelection: root.enableMouseSelection
segmentContent: modelData.content ?? ""
segmentLang: modelData.lang ?? ""
messageData: root.messageData
}
}
DelegateChoice {
roleValue: "text"
MessageTextBlock {
required property var modelData
renderMarkdown: root.renderMarkdown
enableMouseSelection: root.enableMouseSelection
segmentContent: modelData.content ?? ""
messageData: root.messageData
done: root.done
forceDisableChunkSplitting: root.messageData?.content?.includes("```") ?? true
}
}
}
}
Flow { // Annotations
visible: (root.messageData?.annotationSources?.length ?? 0) > 0
spacing: 5
Layout.fillWidth: true
Layout.alignment: Qt.AlignLeft
Repeater {
model: ScriptModel {
values: root.messageData?.annotationSources ?? []
}
delegate: AnnotationSourceButton {
required property var modelData
displayText: modelData.text
url: modelData.url
}
}
}
Flow { // Search queries
visible: (root.messageData?.searchQueries?.length ?? 0) > 0
spacing: 5
Layout.fillWidth: true
Layout.alignment: Qt.AlignLeft
Repeater {
model: ScriptModel {
values: root.messageData?.searchQueries ?? []
}
delegate: SearchQueryButton {
required property var modelData
query: modelData
}
}
}
}
}

View file

@ -0,0 +1,173 @@
pragma ComponentBehavior: Bound
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Layouts
/*
* The footer's agent menu.
*
* The first section controls this pane and therefore lists only Souveraine
* agents. AgentSessions' Claude/Codex records are observations, not selectable
* backends for this conversation; active external sessions remain visible in
* a separately labelled read-only section so status cannot masquerade as a
* control again.
*/
Rectangle {
id: root
signal picked
readonly property var externalSessions: AgentSessions.sessions.filter(session =>
session.provider !== "souveraine" && session.state === "active")
implicitHeight: content.implicitHeight + 14
radius: Appearance.rounding.normal
color: Appearance.colors.colLayer2
border.width: 1
border.color: Appearance.colors.colLayer0Border
clip: true
ColumnLayout {
id: content
anchors.fill: parent
anchors.margins: 7
spacing: 4
RowLayout {
Layout.fillWidth: true
spacing: 6
MaterialSymbol {
text: "neurology"
iconSize: Appearance.font.pixelSize.normal
color: Appearance.colors.colPrimary
}
StyledText {
Layout.fillWidth: true
text: Translation.tr("This conversation")
color: Appearance.colors.colOnLayer2
font.pixelSize: Appearance.font.pixelSize.small
font.bold: true
}
StyledText {
visible: Souveraine.turnActive
text: Translation.tr("turn running")
color: Appearance.colors.colPrimary
font.pixelSize: Appearance.font.pixelSize.smallest
}
}
Repeater {
model: Ai.modelList
delegate: Rectangle {
id: agentRow
required property var modelData
readonly property var agent: Ai.models[modelData] ?? null
readonly property bool selected: modelData === Souveraine.currentAgentId
Layout.fillWidth: true
implicitHeight: 34
radius: Appearance.rounding.small
color: selected ? Appearance.colors.colSecondaryContainer
: picker.containsMouse ? Appearance.colors.colLayer2Hover
: Appearance.colors.colLayer2
RowLayout {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
spacing: 7
Rectangle {
Layout.alignment: Qt.AlignVCenter
implicitWidth: 7
implicitHeight: 7
radius: 4
color: agentRow.selected && Souveraine.turnActive
? Appearance.colors.colPrimary
: agentRow.selected
? Appearance.colors.colOnSecondaryContainer
: Appearance.colors.colOutlineVariant
}
StyledText {
Layout.fillWidth: true
text: agentRow.agent?.name ?? agentRow.modelData
color: agentRow.selected
? Appearance.colors.colOnSecondaryContainer
: Appearance.colors.colOnLayer2
font.pixelSize: Appearance.font.pixelSize.smaller
font.bold: agentRow.selected
elide: Text.ElideRight
}
StyledText {
text: agentRow.selected ? Translation.tr("selected") : ""
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smallest
}
}
MouseArea {
id: picker
anchors.fill: parent
hoverEnabled: true
enabled: !Souveraine.turnActive && !agentRow.selected
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
Ai.setModel(agentRow.modelData, false);
root.picked();
}
}
}
}
Rectangle {
Layout.fillWidth: true
implicitHeight: 1
visible: root.externalSessions.length > 0
color: Appearance.colors.colLayer0Border
}
StyledText {
Layout.fillWidth: true
visible: root.externalSessions.length > 0
text: Translation.tr("Running elsewhere · observed only")
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smallest
}
Repeater {
model: root.externalSessions
delegate: RowLayout {
id: externalRow
required property var modelData
Layout.fillWidth: true
Layout.leftMargin: 8
Layout.rightMargin: 8
spacing: 7
MaterialSymbol {
text: AgentSessions.providerIcon(externalRow.modelData.provider)
iconSize: Appearance.font.pixelSize.smaller
color: Appearance.colors.colSubtext
}
StyledText {
Layout.fillWidth: true
text: AgentSessions.sessionLabel(externalRow.modelData)
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smallest
elide: Text.ElideRight
}
StyledText {
text: AgentSessions.providerLabel(externalRow.modelData.provider)
color: Appearance.colors.colOutlineVariant
font.pixelSize: Appearance.font.pixelSize.smallest
}
}
}
}
}

View file

@ -0,0 +1,312 @@
pragma ComponentBehavior: Bound
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
/*
* Server-owned conversation control opened by the footer chip.
*
* A bubble array is not a thread. Every row here names a server conversation
* and selecting it backfills that transcript before the next send. New and
* offered-resume are explicit acts; opening the menu never attaches by itself.
*/
Rectangle {
id: root
signal picked
readonly property string query: searchField.text.trim().toLowerCase()
readonly property var filteredConversations: Souveraine.conversations
.filter(conversation => {
if (root.query.length === 0) return true;
const date = conversation.updated_at ?? conversation.created_at ?? "";
return String(conversation.id).toLowerCase().includes(root.query)
|| String(date).toLowerCase().includes(root.query);
})
.slice(0, 50)
function shortId(id) {
const value = String(id ?? "");
return value.length > 12 ? value.slice(0, 12) : value;
}
function when(conversation) {
const raw = conversation.updated_at ?? conversation.created_at ?? "";
if (raw.length === 0) return Translation.tr("date unknown");
const date = new Date(raw);
if (isNaN(date.getTime())) return raw;
return date.toLocaleString(Qt.locale(), "MMM d · HH:mm");
}
onVisibleChanged: {
if (!visible) return;
searchField.text = "";
Souveraine.refreshConversations();
}
implicitHeight: content.implicitHeight + 14
radius: Appearance.rounding.normal
color: Appearance.colors.colLayer2
border.width: 1
border.color: Appearance.colors.colLayer0Border
clip: true
ColumnLayout {
id: content
anchors.fill: parent
anchors.margins: 7
spacing: 5
RowLayout {
Layout.fillWidth: true
spacing: 6
MaterialSymbol {
text: "forum"
iconSize: Appearance.font.pixelSize.normal
color: Appearance.colors.colPrimary
}
StyledText {
Layout.fillWidth: true
text: Translation.tr("Conversations")
color: Appearance.colors.colOnLayer2
font.pixelSize: Appearance.font.pixelSize.small
font.bold: true
}
MaterialSymbol {
visible: Souveraine.conversationsLoading
text: "sync"
iconSize: Appearance.font.pixelSize.normal
color: Appearance.colors.colPrimary
RotationAnimation on rotation {
running: Souveraine.conversationsLoading
from: 0
to: 360
duration: 900
loops: Animation.Infinite
}
}
MaterialSymbol {
visible: Souveraine.conversationsStale
text: "sync_problem"
iconSize: Appearance.font.pixelSize.normal
color: Appearance.colors.colError
}
}
Rectangle {
Layout.fillWidth: true
visible: Souveraine.offeredConversationId.length > 0
Layout.preferredHeight: visible ? 38 : 0
radius: Appearance.rounding.small
color: Appearance.colors.colSecondaryContainer
RowLayout {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 6
spacing: 7
MaterialSymbol {
text: "history"
iconSize: Appearance.font.pixelSize.normal
color: Appearance.colors.colOnSecondaryContainer
}
ColumnLayout {
Layout.fillWidth: true
spacing: 0
StyledText {
Layout.fillWidth: true
text: Translation.tr("Continue latest")
color: Appearance.colors.colOnSecondaryContainer
font.pixelSize: Appearance.font.pixelSize.smaller
font.bold: true
}
StyledText {
Layout.fillWidth: true
text: root.shortId(Souveraine.offeredConversationId)
color: Appearance.colors.colOnSecondaryContainer
opacity: 0.72
font.pixelSize: Appearance.font.pixelSize.smallest
elide: Text.ElideRight
}
}
StyledText {
text: Translation.tr("dismiss")
color: Appearance.colors.colOnSecondaryContainer
opacity: dismissOffer.containsMouse ? 1 : 0.65
font.pixelSize: Appearance.font.pixelSize.smallest
MouseArea {
id: dismissOffer
anchors.fill: parent
anchors.margins: -6
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: Souveraine.dismissOfferedResume()
}
}
}
MouseArea {
anchors.fill: parent
anchors.rightMargin: 58
cursorShape: Qt.PointingHandCursor
onClicked: if (Souveraine.acceptOfferedResume()) root.picked()
}
}
Rectangle {
Layout.fillWidth: true
implicitHeight: 34
radius: Appearance.rounding.small
color: newThread.containsMouse
? Appearance.colors.colLayer2Hover
: Appearance.colors.colLayer2
border.width: 1
border.color: Appearance.colors.colOutlineVariant
RowLayout {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
spacing: 7
MaterialSymbol {
text: "add_comment"
iconSize: Appearance.font.pixelSize.normal
color: Appearance.colors.colPrimary
}
StyledText {
Layout.fillWidth: true
text: Translation.tr("New conversation")
color: Appearance.colors.colOnLayer2
font.pixelSize: Appearance.font.pixelSize.smaller
}
}
MouseArea {
id: newThread
anchors.fill: parent
hoverEnabled: true
enabled: !Souveraine.turnActive
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
Ai.clearMessages();
root.picked();
}
}
}
TextField {
id: searchField
Layout.fillWidth: true
visible: Souveraine.conversations.length > 5
Layout.preferredHeight: visible ? 32 : 0
placeholderText: Translation.tr("Filter by id or date")
color: Appearance.colors.colOnLayer2
placeholderTextColor: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
selectByMouse: true
leftPadding: 9
rightPadding: 9
background: Rectangle {
radius: Appearance.rounding.small
color: Appearance.colors.colLayer1
border.width: searchField.activeFocus ? 1 : 0
border.color: Appearance.colors.colPrimary
}
}
ListView {
id: conversationList
Layout.fillWidth: true
Layout.preferredHeight: Math.min(contentHeight, 210)
clip: true
spacing: 3
model: root.filteredConversations
boundsBehavior: Flickable.StopAtBounds
delegate: Rectangle {
id: conversationRow
required property var modelData
readonly property bool selected: modelData.id === Souveraine.conversationId
width: ListView.view.width
implicitHeight: 38
radius: Appearance.rounding.small
color: selected ? Appearance.colors.colSecondaryContainer
: chooseThread.containsMouse ? Appearance.colors.colLayer2Hover
: Appearance.colors.colLayer2
RowLayout {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
spacing: 7
MaterialSymbol {
text: conversationRow.selected ? "chat" : "chat_bubble_outline"
iconSize: Appearance.font.pixelSize.smaller
color: conversationRow.selected
? Appearance.colors.colOnSecondaryContainer
: Appearance.colors.colSubtext
}
ColumnLayout {
Layout.fillWidth: true
spacing: 0
StyledText {
Layout.fillWidth: true
text: root.when(conversationRow.modelData)
color: conversationRow.selected
? Appearance.colors.colOnSecondaryContainer
: Appearance.colors.colOnLayer2
font.pixelSize: Appearance.font.pixelSize.smaller
elide: Text.ElideRight
}
StyledText {
Layout.fillWidth: true
text: root.shortId(conversationRow.modelData.id)
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smallest
elide: Text.ElideRight
}
}
StyledText {
visible: conversationRow.selected
text: Translation.tr("attached")
color: Appearance.colors.colOnSecondaryContainer
font.pixelSize: Appearance.font.pixelSize.smallest
}
}
MouseArea {
id: chooseThread
anchors.fill: parent
hoverEnabled: true
enabled: !Souveraine.turnActive && !conversationRow.selected
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (Souveraine.loadConversationById(conversationRow.modelData.id)) root.picked()
}
}
}
StyledText {
Layout.fillWidth: true
visible: !Souveraine.conversationsLoading
&& root.filteredConversations.length === 0
text: root.query.length > 0
? Translation.tr("No matching conversations")
: Translation.tr("No saved conversations")
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
horizontalAlignment: Text.AlignHCenter
}
}
}

View file

@ -0,0 +1,219 @@
pragma ComponentBehavior: Bound
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
/*
* The itinerary's persistent surface.
*
* A tool card says an itinerary verb happened. This ribbon says where the
* agent is now. It consumes the substrate's structured read-only projection,
* remains beside the composer while chat scrolls, and opens to reveal linked
* todo stops without becoming a second commitment store.
*/
Rectangle {
id: root
property var itinerary: ({})
property bool stale: false
property bool expanded: false
readonly property var stops: root.itinerary?.stops ?? []
readonly property int currentIndex: Number(root.itinerary?.current ?? 0)
readonly property var currentStop: currentIndex >= 0 && currentIndex < stops.length
? stops[currentIndex] : null
readonly property int doneCount: {
let count = 0;
for (const stop of stops) if (stop.status === "done") count++;
return count;
}
readonly property string phase: stops.length < 1 ? ""
: root.itinerary?.active
? `${Math.min(currentIndex + 1, stops.length)}/${stops.length}`
: `${stops.length}/${stops.length}`
implicitHeight: layout.implicitHeight + 2
radius: Appearance.rounding.normal
color: Appearance.colors.colSecondaryContainer
border.width: 1
border.color: Appearance.colors.colSecondaryContainerActive
clip: true
ColumnLayout {
id: layout
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 1
spacing: 0
Item {
Layout.fillWidth: true
implicitHeight: 34
RowLayout {
anchors.fill: parent
anchors.leftMargin: 9
anchors.rightMargin: 7
spacing: 7
MaterialSymbol {
text: root.itinerary?.active ? "route" : "task_alt"
iconSize: Appearance.font.pixelSize.normal
color: Appearance.colors.colOnSecondaryContainer
}
ColumnLayout {
Layout.fillWidth: true
spacing: 0
StyledText {
Layout.fillWidth: true
text: root.itinerary?.title ?? Translation.tr("Itinerary")
color: Appearance.colors.colOnSecondaryContainer
font.pixelSize: Appearance.font.pixelSize.smaller
font.bold: true
elide: Text.ElideRight
}
StyledText {
Layout.fillWidth: true
text: root.currentStop?.name
?? (root.itinerary?.active ? Translation.tr("In progress") : Translation.tr("Route complete"))
color: Appearance.colors.colOnSecondaryContainer
opacity: 0.72
font.pixelSize: Appearance.font.pixelSize.smallest
elide: Text.ElideRight
}
}
StyledText {
text: root.phase
color: Appearance.colors.colOnSecondaryContainer
font.pixelSize: Appearance.font.pixelSize.smallest
font.bold: true
}
MaterialSymbol {
visible: root.stale
text: "sync_problem"
iconSize: Appearance.font.pixelSize.smaller
color: Appearance.colors.colError
}
MaterialSymbol {
text: root.expanded ? "expand_less" : "expand_more"
iconSize: Appearance.font.pixelSize.normal
color: Appearance.colors.colOnSecondaryContainer
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.expanded = !root.expanded
}
}
Rectangle {
Layout.fillWidth: true
Layout.leftMargin: 8
Layout.rightMargin: 8
implicitHeight: 2
radius: 1
color: Appearance.colors.colLayer2
Rectangle {
width: parent.width * (root.stops.length > 0 ? root.doneCount / root.stops.length : 0)
height: parent.height
radius: parent.radius
color: Appearance.colors.colPrimary
}
}
ScrollView {
Layout.fillWidth: true
Layout.preferredHeight: root.expanded ? Math.min(stopList.contentHeight, 190) : 0
visible: root.expanded
clip: true
ScrollBar.vertical.policy: ScrollBar.AsNeeded
ListView {
id: stopList
model: root.stops
spacing: 3
boundsBehavior: Flickable.StopAtBounds
delegate: Rectangle {
id: stopCard
required property var modelData
width: stopList.width
implicitHeight: stopRow.implicitHeight + 10
radius: Appearance.rounding.small
color: stopCard.modelData.status === "current"
? Appearance.colors.colSecondaryContainerActive
: Appearance.colors.colLayer2
RowLayout {
id: stopRow
anchors.fill: parent
anchors.margins: 5
spacing: 7
MaterialSymbol {
text: stopCard.modelData.status === "done" ? "check_circle"
: stopCard.modelData.status === "current" ? "radio_button_checked"
: "radio_button_unchecked"
iconSize: Appearance.font.pixelSize.smaller
color: stopCard.modelData.status === "current"
? Appearance.colors.colPrimary
: Appearance.colors.colSubtext
}
ColumnLayout {
Layout.fillWidth: true
spacing: 0
StyledText {
Layout.fillWidth: true
text: stopCard.modelData.name ?? ""
color: Appearance.colors.colOnLayer2
font.pixelSize: Appearance.font.pixelSize.smaller
font.strikeout: stopCard.modelData.status === "done"
elide: Text.ElideRight
}
StyledText {
Layout.fillWidth: true
visible: (stopCard.modelData.description?.length ?? 0) > 0
text: stopCard.modelData.description ?? ""
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smallest
elide: Text.ElideRight
}
}
MaterialSymbol {
visible: (stopCard.modelData.todo_id?.length ?? 0) > 0
text: "checklist"
iconSize: Appearance.font.pixelSize.smaller
color: Appearance.colors.colSubtext
}
StyledText {
visible: (stopCard.modelData.nature?.length ?? 0) > 0
text: stopCard.modelData.nature ?? ""
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smallest
}
MaterialSymbol {
visible: stopCard.modelData.energy === "generative"
text: "bolt"
iconSize: Appearance.font.pixelSize.smaller
color: Appearance.colors.colPrimary
}
}
}
}
}
}
Behavior on implicitHeight {
NumberAnimation { duration: 140; easing.type: Easing.OutCubic }
}
}

View file

@ -0,0 +1,110 @@
pragma ComponentBehavior: Bound
import qs.services
import qs.modules.common
import qs.modules.common.functions
import qs.modules.common.widgets
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
/*
* Reasoning, drawn as itself.
*
* Owned Souveraine surface. The thing this replaces marked reasoning with
* literal <think> fences inside one flattened markdown string, which is how a
* think-fence collision could eat a reply (renderer-think-fence, 2026-08-11).
* A segment kind cannot collide with punctuation, so the whole class of bug
* goes away by construction rather than by escaping harder.
*
* Visual intent: quieter than speech, and quieter than any tool. Thinking is
* not addressed to anyone. It is legible when wanted and out of the way
* otherwise — open while it is still happening, folded once it is done.
*/
Item {
id: root
property string segmentContent: ""
property bool done: false
property bool completed: false
property bool renderMarkdown: true
property bool enableMouseSelection: false
// Open while the thought is still forming; fold it once it has landed.
// Deliberately not user-sticky yet: a fold state that survives a delegate
// recycle needs to live in the model, not here.
property bool expanded: !root.completed
onCompletedChanged: if (root.completed) root.expanded = false
readonly property color accent: Appearance.colors.colSubtext
Layout.fillWidth: true
implicitHeight: body.implicitHeight
Rectangle {
id: body
anchors.left: parent.left
anchors.right: parent.right
implicitHeight: column.implicitHeight + 12
radius: Appearance.rounding.small
color: Appearance.colors.colLayer2
border.width: 1
border.color: ColorUtils.transparentize(root.accent, 0.75)
ColumnLayout {
id: column
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 6
spacing: 4
MouseArea {
Layout.fillWidth: true
implicitHeight: header.implicitHeight
cursorShape: Qt.PointingHandCursor
onClicked: root.expanded = !root.expanded
RowLayout {
id: header
anchors.left: parent.left
anchors.right: parent.right
spacing: 6
MaterialSymbol {
text: "neurology"
iconSize: Appearance.font.pixelSize.normal
color: root.accent
}
StyledText {
Layout.fillWidth: true
text: root.completed ? Translation.tr("Thought") : Translation.tr("Thinking\u2026")
font.pixelSize: Appearance.font.pixelSize.smaller
font.italic: true
color: root.accent
elide: Text.ElideRight
}
MaterialSymbol {
text: root.expanded ? "expand_less" : "expand_more"
iconSize: Appearance.font.pixelSize.normal
color: root.accent
}
}
}
StyledText {
Layout.fillWidth: true
visible: root.expanded && root.segmentContent.length > 0
text: root.segmentContent
font.pixelSize: Appearance.font.pixelSize.smaller
font.italic: true
color: Appearance.colors.colSubtext
wrapMode: Text.Wrap
textFormat: Text.PlainText
}
}
}
}

View file

@ -0,0 +1,193 @@
pragma ComponentBehavior: Bound
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
/*
* One tool call, drawn as itself.
*
* Owned Souveraine surface — the vendor snapshot's ToolCallBlock is what this
* replaces. Differences that are deliberate, not incidental:
*
* - every tool in the registry has a name, an icon and a summary, including
* the interiority verbs the borrowed card could not see;
* - acts on the machine and acts on herself are visually distinct families,
* because they are not the same kind of event;
* - status is legible without opening the payload (TASK-72 ask 3);
* - collapsed by default, except while running or on failure — the two
* cases where the payload is the point (TASK-72 ask 2).
*
* It draws only. It reads no state and calls nothing; the segment is handed
* to it whole. Ai.qml stays the data boundary and does not choose pixels.
*/
Item {
id: root
property var segment: ({})
readonly property string name: String(segment.name ?? "tool")
readonly property string status: String(segment.status ?? "running")
readonly property string output: String(segment.output ?? "")
readonly property bool failed: segment.failed === true
readonly property bool running: status === "running"
readonly property string kind: ToolVocabulary.kindOf(name)
readonly property string summary: ToolVocabulary.summarize(name, segment.arguments)
// Acts on herself read in the secondary accent; acts on the machine in the
// primary. Sensors are deliberately quiet — she looks constantly, and a
// card per glance should not shout.
readonly property color accent: {
if (root.failed) return Appearance.colors.colError;
switch (root.kind) {
case "sensor": return Appearance.colors.colSubtext;
case "memory":
case "presence":
case "intent":
case "reach": return Appearance.colors.colSecondary;
case "control": return Appearance.colors.colError;
default: return Appearance.colors.colPrimary;
}
}
property bool expanded: root.running || root.failed
Layout.fillWidth: true
// The itinerary ribbon owns successful route state persistently beside
// the composer. Keep failures in the transcript: they are evidence, not
// duplicate chrome.
visible: root.name !== "itinerary" || root.failed
implicitHeight: visible ? card.implicitHeight : 0
function statusLabel() {
if (root.running) return Translation.tr("running");
if (root.status === "unresolved") return Translation.tr("unresolved");
if (root.failed) return Translation.tr("failed");
return Translation.tr("done");
}
Rectangle {
id: card
width: parent.width
implicitHeight: cardLayout.implicitHeight + 14
radius: Appearance.rounding.small
color: root.failed ? Appearance.colors.colErrorContainer : Appearance.colors.colLayer2
border.width: 1
border.color: root.failed ? Appearance.colors.colError : Appearance.colors.colOutlineVariant
// The family stripe. Cheaper to read than an icon and it survives
// being glanced at sideways in a scrolling column.
Rectangle {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.margins: 1
width: 2
radius: 1
color: root.accent
}
ColumnLayout {
id: cardLayout
anchors.fill: parent
anchors.margins: 7
anchors.leftMargin: 10
spacing: 5
MouseArea {
id: header
Layout.fillWidth: true
implicitHeight: headerRow.implicitHeight
hoverEnabled: true
// Nothing to open is not the same as refusing to open.
enabled: root.output.length > 0
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: root.expanded = !root.expanded
RowLayout {
id: headerRow
anchors.fill: parent
spacing: 7
MaterialSymbol {
text: ToolVocabulary.iconOf(root.name)
iconSize: Appearance.font.pixelSize.large
color: root.accent
}
StyledText {
font.pixelSize: Appearance.font.pixelSize.small
font.bold: true
text: root.name
color: Appearance.colors.colOnLayer2
}
StyledText {
Layout.fillWidth: true
elide: Text.ElideRight
font.pixelSize: Appearance.font.pixelSize.small
text: root.summary
color: Appearance.colors.colSubtext
}
MaterialSymbol {
visible: root.running
text: "sync"
iconSize: Appearance.font.pixelSize.normal
color: root.accent
RotationAnimation on rotation {
running: root.running
from: 0
to: 360
duration: 900
loops: Animation.Infinite
}
}
StyledText {
visible: !root.running
font.pixelSize: Appearance.font.pixelSize.small
text: root.statusLabel()
color: root.failed ? Appearance.colors.colError : Appearance.colors.colSubtext
}
MaterialSymbol {
visible: header.enabled
text: root.expanded ? "expand_less" : "expand_more"
iconSize: Appearance.font.pixelSize.normal
color: Appearance.colors.colSubtext
}
}
}
// A height-capped TextArea is clipped, not scrollable. Put it in a
// real ScrollView so a long grep/build result can be inspected in
// place without expanding one card over the whole conversation.
ScrollView {
id: outputScroll
Layout.fillWidth: true
visible: root.expanded && root.output.length > 0
Layout.preferredHeight: visible ? Math.min(outputEditor.implicitHeight, 240) : 0
clip: true
ScrollBar.vertical.policy: ScrollBar.AsNeeded
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
TextArea {
id: outputEditor
width: outputScroll.availableWidth
readOnly: true
selectByMouse: true
wrapMode: TextEdit.WrapAnywhere
textFormat: TextEdit.PlainText
text: root.output
font.family: Appearance.font.family.monospace
font.pixelSize: Appearance.font.pixelSize.smaller
color: root.failed ? Appearance.colors.colOnErrorContainer : Appearance.colors.colOnLayer2
background: Rectangle {
radius: Appearance.rounding.small / 2
color: Appearance.colors.colLayer1
}
}
}
}
}
}

View file

@ -0,0 +1,221 @@
pragma Singleton
import Quickshell
/*
* The Panel's vocabulary for Souveraine's sensors.
*
* Transcribed from src/ui/chat/tool_renderers.rs — the TUI solved this once
* already, for the same event stream. Where a summary here differs from the
* Rust, the Rust is right and this is a bug.
*
* The vendor snapshot's ToolCallBlock knew 8 tools: bash, read, write, edit,
* grep, glob, list_dir, memory. Those are the tools any coding agent has.
* The 11 it could not name — outfit, nickname, subagent, atmosphere, reach,
* consult, itinerary, todo, schedule, halt, intrusive — are precisely the
* ones that make her someone rather than something. They rendered as
* anonymous grey blocks.
*
* Kinds group by what the act *is*, not by which subsystem implements it:
* shell — she acts on the machine
* sensor — she looks
* file — she changes something durable outside herself
* memory — she changes herself
* presence — how she appears, who she is with
* intent — what she has committed to, where she is in it
* reach — she addresses someone else
* control — she interrupts her own flow
*/
Singleton {
id: root
readonly property var kinds: ({
"bash": "shell",
"read": "sensor",
"glob": "sensor",
"grep": "sensor",
"list_dir": "sensor",
"write": "file",
"edit": "file",
"memory": "memory",
"outfit": "presence",
"atmosphere": "presence",
"nickname": "presence",
"todo": "intent",
"itinerary": "intent",
"schedule": "intent",
"reach": "reach",
"consult": "reach",
"subagent": "reach",
"halt": "control",
"intrusive": "control"
})
readonly property var icons: ({
"bash": "terminal",
"read": "article",
"glob": "folder_open",
"grep": "manage_search",
"list_dir": "folder",
"write": "edit_note",
"edit": "edit_note",
"memory": "psychology",
"outfit": "checkroom",
"atmosphere": "palette",
"nickname": "badge",
"todo": "checklist",
"itinerary": "route",
"schedule": "schedule",
"reach": "hub",
"consult": "forum",
"subagent": "account_tree",
"halt": "pan_tool",
"intrusive": "bolt"
})
function kindOf(name) {
return root.kinds[String(name ?? "")] ?? "unknown";
}
function iconOf(name) {
return root.icons[String(name ?? "")] ?? "sensors";
}
// An unknown tool is a real event, not a defect to hide. It renders as
// itself with a generic icon — and it is legible as unknown, which is how
// a new sensor announces that this file needs a line.
function isKnown(name) {
return root.kinds[String(name ?? "")] !== undefined;
}
function clip(text, max) {
const value = String(text ?? "");
return value.length > max ? value.slice(0, Math.max(0, max - 1)) + "…" : value;
}
function parseArguments(raw) {
try {
const parsed = JSON.parse(String(raw ?? "{}"));
return parsed && typeof parsed === "object" ? parsed : {};
} catch (error) {
return {};
}
}
function str(args, key) {
const value = args[key];
return (typeof value === "string" && value.length > 0) ? value : null;
}
// Every key: value pair, joined. The fallback, matching summarize_generic.
function generic(args) {
const parts = [];
for (const key in args) {
const value = args[key];
const text = (typeof value === "string") ? root.clip(value, 60) : root.clip(JSON.stringify(value), 60);
parts.push(key + ": " + text);
}
return parts.join(" · ");
}
/*
* One line describing what this call actually did.
* Mirrors summarize_tool_args(). Falls through to generic() exactly where
* the Rust does — including for a tool whose expected field is missing,
* which is a real case and must not render as empty.
*/
function summarize(name, rawArguments) {
const args = root.parseArguments(rawArguments);
const tool = String(name ?? "");
switch (tool) {
case "bash": {
const command = root.str(args, "command");
if (command === null) return root.generic(args);
return "$ " + root.clip(command, 80) + (args.run_in_background === true ? " [bg]" : "");
}
case "read": {
const path = root.str(args, "path");
if (path === null) return root.generic(args);
return root.clip(path, 60) + (args.force === true ? " [force]" : "");
}
case "write": {
const path = root.str(args, "path");
if (path === null) return root.generic(args);
const mode = root.str(args, "mode");
return (mode === "append" ? "append → " : "write → ") + root.clip(path, 60);
}
case "edit": {
const path = root.str(args, "path");
if (path === null) return root.generic(args);
return "edit → " + root.clip(path, 60) + (args.replace_all === true ? " [all]" : "");
}
case "grep": {
const pattern = root.str(args, "pattern");
if (pattern === null) return root.generic(args);
const where = root.str(args, "path");
return "\"" + root.clip(pattern, 48) + "\"" + (where !== null ? " in " + root.clip(where, 30) : "");
}
case "glob": {
const pattern = root.str(args, "pattern");
return pattern === null ? root.generic(args) : root.clip(pattern, 80);
}
case "list_dir":
return root.clip(root.str(args, "path") ?? ".", 80);
case "memory": {
const command = root.str(args, "command") ?? "list";
const path = root.str(args, "path");
return path === null ? command : command + " " + root.clip(path, 40);
}
case "todo": {
const action = root.str(args, "action") ?? "list";
if (action === "list") return "list";
const what = root.str(args, "text") ?? root.str(args, "id");
return what === null ? action : action + ": " + root.clip(what, 50);
}
case "itinerary": {
const action = root.str(args, "action") ?? "describe";
let title = root.str(args, "title");
if (title === null && Array.isArray(args.stops) && args.stops.length > 0) {
const first = args.stops[0];
// A stop is an object with a name; older callers passed a bare string.
title = (first && typeof first === "object") ? (first.name ?? null) : (typeof first === "string" ? first : null);
}
return title === null ? action : action + ": " + root.clip(title, 40);
}
case "schedule": {
const action = root.str(args, "action") ?? "list";
const named = root.str(args, "name");
return named === null ? action : action + ": " + root.clip(named, 30);
}
case "nickname": {
const action = root.str(args, "action") ?? "get";
const named = root.str(args, "name");
return named === null ? action : action + ": " + root.clip(named, 30);
}
case "atmosphere":
case "outfit": {
const named = root.str(args, "name");
// An empty name is meaningful for both: it is a return to default.
if (named === null) return (args.name === "") ? "default" : root.generic(args);
return named;
}
case "subagent": {
const prompt = root.str(args, "prompt");
const subType = root.str(args, "subagent_type");
let out = "";
if (args.run_in_background === true) out += "[bg] ";
if (subType !== null && subType !== "general-purpose") out += "[" + subType + "] ";
if (prompt !== null) out += root.clip(prompt, 50);
return out.length > 0 ? out : root.generic(args);
}
case "reach":
case "consult": {
const target = root.str(args, "target");
return target === null ? root.generic(args) : root.clip(target, 40);
}
default:
return root.generic(args);
}
}
}

View file

@ -0,0 +1,7 @@
singleton ToolVocabulary 1.0 ToolVocabulary.qml
ToolCard 1.0 ToolCard.qml
ThinkingCard 1.0 ThinkingCard.qml
AgentMessage 1.0 AgentMessage.qml
AgentPaneMenu 1.0 AgentPaneMenu.qml
ConversationMenu 1.0 ConversationMenu.qml
ItineraryRibbon 1.0 ItineraryRibbon.qml

View file

@ -0,0 +1,333 @@
// The admitted AirPods opening card.
//
// It is a projection, not an event source. sessiond supplies the admitted
// presentation through AccessoryPresentation; this surface never wakes the
// display, asks for input, or renders Personal-class fields while locked.
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import Quickshell.Hyprland
import qs
import qs.services
import qs.modules.common
Scope {
id: scope
property int dismissedGeneration: 0
readonly property bool canShow: AccessoryPresentation.generation > dismissedGeneration
&& GlobalStates.displayActive
&& !GlobalStates.screenLockSecure
// Preserve the focused desktop output under Hyprland. Membrane does not
// publish a selected-output fact yet; on its one-panel device, choosing
// Quickshell's first announced screen is the truthful fallback. Returning
// no screen here creates no PanelWindow at all.
readonly property var presentationScreen: Quickshell.screens.find(
screen => screen.name === Hyprland.focusedMonitor?.name)
?? (Quickshell.screens.length > 0 ? Quickshell.screens[0] : null)
Connections {
target: AccessoryPresentation
function onGenerationChanged() {
// A new admitted edge supersedes the old card. It is not a request
// to wake glass: an inactive display leaves it waiting for nothing.
if (GlobalStates.displayActive && !GlobalStates.screenLockSecure)
closeTimer.restart();
}
}
Timer {
id: closeTimer
interval: 6200
repeat: false
onTriggered: scope.dismissedGeneration = AccessoryPresentation.generation
}
Variants {
model: scope.presentationScreen ? [scope.presentationScreen] : []
PanelWindow {
id: win
required property var modelData
screen: modelData
anchors { top: true; left: true; right: true; bottom: true }
color: "transparent"
visible: scope.canShow || card.opacity > 0
WlrLayershell.namespace: "souveraine:airpods"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
exclusionMode: ExclusionMode.Ignore
Item {
id: card
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
width: Math.min(parent.width - 36, 356)
height: 292
opacity: scope.canShow ? 1 : 0
scale: scope.canShow ? 1 : 0.94
Behavior on opacity { NumberAnimation { duration: 240; easing.type: Easing.OutCubic } }
Behavior on scale { NumberAnimation { duration: 300; easing.type: Easing.OutBack } }
Rectangle {
anchors.fill: parent
radius: 32
color: "#eb15171c"
border.width: 1
border.color: "#3ffffffF"
}
Rectangle {
anchors.fill: parent
anchors.margins: 1
radius: 31
gradient: Gradient {
GradientStop { position: 0; color: "#f42b3039" }
GradientStop { position: 1; color: "#f10f1014" }
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 23
spacing: 0
RowLayout {
Layout.fillWidth: true
Text {
text: AccessoryPresentation.label
color: "#f7f7f8"
font.pixelSize: 20
font.weight: Font.DemiBold
}
Item { Layout.fillWidth: true }
Text {
text: AccessoryPresentation.charging ? "charging" : "nearby"
color: "#aeb5bf"
font.pixelSize: 13
}
}
Item {
id: stage
Layout.fillWidth: true
Layout.preferredHeight: 157
Layout.topMargin: 3
property real open: 0
property int seenGeneration: -1
onVisibleChanged: if (visible) reveal.restart()
onSeenGenerationChanged: reveal.restart()
Connections {
target: AccessoryPresentation
function onGenerationChanged() {
stage.seenGeneration = AccessoryPresentation.generation;
stage.open = 0;
reveal.restart();
}
}
SequentialAnimation {
id: reveal
NumberAnimation { target: stage; property: "open"; to: 1; duration: 620; easing.type: Easing.OutQuart }
}
// Keep the object light, not illustrated. The pool
// gives the white case a place to sit without turning
// the card into a product render.
Rectangle {
width: 218; height: 36; radius: height / 2
anchors.horizontalCenter: parent.horizontalCenter
y: 115
color: "#2c70e8"
opacity: 0.20 * stage.open
scale: 0.76 + 0.24 * stage.open
Behavior on opacity { NumberAnimation { duration: 420 } }
}
Item {
id: caseArt
width: 190; height: 146
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
// The old 3-D lid was a flat white slab in a real
// layer surface. This is deliberately a clear 2-D
// silhouette: lid lifts on its hinge, buds clear
// the cavity, then the front of the case settles.
Item {
id: leftBud
z: 2
width: 31; height: 89
x: 34; y: 39 - 16 * stage.open
opacity: Math.max(0, (stage.open - 0.12) / 0.88)
scale: 0.84 + 0.16 * stage.open
rotation: -5 * stage.open
transformOrigin: Item.Bottom
Rectangle {
width: 31; height: 43; radius: 16
color: "#f7f9fb"
border.width: 1; border.color: "#ffffff"
}
Rectangle {
width: 11; height: 47; radius: 6
x: 10; y: 31
color: "#f7f9fb"
}
Rectangle {
width: 7; height: 3; radius: 2
x: 12; y: 55
color: "#bfc8d1"
}
Rectangle {
width: 6; height: 6; radius: 3
x: 6; y: 17
color: "#d1d8e0"
}
}
Item {
id: rightBud
z: 2
width: 31; height: 89
x: 125; y: 39 - 16 * stage.open
opacity: Math.max(0, (stage.open - 0.12) / 0.88)
scale: 0.84 + 0.16 * stage.open
rotation: 5 * stage.open
transformOrigin: Item.Bottom
Rectangle {
width: 31; height: 43; radius: 16
color: "#f7f9fb"
border.width: 1; border.color: "#ffffff"
}
Rectangle {
width: 11; height: 47; radius: 6
x: 10; y: 31
color: "#f7f9fb"
}
Rectangle {
width: 7; height: 3; radius: 2
x: 12; y: 55
color: "#bfc8d1"
}
Rectangle {
width: 6; height: 6; radius: 3
x: 19; y: 17
color: "#d1d8e0"
}
}
Rectangle {
id: caseBody
z: 4
width: 174; height: 68; radius: 34
x: 8; y: 78
gradient: Gradient {
GradientStop { position: 0; color: "#ffffff" }
GradientStop { position: 1; color: "#e9edf1" }
}
border.width: 1
border.color: "#ffffff"
// A small, shadowed mouth makes the buds read
// as nested in a case rather than pasted on it.
Rectangle {
width: 150; height: 29; radius: 15
x: 12; y: 5
color: "#ccd3db"
opacity: 0.14 + 0.52 * stage.open
}
Rectangle {
width: 146; height: 1; radius: 1
x: 14; y: 28
color: "#c8d0d8"
opacity: 0.55
}
Rectangle {
width: 7; height: 7; radius: 4
anchors.horizontalCenter: parent.horizontalCenter
y: 42
color: "#58d979"
opacity: 0.45 + 0.55 * stage.open
}
}
Rectangle {
id: lid
// It is the rear half of an open case: behind
// the buds, not a white bar painted across
// their faces.
z: 1
width: 166; height: 58 - 17 * stage.open; radius: height / 2
// Closed, the lid meets the front shell. As it
// opens, the hinge rises just enough to leave
// the buds a visible throat of air.
x: 12; y: 78 - 8 * stage.open - height
rotation: -4 * stage.open
transformOrigin: Item.Bottom
gradient: Gradient {
GradientStop { position: 0; color: "#ffffff" }
GradientStop { position: 1; color: "#edf1f5" }
}
border.width: 1
border.color: "#ffffff"
Rectangle {
width: 126; height: 1; radius: 1
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: 2
color: "#d8dee5"
opacity: 0.62
}
Rectangle {
width: 136; height: 13; radius: 7
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: 6
color: "#dbe1e7"
opacity: 0.50
}
}
}
}
RowLayout {
Layout.fillWidth: true
Layout.topMargin: 7
spacing: 8
ChargePill { Layout.fillWidth: true; label: "Left"; level: AccessoryPresentation.leftCharge }
ChargePill { Layout.fillWidth: true; label: "Case"; level: AccessoryPresentation.caseCharge }
ChargePill { Layout.fillWidth: true; label: "Right"; level: AccessoryPresentation.rightCharge }
}
}
}
}
}
component ChargePill: Rectangle {
required property string label
required property int level
implicitHeight: 42
radius: 14
color: "#1bffffff"
border.width: 1
border.color: "#24ffffff"
Column {
anchors.centerIn: parent
spacing: 1
Text { anchors.horizontalCenter: parent.horizontalCenter; text: label; color: "#aeb5bf"; font.pixelSize: 11 }
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: level >= 0 ? level + "%" : "—"
color: level >= 0 ? "#f6f7f8" : "#7e8793"
font.pixelSize: 15
font.weight: Font.DemiBold
}
}
}
}

View file

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

Some files were not shown because too many files have changed in this diff Show more