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 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 21:07:01 +01:00
parent 19e7d3f673
commit ea11fe0eb6

View File

@@ -66,6 +66,9 @@ Item {
property string protonActionStatus: "" property string protonActionStatus: ""
property string homeWgActionStatus: "" property string homeWgActionStatus: ""
property bool homeWgImporting: homeWgImportProc.running 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) -- // --- Mullvad advanced options (auto-connect, relay location, multihop) --
// ProtonVPN's CLI is plain-text-only and far less predictable across // ProtonVPN's CLI is plain-text-only and far less predictable across
@@ -1006,7 +1009,124 @@ Item {
Column { Column {
width: parent.width 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 // Mullvad's connect row and its advanced options share a
// single bordered card — the border wraps the whole // single bordered card — the border wraps the whole
@@ -1014,14 +1134,11 @@ Item {
// grows to include the options/picker as they open, // grows to include the options/picker as they open,
// rather than the options having a separate box of // rather than the options having a separate box of
// their own below an unbordered connect row. // 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 width: parent.width
radius: 0 implicitHeight: mullvadSectionColumn.implicitHeight
color: Colors.background
border.color: Colors.muted
border.width: 1
implicitHeight: mullvadSectionColumn.implicitHeight + 16
clip: true
// Without this, the card snapped to its new size // Without this, the card snapped to its new size
// instantly while the popup around it was still // instantly while the popup around it was still
// animating open, so the border visibly lagged // animating open, so the border visibly lagged
@@ -1031,171 +1148,188 @@ Item {
NumberAnimation { duration: Appearance.animationDuration; easing.type: Easing.OutCubic } NumberAnimation { duration: Appearance.animationDuration; easing.type: Easing.OutCubic }
} }
Column { // Outline offset outside the content rather than drawn on
id: mullvadSectionColumn // its own edge — same as Volume.qml's DeviceRow hover —
anchors.left: parent.left // so the row doesn't shift when the options open.
anchors.right: parent.right Rectangle {
anchors.top: parent.top anchors.fill: parent
anchors.margins: 8 anchors.margins: -3
spacing: 8 radius: 0
color: "transparent"
border.color: Colors.foreground
border.width: parent._framed ? 1 : 0
}
ProviderRow { // Clipped separately so the outline above isn't.
label: "Mullvad" Item {
subIcon: "gpp_good" anchors.fill: parent
state: root.mullvadState clip: true
detailText: root.mullvadDetail
installed: root.mullvadInstalled
expandable: root.mullvadInstalled
expanded: root.mullvadOptionsOpen
onToggle: root.toggleMullvad()
onExpandToggle: root.openMullvadOptions()
}
StatusLine { message: root.mullvadActionStatus }
// Advanced options — auto-connect, exit/entry
// relay location, multihop.
Column { Column {
id: mullvadOptionsColumn id: mullvadSectionColumn
width: parent.width anchors.left: parent.left
visible: root.mullvadOptionsOpen && root.mullvadInstalled anchors.right: parent.right
anchors.top: parent.top
spacing: 8 spacing: 8
RowLayout { ProviderRow {
width: parent.width label: "Mullvad"
Text { subIcon: "gpp_good"
Layout.fillWidth: true state: root.mullvadState
text: "Auto-connect" detailText: root.mullvadDetail
font.family: Appearance.fontFamily installed: root.mullvadInstalled
font.pixelSize: Appearance.fontSizeSmall expandable: root.mullvadInstalled
color: Colors.foreground expanded: root.mullvadOptionsOpen
onToggle: root.toggleMullvad()
onExpandToggle: root.openMullvadOptions()
} }
ToggleSwitch {
checked: root.mullvadAutoConnect
busy: mullvadAutoConnectSetProc.running
onToggled: root.setMullvadAutoConnect(!root.mullvadAutoConnect)
}
}
RowLayout { StatusLine { message: root.mullvadActionStatus }
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)
}
}
LocationField { // Advanced options — auto-connect, exit/entry
fieldLabel: root.mullvadMultihop ? "Exit" : "Location" // relay location, multihop.
valueText: root.mullvadLocationLabel Column {
open: root.mullvadPickerTarget === "exit" id: mullvadOptionsColumn
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 {
width: parent.width width: parent.width
implicitHeight: 24 visible: root.mullvadOptionsOpen && root.mullvadInstalled
radius: 0 spacing: 8
color: Colors.surface
border.color: Colors.muted
border.width: 1
RowLayout {
width: parent.width
Text { Text {
anchors.fill: parent Layout.fillWidth: true
anchors.margins: 6 text: "Auto-connect"
visible: mullvadFilterInput.text.length === 0
text: "Search country…"
font.family: Appearance.fontFamily font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall font.pixelSize: Appearance.fontSizeSmall
color: Colors.muted
}
TextInput {
id: mullvadFilterInput
anchors.fill: parent
anchors.margins: 6
color: Colors.foreground color: Colors.foreground
font.family: Appearance.fontFamily }
font.pixelSize: Appearance.fontSizeSmall ToggleSwitch {
clip: true checked: root.mullvadAutoConnect
focus: root.mullvadPickerTarget !== "" busy: mullvadAutoConnectSetProc.running
text: root.mullvadPickerFilter onToggled: root.setMullvadAutoConnect(!root.mullvadAutoConnect)
onTextChanged: root.mullvadPickerFilter = text
} }
} }
Flickable { RowLayout {
id: mullvadPickerFlick
width: parent.width width: parent.width
height: Math.min(mullvadPickerColumn.implicitHeight, 150) Text {
contentWidth: width Layout.fillWidth: true
contentHeight: mullvadPickerColumn.implicitHeight text: "Multihop"
clip: true font.family: Appearance.fontFamily
boundsBehavior: Flickable.StopAtBounds font.pixelSize: Appearance.fontSizeSmall
color: Colors.foreground
}
ToggleSwitch {
checked: root.mullvadMultihop
busy: mullvadRelaySetProc.running
onToggled: root.setMullvadMultihop(!root.mullvadMultihop)
}
}
Column { LocationField {
id: mullvadPickerColumn fieldLabel: root.mullvadMultihop ? "Exit" : "Location"
width: mullvadPickerFlick.width valueText: root.mullvadLocationLabel
spacing: 1 open: root.mullvadPickerTarget === "exit"
onClicked: root.openMullvadPicker("exit")
}
Repeater { LocationField {
model: [{ name: "Any", code: "any" }].concat(root.mullvadFilteredCountries) visible: root.mullvadMultihop
Rectangle { fieldLabel: "Entry"
required property var modelData valueText: root.mullvadEntryLabel
width: parent.width open: root.mullvadPickerTarget === "entry"
implicitHeight: 22 onClicked: root.openMullvadPicker("entry")
radius: 0 }
color: countryMouse.containsMouse ? Colors.primary : "transparent"
Text { // Shared country picker for whichever LocationField
anchors.left: parent.left // was last clicked — filter box plus a capped,
anchors.right: parent.right // scrollable list ("Any" always pinned first).
anchors.verticalCenter: parent.verticalCenter Column {
anchors.leftMargin: 4 width: parent.width
anchors.rightMargin: 4 visible: root.mullvadPickerTarget !== ""
elide: Text.ElideRight spacing: 6
text: modelData.name
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: countryMouse.containsMouse ? Colors.primaryText : Colors.foreground
}
MouseArea { Rectangle {
id: countryMouse width: parent.width
anchors.fill: parent implicitHeight: 24
hoverEnabled: true radius: 0
cursorShape: Qt.PointingHandCursor color: Colors.surface
onClicked: root.pickMullvadLocation(modelData.code) 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 } 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 { Text {