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