Panama had grown into three configuration surfaces that only agreed because they had been typed to agree: looks.lua hardcoded values, DesktopPreferences independently defaulted the same values, and SystemSettings replayed them at startup. Nothing kept them in sync, and the Lua side read no shared state at all. This lands the first three stages of docs/superpowers/plans/2026-08-17-panama-cohesion.md. Fix silently failing Hyprland writes. On a Lua-configured Hyprland, hyprctl keyword refuses the write, prints the refusal to stdout, and still exits 0, so the HDR, VRR, and direct-scanout toggles persisted their value and reported success while the compositor never changed. Writes now go through hyprctl eval, which has the same hazard on syntax and runtime errors, so success is defined as reading the value back and finding it equal. The existing contract passed throughout the outage because it re-applied the values already in place; the new one flips each value to something it does not hold. Derive preferences from a schema. Every setting used to be restated four times -- a property alias, a JSON adapter property, a change handler, and a line in reset -- where omitting any one failed silently. PreferenceSchema.qml is now the single source, and persistence, validation, reset, and the Hyprland mapping all derive from it. Unknown keys on disk survive a write so a rollback does not discard a newer build's settings, and a corrupt file falls back to shipped defaults. The store moved to ~/.config/panama/settings.json, migrating from the old state directory without deleting it. Share that file with Hyprland. prefs.lua reads it at config time with every shipped literal kept as the fallback, so the config still stands alone. The Lua is the default, the JSON is the truth, and Settings is the editor. The compositor-adjustable surface goes from 3 keys to 23. Also fixes two test-hygiene bugs found by running the suite end to end for the first time: settings-pages-contract could see the window settings-window-contract leaves behind, and the new write contract was persisting its deliberately-wrong values into the user's real store. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
408 lines
16 KiB
QML
408 lines
16 KiB
QML
//@ pragma IconTheme Adwaita
|
|
//@ pragma DropExpensiveFonts
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Panama :: Quickshell
|
|
//
|
|
// The desktop shell — top bar, dock, overview, quick settings, notifications,
|
|
// screenshot/record UI and power menu. Replaces GNOME Shell plus the Forge,
|
|
// Dash-to-Dock, Openbar, Vitals, AppIndicator, Caffeine and clipboard
|
|
// extensions it was carrying.
|
|
//
|
|
// This file is wiring only. It owns two things and nothing else:
|
|
// 1. Which windows exist, and on which screens.
|
|
// 2. The IPC surface that hypr/keybinds.lua calls into.
|
|
//
|
|
// All shared state lives in services/ShellState.qml, so no module needs a
|
|
// reference to any other module.
|
|
//
|
|
// Layer namespaces set by the modules below are matched by layer rules in
|
|
// hypr/rules.lua (blur, dim, no-screen-share). Keep the two in sync.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import QtQuick
|
|
|
|
import qs.config
|
|
import qs.services
|
|
import qs.modules.bar
|
|
import qs.modules.dock
|
|
import qs.modules.overview
|
|
import qs.modules.quicksettings
|
|
import qs.modules.notifications
|
|
import qs.modules.capture
|
|
import qs.modules.powermenu
|
|
import qs.modules.clipboard
|
|
import qs.modules.datemenu
|
|
import qs.modules.focus
|
|
import qs.modules.signals
|
|
import qs.modules.settings
|
|
|
|
// Clipboard and datemenu ship explicit qmldir manifests because they were
|
|
// added while this daily-driver shell was already running; that also keeps
|
|
// future hot reloads deterministic instead of relying on module synthesis.
|
|
|
|
ShellRoot {
|
|
id: root
|
|
|
|
// ── Per-screen surfaces ─────────────────────────────────────────────────
|
|
// Variants rebuilds these when monitors are added or removed, so plugging
|
|
// in a second display Just Works without a reload.
|
|
//
|
|
// Do NOT redeclare `required property var modelData` on the delegate here:
|
|
// Bar and Dock already declare a plain `modelData` and bind `screen` to it.
|
|
// Redeclaring it shadows theirs, `screen` never resolves, and the windows
|
|
// are constructed but silently never map — with no error logged.
|
|
Variants {
|
|
model: Quickshell.screens
|
|
Bar {
|
|
idleInhibited: Caffeine.enabled
|
|
onRequestQuickSettings: ShellState.toggle("quicksettings")
|
|
}
|
|
}
|
|
|
|
Variants {
|
|
model: Quickshell.screens
|
|
Dock {}
|
|
}
|
|
|
|
Variants {
|
|
model: Quickshell.screens
|
|
SignalGlass {}
|
|
}
|
|
|
|
// ── Single-instance overlays ────────────────────────────────────────────
|
|
// These are always constructed but only *visible* when ShellState says so.
|
|
// They're cheap while hidden, and keeping them alive means opening the
|
|
// overview or quick settings is instant rather than paying a load.
|
|
Overview {}
|
|
// QuickSettings is the window; QuickSettingsPanel is its content.
|
|
QuickSettings { id: quickSettings }
|
|
DateMenu { id: dateMenu }
|
|
ClipboardPanel {}
|
|
CaptureOverlay {}
|
|
IntelligenceResult {}
|
|
ActivityPanel {}
|
|
PowerMenu {}
|
|
SettingsWindow {}
|
|
|
|
// Toasts are their own always-on layer; they must be able to appear
|
|
// without any overlay being open.
|
|
Toasts {}
|
|
|
|
// ── IPC ─────────────────────────────────────────────────────────────────
|
|
// Called from hypr/keybinds.lua as `qs ipc call <target> <function>`.
|
|
//
|
|
// Every parameter AND the return type must be annotated, or Quickshell
|
|
// silently declines to register the function — it will not warn you.
|
|
|
|
IpcHandler {
|
|
target: "overview"
|
|
function toggle(): void { ShellState.toggle("overview"); }
|
|
function open(): void { ShellState.openOverview(0); }
|
|
function search(query: string): void { ShellState.searchOverview(query); }
|
|
function close(): void { ShellState.close(); }
|
|
}
|
|
|
|
IpcHandler {
|
|
target: "focus"
|
|
|
|
function start(): void { FocusSession.startDefault(); }
|
|
function reveal(): void { FocusSession.reveal(); }
|
|
function dismiss(): void { FocusSession.dismiss(); }
|
|
function pause(): void { FocusSession.pauseOrResume(); }
|
|
function workspace(): void { FocusSession.activateWorkspace(); }
|
|
function overview(): void {
|
|
ShellState.openOverview(FocusSession.workspaceId);
|
|
FocusSession.dismiss();
|
|
}
|
|
function end(): void { FocusSession.end(false); }
|
|
function status(): string {
|
|
return JSON.stringify({
|
|
active: FocusSession.active,
|
|
paused: FocusSession.paused,
|
|
capsuleVisible: FocusSession.capsuleVisible,
|
|
workspaceId: FocusSession.workspaceId,
|
|
remaining: FocusSession.remainingText,
|
|
caffeine: Caffeine.enabled,
|
|
doNotDisturb: Notifs.doNotDisturb
|
|
});
|
|
}
|
|
}
|
|
|
|
// A small diagnostics surface doubles as a deterministic contract harness.
|
|
// Real producers call StatusEvents.publish() directly; fixtures never run
|
|
// unless explicitly requested over IPC by the test suite.
|
|
IpcHandler {
|
|
target: "status-events"
|
|
|
|
function screenshot(path: string): void {
|
|
StatusEvents.publish({
|
|
key: "capture-screenshot",
|
|
glyph: "\u{F0100}",
|
|
title: "Screenshot captured",
|
|
detail: path.split("/").pop(),
|
|
tone: "ok",
|
|
priority: StatusEvents.importantPriority,
|
|
actionId: "open-path",
|
|
actionData: path
|
|
});
|
|
}
|
|
|
|
function fixture(name: string): void {
|
|
if (name === "ambient") {
|
|
StatusEvents.publish({
|
|
key: "device-output",
|
|
glyph: "\u{F07E7}",
|
|
title: "USB Audio",
|
|
detail: "Audio output connected",
|
|
priority: StatusEvents.ambientPriority
|
|
});
|
|
} else if (name === "ambient-replacement") {
|
|
StatusEvents.publish({
|
|
key: "device-output",
|
|
glyph: "\u{F07E7}",
|
|
title: "Studio Display",
|
|
detail: "Audio output connected",
|
|
priority: StatusEvents.ambientPriority
|
|
});
|
|
} else if (name === "critical") {
|
|
StatusEvents.publish({
|
|
key: "privacy-microphone",
|
|
glyph: "\u{F036C}",
|
|
title: "Microphone in use",
|
|
detail: "An application is capturing audio",
|
|
tone: "danger",
|
|
priority: StatusEvents.criticalPriority,
|
|
actionId: "open-activity"
|
|
});
|
|
}
|
|
}
|
|
|
|
function dismiss(): void { StatusEvents.dismiss(); }
|
|
function reset(): void { StatusEvents.reset(); }
|
|
function status(): string {
|
|
const event = StatusEvents.activeEvent;
|
|
return JSON.stringify({
|
|
active: StatusEvents.active,
|
|
activeKey: event?.key ?? "",
|
|
title: event?.title ?? "",
|
|
priority: event?.priority ?? 0,
|
|
queueLength: StatusEvents.queueLength
|
|
});
|
|
}
|
|
}
|
|
|
|
IpcHandler {
|
|
target: "activity"
|
|
|
|
function fixture(name: string): void { PrivacyState.applyFixture(name); }
|
|
function clear(): void { PrivacyState.clearFixture(); }
|
|
function open(): void { ShellState.open("activity"); }
|
|
function close(): void { ShellState.close(); }
|
|
function toggle(): void { ShellState.toggle("activity"); }
|
|
function stopRecording(): void { Capture.stopRecording(); }
|
|
function status(): string {
|
|
return JSON.stringify({
|
|
microphoneActive: PrivacyState.microphoneActive,
|
|
cameraActive: PrivacyState.cameraActive,
|
|
screenSharingActive: PrivacyState.screenSharingActive,
|
|
recordingActive: PrivacyState.recordingActive,
|
|
activeKinds: PrivacyState.activeKinds
|
|
});
|
|
}
|
|
}
|
|
|
|
// Referencing the transition monitor here ensures the singleton is alive
|
|
// even before any quick-settings device list has been opened.
|
|
Connections { target: DeviceEvents }
|
|
|
|
IpcHandler {
|
|
target: "quicksettings"
|
|
function toggle(): void { ShellState.toggle("quicksettings"); }
|
|
function open(): void { ShellState.open("quicksettings"); }
|
|
function close(): void { ShellState.close(); }
|
|
function section(name: string): void { quickSettings.expand(name); }
|
|
function status(): string {
|
|
return JSON.stringify({
|
|
open: ShellState.quickSettingsOpen,
|
|
expandedSection: quickSettings.expandedSection
|
|
});
|
|
}
|
|
}
|
|
|
|
IpcHandler {
|
|
target: "kdeconnect"
|
|
function fixture(name: string): void { KdeConnect.applyFixture(name); }
|
|
function reset(): void { KdeConnect.clearFixture(); }
|
|
function refresh(): void { KdeConnect.refresh(); }
|
|
function cancel(): void { KdeConnect.cancelTransfer(); }
|
|
function status(): string {
|
|
return JSON.stringify({
|
|
fixture: KdeConnect.fixtureMode,
|
|
available: KdeConnect.available,
|
|
reachable: KdeConnect.phoneReachable,
|
|
pairedCount: KdeConnect.pairedCount,
|
|
actionCount: KdeConnect.phoneActions.length,
|
|
transferActive: KdeConnect.transferActive,
|
|
transferFileName: KdeConnect.transferFileName,
|
|
ongoingCount: Ongoing.activities.filter(item => item.kind === "phone-transfer").length,
|
|
lastError: KdeConnect.lastError
|
|
});
|
|
}
|
|
}
|
|
|
|
IpcHandler {
|
|
target: "home-assistant"
|
|
function fixture(name: string): void { HomeAssistant.applyFixture(name); }
|
|
function reset(): void { HomeAssistant.clearFixture(); }
|
|
function refresh(): void { HomeAssistant.refresh(); }
|
|
function status(): string {
|
|
return JSON.stringify({
|
|
fixture: HomeAssistant.fixtureMode,
|
|
phase: HomeAssistant.phase,
|
|
configuredCount: HomeAssistant.configuredCount,
|
|
visibleCount: HomeAssistant.visibleEntities.length,
|
|
stale: HomeAssistant.stale,
|
|
busy: HomeAssistant.busyEntityId !== "",
|
|
lastError: HomeAssistant.lastError
|
|
});
|
|
}
|
|
}
|
|
|
|
IpcHandler {
|
|
target: "settings"
|
|
function open(): void { ShellState.openSettings(DesktopPreferences.get("lastPage") || "home"); }
|
|
function toggle(): void { ShellState.toggleSettings(); }
|
|
function close(): void { ShellState.closeSettings(); }
|
|
function page(name: string): void { ShellState.openSettings(name); }
|
|
function status(): string {
|
|
return JSON.stringify({ open: ShellState.settingsOpen, page: ShellState.settingsPage });
|
|
}
|
|
}
|
|
|
|
IpcHandler {
|
|
target: "settings-system"
|
|
function refresh(): void { SystemSettings.refresh(); }
|
|
function status(): string {
|
|
return JSON.stringify({
|
|
monitor: SystemSettings.monitorName,
|
|
width: SystemSettings.monitorWidth,
|
|
height: SystemSettings.monitorHeight,
|
|
autoHdr: SystemSettings.autoHdr,
|
|
vrrPolicy: SystemSettings.vrrPolicy,
|
|
directScanoutPolicy: SystemSettings.directScanoutPolicy,
|
|
busy: SystemSettings.busy,
|
|
lastError: SystemSettings.lastError
|
|
});
|
|
}
|
|
}
|
|
|
|
IpcHandler {
|
|
target: "notifications"
|
|
function toggle(): void { ShellState.toggleDateMenu("notifications"); }
|
|
function open(): void { ShellState.openDateMenu("notifications"); }
|
|
function clear(): void { Notifs.dismissAll(); }
|
|
function dnd(): bool {
|
|
Notifs.doNotDisturb = !Notifs.doNotDisturb;
|
|
return Notifs.doNotDisturb;
|
|
}
|
|
}
|
|
|
|
IpcHandler {
|
|
target: "calendar-agenda"
|
|
function fixture(name: string): void { CalendarAgenda.applyFixture(name); }
|
|
function reset(): void { CalendarAgenda.clearFixture(); }
|
|
function refresh(): void { CalendarAgenda.refresh(); }
|
|
function open(): void { ShellState.openDateMenu("agenda"); }
|
|
function page(name: string): void { ShellState.openDateMenu(name); }
|
|
function close(): void { ShellState.close(); }
|
|
function select(date: string): void { CalendarAgenda.selectIsoDate(date); }
|
|
function status(): string {
|
|
return JSON.stringify({
|
|
phase: CalendarAgenda.phase,
|
|
fixture: CalendarAgenda.fixtureMode,
|
|
open: ShellState.notificationsOpen,
|
|
page: ShellState.dateMenuPage,
|
|
sourceCount: CalendarAgenda.sources.length,
|
|
eventCount: CalendarAgenda.events.length,
|
|
selectedDate: CalendarAgenda.selectedDateKey,
|
|
selectedEventCount: CalendarAgenda.selectedEvents.length,
|
|
capsuleVisible: CalendarAgenda.capsuleVisible,
|
|
capsuleText: CalendarAgenda.capsuleText,
|
|
ongoingCount: Ongoing.count,
|
|
ongoingFits: dateMenu.ongoingContentFits
|
|
});
|
|
}
|
|
}
|
|
|
|
IpcHandler {
|
|
target: "clipboard"
|
|
function toggle(): void { ShellState.toggle("clipboard"); }
|
|
function open(): void { ShellState.open("clipboard"); }
|
|
}
|
|
|
|
IpcHandler {
|
|
target: "powermenu"
|
|
function toggle(): void { ShellState.toggle("powermenu"); }
|
|
}
|
|
|
|
IpcHandler {
|
|
target: "capture"
|
|
// Pressing Print while recording stops the recording instead of
|
|
// opening the picker again — otherwise there's no way to stop it
|
|
// without the indicator.
|
|
function open(): void {
|
|
if (Capture.recording)
|
|
Capture.stopRecording();
|
|
else
|
|
Capture.open();
|
|
}
|
|
function screenNow(): void { Capture.screenNow(); }
|
|
function windowNow(): void { Capture.windowNow(); }
|
|
function stop(): void { Capture.stopRecording(); }
|
|
function close(): void { Capture.close(); }
|
|
function status(): string {
|
|
return JSON.stringify({
|
|
open: ShellState.captureOpen,
|
|
mode: Capture.mode,
|
|
recordMode: Capture.recordMode,
|
|
intelligenceMode: Capture.intelligenceMode
|
|
});
|
|
}
|
|
}
|
|
|
|
IpcHandler {
|
|
target: "screen-intelligence"
|
|
function open(): void { Capture.openIntelligence(); }
|
|
function close(): void { ScreenIntelligence.close(); }
|
|
function analyzeFile(path: string): void { ScreenIntelligence.analyzeFile(path); }
|
|
function copy(): void { ScreenIntelligence.copyText(); }
|
|
function status(): string {
|
|
return JSON.stringify({
|
|
phase: ScreenIntelligence.phase,
|
|
ocrReady: ScreenIntelligence.ocrReady,
|
|
codeReady: ScreenIntelligence.codeReady,
|
|
englishReady: ScreenIntelligence.englishReady,
|
|
text: ScreenIntelligence.text,
|
|
codeType: ScreenIntelligence.codeType,
|
|
codeValue: ScreenIntelligence.codeValue,
|
|
error: ScreenIntelligence.error
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── Diagnostics ─────────────────────────────────────────────────────────
|
|
// Quickshell pops a reload notice on every hot reload, which is noise when
|
|
// you're editing. Failures still surface in the log.
|
|
Connections {
|
|
target: Quickshell
|
|
function onReloadCompleted(): void { Quickshell.inhibitReloadPopup(); }
|
|
function onReloadFailed(error: string): void {
|
|
console.warn("Panama shell reload failed:", error);
|
|
Quickshell.inhibitReloadPopup();
|
|
}
|
|
}
|
|
}
|