diff --git a/config/dot/hypr/autostart.lua b/config/dot/hypr/autostart.lua
index fa217f4..f495b77 100644
--- a/config/dot/hypr/autostart.lua
+++ b/config/dot/hypr/autostart.lua
@@ -57,8 +57,12 @@ hl.on("hyprland.start", function()
hl.exec_cmd("systemctl --user start espanso.service")
-- The shell: bar, dock, overview, quick settings, notifications, capture.
- -- No systemd unit ships with quickshell, so it runs as a compositor child.
- hl.exec_cmd("quickshell --daemonize")
+ -- A supervised unit rather than a compositor child, for the one property
+ -- a child cannot have: if the shell crashes, systemd restarts it in two
+ -- seconds instead of leaving a desktop with no bar until someone knows
+ -- what to type. The unit ships in config/local/share/systemd/user and is
+ -- started per-session here like every other Panama unit.
+ hl.exec_cmd("systemctl --user start panama-quickshell.service")
-- Removable-media automounting. GNOME did this invisibly via gvfs+udisks;
-- outside GNOME something has to ask udisks to mount. No tray icon: the
diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml
index aa12d50..e2cc491 100644
--- a/config/dot/quickshell/config/PreferenceSchema.qml
+++ b/config/dot/quickshell/config/PreferenceSchema.qml
@@ -1409,6 +1409,22 @@ Singleton {
label: "Recording folder",
detail: "Where screen recordings are saved. Relative to your home folder unless it starts with /"
},
+ // What a recording hears. Off by default, which is GNOME's default
+ // too -- a screencast that silently captured the microphone would be
+ // a privacy incident, not a feature. The @DEFAULT_*@ tokens are
+ // PulseAudio's own always-current aliases, so the recording follows
+ // the device Sound settings has selected rather than naming one.
+ {
+ key: "recorderAudio", type: "enum", def: "none", group: "capture",
+ label: "Recording audio",
+ detail: "What screen recordings capture alongside the video",
+ options: [
+ { value: "none", label: "No audio" },
+ { value: "system", label: "System audio" },
+ { value: "microphone", label: "Microphone" }
+ ]
+ },
+
// "auto" stands in for the render node until record time:
// /dev/dri/renderD128 was baked into every option once, which is one
// machine's enumeration and frequently the wrong node on hybrid
diff --git a/config/dot/quickshell/config/Settings.qml b/config/dot/quickshell/config/Settings.qml
index 8a3f001..cbba360 100644
--- a/config/dot/quickshell/config/Settings.qml
+++ b/config/dot/quickshell/config/Settings.qml
@@ -96,4 +96,5 @@ Singleton {
// Passed to wf-recorder. Uses VAAPI on the AMD card so recording does not
// cost CPU while gaming.
readonly property string recorderArgs: DesktopPreferences.get("recorderArgs")
+ readonly property string recorderAudio: DesktopPreferences.get("recorderAudio")
}
diff --git a/config/dot/quickshell/modules/bar/StatusCluster.qml b/config/dot/quickshell/modules/bar/StatusCluster.qml
index b95ed7a..7f59584 100644
--- a/config/dot/quickshell/modules/bar/StatusCluster.qml
+++ b/config/dot/quickshell/modules/bar/StatusCluster.qml
@@ -84,6 +84,20 @@ Pill {
}
}
+ // The active layout, only where there is a choice to indicate: a machine
+ // with one layout knows what its keys say. GNOME shows the same short
+ // code in the same corner, which is the muscle memory this preserves.
+ Text {
+ visible: KeyboardLayout.multiple
+ anchors.verticalCenter: parent.verticalCenter
+ text: KeyboardLayout.shortLabel
+ color: Theme.fg
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ font.letterSpacing: 0.5
+ }
+
// A tunnel that is up changes what every connection means, so it earns a
// permanent glyph while active -- and its absence is the resting state,
// same shape as Bluetooth below.
diff --git a/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml b/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml
index 251b007..c7be662 100644
--- a/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml
+++ b/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml
@@ -170,6 +170,18 @@ Item {
onToggled: Caffeine.toggle()
}
+ // Caffeine plus Do Not Disturb as one switch, for the projector:
+ // the half you forget to arm is the one that fires a message
+ // preview onto the big screen. Restores both exactly as found.
+ Toggle {
+ width: root.cellWidth
+ icon: "video-display-symbolic"
+ label: "Presentation"
+ sublabel: PresentationMode.active ? "Awake ยท notifications held" : "Off"
+ active: PresentationMode.active
+ onToggled: PresentationMode.toggle()
+ }
+
Toggle {
width: root.cellWidth
icon: ColorScheme.dark ? "weather-clear-night-symbolic" : "weather-clear-symbolic"
diff --git a/config/dot/quickshell/modules/settings/ScreenIntelligencePage.qml b/config/dot/quickshell/modules/settings/ScreenIntelligencePage.qml
index e68f74c..75c53cd 100644
--- a/config/dot/quickshell/modules/settings/ScreenIntelligencePage.qml
+++ b/config/dot/quickshell/modules/settings/ScreenIntelligencePage.qml
@@ -47,7 +47,8 @@ SettingsPage {
TextEntryRow { setting: "screenshotDir"; placeholder: "Pictures/Screenshots" }
TextEntryRow { setting: "recordingDir"; placeholder: "Videos/Screencasts" }
- ChoiceRow { setting: "recorderArgs"; divider: false }
+ ChoiceRow { setting: "recorderArgs" }
+ ChoiceRow { setting: "recorderAudio"; divider: false }
}
SettingsCard {
diff --git a/config/dot/quickshell/services/Capture.qml b/config/dot/quickshell/services/Capture.qml
index 92aa89e..6070dbc 100644
--- a/config/dot/quickshell/services/Capture.qml
+++ b/config/dot/quickshell/services/Capture.qml
@@ -226,6 +226,13 @@ Singleton {
const args = Settings.recorderArgs.split(" ").filter(a => a !== "")
.map(a => a === "auto" ? (root.renderNode || "/dev/dri/renderD128") : a);
cmd = cmd.concat(args);
+ // Audio rides along per the recorderAudio preference. The tokens are
+ // resolved by the pulse layer at record time, so the capture follows
+ // whatever device is currently the default.
+ if (Settings.recorderAudio === "system")
+ cmd.push("--audio=@DEFAULT_MONITOR@");
+ else if (Settings.recorderAudio === "microphone")
+ cmd.push("--audio=@DEFAULT_SOURCE@");
if (geom !== "")
cmd.push("-g", geom);
else if (root._outputName !== "")
diff --git a/config/dot/quickshell/services/KeyboardLayout.qml b/config/dot/quickshell/services/KeyboardLayout.qml
new file mode 100644
index 0000000..7bb670c
--- /dev/null
+++ b/config/dot/quickshell/services/KeyboardLayout.qml
@@ -0,0 +1,109 @@
+pragma Singleton
+
+// The active keyboard layout, for the bar.
+//
+// Only interesting on a machine with more than one layout configured -- a
+// single-layout machine gets no indicator at all, which is GNOME's behavior
+// too. "More than one" is read straight from the keyboardLayout preference,
+// the same value input.lua feeds the compositor, so no probe can disagree
+// with the config.
+//
+// Hyprland reports the active keymap by DESCRIPTION ("English (US)"), and
+// the bar wants the short code ("us"). Deriving one from the other by
+// truncation gives wrong answers immediately -- "German" is de, not "ge" --
+// so the mapping comes from xkb's own registry (evdev.lst), the file every
+// layout chooser is built from. Parsed once; it changes when xkeyboard-config
+// updates, which is to say between logins.
+
+import Quickshell
+import Quickshell.Io
+import Quickshell.Hyprland
+import QtQuick
+import qs.config
+
+Singleton {
+ id: root
+
+ readonly property var configured: String(DesktopPreferences.get("keyboardLayout"))
+ .split(",").map(code => code.trim()).filter(code => code !== "")
+ readonly property bool multiple: root.configured.length > 1
+
+ // "English (US)", as Hyprland reports it.
+ property string activeKeymap: ""
+
+ // description -> code, from evdev.lst's layout and variant sections.
+ property var codeByDescription: ({})
+
+ readonly property string shortLabel: {
+ const mapped = root.codeByDescription[root.activeKeymap];
+ if (mapped)
+ return mapped;
+ // Registry miss (a custom keymap): fall back to the first configured
+ // code, which at least names something real on this machine.
+ return root.configured[0] ?? "";
+ }
+
+ function refresh(): void {
+ if (!query.running)
+ query.running = true;
+ }
+
+ Process {
+ id: query
+ command: ["hyprctl", "-j", "devices"]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try {
+ const keyboards = JSON.parse(this.text).keyboards ?? [];
+ const main = keyboards.find(keyboard => keyboard.main) ?? keyboards[0];
+ root.activeKeymap = String(main?.active_keymap ?? "");
+ } catch (error) {
+ console.warn("KeyboardLayout: could not parse hyprctl devices:", error);
+ }
+ }
+ }
+ }
+
+ FileView {
+ path: "/usr/share/X11/xkb/rules/evdev.lst"
+ blockLoading: false
+ printErrors: false
+ onLoaded: {
+ const map = {};
+ let section = "";
+ for (const line of this.text().split("\n")) {
+ if (line.startsWith("!")) {
+ section = line.slice(1).trim();
+ continue;
+ }
+ const trimmed = line.trim();
+ if (trimmed === "")
+ continue;
+ if (section === "layout") {
+ // " us English (US)"
+ const m = /^(\S+)\s+(.+)$/.exec(trimmed);
+ if (m)
+ map[m[2].trim()] = m[1];
+ } else if (section === "variant") {
+ // " dvorak us: English (Dvorak)"
+ const m = /^\S+\s+(\S+):\s+(.+)$/.exec(trimmed);
+ if (m)
+ map[m[2].trim()] = m[1];
+ }
+ }
+ root.codeByDescription = map;
+ }
+ }
+
+ // activelayout fires on every switch; configreloaded catches a layout
+ // list changed from Settings.
+ Connections {
+ target: Hyprland
+ function onRawEvent(event: var): void {
+ if (event.name === "activelayout" || event.name === "configreloaded")
+ root.refresh();
+ }
+ }
+
+ Component.onCompleted: root.refresh()
+}
diff --git a/config/dot/quickshell/services/PresentationMode.qml b/config/dot/quickshell/services/PresentationMode.qml
new file mode 100644
index 0000000..e296669
--- /dev/null
+++ b/config/dot/quickshell/services/PresentationMode.qml
@@ -0,0 +1,47 @@
+pragma Singleton
+
+// Presentation mode: the screen stays on and notifications hold, as one
+// switch.
+//
+// Both halves already exist -- Caffeine inhibits idle, Do Not Disturb quiets
+// the toasts -- but a person plugging into a projector had to remember to arm
+// both, every time, and the one they forgot was the one that fired a message
+// preview onto the big screen. Same record-and-restore idiom as FocusSession:
+// turning presentation off puts each half back exactly the way it was found,
+// so a caffeine you had on for your own reasons survives the meeting.
+
+import Quickshell
+import QtQuick
+
+Singleton {
+ id: root
+
+ property bool active: false
+ property bool previousCaffeine: false
+ property bool previousDnd: false
+
+ function toggle(): void {
+ if (root.active)
+ root.stop();
+ else
+ root.start();
+ }
+
+ function start(): void {
+ if (root.active)
+ return;
+ root.previousCaffeine = Caffeine.enabled;
+ root.previousDnd = Notifs.doNotDisturb;
+ Caffeine.enabled = true;
+ Notifs.doNotDisturb = true;
+ root.active = true;
+ }
+
+ function stop(): void {
+ if (!root.active)
+ return;
+ Caffeine.enabled = root.previousCaffeine;
+ Notifs.doNotDisturb = root.previousDnd;
+ root.active = false;
+ }
+}
diff --git a/config/local/share/systemd/user/panama-quickshell.service b/config/local/share/systemd/user/panama-quickshell.service
new file mode 100644
index 0000000..276bbf9
--- /dev/null
+++ b/config/local/share/systemd/user/panama-quickshell.service
@@ -0,0 +1,24 @@
+[Unit]
+Description=The Panama shell (Quickshell)
+Documentation=https://github.com/gibbyb/Panama
+# Started per-session by hypr/autostart.lua, never enabled globally --
+# graphical-session.target is active under GNOME too, and two shells fighting
+# over org.freedesktop.Notifications is the exact bug the notification-daemon
+# warning in change-settings exists to prevent.
+#
+# The unit exists for one property a compositor child cannot have: the shell
+# comes BACK. Quickshell segfaulted mid-session (a Qt image-teardown bug, not
+# this configuration) and the desktop stayed bar-less until someone noticed
+# and knew what to type. Restart=on-failure turns that into a two-second
+# flicker -- and panama-crash-watch's report of the crash finally has a
+# notification server to arrive on, because the restarted shell is serving
+# again by the time the watcher's bus wait comes around.
+PartOf=graphical-session.target
+StartLimitIntervalSec=120
+StartLimitBurst=5
+
+[Service]
+Type=simple
+ExecStart=/usr/bin/quickshell
+Restart=on-failure
+RestartSec=2
diff --git a/config/local/share/vicinae/scripts/settings-screen-intelligence b/config/local/share/vicinae/scripts/settings-screen-intelligence
index f7c7cc6..92e1787 100755
--- a/config/local/share/vicinae/scripts/settings-screen-intelligence
+++ b/config/local/share/vicinae/scripts/settings-screen-intelligence
@@ -5,6 +5,6 @@
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Screen Intelligence in Settings.
-# @vicinae.keywords ["settings", "screenshot folder", "recording folder", "recording encoder"]
+# @vicinae.keywords ["settings", "screenshot folder", "recording folder", "recording audio", "recording encoder"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page screen-intelligence
diff --git a/docs/settings.md b/docs/settings.md
index 9d5aeca..d4a4392 100644
--- a/docs/settings.md
+++ b/docs/settings.md
@@ -4,7 +4,7 @@
Do not edit this file. Run `quickshell/scripts/panama-settings-docs`
after changing the schema; a contract fails when this copy is stale.
-150 settings across 30 groups. 70 of them are applied to the compositor and confirmed by reading the value back.
+151 settings across 30 groups. 70 of them are applied to the compositor and confirmed by reading the value back.
## accessibility
@@ -48,6 +48,7 @@ Found on **Screen Intelligence**.
|---|---|---|
| **Screenshot folder**
`screenshotDir` | Pictures/Screenshots | Where screenshots are saved. Relative to your home folder unless it starts with / |
| **Recording folder**
`recordingDir` | Videos/Screencasts | Where screen recordings are saved. Relative to your home folder unless it starts with / |
+| **Recording audio**
`recorderAudio` | none | What screen recordings capture alongside the video Choices: No audio, System audio, Microphone. |
| **Recording encoder**
`recorderArgs` | -c h264_vaapi -d auto | Hardware encoding keeps recording off the processor while gaming Choices: VAAPI H.264, VAAPI HEVC, CPU x264. |
## clock
diff --git a/tests/quickshell/focus-session-expiry b/tests/quickshell/focus-session-expiry
index f9efa7a..5b18928 100755
--- a/tests/quickshell/focus-session-expiry
+++ b/tests/quickshell/focus-session-expiry
@@ -37,15 +37,23 @@ jq '.paused = false | .deadlineMs = 1' "$state_file" >"$expired"
chmod --reference="$state_file" "$expired"
mv "$expired" "$state_file"
-qs kill >/dev/null
-for _ in $(seq 1 50); do
- qs list 2>&1 | rg -q '^Instance ' || break
- sleep 0.1
-done
-if qs list 2>&1 | rg -q '^Instance '; then
- fail 'shell did not stop before restart'
+# Restart the shell the way it is actually running. Under supervision
+# (panama-quickshell.service), `qs kill` + a bare relaunch would race the
+# unit's own Restart=on-failure and leave two shells fighting over the bus;
+# unsupervised, the unit path does not exist and the old dance is right.
+if systemctl --user is-active -q panama-quickshell.service; then
+ systemctl --user restart panama-quickshell.service
+else
+ qs kill >/dev/null
+ for _ in $(seq 1 50); do
+ qs list 2>&1 | rg -q '^Instance ' || break
+ sleep 0.1
+ done
+ if qs list 2>&1 | rg -q '^Instance '; then
+ fail 'shell did not stop before restart'
+ fi
+ quickshell --daemonize >/dev/null
fi
-quickshell --daemonize >/dev/null
status=""
for _ in $(seq 1 60); do
diff --git a/tests/quickshell/focus-session-restart b/tests/quickshell/focus-session-restart
index 6bed0ce..e6c776c 100755
--- a/tests/quickshell/focus-session-restart
+++ b/tests/quickshell/focus-session-restart
@@ -17,15 +17,23 @@ qs ipc call focus start >/dev/null
qs ipc call focus pause >/dev/null
before=$(qs ipc call focus status)
-qs kill >/dev/null
-for _ in $(seq 1 50); do
- qs list 2>&1 | rg -q '^Instance ' || break
- sleep 0.1
-done
-if qs list 2>&1 | rg -q '^Instance '; then
- fail 'shell did not stop before restart'
+# Restart the shell the way it is actually running. Under supervision
+# (panama-quickshell.service), `qs kill` + a bare relaunch would race the
+# unit's own Restart=on-failure and leave two shells fighting over the bus;
+# unsupervised, the unit path does not exist and the old dance is right.
+if systemctl --user is-active -q panama-quickshell.service; then
+ systemctl --user restart panama-quickshell.service
+else
+ qs kill >/dev/null
+ for _ in $(seq 1 50); do
+ qs list 2>&1 | rg -q '^Instance ' || break
+ sleep 0.1
+ done
+ if qs list 2>&1 | rg -q '^Instance '; then
+ fail 'shell did not stop before restart'
+ fi
+ quickshell --daemonize >/dev/null
fi
-quickshell --daemonize >/dev/null
status=""
for _ in $(seq 1 60); do