Files
Panama/config/dot/quickshell/modules/bar/ActivityPanel.qml
T
Gabriel Brown e6b4d3c1a1 Stop lists and sliders from losing input under the user
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
2026-08-18 21:22:58 -04:00

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
}
}