WifiList and the notification toasts built their model from a plain computed array, so any background property tick (a scan result, an unrelated notification arriving) reassigned the whole array and the Repeater destroyed and recreated every delegate -- including one with an open, focused password field or an in-progress reply. Switched both to a ScriptModel, which diffs by identity instead of resetting. ValueSlider had its pointer-to-value mapping offset by 16px (the hit-area margin was applied with the wrong sign), so 0% was unreachable and every click landed to the right of where it was placed -- affects every slider in the shell. SliderRow used -1 as a sentinel for "nothing pending," which collides with legitimate negative preference values like pointer sensitivity. Dock intellihide read the globally focused workspace instead of each monitor's own, so an empty workspace on one screen could hide the dock on another; ActivityPanel rebuilt every row once a second during a recording because the elapsed-time read lived in the model construction instead of each row's own binding. Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
232 lines
8.8 KiB
QML
232 lines
8.8 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
|
|
margins.top: Theme.barHeight + 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
|
|
}
|
|
}
|