The control center fix applies to five more surfaces: the date menu, the clipboard panel, the activity panel, notification toasts, and the signal glass all opened 48 pixels under the bar while their own expression asked for 12. wlr-layer-shell has three behaviors and only the middle one is subtle. A positive exclusiveZone reserves space; a negative one ignores what others reserved; zero reserves nothing but RESPECTS what others reserved. Every popover here uses zero, so the compositor had already placed them below the bar before their own margin applied, and adding Theme.barHeight counted the bar twice. It is not a crash or a warning -- the surface simply opens lower than written -- so a contract now holds the rule mechanically: a surface with exclusiveZone 0 may not name Theme.barHeight in a margin. It also checks the premise it rests on, and fails if the bar ever stops reserving its own height rather than quietly checking the wrong thing. Measured after: date menu and clipboard at 12px, control center at 2px, each matching what its code asks for. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
236 lines
9.1 KiB
QML
236 lines
9.1 KiB
QML
// Compact explanation and control surface for persistent privacy indicators.
|
|
|
|
import Quickshell
|
|
import Quickshell.Hyprland
|
|
import Quickshell.Wayland
|
|
import QtQuick
|
|
import qs.config
|
|
import qs.services
|
|
import qs.widgets
|
|
|
|
PanelWindow {
|
|
id: root
|
|
|
|
visible: ShellState.activityOpen && PrivacyState.anyActive
|
|
color: "transparent"
|
|
anchors.top: true
|
|
anchors.right: true
|
|
// The gap ALONE, not the bar height plus the gap. exclusiveZone 0 means
|
|
// "reserve nothing, but respect what others reserved", so this surface
|
|
// already begins below the bar's zone -- adding the bar height here counted
|
|
// it twice and left the surface floating 48px under the bar instead of 12.
|
|
margins.top: Theme.barGap * 2
|
|
margins.right: Theme.barSideMargin
|
|
exclusiveZone: 0
|
|
implicitWidth: 350
|
|
implicitHeight: surface.implicitHeight
|
|
|
|
WlrLayershell.namespace: "qs-popover-activity"
|
|
WlrLayershell.layer: WlrLayer.Overlay
|
|
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
|
|
|
// Deliberately does not read Capture.recordingSeconds (or anything else
|
|
// that ticks once a second): this array is a plain JS array, not an
|
|
// identity-preserving model, so any dependency that changes every second
|
|
// would make the whole thing re-derive every second, and the Repeater
|
|
// below would destroy and recreate every row -- including one the user
|
|
// might be hovering or about to click. The set of activities should only
|
|
// change when an activity actually starts or stops. Elapsed time is
|
|
// rendered by each row's own Text binding instead, further down.
|
|
readonly property var activities: {
|
|
const result = [];
|
|
if (PrivacyState.recordingActive)
|
|
result.push({ kind: "recording", glyph: "\u{F044A}", label: "Screen recording", detail: "Panama", tone: "danger", stoppable: true });
|
|
if (PrivacyState.screenSharingActive)
|
|
result.push({ kind: "screen", glyph: "\u{F0379}", label: "Screen sharing", detail: PrivacyState.screenSharingApp || "Managed by the application", tone: "warn", stoppable: false });
|
|
if (PrivacyState.cameraActive)
|
|
result.push({ kind: "camera", glyph: "\u{F0100}", label: "Camera", detail: PrivacyState.cameraApp || "Managed by the application", tone: "warn", stoppable: false });
|
|
if (PrivacyState.microphoneActive)
|
|
result.push({ kind: "microphone", glyph: "\u{F036C}", label: "Microphone", detail: PrivacyState.microphoneApp || "Managed by the application", tone: "warn", stoppable: false });
|
|
return result;
|
|
}
|
|
|
|
// Pure formatter, no ticking property read here -- callers decide what
|
|
// seconds value to pass, and only they take on the per-second dependency.
|
|
function formatElapsed(totalSeconds: int): string {
|
|
const seconds = String(totalSeconds % 60).padStart(2, "0");
|
|
const minutes = Math.floor(totalSeconds / 60) % 60;
|
|
const hours = Math.floor(totalSeconds / 3600);
|
|
return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}` : `${minutes}:${seconds}`;
|
|
}
|
|
|
|
onVisibleChanged: {
|
|
if (root.visible)
|
|
openAnimation.restart();
|
|
}
|
|
|
|
Connections {
|
|
target: PrivacyState
|
|
function onAnyActiveChanged(): void {
|
|
if (!PrivacyState.anyActive && ShellState.activityOpen)
|
|
ShellState.close();
|
|
}
|
|
}
|
|
|
|
HyprlandFocusGrab {
|
|
windows: [root]
|
|
active: root.visible
|
|
onCleared: ShellState.close()
|
|
}
|
|
|
|
Rectangle {
|
|
id: surface
|
|
implicitHeight: content.implicitHeight + Theme.popoverPadding * 2
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
radius: Theme.popoverRadius
|
|
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
|
|
border.width: 1
|
|
border.color: Theme.alpha(Theme.fg, 0.08)
|
|
|
|
PrismEdge {
|
|
anchors.top: parent.top
|
|
anchors.topMargin: 1
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
inset: parent.radius
|
|
}
|
|
|
|
transform: Scale {
|
|
id: openScale
|
|
origin.x: surface.width
|
|
origin.y: 0
|
|
xScale: 1
|
|
yScale: 1
|
|
}
|
|
|
|
Column {
|
|
id: content
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.margins: Theme.popoverPadding
|
|
spacing: 8
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: "Privacy & activity"
|
|
color: Theme.fg
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSizeLarge
|
|
font.weight: Font.DemiBold
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: "Active access stays visible until it ends."
|
|
color: Theme.fgDim
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
}
|
|
|
|
Rectangle {
|
|
width: parent.width
|
|
height: 1
|
|
color: Theme.alpha(Theme.fg, 0.08)
|
|
}
|
|
|
|
Repeater {
|
|
model: root.activities
|
|
|
|
Rectangle {
|
|
id: activityRow
|
|
required property var modelData
|
|
width: content.width
|
|
height: 50
|
|
radius: Theme.cardRadius
|
|
border.width: 0
|
|
color: Theme.alpha(Theme.fg, 0.045)
|
|
|
|
Text {
|
|
id: activityIcon
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: 12
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: activityRow.modelData.glyph
|
|
color: activityRow.modelData.tone === "danger" ? Theme.danger : Theme.warn
|
|
font.family: Theme.fontMono
|
|
font.pixelSize: 17
|
|
}
|
|
|
|
Column {
|
|
anchors.left: activityIcon.right
|
|
anchors.leftMargin: 11
|
|
anchors.right: stopButton.visible ? stopButton.left : parent.right
|
|
anchors.rightMargin: 12
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 2
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: activityRow.modelData.label
|
|
color: Theme.fg
|
|
elide: Text.ElideRight
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSize
|
|
font.weight: Font.DemiBold
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
// Only this Text re-evaluates every second while
|
|
// recording -- Capture.recordingSeconds is read
|
|
// here, not in the parent `activities` array, so
|
|
// the row itself is never torn down for a tick.
|
|
text: activityRow.modelData.kind === "recording" ? activityRow.modelData.detail + " · " + root.formatElapsed(Capture.recordingSeconds) : activityRow.modelData.detail
|
|
color: Theme.fgDim
|
|
elide: Text.ElideRight
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
id: stopButton
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 8
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: activityRow.modelData.stoppable
|
|
width: 54
|
|
height: 28
|
|
radius: Theme.pillRadius
|
|
border.width: 0
|
|
color: stopMouse.containsMouse ? Theme.alpha(Theme.danger, 0.26) : Theme.alpha(Theme.danger, 0.15)
|
|
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: "Stop"
|
|
color: Theme.danger
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
font.weight: Font.DemiBold
|
|
}
|
|
|
|
MouseArea {
|
|
id: stopMouse
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: Capture.stopRecording()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
NumberAnimation {
|
|
id: openAnimation
|
|
target: openScale
|
|
property: "yScale"
|
|
from: 0.96
|
|
to: 1
|
|
duration: Theme.durNormal
|
|
easing.type: Easing.OutCubic
|
|
}
|
|
}
|