From ea11fe0eb64e39d472eaff2093fb9edeb13b9957 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 19 Oct 2026 21:07:01 +0100 Subject: [PATCH] Feature: Make home UniFi VPN row a collapsible card Put the Replace config option behind an expand chevron, like Mullvad's options, and frame the card while it's expanded. Co-Authored-By: Claude Opus 5.5 --- .../quickshell/modules/bar/VpnIndicator.qml | 484 ++++++++++-------- 1 file changed, 280 insertions(+), 204 deletions(-) diff --git a/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml b/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml index 1d5106d..1628be7 100644 --- a/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml @@ -66,6 +66,9 @@ Item { property string protonActionStatus: "" property string homeWgActionStatus: "" property bool homeWgImporting: homeWgImportProc.running + // Whether Home's options (Replace config) are expanded — kept across + // panel opens, like mullvadOptionsOpen. + property bool homeWgOptionsOpen: false // --- Mullvad advanced options (auto-connect, relay location, multihop) -- // ProtonVPN's CLI is plain-text-only and far less predictable across @@ -1006,7 +1009,124 @@ Item { Column { width: parent.width - spacing: 2 + // Room for the cards' outlines (drawn 3px outside them) + // so two expanded ones don't touch. + spacing: 8 + + // Same collapsible card as Mullvad below — framed only + // while its options (Replace config) are expanded. + Item { + readonly property bool _framed: root.homeWgOptionsOpen && root.homeWgInstalled + width: parent.width + implicitHeight: homeWgSectionColumn.implicitHeight + Behavior on implicitHeight { + NumberAnimation { duration: Appearance.animationDuration; easing.type: Easing.OutCubic } + } + + // Outline offset outside the content rather than drawn on + // its own edge — same as Volume.qml's DeviceRow hover — + // so the row doesn't shift when the options open. + Rectangle { + anchors.fill: parent + anchors.margins: -3 + radius: 0 + color: "transparent" + border.color: Colors.foreground + border.width: parent._framed ? 1 : 0 + } + + // Clipped separately so the outline above isn't. + Item { + anchors.fill: parent + clip: true + + Column { + id: homeWgSectionColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + spacing: 8 + + ProviderRow { + label: "Home (UniFi)" + subIcon: "home" + state: root.homeWgState + detailText: root.homeWgDetail + installed: root.homeWgInstalled + missingText: "Not set up" + expandable: root.homeWgInstalled + expanded: root.homeWgOptionsOpen + onToggle: root.toggleHomeWg() + onExpandToggle: root.homeWgOptionsOpen = !root.homeWgOptionsOpen + } + + StatusLine { message: root.homeWgActionStatus } + + // Imports (or replaces) the UniFi client .conf through a + // file chooser. Shown outright until a profile exists + // (so first-time setup isn't hidden behind the + // chevron), then only inside the expanded options. + // Hidden while connected — replacing deletes the + // profile, which would drop the tunnel. + Item { + width: parent.width + implicitHeight: importRow.implicitHeight + 8 + visible: root.homeWgState === "disconnected" + && (!root.homeWgInstalled || root.homeWgOptionsOpen) + + Row { + id: importRow + anchors.left: parent.left + anchors.leftMargin: 34 + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + opacity: root.homeWgImporting ? 0.5 : 1 + + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.homeWgInstalled ? "sync" : "upload_file" + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 13 + color: importMouse.containsMouse ? Colors.foreground : Colors.primary + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.homeWgImporting ? "Choosing file…" + : root.homeWgInstalled ? "Replace config" : "Add config" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + font.underline: importMouse.containsMouse + color: importMouse.containsMouse ? Colors.foreground : Colors.primary + } + } + + MouseArea { + id: importMouse + anchors.fill: importRow + anchors.margins: -4 + enabled: !root.homeWgImporting + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.importHomeWgConfig() + } + } + + Text { + x: 34 + width: parent.width - x + visible: root.homeWgOptionsOpen && root.homeWgInstalled + && root.homeWgState !== "disconnected" + text: "Disconnect to replace config" + bottomPadding: 6 + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + wrapMode: Text.WordWrap + } + } + } + } // Mullvad's connect row and its advanced options share a // single bordered card — the border wraps the whole @@ -1014,14 +1134,11 @@ Item { // grows to include the options/picker as they open, // rather than the options having a separate box of // their own below an unbordered connect row. - Rectangle { + // Only outlined while the options are expanded. + Item { + readonly property bool _framed: root.mullvadOptionsOpen && root.mullvadInstalled width: parent.width - radius: 0 - color: Colors.background - border.color: Colors.muted - border.width: 1 - implicitHeight: mullvadSectionColumn.implicitHeight + 16 - clip: true + implicitHeight: mullvadSectionColumn.implicitHeight // Without this, the card snapped to its new size // instantly while the popup around it was still // animating open, so the border visibly lagged @@ -1031,171 +1148,188 @@ Item { NumberAnimation { duration: Appearance.animationDuration; easing.type: Easing.OutCubic } } - Column { - id: mullvadSectionColumn - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.margins: 8 - spacing: 8 + // Outline offset outside the content rather than drawn on + // its own edge — same as Volume.qml's DeviceRow hover — + // so the row doesn't shift when the options open. + Rectangle { + anchors.fill: parent + anchors.margins: -3 + radius: 0 + color: "transparent" + border.color: Colors.foreground + border.width: parent._framed ? 1 : 0 + } - ProviderRow { - label: "Mullvad" - subIcon: "gpp_good" - state: root.mullvadState - detailText: root.mullvadDetail - installed: root.mullvadInstalled - expandable: root.mullvadInstalled - expanded: root.mullvadOptionsOpen - onToggle: root.toggleMullvad() - onExpandToggle: root.openMullvadOptions() - } + // Clipped separately so the outline above isn't. + Item { + anchors.fill: parent + clip: true - StatusLine { message: root.mullvadActionStatus } - - // Advanced options — auto-connect, exit/entry - // relay location, multihop. Column { - id: mullvadOptionsColumn - width: parent.width - visible: root.mullvadOptionsOpen && root.mullvadInstalled + id: mullvadSectionColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top spacing: 8 - RowLayout { - width: parent.width - Text { - Layout.fillWidth: true - text: "Auto-connect" - font.family: Appearance.fontFamily - font.pixelSize: Appearance.fontSizeSmall - color: Colors.foreground + ProviderRow { + label: "Mullvad" + subIcon: "gpp_good" + state: root.mullvadState + detailText: root.mullvadDetail + installed: root.mullvadInstalled + expandable: root.mullvadInstalled + expanded: root.mullvadOptionsOpen + onToggle: root.toggleMullvad() + onExpandToggle: root.openMullvadOptions() } - ToggleSwitch { - checked: root.mullvadAutoConnect - busy: mullvadAutoConnectSetProc.running - onToggled: root.setMullvadAutoConnect(!root.mullvadAutoConnect) - } - } - RowLayout { - width: parent.width - Text { - Layout.fillWidth: true - text: "Multihop" - font.family: Appearance.fontFamily - font.pixelSize: Appearance.fontSizeSmall - color: Colors.foreground - } - ToggleSwitch { - checked: root.mullvadMultihop - busy: mullvadRelaySetProc.running - onToggled: root.setMullvadMultihop(!root.mullvadMultihop) - } - } + StatusLine { message: root.mullvadActionStatus } - LocationField { - fieldLabel: root.mullvadMultihop ? "Exit" : "Location" - valueText: root.mullvadLocationLabel - open: root.mullvadPickerTarget === "exit" - onClicked: root.openMullvadPicker("exit") - } - - LocationField { - visible: root.mullvadMultihop - fieldLabel: "Entry" - valueText: root.mullvadEntryLabel - open: root.mullvadPickerTarget === "entry" - onClicked: root.openMullvadPicker("entry") - } - - // Shared country picker for whichever LocationField - // was last clicked — filter box plus a capped, - // scrollable list ("Any" always pinned first). - Column { - width: parent.width - visible: root.mullvadPickerTarget !== "" - spacing: 6 - - Rectangle { + // Advanced options — auto-connect, exit/entry + // relay location, multihop. + Column { + id: mullvadOptionsColumn width: parent.width - implicitHeight: 24 - radius: 0 - color: Colors.surface - border.color: Colors.muted - border.width: 1 + visible: root.mullvadOptionsOpen && root.mullvadInstalled + spacing: 8 + RowLayout { + width: parent.width Text { - anchors.fill: parent - anchors.margins: 6 - visible: mullvadFilterInput.text.length === 0 - text: "Search country…" + Layout.fillWidth: true + text: "Auto-connect" font.family: Appearance.fontFamily font.pixelSize: Appearance.fontSizeSmall - color: Colors.muted - } - - TextInput { - id: mullvadFilterInput - anchors.fill: parent - anchors.margins: 6 color: Colors.foreground - font.family: Appearance.fontFamily - font.pixelSize: Appearance.fontSizeSmall - clip: true - focus: root.mullvadPickerTarget !== "" - text: root.mullvadPickerFilter - onTextChanged: root.mullvadPickerFilter = text + } + ToggleSwitch { + checked: root.mullvadAutoConnect + busy: mullvadAutoConnectSetProc.running + onToggled: root.setMullvadAutoConnect(!root.mullvadAutoConnect) } } - Flickable { - id: mullvadPickerFlick + RowLayout { width: parent.width - height: Math.min(mullvadPickerColumn.implicitHeight, 150) - contentWidth: width - contentHeight: mullvadPickerColumn.implicitHeight - clip: true - boundsBehavior: Flickable.StopAtBounds + Text { + Layout.fillWidth: true + text: "Multihop" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.foreground + } + ToggleSwitch { + checked: root.mullvadMultihop + busy: mullvadRelaySetProc.running + onToggled: root.setMullvadMultihop(!root.mullvadMultihop) + } + } - Column { - id: mullvadPickerColumn - width: mullvadPickerFlick.width - spacing: 1 + LocationField { + fieldLabel: root.mullvadMultihop ? "Exit" : "Location" + valueText: root.mullvadLocationLabel + open: root.mullvadPickerTarget === "exit" + onClicked: root.openMullvadPicker("exit") + } - Repeater { - model: [{ name: "Any", code: "any" }].concat(root.mullvadFilteredCountries) - Rectangle { - required property var modelData - width: parent.width - implicitHeight: 22 - radius: 0 - color: countryMouse.containsMouse ? Colors.primary : "transparent" + LocationField { + visible: root.mullvadMultihop + fieldLabel: "Entry" + valueText: root.mullvadEntryLabel + open: root.mullvadPickerTarget === "entry" + onClicked: root.openMullvadPicker("entry") + } - Text { - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - anchors.leftMargin: 4 - anchors.rightMargin: 4 - elide: Text.ElideRight - text: modelData.name - font.family: Appearance.fontFamily - font.pixelSize: Appearance.fontSizeSmall - color: countryMouse.containsMouse ? Colors.primaryText : Colors.foreground - } + // Shared country picker for whichever LocationField + // was last clicked — filter box plus a capped, + // scrollable list ("Any" always pinned first). + Column { + width: parent.width + visible: root.mullvadPickerTarget !== "" + spacing: 6 - MouseArea { - id: countryMouse - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: root.pickMullvadLocation(modelData.code) + Rectangle { + width: parent.width + implicitHeight: 24 + radius: 0 + color: Colors.surface + border.color: Colors.muted + border.width: 1 + + Text { + anchors.fill: parent + anchors.margins: 6 + visible: mullvadFilterInput.text.length === 0 + text: "Search country…" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + + TextInput { + id: mullvadFilterInput + anchors.fill: parent + anchors.margins: 6 + color: Colors.foreground + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + clip: true + focus: root.mullvadPickerTarget !== "" + text: root.mullvadPickerFilter + onTextChanged: root.mullvadPickerFilter = text + } + } + + Flickable { + id: mullvadPickerFlick + width: parent.width + height: Math.min(mullvadPickerColumn.implicitHeight, 150) + contentWidth: width + contentHeight: mullvadPickerColumn.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + + Column { + id: mullvadPickerColumn + width: mullvadPickerFlick.width + spacing: 1 + + Repeater { + model: [{ name: "Any", code: "any" }].concat(root.mullvadFilteredCountries) + Rectangle { + required property var modelData + width: parent.width + implicitHeight: 22 + radius: 0 + color: countryMouse.containsMouse ? Colors.primary : "transparent" + + Text { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 4 + anchors.rightMargin: 4 + elide: Text.ElideRight + text: modelData.name + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: countryMouse.containsMouse ? Colors.primaryText : Colors.foreground + } + + MouseArea { + id: countryMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.pickMullvadLocation(modelData.code) + } } } } } } - } + } } } } @@ -1210,64 +1344,6 @@ Item { } StatusLine { message: root.protonActionStatus } - - ProviderRow { - label: "Home (UniFi)" - subIcon: "home" - state: root.homeWgState - detailText: root.homeWgDetail - installed: root.homeWgInstalled - missingText: "Not set up" - onToggle: root.toggleHomeWg() - } - - StatusLine { message: root.homeWgActionStatus } - - // Imports (or replaces) the UniFi client .conf through a - // file chooser. Hidden while connected — replacing - // deletes the profile, which would drop the tunnel. - Item { - width: parent.width - implicitHeight: importRow.implicitHeight + 8 - visible: root.homeWgState === "disconnected" - - Row { - id: importRow - anchors.left: parent.left - anchors.leftMargin: 34 - anchors.verticalCenter: parent.verticalCenter - spacing: 4 - opacity: root.homeWgImporting ? 0.5 : 1 - - Text { - anchors.verticalCenter: parent.verticalCenter - text: root.homeWgInstalled ? "sync" : "upload_file" - font.family: Appearance.iconFontFamilyRounded - font.variableAxes: Appearance.iconFontAxes - font.pixelSize: 13 - color: importMouse.containsMouse ? Colors.foreground : Colors.primary - } - Text { - anchors.verticalCenter: parent.verticalCenter - text: root.homeWgImporting ? "Choosing file…" - : root.homeWgInstalled ? "Replace config" : "Add config" - font.family: Appearance.fontFamily - font.pixelSize: Appearance.fontSizeSmall - font.underline: importMouse.containsMouse - color: importMouse.containsMouse ? Colors.foreground : Colors.primary - } - } - - MouseArea { - id: importMouse - anchors.fill: importRow - anchors.margins: -4 - enabled: !root.homeWgImporting - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: root.importHomeWgConfig() - } - } } Text {