Compare commits
6 Commits
c2c6675f4b
...
features/a
| Author | SHA1 | Date | |
|---|---|---|---|
| 03b043d9d3 | |||
| 1ff92f3b79 | |||
| e5809efc6a | |||
| c64cf5ff12 | |||
| d6f79685f0 | |||
| 9e532023bd |
@@ -2,6 +2,7 @@ import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
import Quickshell.Io
|
||||
import Quickshell.Services.UPower
|
||||
import "../../config"
|
||||
|
||||
@@ -14,7 +15,7 @@ import "../../config"
|
||||
// Nerd Font ones. The charge-threshold ("Holding"/limited-charge) concept from
|
||||
// the original is hardware-specific (vendor charge-limit sysfs knobs) and has
|
||||
// no equivalent in Quickshell's UPower binding, so it's dropped rather than
|
||||
// guessed at; "Battery health" (device.healthPercentage) is shown in its
|
||||
// guessed at; "Battery health" (computed from sysfs energy_full/design) is shown in its
|
||||
// place, using data UPower actually reports.
|
||||
Item {
|
||||
id: root
|
||||
@@ -51,10 +52,162 @@ Item {
|
||||
readonly property real fraction: root.batteryPresent ? Math.max(0, Math.min(1, root.device.percentage)) : 0
|
||||
readonly property bool low: root.batteryPresent && root.fraction <= 0.15 && !root.charging
|
||||
|
||||
// Quickshell's UPower binding has no charge-cycle property (see the
|
||||
// file-header comment on why the old charge-until threshold was
|
||||
// dropped) — it comes straight from sysfs instead.
|
||||
//
|
||||
// device.nativePath turned out to be empty on real hardware (tested
|
||||
// live), so rather than build a path from it, a shell glob finds
|
||||
// whichever BAT* directory actually exists — same shell-out-to-a-CLI
|
||||
// convention TailscaleIndicator.qml uses for its own live stats.
|
||||
// This barely changes during a session, so one read at startup is
|
||||
// enough; no polling timer.
|
||||
property int cycleCount: -1
|
||||
|
||||
Process {
|
||||
id: cycleCountProc
|
||||
command: ["sh", "-c", "cat \"$(ls -d /sys/class/power_supply/BAT* 2>/dev/null | head -n1)/cycle_count\" 2>/dev/null"]
|
||||
running: root.batteryPresent
|
||||
stdout: StdioCollector {
|
||||
id: cycleCountCollector
|
||||
onStreamFinished: {
|
||||
const n = parseInt(cycleCountCollector.text.trim(), 10)
|
||||
root.cycleCount = (isNaN(n) || n <= 0) ? -1 : n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// device.healthPercentage/healthSupported come from upowerd over D-Bus,
|
||||
// and upowerd has been observed reporting energy-full as a flat 0 (a
|
||||
// live daemon glitch, `upower -i` shows it too) while sysfs still has
|
||||
// the real numbers underneath — same class of unreliability that put
|
||||
// cycle_count on sysfs above instead of trusting upowerd's own
|
||||
// charge-cycles field. Health is computed the same way UPower computes
|
||||
// it (current full-charge capacity over the battery's original design
|
||||
// capacity), just read directly so a daemon hiccup doesn't blank it.
|
||||
property real energyFullWh: -1
|
||||
property real energyFullDesignWh: -1
|
||||
readonly property real healthPercent: (root.energyFullWh > 0 && root.energyFullDesignWh > 0)
|
||||
? Math.min(100, (root.energyFullWh / root.energyFullDesignWh) * 100) : -1
|
||||
|
||||
Process {
|
||||
id: energyFullProc
|
||||
command: ["sh", "-c", "cat \"$(ls -d /sys/class/power_supply/BAT* 2>/dev/null | head -n1)/energy_full\" 2>/dev/null"]
|
||||
running: root.batteryPresent
|
||||
stdout: StdioCollector {
|
||||
id: energyFullCollector
|
||||
onStreamFinished: {
|
||||
const n = parseFloat(energyFullCollector.text.trim())
|
||||
root.energyFullWh = (isNaN(n) || n <= 0) ? -1 : (n / 1000000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: energyFullDesignProc
|
||||
command: ["sh", "-c", "cat \"$(ls -d /sys/class/power_supply/BAT* 2>/dev/null | head -n1)/energy_full_design\" 2>/dev/null"]
|
||||
running: root.batteryPresent
|
||||
stdout: StdioCollector {
|
||||
id: energyFullDesignCollector
|
||||
onStreamFinished: {
|
||||
const n = parseFloat(energyFullDesignCollector.text.trim())
|
||||
root.energyFullDesignWh = (isNaN(n) || n <= 0) ? -1 : (n / 1000000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// device.timeToFull/timeToEmpty are computed by Quickshell from
|
||||
// energy/energyCapacity/changeRate — when upowerd's EnergyFull glitches
|
||||
// to 0 (the same daemon flake HEALTH routes around above), that formula
|
||||
// divides by a bogus capacity and comes out negative rather than a
|
||||
// real estimate (confirmed live: timeToFull sat at -3751 while `upower
|
||||
// -i` itself still reported a normal 26.7 minutes). This recomputes
|
||||
// the same estimate straight from sysfs as a fallback, only used when
|
||||
// Quickshell's own value isn't positive. Unlike cycle_count/design
|
||||
// capacity above, energy_now and power_now change continuously while
|
||||
// (dis)charging, so this polls on a timer instead of reading once.
|
||||
property real sysfsEnergyNowWh: -1
|
||||
property real sysfsPowerNowW: -1
|
||||
|
||||
readonly property real fallbackTimeToFullSeconds: (root.sysfsPowerNowW > 0 && root.energyFullWh > 0 && root.sysfsEnergyNowWh >= 0)
|
||||
? Math.max(0, (root.energyFullWh - root.sysfsEnergyNowWh) / root.sysfsPowerNowW * 3600) : -1
|
||||
readonly property real fallbackTimeToEmptySeconds: (root.sysfsPowerNowW > 0 && root.sysfsEnergyNowWh > 0)
|
||||
? (root.sysfsEnergyNowWh / root.sysfsPowerNowW * 3600) : -1
|
||||
|
||||
// Prefers upowerd's own live estimate when it's sane; only falls back
|
||||
// to the sysfs-computed one when upowerd's has gone zero/negative.
|
||||
readonly property real timeRemainingSeconds: {
|
||||
const primary = root.onBattery ? root.device.timeToEmpty : root.device.timeToFull
|
||||
if (primary > 0) return primary
|
||||
return root.onBattery ? root.fallbackTimeToEmptySeconds : root.fallbackTimeToFullSeconds
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: sysfsRateTimer
|
||||
interval: 15000
|
||||
repeat: true
|
||||
running: root.batteryPresent
|
||||
triggeredOnStart: true
|
||||
onTriggered: {
|
||||
if (!sysfsEnergyNowProc.running) sysfsEnergyNowProc.running = true
|
||||
if (!sysfsPowerNowProc.running) sysfsPowerNowProc.running = true
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: sysfsEnergyNowProc
|
||||
command: ["sh", "-c", "cat \"$(ls -d /sys/class/power_supply/BAT* 2>/dev/null | head -n1)/energy_now\" 2>/dev/null"]
|
||||
stdout: StdioCollector {
|
||||
id: sysfsEnergyNowCollector
|
||||
onStreamFinished: {
|
||||
const n = parseFloat(sysfsEnergyNowCollector.text.trim())
|
||||
root.sysfsEnergyNowWh = (isNaN(n) || n < 0) ? -1 : (n / 1000000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: sysfsPowerNowProc
|
||||
command: ["sh", "-c", "cat \"$(ls -d /sys/class/power_supply/BAT* 2>/dev/null | head -n1)/power_now\" 2>/dev/null"]
|
||||
stdout: StdioCollector {
|
||||
id: sysfsPowerNowCollector
|
||||
onStreamFinished: {
|
||||
const n = parseFloat(sysfsPowerNowCollector.text.trim())
|
||||
root.sysfsPowerNowW = (isNaN(n) || n <= 0) ? -1 : (n / 1000000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Raw UPower state string (Charging/Discharging/PendingCharge/…) — more
|
||||
// granular than modeLabel below, which collapses those down to the
|
||||
// three phrases the hero text and STATE row show.
|
||||
readonly property string rawStateLabel: root.batteryPresent
|
||||
? UPowerDeviceState.toString(root.deviceState) : "—"
|
||||
|
||||
// Material Symbols' charging glyphs aren't an evenly-spaced "_bar"
|
||||
// scale like the non-charging ones below — they're fixed at these
|
||||
// seven breakpoints (confirmed against the installed font's ligature
|
||||
// table), so this picks the highest one at or below the current
|
||||
// percentage rather than rounding to an index.
|
||||
function chargingIcon(fraction) {
|
||||
const percent = fraction * 100
|
||||
const thresholds = [20, 30, 50, 60, 80, 90, 100]
|
||||
const icons = [
|
||||
"battery_charging_20", "battery_charging_30", "battery_charging_50", "battery_charging_60",
|
||||
"battery_charging_80", "battery_charging_90", "battery_charging_full"
|
||||
]
|
||||
for (let i = thresholds.length - 1; i >= 0; i--) {
|
||||
if (percent >= thresholds[i]) return icons[i]
|
||||
}
|
||||
// Below 20% there's no lower charging glyph — it's still the
|
||||
// truthful floor of what's available while current is flowing in.
|
||||
return icons[0]
|
||||
}
|
||||
|
||||
function batteryIcon() {
|
||||
if (!root.batteryPresent) return "battery_unknown"
|
||||
if (root.fullyCharged) return "battery_full"
|
||||
if (root.charging) return "battery_charging_full"
|
||||
if (root.charging) return root.chargingIcon(root.fraction)
|
||||
if (root.fraction <= 0.1) return "battery_alert"
|
||||
const bars = ["battery_0_bar", "battery_1_bar", "battery_2_bar", "battery_3_bar", "battery_4_bar", "battery_5_bar", "battery_full"]
|
||||
return bars[Math.max(0, Math.min(bars.length - 1, Math.round(root.fraction * (bars.length - 1))))]
|
||||
@@ -74,6 +227,32 @@ Item {
|
||||
return root.onBattery ? "On battery" : "Charging"
|
||||
}
|
||||
|
||||
// Info grid rows — same shape as NetworkIndicator.qml's statRows/
|
||||
// statCells: a flat label/value list, flattened again into individual
|
||||
// grid cells (label, value, label, value, …) so a 4-column GridLayout
|
||||
// lays them out as two label/value pairs per row.
|
||||
readonly property var statRows: [
|
||||
{
|
||||
label: "HEALTH",
|
||||
value: root.healthPercent > 0 ? Math.round(root.healthPercent) + "%" : "—"
|
||||
},
|
||||
{
|
||||
label: root.onBattery ? "TIME LEFT" : "TIME TO FULL",
|
||||
value: root.batteryPresent ? root.formatDuration(root.timeRemainingSeconds) : "—"
|
||||
},
|
||||
{ label: "STATUS", value: root.rawStateLabel },
|
||||
{ label: "CYCLES", value: root.cycleCount > 0 ? String(root.cycleCount) : "—" }
|
||||
]
|
||||
|
||||
readonly property var statCells: {
|
||||
const cells = []
|
||||
for (let i = 0; i < root.statRows.length; i++) {
|
||||
cells.push({ text: root.statRows[i].label, isLabel: true })
|
||||
cells.push({ text: root.statRows[i].value, isLabel: false })
|
||||
}
|
||||
return cells
|
||||
}
|
||||
|
||||
// Cute rotating status phrases while current is actually flowing —
|
||||
// ported as-is from omarchy's power panel, which pairs the same phrase
|
||||
// sets with the same charging/on-battery states.
|
||||
@@ -142,6 +321,34 @@ Item {
|
||||
return entries
|
||||
}
|
||||
|
||||
// --- Automatic power profile --------------------------------------
|
||||
// Battery-state-driven, on top of the manual picker above: below 20%
|
||||
// → Power Saver (protect what's left), plugged in → Performance,
|
||||
// unplugged and healthy → Balanced. Low battery wins even if a weak
|
||||
// charger is still plugged in — running low is the more urgent signal.
|
||||
// Performance only gets requested when the daemon actually reports it
|
||||
// as available, falling back to Balanced instead of erroring on
|
||||
// hardware without a performance profile (e.g. the ERROR this exact
|
||||
// widget hit before power-profiles-daemon was installed).
|
||||
//
|
||||
// This only ever writes when the *target* changes (crossing the 20%
|
||||
// line, plugging/unplugging) rather than on every poll, so a manual
|
||||
// pick from the buttons above still sticks until the next real
|
||||
// transition instead of being fought immediately.
|
||||
readonly property int desiredProfile: {
|
||||
if (root.fraction <= 0.20) return PowerProfile.PowerSaver
|
||||
if (!root.onBattery) return PowerProfiles.hasPerformanceProfile ? PowerProfile.Performance : PowerProfile.Balanced
|
||||
return PowerProfile.Balanced
|
||||
}
|
||||
|
||||
function applyDesiredProfile() {
|
||||
if (!root.batteryPresent) return
|
||||
if (PowerProfiles.profile !== root.desiredProfile) PowerProfiles.profile = root.desiredProfile
|
||||
}
|
||||
|
||||
onDesiredProfileChanged: root.applyDesiredProfile()
|
||||
Component.onCompleted: Qt.callLater(root.applyDesiredProfile)
|
||||
|
||||
Text {
|
||||
id: label
|
||||
anchors.centerIn: parent
|
||||
@@ -217,33 +424,16 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
component StatRow: RowLayout {
|
||||
id: statRow
|
||||
width: parent.width
|
||||
property string label: ""
|
||||
property string value: ""
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: statRow.label
|
||||
font.family: Appearance.fontFamily
|
||||
font.pixelSize: Appearance.fontSizeSmall
|
||||
color: Colors.muted
|
||||
}
|
||||
Text {
|
||||
text: statRow.value
|
||||
font.family: Appearance.fontFamily
|
||||
font.pixelSize: Appearance.fontSizeSmall
|
||||
color: Colors.muted
|
||||
}
|
||||
}
|
||||
|
||||
// --- Panel -----------------------------------------------------------
|
||||
|
||||
PopupWindow {
|
||||
id: panel
|
||||
visible: false
|
||||
color: "transparent"
|
||||
readonly property int contentWidth: 244
|
||||
// Wider than before (244) to give the 2-column info grid's
|
||||
// label/value pairs enough room per half without eliding —
|
||||
// same reasoning as NetworkIndicator.qml's own wider panel.
|
||||
readonly property int contentWidth: 280
|
||||
readonly property int contentPadding: 16
|
||||
implicitWidth: contentWidth + contentPadding * 2
|
||||
implicitHeight: mainColumn.implicitHeight + contentPadding * 2
|
||||
@@ -350,34 +540,27 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
// Stats
|
||||
RowLayout {
|
||||
// Stats — a 2-column, 2-row info grid, same layout
|
||||
// NetworkIndicator.qml uses for its own live stats: a
|
||||
// 4-column GridLayout fed a flattened label/value/label/value
|
||||
// list, so each row holds two label/value pairs side by side
|
||||
// with the values right-aligned against their own half.
|
||||
GridLayout {
|
||||
width: parent.width
|
||||
spacing: 16
|
||||
columns: 4
|
||||
columnSpacing: 12
|
||||
rowSpacing: 5
|
||||
|
||||
Column {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
StatRow { label: "STATE"; value: root.modeLabel }
|
||||
StatRow {
|
||||
label: "HEALTH"
|
||||
value: (root.batteryPresent && root.device.healthSupported)
|
||||
? Math.round(root.device.healthPercentage) + "%" : "—"
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
StatRow {
|
||||
label: root.onBattery ? "TIME LEFT" : "TIME TO FULL"
|
||||
value: root.batteryPresent
|
||||
? root.formatDuration(root.onBattery ? root.device.timeToEmpty : root.device.timeToFull)
|
||||
: "—"
|
||||
}
|
||||
StatRow {
|
||||
label: "RATE"
|
||||
value: root.batteryPresent ? Math.abs(root.device.changeRate).toFixed(1) + " W" : "—"
|
||||
Repeater {
|
||||
model: root.statCells
|
||||
Text {
|
||||
required property var modelData
|
||||
Layout.fillWidth: !modelData.isLabel
|
||||
horizontalAlignment: modelData.isLabel ? Text.AlignLeft : Text.AlignRight
|
||||
text: modelData.text
|
||||
font.family: Appearance.fontFamily
|
||||
font.pixelSize: Appearance.fontSizeSmall
|
||||
color: modelData.isLabel ? Colors.muted : Colors.foreground
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ Item {
|
||||
bonded: !!d.bonded,
|
||||
trusted: !!d.trusted,
|
||||
pairing: !!d.pairing,
|
||||
state: d.state,
|
||||
batteryAvailable: !!d.batteryAvailable,
|
||||
battery: d.battery || 0
|
||||
}
|
||||
@@ -105,19 +106,107 @@ Item {
|
||||
return null
|
||||
}
|
||||
|
||||
// --- Action feedback ---------------------------------------------------
|
||||
// BlueZ operations here are fire-and-forget from QML's side — connect()/
|
||||
// disconnect()/pair()/forget() report failure only via a C++-side log
|
||||
// line (see quickshell's bluetooth/device.cpp), nothing reaches QML. So
|
||||
// "did it work" has to be inferred from the device's own properties
|
||||
// instead of a success/failure callback. Shown as a line right under the
|
||||
// specific row it's about (DeviceRow.statusText below) rather than a
|
||||
// panel-wide banner — actionStatusAddress is how a row knows a message
|
||||
// is meant for it; since it's address-keyed rather than tied to a
|
||||
// particular DeviceRow instance, the message still finds its row even
|
||||
// when the device jumps sections (Paired → Connected on a successful
|
||||
// connect, say), because that's a new DeviceRow for the same address.
|
||||
// - busyStatus is purely reactive — it just reads pairing/state off
|
||||
// whichever row is currently mid-operation, however that operation
|
||||
// was triggered. It's not shown directly (each row already derives
|
||||
// the same "Connecting…"/"Disconnecting…" off its own dev.state);
|
||||
// it only exists here to tell resolvePendingAction when to fire.
|
||||
// - pendingAction+the busyStatus watcher below is how a *specific*
|
||||
// click gets its own outcome message: it snapshots what was asked
|
||||
// for, waits for busyStatus to clear, then checks whether the
|
||||
// expected end state (connected/paired) actually landed.
|
||||
readonly property string busyStatus: {
|
||||
for (const r of root.deviceRows) {
|
||||
if (r.pairing) return "pairing:" + r.address
|
||||
if (r.state === BluetoothDeviceState.Connecting) return "connecting:" + r.address
|
||||
if (r.state === BluetoothDeviceState.Disconnecting) return "disconnecting:" + r.address
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
property var pendingAction: null
|
||||
property string actionStatus: ""
|
||||
property string actionStatusAddress: ""
|
||||
|
||||
// Checks whether pendingAction's expected end state actually landed and
|
||||
// reports it, then clears pendingAction. Called both when busyStatus
|
||||
// clears normally and from pendingTimeoutTimer below — an operation
|
||||
// that resolves inside a single binding re-evaluation (a device already
|
||||
// in range reconnecting near-instantly, say) can flip busyStatus from ""
|
||||
// straight back to "" without ever visibly changing, which would
|
||||
// otherwise leave pendingAction stuck forever and misattribute whatever
|
||||
// busyStatus transition happens to come along next.
|
||||
function resolvePendingAction() {
|
||||
if (!root.pendingAction) return
|
||||
const p = root.pendingAction
|
||||
root.pendingAction = null
|
||||
const d = root.deviceForAddress(p.address)
|
||||
root.actionStatusAddress = p.address
|
||||
if (p.kind === "disconnect") {
|
||||
root.actionStatus = (d && !d.connected) ? "Disconnected" : "Couldn't disconnect"
|
||||
} else if (p.kind === "pair") {
|
||||
root.actionStatus = (d && d.paired) ? "Paired" : "Couldn't pair"
|
||||
} else {
|
||||
root.actionStatus = (d && d.connected) ? "Connected" : "Couldn't connect"
|
||||
}
|
||||
actionStatusTimer.restart()
|
||||
}
|
||||
|
||||
onBusyStatusChanged: if (root.busyStatus === "") root.resolvePendingAction()
|
||||
|
||||
Timer {
|
||||
id: actionStatusTimer
|
||||
interval: 3500
|
||||
repeat: false
|
||||
onTriggered: root.actionStatus = ""
|
||||
}
|
||||
|
||||
// Backstop for the "resolved before busyStatus ever visibly changed"
|
||||
// case above, and a genuine timeout for an operation that's just stuck
|
||||
// (device out of range, etc.) — either way, restarted whenever
|
||||
// pendingAction is (re)set, so it always reflects the latest click.
|
||||
Timer {
|
||||
id: pendingTimeoutTimer
|
||||
interval: 8000
|
||||
repeat: false
|
||||
onTriggered: root.resolvePendingAction()
|
||||
}
|
||||
|
||||
function connectDevice(row) {
|
||||
const d = root.deviceForAddress(row.address)
|
||||
if (!d) return
|
||||
// Already-paired devices just reconnect; a fresh discovery result
|
||||
// has to pair first (BlueZ's own agent handles the SSP/passkey
|
||||
// handshake — there's no PIN-entry surface exposed by this API).
|
||||
if (row.paired || row.bonded || row.trusted) d.connect()
|
||||
else d.pair()
|
||||
if (row.paired || row.bonded || row.trusted) {
|
||||
root.pendingAction = { address: row.address, kind: "connect" }
|
||||
pendingTimeoutTimer.restart()
|
||||
d.connect()
|
||||
} else {
|
||||
root.pendingAction = { address: row.address, kind: "pair" }
|
||||
pendingTimeoutTimer.restart()
|
||||
d.pair()
|
||||
}
|
||||
}
|
||||
|
||||
function disconnectDevice(row) {
|
||||
const d = root.deviceForAddress(row.address)
|
||||
if (d) d.disconnect()
|
||||
if (!d) return
|
||||
root.pendingAction = { address: row.address, kind: "disconnect" }
|
||||
pendingTimeoutTimer.restart()
|
||||
d.disconnect()
|
||||
}
|
||||
|
||||
function forgetDevice(row) {
|
||||
@@ -125,6 +214,18 @@ Item {
|
||||
if (!d) return
|
||||
if (d.connected) d.disconnect()
|
||||
d.forget()
|
||||
// No feedback line here — forgetting removes this row from every
|
||||
// list immediately, so there's nothing left to show a message
|
||||
// under. The row vanishing is the confirmation.
|
||||
}
|
||||
|
||||
function setTrusted(row, trusted) {
|
||||
const d = root.deviceForAddress(row.address)
|
||||
if (!d) return
|
||||
d.trusted = trusted
|
||||
root.actionStatusAddress = row.address
|
||||
root.actionStatus = trusted ? "Trusting" : "No longer trusting"
|
||||
actionStatusTimer.restart()
|
||||
}
|
||||
|
||||
readonly property bool anyConnected: root.connectedRows.length > 0
|
||||
@@ -203,8 +304,13 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
// One device row. `showForget` hides the forget (x) button for
|
||||
// discovered-but-unpaired devices, which have nothing to forget yet.
|
||||
// One device row. `showForget` gates the whole trailing action cluster —
|
||||
// trust toggle, disconnect, forget — for discovered-but-unpaired devices,
|
||||
// which have none of those to act on yet (a plain click there pairs
|
||||
// instead, via activate()). For a paired/connected row, the row body
|
||||
// itself still connects/disconnects (activate()) same as before; the
|
||||
// actions below are the explicit, discoverable versions of that plus
|
||||
// the two this row previously had no way to reach at all.
|
||||
component DeviceRow: Rectangle {
|
||||
id: devRow
|
||||
required property var dev
|
||||
@@ -213,6 +319,27 @@ Item {
|
||||
width: parent ? parent.width : 0
|
||||
radius: 0
|
||||
implicitHeight: rowContent.implicitHeight + 12
|
||||
|
||||
// Which action icon (if any) the pointer is currently over — drives
|
||||
// both that icon's own hover color below and rowMouse's click
|
||||
// dispatch. Derived from rowMouse.mouseX/containsMouse rather than a
|
||||
// MouseArea on each icon: those icons live inside rowContent, which
|
||||
// sits underneath rowMouse in stacking order, so a nested MouseArea
|
||||
// there would just be shadowed by it (same reason
|
||||
// NetworkIndicator.qml's NetworkRow dispatches by position too).
|
||||
// Checked right-to-left since that's the order the icons are laid
|
||||
// out in, so an earlier icon's "from here to the row's right edge"
|
||||
// zone doesn't swallow the ones after it.
|
||||
readonly property string hoveredAction: {
|
||||
if (!rowMouse.containsMouse) return ""
|
||||
if (devRow.showForget && forgetIcon.visible
|
||||
&& rowMouse.mouseX >= devRow.mapFromItem(forgetIcon, 0, 0).x - 8) return "forget"
|
||||
if (devRow.showForget && trustIcon.visible
|
||||
&& rowMouse.mouseX >= devRow.mapFromItem(trustIcon, 0, 0).x - 8) return "trust"
|
||||
if (disconnectIcon.visible
|
||||
&& rowMouse.mouseX >= devRow.mapFromItem(disconnectIcon, 0, 0).x - 8) return "disconnect"
|
||||
return ""
|
||||
}
|
||||
// Hover checked first (same ordering as Volume.qml's DeviceRow):
|
||||
// hovering always shows a solid Colors.primary fill, even for an
|
||||
// already-connected row, so the primaryText used below never has to
|
||||
@@ -270,6 +397,17 @@ Item {
|
||||
Text {
|
||||
readonly property string statusText: {
|
||||
if (devRow.dev.pairing) return "Pairing…"
|
||||
if (devRow.dev.state === BluetoothDeviceState.Connecting) return "Connecting…"
|
||||
if (devRow.dev.state === BluetoothDeviceState.Disconnecting) return "Disconnecting…"
|
||||
// The outcome of the last action taken on this
|
||||
// specific device (connect/disconnect/pair/trust) —
|
||||
// see the "Action feedback" comment above
|
||||
// root.busyStatus. Matched by address rather than by
|
||||
// this being "the same row" that was clicked, since
|
||||
// a successful connect/disconnect moves the device
|
||||
// to a different section (a new DeviceRow entirely).
|
||||
if (root.actionStatusAddress === devRow.dev.address && root.actionStatus !== "")
|
||||
return root.actionStatus
|
||||
if (devRow.dev.connected && devRow.dev.batteryAvailable)
|
||||
return Math.round(devRow.dev.battery * 100) + "%"
|
||||
if (devRow.dev.connected) return "Connected"
|
||||
@@ -283,14 +421,97 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
// Each action below is a fixed-size slot with its own
|
||||
// hover-halo outline — offset outside the slot rather than drawn
|
||||
// on its own edge, same treatment as devRow's own outline above
|
||||
// and every other hoverable box in this file (ToggleSwitch,
|
||||
// DeviceRow itself, etc.) — instead of just the color swap the
|
||||
// row's own icons get.
|
||||
|
||||
// Disconnect without forgetting — same effect as clicking the
|
||||
// row body on a Connected row (activate() below calls
|
||||
// disconnectDevice too), just given its own explicit, always-
|
||||
// visible icon instead of relying on that being guessed.
|
||||
Item {
|
||||
id: disconnectIcon
|
||||
visible: devRow.showForget && devRow.dev.connected
|
||||
Layout.preferredWidth: 20
|
||||
Layout.preferredHeight: 20
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.margins: -3
|
||||
radius: 0
|
||||
color: "transparent"
|
||||
border.color: Colors.foreground
|
||||
border.width: devRow.hoveredAction === "disconnect" ? 1 : 0
|
||||
}
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "link_off"
|
||||
font.family: Appearance.iconFontFamilyRounded
|
||||
font.variableAxes: Appearance.iconFontAxes
|
||||
font.pixelSize: 14
|
||||
// Stays primaryText (dark) rather than switching to
|
||||
// Colors.primary while targeted — devRow's own fill is
|
||||
// already Colors.primary on hover, so that would've put
|
||||
// a primary-colored glyph on a primary-colored
|
||||
// background. The outline Rectangle above is what shows
|
||||
// which slot is targeted.
|
||||
color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted
|
||||
}
|
||||
}
|
||||
|
||||
// Trust toggle — full brightness when trusted, dimmed when not,
|
||||
// same "state via opacity" convention PowerButton.qml uses for
|
||||
// its disabled entries.
|
||||
Item {
|
||||
id: trustIcon
|
||||
visible: devRow.showForget
|
||||
Layout.preferredWidth: 20
|
||||
Layout.preferredHeight: 20
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.margins: -3
|
||||
radius: 0
|
||||
color: "transparent"
|
||||
border.color: Colors.foreground
|
||||
border.width: devRow.hoveredAction === "trust" ? 1 : 0
|
||||
}
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "verified_user"
|
||||
opacity: devRow.dev.trusted ? 1 : 0.4
|
||||
font.family: Appearance.iconFontFamilyRounded
|
||||
font.variableAxes: Appearance.iconFontAxes
|
||||
font.pixelSize: 14
|
||||
color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: forgetIcon
|
||||
visible: devRow.showForget
|
||||
text: "close"
|
||||
font.family: Appearance.iconFontFamilyRounded
|
||||
font.variableAxes: Appearance.iconFontAxes
|
||||
font.pixelSize: 14
|
||||
color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted
|
||||
Layout.preferredWidth: 20
|
||||
Layout.preferredHeight: 20
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.margins: -3
|
||||
radius: 0
|
||||
color: "transparent"
|
||||
border.color: Colors.foreground
|
||||
border.width: devRow.hoveredAction === "forget" ? 1 : 0
|
||||
}
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "close"
|
||||
font.family: Appearance.iconFontFamilyRounded
|
||||
font.variableAxes: Appearance.iconFontAxes
|
||||
font.pixelSize: 14
|
||||
color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,21 +523,15 @@ Item {
|
||||
height: rowContent.height + 12
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
// forgetIcon has no MouseArea of its own — nested inside
|
||||
// rowContent, which sits underneath this full-row area in
|
||||
// stacking order, so a separate MouseArea there would just be
|
||||
// shadowed (see the identical fix in NetworkIndicator.qml's
|
||||
// NetworkRow). Dispatch by click position against this single
|
||||
// area instead.
|
||||
onClicked: (mouse) => {
|
||||
if (devRow.showForget && forgetIcon.visible) {
|
||||
const forgetLeft = devRow.mapFromItem(forgetIcon, 0, 0).x
|
||||
if (mouse.x >= forgetLeft - 8) {
|
||||
root.forgetDevice(devRow.dev)
|
||||
return
|
||||
}
|
||||
// Dispatches on devRow.hoveredAction rather than its own
|
||||
// MouseArea per icon — see that property's comment above.
|
||||
onClicked: {
|
||||
switch (devRow.hoveredAction) {
|
||||
case "forget": root.forgetDevice(devRow.dev); break
|
||||
case "trust": root.setTrusted(devRow.dev, !devRow.dev.trusted); break
|
||||
case "disconnect": root.disconnectDevice(devRow.dev); break
|
||||
default: devRow.activate()
|
||||
}
|
||||
devRow.activate()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,7 +542,11 @@ Item {
|
||||
id: panel
|
||||
visible: false
|
||||
color: "transparent"
|
||||
readonly property int contentWidth: 244
|
||||
// Wider than the other panels' usual 244 — each device row now ends
|
||||
// in a row of three 20px action slots (disconnect/trust/forget)
|
||||
// instead of a single close icon, and the name column needs room to
|
||||
// not immediately start eliding against that.
|
||||
readonly property int contentWidth: 300
|
||||
readonly property int contentPadding: 16
|
||||
implicitWidth: contentWidth + contentPadding * 2
|
||||
implicitHeight: mainColumn.implicitHeight + contentPadding * 2
|
||||
@@ -470,6 +689,42 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
// Paired — always shown in full, same as Connected above and
|
||||
// for the same reason: these are one click away from
|
||||
// reconnecting (root.connectDevice reconnects rather than
|
||||
// re-pairing whenever a row is already paired/bonded/trusted),
|
||||
// so they shouldn't be buried in the capped-height scroll area
|
||||
// below or hidden behind an active scan turning up other
|
||||
// nearby devices.
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 4
|
||||
visible: root.knownRows.length > 0
|
||||
|
||||
Text {
|
||||
text: "Paired Devices"
|
||||
font.family: Appearance.fontFamily
|
||||
font.pixelSize: Appearance.fontSizeSmall
|
||||
color: Colors.muted
|
||||
}
|
||||
Column {
|
||||
// Inset 3px on each side to match deviceListColumn
|
||||
// below, so DeviceRow's hover halo has room here too.
|
||||
x: 3
|
||||
width: parent.width - 6
|
||||
spacing: 2
|
||||
Repeater {
|
||||
model: root.knownRows
|
||||
DeviceRow {
|
||||
required property var modelData
|
||||
dev: modelData
|
||||
showForget: true
|
||||
onActivate: root.connectDevice(dev)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.adapter !== null && root.adapter.enabled && root.deviceRows.length === 0
|
||||
@@ -479,9 +734,9 @@ Item {
|
||||
color: Colors.muted
|
||||
}
|
||||
|
||||
// Paired/Available — capped height, scrolls once there are
|
||||
// more devices than fit rather than growing the popup
|
||||
// indefinitely.
|
||||
// Available — freshly discovered, unpaired devices only.
|
||||
// Capped height, scrolls once there are more than fit rather
|
||||
// than growing the popup indefinitely.
|
||||
Flickable {
|
||||
id: deviceListFlick
|
||||
width: parent.width
|
||||
@@ -507,32 +762,6 @@ Item {
|
||||
bottomPadding: 3
|
||||
spacing: 6
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 4
|
||||
visible: root.knownRows.length > 0
|
||||
|
||||
Text {
|
||||
text: "Paired Devices"
|
||||
font.family: Appearance.fontFamily
|
||||
font.pixelSize: Appearance.fontSizeSmall
|
||||
color: Colors.muted
|
||||
}
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 2
|
||||
Repeater {
|
||||
model: root.knownRows
|
||||
DeviceRow {
|
||||
required property var modelData
|
||||
dev: modelData
|
||||
showForget: true
|
||||
onActivate: root.connectDevice(dev)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 4
|
||||
|
||||
Reference in New Issue
Block a user