Merge branch 'quattro' into unified-input-method-setup
This commit is contained in:
commit
101bb547bb
245 files changed
+19949
-375
No files matched your search
@@ -34,6 +34,7 @@ User-installed plugins live alongside these conceptually but on disk under
|
||||
| Battery | `omarchy.battery` | `service` | `services/battery/Service.qml` |
|
||||
| Idle | `omarchy.idle` | `service` | `services/idle/Service.qml` |
|
||||
| Night light | `omarchy.nightlight` | `service` | `services/nightlight/Service.qml` |
|
||||
| Remote session | `omarchy.remote-session` | `service` | `services/remote-session/Service.qml` |
|
||||
| Lock screen | `omarchy.lock` | `service` | `lock/Service.qml` |
|
||||
| OSD | `omarchy.osd` | `panel` | `osd/Osd.qml` |
|
||||
| Polkit agent | `omarchy.polkit` | `service` | `polkit/PolkitAgent.qml` |
|
||||
|
||||
+105
-11
@@ -569,6 +569,61 @@ Item {
|
||||
readonly property bool vertical: position === "left" || position === "right"
|
||||
readonly property int barSize: vertical ? Style.bar.sizeVertical : Style.bar.sizeHorizontal
|
||||
|
||||
// Display cutouts (a camera notch) the platform's own package describes in
|
||||
// the fixed platform root, which no environment variable moves. Most machines
|
||||
// have none. It is read as the shell starts (blockLoading), so a bar surface
|
||||
// knows before it maps whether its screen may have one. See
|
||||
// BarModel.parseCutouts.
|
||||
readonly property var displayCutouts: displayCutoutsFile.missing ? [] : BarModel.parseCutouts(displayCutoutsFile.text())
|
||||
FileView {
|
||||
id: displayCutoutsFile
|
||||
|
||||
// A file that went away describes nothing, whatever text() still holds.
|
||||
property bool missing: false
|
||||
|
||||
path: "/usr/share/omarchy-platform/display-cutouts.json"
|
||||
blockLoading: true
|
||||
watchChanges: true
|
||||
printErrors: false
|
||||
onLoaded: missing = false
|
||||
onLoadFailed: missing = true
|
||||
onFileChanged: reload()
|
||||
}
|
||||
|
||||
// The physical mode Hyprland reports for a screen, as hyprctl monitors does.
|
||||
// Qt's whole-number devicePixelRatio can't rebuild it at a fractional scale.
|
||||
function panelModeFor(screen) {
|
||||
var monitor = screen ? Hyprland.monitorFor(screen) : null
|
||||
return monitor ? ({
|
||||
width: monitor.width,
|
||||
height: monitor.height,
|
||||
transform: monitor.lastIpcObject ? monitor.lastIpcObject.transform : 0
|
||||
}) : null
|
||||
}
|
||||
|
||||
// How thick the bar is on a screen. A top bar shorter than a panel's camera
|
||||
// cutout leaves a sliver of every window peeking out beside the camera, so
|
||||
// the cutout is this panel's minimum sensible top-bar height. An
|
||||
// intentionally taller bar still wins.
|
||||
function thicknessFor(screen) {
|
||||
if (root.vertical || !screen) return root.barSize
|
||||
return Math.max(root.barSize, BarModel.notchFloor(root.displayCutouts, root.position, screen.name, screen.width, screen.height, screen.devicePixelRatio, root.panelModeFor(screen), Style.bar.notchHeight))
|
||||
}
|
||||
|
||||
// Each screen's bar thickness, by screen name: barSize, or more where a notch
|
||||
// floor raises a top bar. Toasts and plugins clear the bar by it; barSize
|
||||
// stays the configured size the widgets are drawn at.
|
||||
readonly property var screenBarSizes: {
|
||||
var sizes = ({})
|
||||
var screens = Quickshell.screens
|
||||
for (var i = 0; i < screens.length; i++) sizes[screens[i].name] = root.thicknessFor(screens[i])
|
||||
return sizes
|
||||
}
|
||||
|
||||
function barSizeFor(screenName) {
|
||||
return BarModel.barSizeFor(root.screenBarSizes, screenName, root.barSize)
|
||||
}
|
||||
|
||||
function normalizePosition(value) {
|
||||
return BarModel.normalizePosition(value)
|
||||
}
|
||||
@@ -1253,7 +1308,12 @@ Item {
|
||||
// reveal has to rebuild them — new surface, re-shaped glyphs, re-uploaded
|
||||
// textures — which measures ~150ms against ~20ms to tear down. Parking
|
||||
// keeps the surface alive, so showing is only a margin change.
|
||||
visible: !remapGuard.remapping
|
||||
//
|
||||
// A top bar that can't tell its notch floor yet waits for Hyprland's mode
|
||||
// before it maps (see BarModel.cutoutPending), so it maps at its floor
|
||||
// instead of growing once the windows are laid out. The wait ends once, for
|
||||
// good: when the mode settles it, or after two seconds without one.
|
||||
visible: !remapGuard.remapping && (cutoutWaitOver || !cutoutPending)
|
||||
exclusionMode: root.barHidden ? ExclusionMode.Ignore : ExclusionMode.Auto
|
||||
|
||||
ScreenMoveRemap {
|
||||
@@ -1261,11 +1321,13 @@ Item {
|
||||
window: barWindow
|
||||
}
|
||||
|
||||
// Parked by its full thickness, which a notch floor may make more than
|
||||
// barSize, so no strip of it stays on screen.
|
||||
margins {
|
||||
top: root.barHidden && root.position === "top" ? -root.barSize : 0
|
||||
bottom: root.barHidden && root.position === "bottom" ? -root.barSize : 0
|
||||
left: root.barHidden && root.position === "left" ? -root.barSize : 0
|
||||
right: root.barHidden && root.position === "right" ? -root.barSize : 0
|
||||
top: root.barHidden && root.position === "top" ? -barWindow.thickness : 0
|
||||
bottom: root.barHidden && root.position === "bottom" ? -barWindow.thickness : 0
|
||||
left: root.barHidden && root.position === "left" ? -barWindow.thickness : 0
|
||||
right: root.barHidden && root.position === "right" ? -barWindow.thickness : 0
|
||||
}
|
||||
|
||||
anchors {
|
||||
@@ -1275,8 +1337,26 @@ Item {
|
||||
right: root.position === "right" || !root.vertical
|
||||
}
|
||||
|
||||
implicitWidth: root.vertical ? root.barSize : 0
|
||||
implicitHeight: root.vertical ? 0 : root.barSize
|
||||
readonly property var panelMode: root.panelModeFor(screen)
|
||||
|
||||
readonly property bool centerBesideRight: BarModel.centerBesideRight(root.displayCutouts, root.position, screen.name, screen.width, screen.height, screen.devicePixelRatio, panelMode)
|
||||
|
||||
readonly property bool cutoutPending: BarModel.cutoutPending(root.displayCutouts, root.position, screen.name, screen.width, screen.height, screen.devicePixelRatio, panelMode)
|
||||
property bool cutoutWaitOver: false
|
||||
onCutoutPendingChanged: if (!cutoutPending) cutoutWaitOver = true
|
||||
Component.onCompleted: if (!cutoutPending) cutoutWaitOver = true
|
||||
|
||||
Timer {
|
||||
interval: 2000
|
||||
running: barWindow.cutoutPending && !barWindow.cutoutWaitOver
|
||||
onTriggered: barWindow.cutoutWaitOver = true
|
||||
}
|
||||
|
||||
// The same thickness the bar publishes for its screen (screenBarSizes).
|
||||
readonly property int thickness: root.thicknessFor(screen)
|
||||
|
||||
implicitWidth: root.vertical ? thickness : 0
|
||||
implicitHeight: root.vertical ? 0 : thickness
|
||||
color: root.transparent ? "transparent" : root.background
|
||||
surfaceFormat.opaque: false
|
||||
WlrLayershell.namespace: "omarchy-bar"
|
||||
@@ -1367,7 +1447,10 @@ Item {
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
|
||||
CenterModules { anchors.fill: parent }
|
||||
CenterModules {
|
||||
anchors.fill: parent
|
||||
entries: barWindow.centerBesideRight ? [] : root.layoutEntries("center")
|
||||
}
|
||||
|
||||
LeftModules {
|
||||
anchors.left: parent.left
|
||||
@@ -1376,10 +1459,22 @@ Item {
|
||||
}
|
||||
|
||||
RightModules {
|
||||
id: rightModules
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Style.space(8)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
// Keeps the center region, so settings pushes, drag and drop, and panel
|
||||
// routing still address it. The gap lets a drop at the seam land in
|
||||
// the section the pointer is nearer to.
|
||||
ModuleList {
|
||||
entries: barWindow.centerBesideRight ? root.layoutEntries("center") : []
|
||||
region: "center"
|
||||
anchors.right: rightModules.left
|
||||
anchors.rightMargin: Style.space(4)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1529,8 +1624,7 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
function findCenterAnchorEntry() {
|
||||
var entries = root.layoutEntries("center")
|
||||
function findCenterAnchorEntry(entries) {
|
||||
var idx = root.entryIndex(entries, root.centerAnchor)
|
||||
return idx === -1 ? null : entries[idx]
|
||||
}
|
||||
@@ -1550,7 +1644,7 @@ Item {
|
||||
|
||||
property var entries: root.layoutEntries("center")
|
||||
readonly property bool hasAnchor: root.entryIndex(entries, root.centerAnchor) !== -1
|
||||
readonly property var anchorEntry: root.findCenterAnchorEntry()
|
||||
readonly property var anchorEntry: root.findCenterAnchorEntry(entries)
|
||||
|
||||
Loader {
|
||||
anchors.fill: parent
|
||||
|
||||
@@ -208,9 +208,122 @@ function nearestDropTarget(candidates, point, vertical) {
|
||||
return best
|
||||
}
|
||||
|
||||
// Display cutouts (a camera notch at the top of a laptop panel) are described
|
||||
// by the platform's own package, in /usr/share/omarchy-platform/display-cutouts.json:
|
||||
//
|
||||
// { "panels": [ { "connector": "eDP", "width": 3024, "height": 1964, "top": 64 } ] }
|
||||
//
|
||||
// A panel matches a screen whose connector name starts with `connector` and
|
||||
// whose mode is width x height physical pixels; `top` is how many physical rows
|
||||
// at its top the cutout covers. Anything malformed is dropped.
|
||||
function parseCutouts(text) {
|
||||
var parsed
|
||||
try {
|
||||
parsed = JSON.parse(String(text || ""))
|
||||
} catch (error) {
|
||||
return []
|
||||
}
|
||||
var panels = parsed && Array.isArray(parsed.panels) ? parsed.panels : []
|
||||
var cutouts = []
|
||||
for (var i = 0; i < panels.length; i++) {
|
||||
var panel = panels[i] || {}
|
||||
var width = Number(panel.width)
|
||||
var height = Number(panel.height)
|
||||
var top = Number(panel.top)
|
||||
if (typeof panel.connector !== "string" || panel.connector === "") continue
|
||||
if (!(width > 0) || !(height > 0) || !(top > 0) || top >= height) continue
|
||||
cutouts.push({ connector: panel.connector, width: width, height: height, top: top })
|
||||
}
|
||||
return cutouts
|
||||
}
|
||||
|
||||
// The cutout at the top of this screen, in logical pixels, or 0. `mode` is the
|
||||
// output's physical mode as Hyprland reports it ({ width, height, transform }).
|
||||
// Qt's devicePixelRatio is a whole number even at a fractional scale, so the
|
||||
// logical size times it rebuilds the mode only at whole scales; that stands in
|
||||
// just until Hyprland's answer arrives. A mode Hyprland reports that matches no
|
||||
// panel has no cutout.
|
||||
function cutoutTop(cutouts, screenName, logicalWidth, logicalHeight, devicePixelRatio, mode) {
|
||||
var name = String(screenName || "")
|
||||
var logical = Number(logicalWidth)
|
||||
var logicalTall = Number(logicalHeight)
|
||||
if (!(logical > 0) || !(logicalTall > 0)) return 0
|
||||
var width = Number(mode && mode.width)
|
||||
var height = Number(mode && mode.height)
|
||||
if (width > 0 && height > 0) {
|
||||
// Turned a quarter or upside down, the cutout is on another edge.
|
||||
var transform = Number(mode.transform) || 0
|
||||
if (transform !== 0 && transform !== 4) return 0
|
||||
if ((width > height) !== (logical > logicalTall)) return 0
|
||||
} else {
|
||||
var scale = Number(devicePixelRatio) > 0 ? Number(devicePixelRatio) : 1
|
||||
width = Math.round(logical * scale)
|
||||
height = Math.round(logicalTall * scale)
|
||||
}
|
||||
var list = Array.isArray(cutouts) ? cutouts : []
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
var panel = list[i]
|
||||
if (name.indexOf(panel.connector) !== 0) continue
|
||||
// Logical sizes are rounded, so a rebuilt mode can be a couple of pixels off.
|
||||
if (Math.abs(width - panel.width) <= 4 && Math.abs(height - panel.height) <= 4)
|
||||
return Math.ceil(panel.top * logical / width)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// The height a top bar on this screen must reach to cover its cutout, or a
|
||||
// calibrated [bar] notch-height in its place. A screen without a cutout, or a
|
||||
// bar on another edge, has no floor, so a calibration never reaches an
|
||||
// external monitor.
|
||||
function notchFloor(cutouts, position, screenName, logicalWidth, logicalHeight, devicePixelRatio, mode, calibrated) {
|
||||
if (position !== "top") return 0
|
||||
var top = cutoutTop(cutouts, screenName, logicalWidth, logicalHeight, devicePixelRatio, mode)
|
||||
if (!(top > 0)) return 0
|
||||
return Number(calibrated) > 0 ? Math.round(Number(calibrated)) : top
|
||||
}
|
||||
|
||||
// A cutout covers the middle of a top bar, so that bar draws its center
|
||||
// section beside the right one.
|
||||
function centerBesideRight(cutouts, position, screenName, logicalWidth, logicalHeight, devicePixelRatio, mode) {
|
||||
return position === "top" && cutoutTop(cutouts, screenName, logicalWidth, logicalHeight, devicePixelRatio, mode) > 0
|
||||
}
|
||||
|
||||
// Whether a top bar can't tell its floor yet: Hyprland hasn't reported the
|
||||
// screen's mode, the logical size times Qt's ratio matched no panel (as at a
|
||||
// fractional scale), and a described panel's connector matches the screen, so
|
||||
// the mode may still bring a cutout. Such a bar waits for the mode before it
|
||||
// maps rather than guess a floor. A screen no entry's connector matches never
|
||||
// waits.
|
||||
function cutoutPending(cutouts, position, screenName, logicalWidth, logicalHeight, devicePixelRatio, mode) {
|
||||
if (position !== "top") return false
|
||||
if (Number(mode && mode.width) > 0 && Number(mode && mode.height) > 0) return false
|
||||
var name = String(screenName || "")
|
||||
var list = Array.isArray(cutouts) ? cutouts : []
|
||||
var described = false
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
if (name.indexOf(list[i].connector) === 0) described = true
|
||||
}
|
||||
return described && !(cutoutTop(cutouts, screenName, logicalWidth, logicalHeight, devicePixelRatio, mode) > 0)
|
||||
}
|
||||
|
||||
// The bar's thickness on the screen with this name, from the sizes a bar
|
||||
// publishes by screen name, or `fallback` (its configured size) for a screen
|
||||
// it has none for.
|
||||
function barSizeFor(sizes, screenName, fallback) {
|
||||
var name = String(screenName || "")
|
||||
var size = sizes && Object.prototype.hasOwnProperty.call(sizes, name) ? Number(sizes[name]) : 0
|
||||
return size > 0 ? size : fallback
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = {
|
||||
barSizeFor: barSizeFor,
|
||||
centerBesideRight: centerBesideRight,
|
||||
cutoutPending: cutoutPending,
|
||||
cutoutTop: cutoutTop,
|
||||
isDrawnSlot: isDrawnSlot,
|
||||
notchFloor: notchFloor,
|
||||
parseCutouts: parseCutouts,
|
||||
pickDrawnSlot: pickDrawnSlot,
|
||||
pickPanelSlot: pickPanelSlot,
|
||||
nearestDropTarget: nearestDropTarget,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import QtQuick
|
||||
import qs.Commons as Commons
|
||||
import qs.Ui
|
||||
|
||||
BarIndicator {
|
||||
id: root
|
||||
|
||||
readonly property var remoteSessionService: bar?.shell?.firstPartyServiceFor("omarchy.remote-session")
|
||||
readonly property var peers: remoteSessionService && Array.isArray(remoteSessionService.peers) ? remoteSessionService.peers : []
|
||||
|
||||
active: remoteSessionService ? remoteSessionService.active : false
|
||||
activeText: ""
|
||||
inactiveText: ""
|
||||
activeTooltipText: peers.length > 0 ? "Remote session from " + peers.join(", ") : "Remote session"
|
||||
inactiveTooltipText: "Remote Session"
|
||||
useActiveColor: true
|
||||
activeColor: Commons.Color.urgent
|
||||
|
||||
Connections {
|
||||
target: root.indicatorHost
|
||||
ignoreUnknownSignals: true
|
||||
function onRefreshRequested() {
|
||||
if (root.remoteSessionService) root.remoteSessionService.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ BarIndicator {
|
||||
|
||||
function refresh() {
|
||||
if (!root.bar || statusProc.running) return
|
||||
statusProc.command = ["pgrep", "--quiet", "-f", "^gpu-screen-recorder"]
|
||||
statusProc.command = ["omarchy-capture-screenrecording", "--status"]
|
||||
statusProc.running = true
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,11 @@
|
||||
"label": "Screen recording",
|
||||
"description": "GPU screen recorder status"
|
||||
},
|
||||
{
|
||||
"value": "RemoteSession",
|
||||
"label": "Remote session",
|
||||
"description": "Warning while someone is connected through gliff"
|
||||
},
|
||||
{
|
||||
"value": "Reminder",
|
||||
"label": "Reminder",
|
||||
|
||||
@@ -8,7 +8,7 @@ BarWidget {
|
||||
id: root
|
||||
moduleName: "omarchy.indicators"
|
||||
|
||||
readonly property var defaultIndicatorEntries: [ "PasswordlessSudo", "ScreenRecording", "Reminder", "NightLight", "Dnd", "StayAwake" ]
|
||||
readonly property var defaultIndicatorEntries: [ "PasswordlessSudo", "ScreenRecording", "RemoteSession", "Reminder", "NightLight", "Dnd", "StayAwake" ]
|
||||
readonly property var indicatorEntries: indicatorEntriesFromSettings(settings)
|
||||
property var activeIndicatorIds: []
|
||||
property var indicatorActiveStates: ({})
|
||||
|
||||
@@ -2,6 +2,7 @@ import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Services.Pipewire
|
||||
import qs.Ui
|
||||
import qs.Commons
|
||||
|
||||
BarWidget {
|
||||
id: root
|
||||
@@ -9,15 +10,28 @@ BarWidget {
|
||||
|
||||
|
||||
readonly property var source: Pipewire.defaultAudioSource
|
||||
readonly property bool muted: source && source.audio ? source.audio.muted : true
|
||||
readonly property real volume: source && source.audio ? source.audio.volume : 0
|
||||
// A virtual source is untyped in Quickshell, so it has no PwNode.audio; its
|
||||
// volume and mute go through wpctl (UntypedInput).
|
||||
readonly property bool viaWpctl: UntypedInput.active
|
||||
readonly property bool muted: viaWpctl ? (!UntypedInput.known || UntypedInput.muted) : (source && source.audio ? source.audio.muted : true)
|
||||
readonly property real volume: viaWpctl ? UntypedInput.volume : (source && source.audio ? source.audio.volume : 0)
|
||||
readonly property var nodes: Pipewire.nodes ? Pipewire.nodes.values : []
|
||||
readonly property var nodeNames: {
|
||||
var names = []
|
||||
for (var i = 0; i < nodes.length; i++)
|
||||
if (nodes[i] && nodes[i].name) names.push(String(nodes[i].name))
|
||||
return names
|
||||
}
|
||||
|
||||
readonly property var activeStreams: {
|
||||
var list = []
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
var node = nodes[i]
|
||||
if (node && node.isStream && node.isSink === false && !node.audio?.muted) list.push(node)
|
||||
if (!node || !node.isStream || node.isSink !== false || node.audio?.muted) continue
|
||||
// The shell's own level meters, and a platform's audio processing that
|
||||
// records all the time, are not an app using the microphone.
|
||||
if (AudioNodes.isShellLevelMeter(node.name) || AudioNodes.platformHides(node.name, nodeNames)) continue
|
||||
list.push(node)
|
||||
}
|
||||
return list
|
||||
}
|
||||
@@ -29,7 +43,10 @@ BarWidget {
|
||||
implicitHeight: button.implicitHeight
|
||||
|
||||
function toggleMute() {
|
||||
if (source && source.audio) source.audio.muted = !source.audio.muted
|
||||
if (viaWpctl) {
|
||||
if (UntypedInput.known) UntypedInput.setMuted(!UntypedInput.muted)
|
||||
}
|
||||
else if (source && source.audio) source.audio.muted = !source.audio.muted
|
||||
}
|
||||
|
||||
PwObjectTracker { objects: root.source ? [root.source] : [] }
|
||||
@@ -46,8 +63,12 @@ BarWidget {
|
||||
else root.toggleMute()
|
||||
}
|
||||
onWheelMoved: function(delta) {
|
||||
if (!root.source || !root.source.audio) return
|
||||
var step = 0.05
|
||||
if (root.viaWpctl) {
|
||||
if (UntypedInput.known) UntypedInput.setVolume(root.volume + (delta > 0 ? step : -step))
|
||||
return
|
||||
}
|
||||
if (!root.source || !root.source.audio) return
|
||||
root.source.audio.volume = Math.max(0, Math.min(1, root.volume + (delta > 0 ? step : -step)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,6 +421,19 @@ function popupExpired(entry, duration, now) {
|
||||
return (Number(now) - Number((entry || {}).timestamp || 0)) >= lifetime
|
||||
}
|
||||
|
||||
// How far a toast on a screen keeps from a top or right bar: the bar's
|
||||
// thickness on that screen (a notch floor can make a top bar thicker than its
|
||||
// configured size on one screen), plus the gap. A bar without per-screen
|
||||
// sizes counts its configured size; a hidden or missing one, the default size.
|
||||
function barClearance(bar, screenName, defaultBarSize, gapsOut) {
|
||||
var size = Number(defaultBarSize)
|
||||
if (bar && !bar.barHidden) {
|
||||
size = Number(typeof bar.barSizeFor === "function" ? bar.barSizeFor(screenName) : bar.barSize)
|
||||
size = isFinite(size) ? Math.max(0, size) : 0
|
||||
}
|
||||
return size + Number(gapsOut)
|
||||
}
|
||||
|
||||
function popupPlacement(barPosition, barClearance, gapsOut) {
|
||||
var position = String(barPosition || "top")
|
||||
var clearance = Number(barClearance)
|
||||
@@ -501,6 +514,7 @@ if (typeof module !== "undefined") {
|
||||
serializePopup: serializePopup,
|
||||
parsePopupFiles: parsePopupFiles,
|
||||
popupExpired: popupExpired,
|
||||
barClearance: barClearance,
|
||||
popupPlacement: popupPlacement
|
||||
}
|
||||
}
|
||||
@@ -45,11 +45,15 @@ Item {
|
||||
// pull notification popups away from the expected top-right location.
|
||||
// Falls back to the bar's default size (26 horizontal / 28 vertical) when
|
||||
// shell.bar isn't reachable so the popup never lands on top of the bar.
|
||||
// Each screen clears the bar as thick as it is there: a notch floor can make
|
||||
// a top bar taller than its configured size on one screen only.
|
||||
readonly property string barPosition: shell && shell.barConfig ? String(shell.barConfig.position || "top") : "top"
|
||||
readonly property bool barVertical: barPosition === "left" || barPosition === "right"
|
||||
readonly property int defaultBarSize: barVertical ? Style.bar.sizeVertical : Style.bar.sizeHorizontal
|
||||
readonly property int liveBarSize: shell && shell.bar && !shell.bar.barHidden ? Math.max(0, shell.bar.barSize) : defaultBarSize
|
||||
readonly property int barClearance: liveBarSize + Style.gapsOut
|
||||
|
||||
function barClearanceFor(screenName) {
|
||||
return NotificationLogic.barClearance(shell ? shell.bar : null, screenName, defaultBarSize, Style.gapsOut)
|
||||
}
|
||||
|
||||
// Live Notification objects by originalId, kept OUT of the ListModels: a
|
||||
// QObject stored in a model role becomes a dangling C++ pointer when the
|
||||
@@ -1026,7 +1030,7 @@ Item {
|
||||
color: "transparent"
|
||||
|
||||
readonly property var popupPlacement: NotificationLogic.popupPlacement(
|
||||
service.barPosition, service.barClearance, Style.gapsOut)
|
||||
service.barPosition, service.barClearanceFor(modelData ? modelData.name : ""), Style.gapsOut)
|
||||
|
||||
// Full-screen, fixed-size surface (like the OSD overlay). Adding or
|
||||
// removing a toast changes only the content inside; the Wayland surface
|
||||
|
||||
@@ -256,6 +256,16 @@ function streamRepresentsPlayer(node, player, players, streams) {
|
||||
return streamRepresentsMprisPlayer(streamLabel(node, players, streams), playerLabel)
|
||||
}
|
||||
|
||||
// Quickshell types only exact media classes, so a virtual source (an
|
||||
// Audio/Source/Virtual, such as EasyEffects' or a platform's microphone
|
||||
// mapping) is a node with no PwNode.audio. PulseAudio lists it as a source, so
|
||||
// the source availability listing names it; a non-stream node it names is one.
|
||||
function isUntypedSource(node, sourceAvailability) {
|
||||
if (!node || node.audio || node.isStream || node.isSink) return false
|
||||
var name = String(node.name || "")
|
||||
return name !== "" && !!sourceAvailability && Object.prototype.hasOwnProperty.call(sourceAvailability, name)
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = {
|
||||
isPlaybackStream: isPlaybackStream,
|
||||
@@ -281,6 +291,7 @@ if (typeof module !== "undefined") {
|
||||
matchingMprisStreamLabel: matchingMprisStreamLabel,
|
||||
unmatchedMprisStreamLabel: unmatchedMprisStreamLabel,
|
||||
streamLabel: streamLabel,
|
||||
streamRepresentsPlayer: streamRepresentsPlayer
|
||||
streamRepresentsPlayer: streamRepresentsPlayer,
|
||||
isUntypedSource: isUntypedSource
|
||||
}
|
||||
}
|
||||
@@ -21,11 +21,25 @@ Panel {
|
||||
readonly property var mediaService: bar?.shell?.firstPartyServiceFor("omarchy.media")
|
||||
readonly property var activeMediaPlayer: mediaService ? mediaService.activePlayer : null
|
||||
|
||||
// Nodes of a platform's audio processing, which are neither devices nor apps,
|
||||
// stay out of every list (AudioNodes.platformHides). Most machines have none.
|
||||
readonly property var nodeNames: {
|
||||
var names = []
|
||||
for (var i = 0; i < nodes.length; i++)
|
||||
if (nodes[i] && nodes[i].name) names.push(String(nodes[i].name))
|
||||
return names
|
||||
}
|
||||
|
||||
function platformHides(node) {
|
||||
return !!node && AudioNodes.platformHides(node.name, nodeNames)
|
||||
}
|
||||
|
||||
readonly property var candidateSinks: {
|
||||
var list = []
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
var n = nodes[i]
|
||||
if (n && n.isSink && !n.isStream) list.push(n)
|
||||
if (!n || !n.isSink || n.isStream || platformHides(n)) continue
|
||||
list.push(n)
|
||||
}
|
||||
return list
|
||||
}
|
||||
@@ -34,11 +48,11 @@ Panel {
|
||||
var list = []
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
var n = nodes[i]
|
||||
if (n && !n.isSink && !n.isStream && isAudioSource(n)) {
|
||||
var name = n.name || ""
|
||||
if (name === "quickshell") continue
|
||||
list.push(n)
|
||||
}
|
||||
if (!n || n.isSink || n.isStream) continue
|
||||
if (!isAudioSource(n) && !Model.isUntypedSource(n, sourceAvailability)) continue
|
||||
var name = n.name || ""
|
||||
if (name === "quickshell" || platformHides(n)) continue
|
||||
list.push(n)
|
||||
}
|
||||
return list
|
||||
}
|
||||
@@ -51,6 +65,7 @@ Panel {
|
||||
// A tuning's output is a playback stream too, but it is the processing
|
||||
// itself rather than an application, so it does not belong in the list.
|
||||
if (String(n.name || "").indexOf("omarchy_speaker_tuning") === 0) continue
|
||||
if (platformHides(n)) continue
|
||||
list.push(n)
|
||||
}
|
||||
return list
|
||||
@@ -58,6 +73,7 @@ Panel {
|
||||
|
||||
property var sinkAvailability: ({})
|
||||
property bool sinkAvailabilityLoaded: false
|
||||
property var sourceAvailability: ({})
|
||||
|
||||
// Identify true playback streams without reading node.properties here:
|
||||
// PwNode.properties is invalid until the node is bound, and reading it while
|
||||
@@ -148,8 +164,15 @@ Panel {
|
||||
|
||||
readonly property real outputVolume: volumeSink && volumeSink.audio ? volumeSink.audio.volume : 0
|
||||
readonly property bool outputMuted: volumeSink && volumeSink.audio ? volumeSink.audio.muted : false
|
||||
readonly property real inputVolume: source && source.audio ? source.audio.volume : 0
|
||||
readonly property bool inputMuted: source && source.audio ? source.audio.muted : false
|
||||
// A virtual source is untyped in Quickshell (see Model.isUntypedSource), so
|
||||
// its volume and mute go through wpctl (UntypedInput), and the panel shows no
|
||||
// level meter for it: Quickshell's peak monitor takes typed nodes only.
|
||||
readonly property bool inputViaWpctl: UntypedInput.active
|
||||
readonly property bool inputLevelKnown: !inputViaWpctl || UntypedInput.known
|
||||
readonly property real inputVolume: inputViaWpctl ? UntypedInput.volume : (source && source.audio ? source.audio.volume : 0)
|
||||
readonly property bool inputMuted: inputViaWpctl ? UntypedInput.muted : (source && source.audio ? source.audio.muted : false)
|
||||
readonly property var inputPeakNode: inputViaWpctl ? null : source
|
||||
readonly property bool inputLevelShown: !!inputPeakNode
|
||||
|
||||
onRawAudioSinksChanged: if (rawAudioSinks.length > 0) cachedAudioSinks = rawAudioSinks
|
||||
onRawAudioSourcesChanged: if (rawAudioSources.length > 0) cachedAudioSources = rawAudioSources
|
||||
@@ -175,7 +198,7 @@ Panel {
|
||||
// would otherwise report "input unmuted" forever, leaving the hero switch
|
||||
// able to mute but never to unmute.
|
||||
readonly property bool hasOutput: !!(volumeSink && volumeSink.audio)
|
||||
readonly property bool hasInput: !!(source && source.audio)
|
||||
readonly property bool hasInput: !!(source && source.audio) || (inputViaWpctl && UntypedInput.known)
|
||||
readonly property bool anyAudible: (hasOutput && !outputMuted) || (hasInput && !inputMuted)
|
||||
readonly property string toggleHint: anyAudible ? "Mute" : "Unmute"
|
||||
|
||||
@@ -427,7 +450,7 @@ Panel {
|
||||
}
|
||||
|
||||
function inputIcon() {
|
||||
if (!source || !source.audio) return ""
|
||||
if (!hasInput) return ""
|
||||
return inputMuted ? "" : ""
|
||||
}
|
||||
|
||||
@@ -454,6 +477,10 @@ Panel {
|
||||
}
|
||||
|
||||
function setInputVolume(v) {
|
||||
if (inputViaWpctl) {
|
||||
UntypedInput.setVolume(v)
|
||||
return
|
||||
}
|
||||
if (!source || !source.audio) return
|
||||
source.audio.volume = Math.max(0, Math.min(1, v))
|
||||
}
|
||||
@@ -463,7 +490,10 @@ Panel {
|
||||
}
|
||||
|
||||
function toggleInputMute() {
|
||||
if (source && source.audio) source.audio.muted = !source.audio.muted
|
||||
if (inputViaWpctl) {
|
||||
if (UntypedInput.known) UntypedInput.setMuted(!UntypedInput.muted)
|
||||
}
|
||||
else if (source && source.audio) source.audio.muted = !source.audio.muted
|
||||
}
|
||||
|
||||
// The hero switch is the whole panel's on/off, so it carries both channels
|
||||
@@ -472,7 +502,8 @@ Panel {
|
||||
function toggleAllMuted() {
|
||||
var mute = anyAudible
|
||||
if (hasOutput) volumeSink.audio.muted = mute
|
||||
if (hasInput) source.audio.muted = mute
|
||||
if (hasInput && inputViaWpctl) UntypedInput.setMuted(mute)
|
||||
else if (hasInput) source.audio.muted = mute
|
||||
}
|
||||
|
||||
function setDefaultSink(node) {
|
||||
@@ -489,7 +520,8 @@ Panel {
|
||||
|
||||
function setDefaultSource(node) {
|
||||
if (!node) return
|
||||
Pipewire.preferredDefaultAudioSource = node
|
||||
// Quickshell refuses an untyped node; the command below still selects it.
|
||||
if (node.audio) Pipewire.preferredDefaultAudioSource = node
|
||||
if (node.id !== undefined && node.name) {
|
||||
Quickshell.execDetached([
|
||||
"omarchy-audio-input-set-default",
|
||||
@@ -597,8 +629,8 @@ Panel {
|
||||
|
||||
PwNodePeakMonitor {
|
||||
id: inputPeakMonitor
|
||||
node: root.source
|
||||
enabled: root.opened && !!root.source
|
||||
node: root.inputPeakNode
|
||||
enabled: root.opened && !!root.inputPeakNode
|
||||
}
|
||||
|
||||
Process {
|
||||
@@ -610,6 +642,15 @@ Panel {
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: sourceAvailabilityProc
|
||||
command: ["omarchy-audio-sink-availability", "sources"]
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: root.sourceAvailability = Model.parseSinkAvailability(text)
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: volumeSinkProc
|
||||
command: ["omarchy-audio-output-sink"]
|
||||
@@ -624,7 +665,10 @@ Panel {
|
||||
running: root.opened
|
||||
repeat: true
|
||||
triggeredOnStart: true
|
||||
onTriggered: if (!sinkAvailabilityProc.running) sinkAvailabilityProc.running = true
|
||||
onTriggered: {
|
||||
if (!sinkAvailabilityProc.running) sinkAvailabilityProc.running = true
|
||||
if (!sourceAvailabilityProc.running) sourceAvailabilityProc.running = true
|
||||
}
|
||||
}
|
||||
|
||||
// Runs whether or not the panel is open: the bar shows and scrolls the output
|
||||
@@ -908,7 +952,7 @@ Panel {
|
||||
Text {
|
||||
id: microphonePercent
|
||||
textFormat: Text.PlainText
|
||||
text: Math.round((inputSlider.dragging ? inputSlider.liveValue : root.inputVolume) * 100) + "%"
|
||||
text: root.inputLevelKnown ? Math.round((inputSlider.dragging ? inputSlider.liveValue : root.inputVolume) * 100) + "%" : "–"
|
||||
color: Qt.darker(root.bar.foreground, 1.4)
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
@@ -932,9 +976,13 @@ Panel {
|
||||
|
||||
Column {
|
||||
id: inputControls
|
||||
anchors.fill: parent
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.leftMargin: Style.space(6)
|
||||
anchors.rightMargin: Style.space(6)
|
||||
// Alone, the slider centres in the row the way the output slider does.
|
||||
anchors.topMargin: root.inputLevelShown ? 0 : Style.spacing.controlGap / 2
|
||||
spacing: Style.space(5)
|
||||
|
||||
PanelSlider {
|
||||
@@ -953,6 +1001,7 @@ Panel {
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
visible: root.inputLevelShown
|
||||
width: parent.width
|
||||
height: Math.max(Style.space(5), Style.spacing.xs)
|
||||
color: Util.alpha(root.bar.foreground, 0.18)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Hyprland emits this whenever a screencopy session starts or stops, which
|
||||
// covers gliff-server (ext-image-copy-capture) as well as screen recorders
|
||||
// and browser screen shares, so the event is only a cue to re-probe. The
|
||||
// matching screencastv2 event always accompanies it and is left alone so one
|
||||
// transition costs one probe.
|
||||
function isCaptureEvent(name) {
|
||||
return String(name || "") === "screencast"
|
||||
}
|
||||
|
||||
// Parses the probe output: one "<pid> [peer]" line per running gliff-server,
|
||||
// where peer is the ssh client address when the server was spawned over ssh.
|
||||
function stateFromOutput(text) {
|
||||
var lines = String(text || "").split("\n")
|
||||
var active = false
|
||||
var peers = []
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var parts = lines[i].trim().split(/\s+/)
|
||||
if (parts[0] === "") continue
|
||||
active = true
|
||||
var peer = parts[1] || ""
|
||||
if (peer !== "" && peers.indexOf(peer) === -1) peers.push(peer)
|
||||
}
|
||||
return { active: active, peers: peers }
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = {
|
||||
isCaptureEvent: isCaptureEvent,
|
||||
stateFromOutput: stateFromOutput
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
import Quickshell.Io
|
||||
import "RemoteSessionModel.js" as RemoteSessionModel
|
||||
|
||||
// Watches for gliff-server, the Hyprland remote desktop, so the machine being
|
||||
// controlled can see that someone is on it: the RemoteSession bar indicator
|
||||
// reads `active` and `peers`.
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// Injected by omarchy-shell (the first-party service loader).
|
||||
property var shell: null
|
||||
|
||||
property bool stateLoaded: false
|
||||
property bool active: false
|
||||
property var peers: []
|
||||
|
||||
property bool refreshPending: false
|
||||
|
||||
function refresh() {
|
||||
if (statusProbe.running) {
|
||||
root.refreshPending = true
|
||||
return
|
||||
}
|
||||
root.refreshPending = false
|
||||
statusProbe.running = true
|
||||
}
|
||||
|
||||
function applyProbe(text) {
|
||||
var state = RemoteSessionModel.stateFromOutput(text)
|
||||
root.peers = state.peers
|
||||
root.active = state.active
|
||||
root.stateLoaded = true
|
||||
}
|
||||
|
||||
Component.onCompleted: refresh()
|
||||
|
||||
// gliff-server captures the screen through ext-image-copy-capture, so a
|
||||
// session start surfaces as a Hyprland screencast event. The probe decides
|
||||
// whether it was gliff or another screencopy client, and a short debounce
|
||||
// folds the event bursts Hyprland sends around one transition into one probe.
|
||||
Connections {
|
||||
target: Hyprland
|
||||
function onRawEvent(event) {
|
||||
if (RemoteSessionModel.isCaptureEvent(event ? event.name : "")) eventDebounce.restart()
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: eventDebounce
|
||||
interval: 150
|
||||
repeat: false
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
// The end of a session often has no event: Hyprland reports the screencast
|
||||
// stopped as soon as frames pause for half a second, and says nothing more
|
||||
// when the server later exits. So poll while a session is active.
|
||||
Timer {
|
||||
interval: 2000
|
||||
repeat: true
|
||||
running: root.active
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
Process {
|
||||
id: statusProbe
|
||||
command: ["bash", Quickshell.env("OMARCHY_PATH") + "/shell/plugins/services/remote-session/probe.sh"]
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: root.applyProbe(text)
|
||||
}
|
||||
onExited: function() {
|
||||
if (root.refreshPending) Qt.callLater(root.refresh)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "omarchy.remote-session",
|
||||
"name": "Remote Session",
|
||||
"version": "1.0.0",
|
||||
"author": "Omarchy",
|
||||
"description": "Detects a gliff remote-desktop session on this machine for the bar indicator.",
|
||||
"kinds": [
|
||||
"service"
|
||||
],
|
||||
"entryPoints": {
|
||||
"service": "Service.qml"
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Lists this user's running gliff-server processes, one "<pid> [peer]" line
|
||||
# each. The peer is the ssh client address when the server was spawned over
|
||||
# ssh (the real path); a local --listen server has none. Another account's
|
||||
# server captures its own desktop, not this one, so other users are skipped.
|
||||
|
||||
for pid in $(pgrep -x -u "$(id -u)" gliff-server); do
|
||||
peer=$(tr '\0' '\n' <"/proc/$pid/environ" 2>/dev/null | sed -n 's/^SSH_CONNECTION=\([^ ]*\).*/\1/p')
|
||||
printf '%s %s\n' "$pid" "$peer"
|
||||
done
|
||||
Reference in new issue
Block a user