4004 lines
206 KiB
Plaintext
4004 lines
206 KiB
Plaintext
import Quickshell
|
||
import Quickshell.Io
|
||
import Quickshell.Hyprland
|
||
import Quickshell.Wayland
|
||
import Quickshell.Widgets
|
||
import Quickshell.Services.SystemTray
|
||
import Quickshell.Services.Pipewire
|
||
import Quickshell.Bluetooth
|
||
import QtQuick
|
||
import QtQuick.Layouts
|
||
import QtQuick.Controls
|
||
|
||
ShellRoot {
|
||
id: root
|
||
|
||
// ───────────── SETTINGS & THEMING (from ~/.config/foot/foot.ini) ─────────────
|
||
readonly property string mono: "Maple Mono, JetBrainsMono Nerd Font, monospace"
|
||
readonly property int barWidth: 45
|
||
readonly property int wsCount: 5
|
||
|
||
// Foot colorscheme palette: no harsh pure white, cohesive dark aesthetic
|
||
readonly property color cBar: "#151515" // background
|
||
readonly property color cPanel: "#181818" // opaque Foot-black popup surface
|
||
readonly property color cSurface: "#202020" // card / elevated item
|
||
readonly property color cSurfaceHover: "#282828" // hover state
|
||
readonly property color cBorder: "#2f2f2f" // subtle border
|
||
readonly property color cBorderFocus: "#454545" // active border
|
||
readonly property color cText: "#D0D0D0" // foreground text
|
||
readonly property color cDim: "#505050" // bright0 (dim / muted)
|
||
readonly property color cSubtle: "#757575" // secondary text
|
||
readonly property color cBright: "#F5F5F5" // selection-foreground (soft crisp white)
|
||
readonly property color cAccent: "#6C99BB" // selection-background / regular4 (steel blue)
|
||
readonly property color cSelected: "#303030" // neutral selected state, aligned with Foot's dark palette
|
||
readonly property color cSuccess: "#7B9246" // regular2 (olive green)
|
||
readonly property color cWarn: "#D3A04D" // regular3 (warm amber)
|
||
readonly property color cDanger: "#A53C23" // regular1 (crimson rust)
|
||
readonly property color cTeal: "#5EA8A2" // regular6 (teal cyan)
|
||
|
||
// ───────────── SYSTEM STATE ─────────────
|
||
property bool launcherOpen: false
|
||
property bool powerMenuOpen: false
|
||
property bool trayExpanded: false
|
||
property bool trayMenuOpen: false
|
||
property var trayMenuItem: null
|
||
property var trayMenuHandle: null
|
||
property var trayMenuStack: []
|
||
property var trayMenuScreen: null
|
||
property real trayMenuY: 0
|
||
property string powerConfirmAction: ""
|
||
property string activeApplet: "" // "wifi", "bluetooth", "sound", "calendar", or ""
|
||
|
||
function closeMenus(): void {
|
||
root.launcherOpen = false
|
||
root.activeApplet = ""
|
||
root.powerMenuOpen = false
|
||
root.powerConfirmAction = ""
|
||
root.trayMenuOpen = false
|
||
root.trayMenuItem = null
|
||
root.trayMenuHandle = null
|
||
root.trayMenuStack = []
|
||
}
|
||
|
||
function openTrayMenu(item: var, targetScreen: var, y: real): void {
|
||
root.closeMenus()
|
||
root.trayMenuItem = item
|
||
root.trayMenuHandle = item.menu
|
||
root.trayMenuScreen = targetScreen
|
||
root.trayMenuY = y
|
||
root.trayMenuOpen = true
|
||
}
|
||
|
||
function enterTraySubmenu(entry: var): void {
|
||
if (!entry.hasChildren) return
|
||
root.trayMenuStack = root.trayMenuStack.concat([root.trayMenuHandle])
|
||
root.trayMenuHandle = entry
|
||
}
|
||
|
||
function returnFromTraySubmenu(): void {
|
||
if (root.trayMenuStack.length === 0) return
|
||
root.trayMenuHandle = root.trayMenuStack[root.trayMenuStack.length - 1]
|
||
root.trayMenuStack = root.trayMenuStack.slice(0, -1)
|
||
}
|
||
|
||
function runPowerAction(action: string): void {
|
||
root.closeMenus()
|
||
if (action === "logout") Quickshell.execDetached(["hyprctl", "dispatch", "exit"])
|
||
else if (action === "reboot") Quickshell.execDetached(["foot", "-e", "doas", "/sbin/reboot"])
|
||
else if (action === "poweroff") Quickshell.execDetached(["foot", "-e", "doas", "/sbin/poweroff"])
|
||
}
|
||
|
||
function runBluetoothAction(action: string, address: string): void {
|
||
root.btActionPending = true
|
||
root.btActionAddress = address
|
||
root.btActionType = action
|
||
root.btActionMessage = ""
|
||
if (root.btScanning) {
|
||
root.btScanning = false
|
||
btScanTimer.stop()
|
||
root.runBluetoothCommand("scan off")
|
||
}
|
||
root.runBluetoothCommand(action + " " + address)
|
||
}
|
||
|
||
function runBluetoothCommand(command: string): void {
|
||
if (btAgentProc.running && root.btAgentReady) {
|
||
btAgentProc.write(command + "\n")
|
||
} else {
|
||
root.btQueuedCommands.push(command)
|
||
if (!btAgentProc.running)
|
||
btAgentProc.running = true
|
||
}
|
||
}
|
||
|
||
function startBluetoothScan(): void {
|
||
if (!root.btOn) {
|
||
if (Bluetooth.defaultAdapter)
|
||
Bluetooth.defaultAdapter.enabled = true
|
||
statusProc.running = true
|
||
return
|
||
}
|
||
root.btScanning = true
|
||
root.btActionMessage = ""
|
||
root.runBluetoothCommand("scan on")
|
||
btScanTimer.restart()
|
||
}
|
||
|
||
function finishBluetoothAction(message: string): void {
|
||
root.btActionMessage = message
|
||
root.btActionPending = false
|
||
root.btActionAddress = ""
|
||
root.btActionType = ""
|
||
statusProc.running = true
|
||
}
|
||
|
||
function readBluetoothOutput(rawLine: string): void {
|
||
const line = rawLine.trim()
|
||
if (!line) return
|
||
console.info("bluetoothctl:", line)
|
||
|
||
if (!root.btAgentReady && line.includes("Agent registered")) {
|
||
root.btAgentReady = true
|
||
btAgentProc.write("default-agent\n")
|
||
for (const command of root.btQueuedCommands)
|
||
btAgentProc.write(command + "\n")
|
||
root.btQueuedCommands = []
|
||
}
|
||
|
||
const failed = /failed|not available|no default controller|no agent|not authorized/i.test(line)
|
||
if (failed && root.btActionPending) {
|
||
root.finishBluetoothAction(line.replace(/^.*?bluetooth\]#\s*/, ""))
|
||
return
|
||
}
|
||
if (root.btActionPending && line.includes(root.btActionAddress)) {
|
||
if (root.btActionType === "pair" && /Paired: yes|Pairing successful/i.test(line))
|
||
root.finishBluetoothAction("Pairing complete")
|
||
else if (root.btActionType === "connect" && /Connected: yes|Connection successful/i.test(line))
|
||
root.finishBluetoothAction("Connected")
|
||
else if (root.btActionType === "disconnect" && /Connected: no|Successful disconnected/i.test(line))
|
||
root.finishBluetoothAction("Disconnected")
|
||
}
|
||
}
|
||
|
||
function showVolumeOsd(): void {
|
||
const audio = Pipewire.defaultAudioSink?.audio
|
||
root.osdKind = "volume"
|
||
volumeOsdTimer.interval = 1600
|
||
root.osdVolume = audio?.volume ?? 0
|
||
root.osdMuted = audio?.muted ?? false
|
||
root.volumeOsdVisible = true
|
||
volumeOsdTimer.restart()
|
||
}
|
||
|
||
function showBrightnessOsd(): void {
|
||
root.osdKind = "brightness"
|
||
volumeOsdTimer.interval = 1600
|
||
root.volumeOsdVisible = true
|
||
volumeOsdTimer.restart()
|
||
}
|
||
|
||
function showAppleMusicOsd(player: var): void {
|
||
root.osdKind = "track"
|
||
root.osdTrackTitle = player.title || "Unknown track"
|
||
root.osdTrackArtist = player.artist || "Apple Music"
|
||
root.osdTrackArtUrl = player.artUrl || ""
|
||
root.volumeOsdVisible = true
|
||
volumeOsdTimer.interval = 3500
|
||
volumeOsdTimer.restart()
|
||
}
|
||
|
||
function setScreenBrightness(value: real): void {
|
||
root.brightnessLevel = Math.max(0, Math.min(100, Math.round(value)))
|
||
root.showBrightnessOsd()
|
||
brightnessSetTimer.restart()
|
||
brightnessReadTimer.restart()
|
||
}
|
||
|
||
function changeScreenBrightness(delta: int): void {
|
||
Quickshell.execDetached(["brightnessctl", "-e4", "-n2", "set", delta > 0 ? "5%+" : "5%-"])
|
||
if (root.brightnessLevel >= 0)
|
||
root.brightnessLevel = Math.max(0, Math.min(100, root.brightnessLevel + (delta > 0 ? 5 : -5)))
|
||
root.showBrightnessOsd()
|
||
brightnessReadTimer.restart()
|
||
}
|
||
|
||
function changeOutputVolume(delta: real): void {
|
||
const audio = Pipewire.defaultAudioSink?.audio
|
||
if (!audio) return
|
||
audio.volume = Math.max(0, Math.min(1, audio.volume + delta))
|
||
if (audio.muted) audio.muted = false
|
||
root.showVolumeOsd()
|
||
}
|
||
|
||
function toggleOutputMute(): void {
|
||
const audio = Pipewire.defaultAudioSink?.audio
|
||
if (!audio) return
|
||
audio.muted = !audio.muted
|
||
root.showVolumeOsd()
|
||
}
|
||
|
||
function setOutputVolume(value: real): void {
|
||
const audio = Pipewire.defaultAudioSink?.audio
|
||
if (!audio) return
|
||
audio.volume = Math.max(0, Math.min(1, value))
|
||
if (audio.muted) audio.muted = false
|
||
root.showVolumeOsd()
|
||
}
|
||
|
||
function setInputVolume(value: real): void {
|
||
const audio = Pipewire.defaultAudioSource?.audio
|
||
if (!audio) return
|
||
audio.volume = Math.max(0, Math.min(1, value))
|
||
if (audio.muted) audio.muted = false
|
||
}
|
||
|
||
function formatBatteryTime(seconds: int): string {
|
||
if (seconds < 0) return "Unavailable"
|
||
const totalMinutes = Math.max(1, Math.round(seconds / 60))
|
||
const hours = Math.floor(totalMinutes / 60)
|
||
const minutes = totalMinutes % 60
|
||
return hours > 0 ? (hours + " hr " + minutes + " min") : (minutes + " min")
|
||
}
|
||
|
||
function batteryChargeColor(): color {
|
||
if (root.batteryPercent < 0) return root.cSubtle
|
||
if (root.batteryPercent >= 80) return root.cSuccess
|
||
if (root.batteryPercent >= 30) return root.cWarn
|
||
return root.cDanger
|
||
}
|
||
|
||
function sliderValueFromPointer(slider: var, x: real): real {
|
||
const span = Math.max(1, slider.availableWidth)
|
||
const fraction = Math.max(0, Math.min(1, (x - slider.leftPadding) / span))
|
||
return slider.from + fraction * (slider.to - slider.from)
|
||
}
|
||
|
||
function isPlaybackNode(node: var): bool {
|
||
return node.type === "Stream/Output/Audio"
|
||
|| (node.isStream && node.audio && !node.isSink)
|
||
}
|
||
|
||
property bool netUp: false
|
||
property bool wifiEnabled: true
|
||
property string activeSsid: ""
|
||
property bool ethernetConnected: false
|
||
property string ipAddress: ""
|
||
property var wifiNetworks: []
|
||
property bool wireguardAvailable: false
|
||
property bool wireguardEnabled: false
|
||
property string wireguardProfile: ""
|
||
property string wireguardInterface: ""
|
||
property string wireguardAutoTarget: ""
|
||
property bool simPresent: false
|
||
property bool simEnabled: true
|
||
property string simOperator: ""
|
||
property string simAccessMode: ""
|
||
property string simIpAddress: ""
|
||
property int simSignalPercent: -1
|
||
|
||
property bool btOn: false
|
||
property bool btConnected: false
|
||
property bool btScanning: false
|
||
property bool btActionPending: false
|
||
property string btActionAddress: ""
|
||
property string btActionType: ""
|
||
property string btActionMessage: ""
|
||
property bool btAgentReady: false
|
||
property var btQueuedCommands: []
|
||
property var btDevices: []
|
||
property bool volumeOsdVisible: false
|
||
property string osdKind: "volume"
|
||
property real osdVolume: 0
|
||
property bool osdMuted: false
|
||
property string osdTrackTitle: ""
|
||
property string osdTrackArtist: ""
|
||
property string osdTrackArtUrl: ""
|
||
property string appleMusicTrackKey: ""
|
||
property var pulsePlaybackStreams: []
|
||
property var mprisPlayers: []
|
||
property var pulseOutputs: []
|
||
property var pulseInputs: []
|
||
property bool outputDevicesExpanded: false
|
||
property bool inputDevicesExpanded: false
|
||
property int batteryPercent: -1
|
||
property string batteryStatus: "Unknown"
|
||
property int batteryTimeSeconds: -1
|
||
property string systemLoad: "—"
|
||
property bool nightLightEnabled: false
|
||
property bool onExternalPower: false
|
||
property int brightnessLevel: -1
|
||
|
||
// Bind every node before inspecting its media.class. Application streams
|
||
// such as Firefox may not expose their type and properties until bound.
|
||
PwObjectTracker {
|
||
objects: [Pipewire.defaultAudioSink, Pipewire.defaultAudioSource]
|
||
.concat(Pipewire.nodes.values)
|
||
}
|
||
|
||
function refreshPlaybackStreams(): void {
|
||
if (!playbackStreamsProc.running)
|
||
playbackStreamsProc.running = true
|
||
if (!mprisPlayersProc.running)
|
||
mprisPlayersProc.running = true
|
||
if (!pulseOutputsProc.running)
|
||
pulseOutputsProc.running = true
|
||
if (!pulseInputsProc.running)
|
||
pulseInputsProc.running = true
|
||
}
|
||
|
||
function updatePlaybackMetadata(): void {
|
||
const firefoxStreams = root.pulsePlaybackStreams.filter(stream =>
|
||
(stream.application || "").toLowerCase().includes("firefox"))
|
||
root.pulsePlaybackStreams = root.pulsePlaybackStreams.map(stream => {
|
||
const app = (stream.application || "").toLowerCase()
|
||
let candidates = root.mprisPlayers.filter(player =>
|
||
player.name.toLowerCase().includes(app.includes("firefox") ? "firefox" : app))
|
||
let matched = null
|
||
if (app.includes("firefox")) {
|
||
const pid = String(stream.pid || "")
|
||
const pidMatches = pid ? candidates.filter(player => player.name.endsWith("_" + pid)) : []
|
||
if (pidMatches.length === 1)
|
||
matched = pidMatches[0]
|
||
else if (candidates.length === 1 && firefoxStreams.length === 1)
|
||
matched = candidates[0]
|
||
} else if (candidates.length === 1) {
|
||
matched = candidates[0]
|
||
}
|
||
const appleMusic = matched && matched.url.toLowerCase().includes("music.apple.com")
|
||
return Object.assign({}, stream, {
|
||
player: matched ? matched.name : "",
|
||
applicationLabel: appleMusic ? "Apple Music" : stream.application,
|
||
title: matched && matched.title ? matched.title : stream.media,
|
||
artist: matched ? matched.artist : "",
|
||
artUrl: matched ? matched.artUrl : ""
|
||
})
|
||
})
|
||
}
|
||
|
||
function setPulseDeviceVolume(kind: string, name: string, value: real): void {
|
||
Quickshell.execDetached(["pactl", "set-" + kind + "-volume", name, Math.round(value * 100) + "%"])
|
||
}
|
||
|
||
function togglePulseDeviceMute(kind: string, name: string, muted: bool): void {
|
||
Quickshell.execDetached(["pactl", "set-" + kind + "-mute", name, muted ? "1" : "0"])
|
||
}
|
||
|
||
Timer {
|
||
interval: 2500
|
||
repeat: true
|
||
running: root.activeApplet === "sound"
|
||
onTriggered: root.refreshPlaybackStreams()
|
||
onRunningChanged: if (running) root.refreshPlaybackStreams()
|
||
}
|
||
|
||
Process {
|
||
id: playbackStreamsProc
|
||
command: ["pactl", "-f", "json", "list", "sink-inputs"]
|
||
stdout: StdioCollector {
|
||
onStreamFinished: {
|
||
try {
|
||
const inputs = JSON.parse(text)
|
||
root.pulsePlaybackStreams = inputs
|
||
.filter(input => !input.corked)
|
||
.map(input => {
|
||
const properties = input.properties || {}
|
||
const media = properties["media.title"] || properties["media.name"]
|
||
return {
|
||
application: properties["application.name"] || properties["node.name"] || "Audio stream",
|
||
pid: properties["application.process.id"] || "",
|
||
media: media && media !== "(null)" && media !== "null" ? media : ""
|
||
}
|
||
})
|
||
root.updatePlaybackMetadata()
|
||
} catch (error) {
|
||
console.warn("Unable to read active audio streams:", error)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Process {
|
||
id: mprisPlayersProc
|
||
command: ["playerctl", "--all-players", "metadata", "--format", "{{playerName}}|{{xesam:artist}}|{{xesam:title}}|{{xesam:url}}|{{mpris:artUrl}}"]
|
||
stdout: StdioCollector {
|
||
onStreamFinished: {
|
||
const entries = []
|
||
for (const line of text.split("\n")) {
|
||
const fields = line.split("|")
|
||
if (fields.length < 5) continue
|
||
entries.push({ name: fields[0], artist: fields[1], title: fields[2], url: fields[3], artUrl: fields.slice(4).join("|") })
|
||
}
|
||
const appleMusic = entries.find(player => player.url.toLowerCase().includes("music.apple.com") && player.title)
|
||
if (appleMusic) {
|
||
const key = [appleMusic.name, appleMusic.url, appleMusic.artist, appleMusic.title].join("|")
|
||
if (root.appleMusicTrackKey && root.appleMusicTrackKey !== key)
|
||
root.showAppleMusicOsd(appleMusic)
|
||
root.appleMusicTrackKey = key
|
||
} else {
|
||
root.appleMusicTrackKey = ""
|
||
}
|
||
root.mprisPlayers = entries
|
||
root.updatePlaybackMetadata()
|
||
}
|
||
}
|
||
}
|
||
|
||
Process {
|
||
id: pulseOutputsProc
|
||
command: ["pactl", "-f", "json", "list", "sinks"]
|
||
stdout: StdioCollector {
|
||
onStreamFinished: {
|
||
try {
|
||
root.pulseOutputs = JSON.parse(text).map(device => {
|
||
const channels = Object.values(device.volume || {})
|
||
return {
|
||
name: device.name,
|
||
description: device.description || device.name,
|
||
volume: channels.length ? Math.max(0, Math.min(1, parseInt(channels[0].value_percent) / 100)) : 0,
|
||
muted: device.mute
|
||
}
|
||
})
|
||
} catch (error) {
|
||
console.warn("Unable to read audio outputs:", error)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Process {
|
||
id: pulseInputsProc
|
||
command: ["pactl", "-f", "json", "list", "sources"]
|
||
stdout: StdioCollector {
|
||
onStreamFinished: {
|
||
try {
|
||
root.pulseInputs = JSON.parse(text)
|
||
.filter(device => !(device.name || "").endsWith(".monitor") && (device.properties || {})["device.class"] !== "monitor")
|
||
.map(device => {
|
||
const channels = Object.values(device.volume || {})
|
||
return {
|
||
name: device.name,
|
||
description: device.description || device.name,
|
||
volume: channels.length ? Math.max(0, Math.min(1, parseInt(channels[0].value_percent) / 100)) : 0,
|
||
muted: device.mute
|
||
}
|
||
})
|
||
} catch (error) {
|
||
console.warn("Unable to read audio inputs:", error)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
IpcHandler {
|
||
target: "launcher"
|
||
function toggle(): void { root.launcherOpen = !root.launcherOpen }
|
||
function open(): void { root.launcherOpen = true }
|
||
function close(): void { root.launcherOpen = false }
|
||
}
|
||
|
||
IpcHandler {
|
||
target: "power"
|
||
function toggle(): void {
|
||
root.launcherOpen = false
|
||
root.activeApplet = ""
|
||
root.powerConfirmAction = ""
|
||
root.powerMenuOpen = !root.powerMenuOpen
|
||
}
|
||
function open(): void {
|
||
root.launcherOpen = false
|
||
root.activeApplet = ""
|
||
root.powerConfirmAction = ""
|
||
root.powerMenuOpen = true
|
||
}
|
||
function close(): void { root.closeMenus() }
|
||
}
|
||
|
||
IpcHandler {
|
||
target: "volume"
|
||
function up(): void { root.changeOutputVolume(0.05) }
|
||
function down(): void { root.changeOutputVolume(-0.05) }
|
||
function mute(): void { root.toggleOutputMute() }
|
||
}
|
||
|
||
IpcHandler {
|
||
target: "brightness"
|
||
function up(): void { root.changeScreenBrightness(1) }
|
||
function down(): void { root.changeScreenBrightness(-1) }
|
||
}
|
||
|
||
SystemClock { id: clock; precision: SystemClock.Seconds }
|
||
|
||
// Periodic system status collector (Network + Bluetooth)
|
||
Process {
|
||
id: statusProc
|
||
command: ["python3", "-c", `
|
||
import subprocess, json
|
||
|
||
data = {
|
||
'net_up': False,
|
||
'wifi_enabled': False,
|
||
'active_ssid': '',
|
||
'ethernet_connected': False,
|
||
'ip': '',
|
||
'wireguard_available': False,
|
||
'wireguard_enabled': False,
|
||
'wireguard_profile': '',
|
||
'wireguard_interface': '',
|
||
'sim_present': False,
|
||
'sim_enabled': False,
|
||
'sim_operator': '',
|
||
'sim_mode': '',
|
||
'sim_ip': '',
|
||
'sim_signal': -1,
|
||
'bt_on': False,
|
||
'bt_connected': False,
|
||
'bt_devices': []
|
||
}
|
||
|
||
def nm_fields(line):
|
||
fields = []
|
||
value = ''
|
||
escaped = False
|
||
for char in line:
|
||
if escaped:
|
||
value += char
|
||
escaped = False
|
||
elif char == '\\\\':
|
||
escaped = True
|
||
elif char == ':':
|
||
fields.append(value)
|
||
value = ''
|
||
else:
|
||
value += char
|
||
if escaped:
|
||
value += '\\\\'
|
||
fields.append(value)
|
||
return fields
|
||
|
||
# Network check
|
||
try:
|
||
state = subprocess.run(['nmcli', '-t', '-f', 'STATE', 'general'], capture_output=True, text=True, timeout=2).stdout.strip()
|
||
data['net_up'] = state.startswith('connected')
|
||
|
||
wifi_rad = subprocess.run(['nmcli', '-t', '-f', 'WIFI', 'general'], capture_output=True, text=True, timeout=2).stdout.strip()
|
||
data['wifi_enabled'] = 'enabled' in wifi_rad
|
||
|
||
conns = subprocess.run(['nmcli', '-t', '-f', 'NAME,TYPE,DEVICE,STATE', 'con', 'show', '--active'], capture_output=True, text=True, timeout=2).stdout.strip()
|
||
for line in conns.splitlines():
|
||
if line:
|
||
parts = nm_fields(line)
|
||
if len(parts) >= 2:
|
||
connection_type = parts[1].lower()
|
||
if 'wireless' in connection_type and not data['active_ssid']:
|
||
data['active_ssid'] = parts[0]
|
||
ssid = subprocess.run(['nmcli', '-g', '802-11-wireless.ssid', 'connection', 'show', 'id', parts[0]], capture_output=True, text=True, timeout=2).stdout.strip()
|
||
if ssid:
|
||
data['active_ssid'] = ssid
|
||
elif 'ethernet' in connection_type:
|
||
data['ethernet_connected'] = True
|
||
|
||
ip_out = subprocess.run(['sh', '-c', "ip -4 route get 1.1.1.1 2>/dev/null | awk '{print $7}'"], capture_output=True, text=True, timeout=2).stdout.strip()
|
||
data['ip'] = ip_out
|
||
except Exception:
|
||
pass
|
||
|
||
# Select the WireGuard config for this network and read active wg-quick interfaces.
|
||
data['wireguard_available'] = True
|
||
data['wireguard_profile'] = 'int' if data['active_ssid'] in ('TCH5298221-5G', 'TCH5298221') else 'home'
|
||
try:
|
||
interfaces = subprocess.run(['wg', 'show', 'interfaces'], capture_output=True, text=True, timeout=2).stdout.strip().split()
|
||
active_interface = next((name for name in ('int', 'home') if name in interfaces), '')
|
||
data['wireguard_interface'] = active_interface
|
||
data['wireguard_enabled'] = bool(active_interface)
|
||
except Exception:
|
||
pass
|
||
|
||
# Cellular status from ModemManager. Parse the stable section labels emitted by
|
||
# mmcli's human-readable output; keep the SIM card hidden when no SIM is present.
|
||
try:
|
||
active_profiles = subprocess.run(['nmcli', '-t', '-f', 'NAME', 'connection', 'show', '--active'], capture_output=True, text=True, timeout=2).stdout.strip().splitlines()
|
||
data['sim_enabled'] = any(profile.split(':', 1)[0] == 'mobile' for profile in active_profiles)
|
||
listing = subprocess.run(['mmcli', '-L'], capture_output=True, text=True, timeout=3).stdout
|
||
match = __import__('re').search(r'/Modem/(\\d+)', listing)
|
||
if match:
|
||
modem_id = match.group(1)
|
||
modem = subprocess.run(['mmcli', '-m', modem_id], capture_output=True, text=True, timeout=3).stdout
|
||
sim_path = __import__('re').search(r'primary sim path:\\s*(\\S+)', modem)
|
||
if sim_path and sim_path.group(1) not in ('none', '/'):
|
||
data['sim_present'] = True
|
||
operator = __import__('re').search(r'operator name:\\s*(.*?)\\s*$', modem, __import__('re').M)
|
||
mode = __import__('re').search(r'access tech:\\s*(.*?)\\s*$', modem, __import__('re').M)
|
||
signal = __import__('re').search(r'signal quality:\\s*(\\d+)%', modem)
|
||
net_port = __import__('re').search(r'(wwp\\S+)\\s*\\(net\\)', modem)
|
||
data['sim_operator'] = operator.group(1).strip() if operator else 'Operator unavailable'
|
||
raw_mode = mode.group(1).strip().lower() if mode else ''
|
||
if any(tech in raw_mode for tech in ('nr5g', '5g')):
|
||
data['sim_mode'] = '5G'
|
||
elif 'lte' in raw_mode:
|
||
data['sim_mode'] = '4G'
|
||
elif any(tech in raw_mode for tech in ('umts', 'hspa', 'hsdpa', 'hsupa', 'wcdma')):
|
||
data['sim_mode'] = '3G'
|
||
elif any(tech in raw_mode for tech in ('gprs', 'edge', 'gsm')):
|
||
data['sim_mode'] = '2G'
|
||
else:
|
||
data['sim_mode'] = '—'
|
||
data['sim_signal'] = int(signal.group(1)) if signal else -1
|
||
if net_port:
|
||
addresses = subprocess.run(['ip', '-4', '-o', 'addr', 'show', 'dev', net_port.group(1), 'scope', 'global'], capture_output=True, text=True, timeout=2).stdout.strip().splitlines()
|
||
if addresses:
|
||
fields = addresses[0].split()
|
||
if len(fields) >= 4 and '/' in fields[3]:
|
||
data['sim_ip'] = fields[3].split('/')[0]
|
||
except Exception:
|
||
pass
|
||
|
||
# Bluetooth check
|
||
try:
|
||
show = subprocess.run(['bluetoothctl', 'show'], capture_output=True, text=True, timeout=2).stdout
|
||
if 'Powered: yes' in show:
|
||
data['bt_on'] = True
|
||
devs = subprocess.run(['bluetoothctl', 'devices'], capture_output=True, text=True, timeout=2).stdout
|
||
for line in devs.splitlines():
|
||
if line.startswith('Device '):
|
||
parts = line.split(' ', 2)
|
||
if len(parts) >= 3:
|
||
mac, name = parts[1], parts[2]
|
||
info = subprocess.run(['bluetoothctl', 'info', mac], capture_output=True, text=True, timeout=1).stdout
|
||
conn = 'Connected: yes' in info
|
||
if conn:
|
||
data['bt_connected'] = True
|
||
paired = 'Paired: yes' in info
|
||
trusted = 'Trusted: yes' in info
|
||
data['bt_devices'].append({'mac': mac, 'name': name, 'connected': conn, 'paired': paired, 'trusted': trusted})
|
||
data['bt_devices'].sort(key=lambda d: (not d['connected'], not d['paired'], d['name'].lower()))
|
||
except Exception:
|
||
pass
|
||
|
||
print(json.dumps(data))
|
||
`]
|
||
stdout: StdioCollector {
|
||
onStreamFinished: {
|
||
try {
|
||
const res = JSON.parse(text)
|
||
root.netUp = res.net_up
|
||
root.wifiEnabled = res.wifi_enabled
|
||
root.activeSsid = res.active_ssid
|
||
root.ethernetConnected = res.ethernet_connected
|
||
root.ipAddress = res.ip
|
||
root.wireguardAvailable = res.wireguard_available
|
||
root.wireguardEnabled = res.wireguard_enabled
|
||
root.wireguardProfile = res.wireguard_profile
|
||
root.wireguardInterface = res.wireguard_interface
|
||
if (root.wireguardInterface === root.wireguardProfile) {
|
||
root.wireguardAutoTarget = root.wireguardProfile
|
||
} else if (root.wireguardAutoTarget !== root.wireguardProfile) {
|
||
const target = root.wireguardProfile
|
||
const active = root.wireguardInterface
|
||
root.wireguardAutoTarget = target
|
||
const command = active
|
||
? "doas /usr/bin/wg-quick down " + active + " && doas /usr/bin/wg-quick up " + target
|
||
: "doas /usr/bin/wg-quick up " + target
|
||
Quickshell.execDetached(["sh", "-c", command])
|
||
}
|
||
root.simPresent = res.sim_present
|
||
root.simEnabled = res.sim_enabled
|
||
root.simOperator = res.sim_operator
|
||
root.simAccessMode = res.sim_mode
|
||
root.simIpAddress = res.sim_ip
|
||
root.simSignalPercent = res.sim_signal
|
||
root.btOn = res.bt_on
|
||
root.btConnected = res.bt_connected
|
||
root.btDevices = res.bt_devices
|
||
if (root.btActionPending) {
|
||
const device = res.bt_devices.find(d => d.mac === root.btActionAddress)
|
||
if (device && root.btActionType === "pair" && device.paired)
|
||
root.finishBluetoothAction("Pairing complete")
|
||
else if (device && root.btActionType === "connect" && device.connected)
|
||
root.finishBluetoothAction("Connected")
|
||
else if (device && root.btActionType === "disconnect" && !device.connected)
|
||
root.finishBluetoothAction("Disconnected")
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Read Linux power_supply sysfs directly so the indicator also works on
|
||
// minimal Gentoo installs that do not run the optional UPower daemon.
|
||
Process {
|
||
id: batteryProc
|
||
command: ["python3", "-c", `
|
||
import glob, json, os
|
||
result = {'percent': -1, 'status': 'Unknown', 'seconds': -1, 'external': False}
|
||
for path in glob.glob('/sys/class/power_supply/*'):
|
||
if os.path.basename(path).startswith(('AC', 'ADP')) or os.path.basename(path) in ('USB', 'USB_C'):
|
||
try:
|
||
with open(path + '/online') as f:
|
||
result['external'] = result['external'] or f.read().strip() == '1'
|
||
except OSError:
|
||
pass
|
||
for path in glob.glob('/sys/class/power_supply/*'):
|
||
try:
|
||
with open(path + '/type') as f:
|
||
if f.read().strip() != 'Battery':
|
||
continue
|
||
with open(path + '/capacity') as f:
|
||
result['percent'] = int(f.read().strip())
|
||
with open(path + '/status') as f:
|
||
result['status'] = f.read().strip()
|
||
if result['status'] not in ('Full', 'Not charging'):
|
||
try:
|
||
with open(path + '/energy_now') as f: remaining = int(f.read().strip())
|
||
with open(path + '/energy_full') as f: full = int(f.read().strip())
|
||
with open(path + '/power_now') as f: rate = int(f.read().strip())
|
||
except OSError:
|
||
try:
|
||
with open(path + '/charge_now') as f: remaining = int(f.read().strip())
|
||
with open(path + '/charge_full') as f: full = int(f.read().strip())
|
||
with open(path + '/current_now') as f: rate = int(f.read().strip())
|
||
except OSError:
|
||
remaining = full = rate = 0
|
||
if rate > 0:
|
||
amount = max(0, full - remaining) if result['status'] == 'Charging' else remaining
|
||
result['seconds'] = int(amount / rate * 3600)
|
||
break
|
||
except (OSError, ValueError):
|
||
continue
|
||
print(json.dumps(result))
|
||
`]
|
||
stdout: StdioCollector {
|
||
onStreamFinished: {
|
||
try {
|
||
const status = JSON.parse(text)
|
||
root.batteryPercent = status.percent
|
||
root.batteryStatus = status.status
|
||
root.batteryTimeSeconds = status.seconds
|
||
root.onExternalPower = status.external
|
||
} catch (e) {
|
||
root.batteryPercent = -1
|
||
root.batteryStatus = "Unknown"
|
||
root.batteryTimeSeconds = -1
|
||
root.onExternalPower = false
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Timer {
|
||
interval: 30000
|
||
running: true
|
||
repeat: true
|
||
triggeredOnStart: true
|
||
onTriggered: if (!batteryProc.running) batteryProc.running = true
|
||
}
|
||
|
||
Process {
|
||
id: loadReadProc
|
||
command: ["cat", "/proc/loadavg"]
|
||
stdout: StdioCollector {
|
||
onStreamFinished: {
|
||
const fields = text.trim().split(/\s+/)
|
||
root.systemLoad = fields.length >= 3 ? fields.slice(0, 3).join(" ") : "Unavailable"
|
||
}
|
||
}
|
||
}
|
||
|
||
Timer {
|
||
interval: 5000
|
||
running: root.activeApplet === "battery"
|
||
repeat: true
|
||
triggeredOnStart: true
|
||
onTriggered: if (!loadReadProc.running) loadReadProc.running = true
|
||
}
|
||
|
||
Process {
|
||
id: brightnessReadProc
|
||
command: ["brightnessctl", "-m"]
|
||
stdout: StdioCollector {
|
||
onStreamFinished: {
|
||
const fields = text.trim().split(",")
|
||
const current = Number(fields[2])
|
||
const maximum = Number(fields[4])
|
||
root.brightnessLevel = maximum > 0 && Number.isFinite(current) && Number.isFinite(maximum)
|
||
? Math.round(current * 100 / maximum) : -1
|
||
}
|
||
}
|
||
}
|
||
|
||
Timer {
|
||
id: brightnessSetTimer
|
||
interval: 100
|
||
repeat: false
|
||
onTriggered: if (root.brightnessLevel >= 0)
|
||
Quickshell.execDetached(["brightnessctl", "-e4", "-n2", "set", root.brightnessLevel + "%"])
|
||
}
|
||
|
||
Timer {
|
||
id: brightnessReadTimer
|
||
interval: 160
|
||
repeat: false
|
||
onTriggered: if (!brightnessReadProc.running && !brightnessSetTimer.running) brightnessReadProc.running = true
|
||
}
|
||
|
||
Timer {
|
||
interval: 5000
|
||
running: root.activeApplet === "battery"
|
||
repeat: true
|
||
triggeredOnStart: true
|
||
onTriggered: if (!brightnessReadProc.running && !brightnessSetTimer.running && !brightnessSlider.pressed) brightnessReadProc.running = true
|
||
}
|
||
|
||
onActiveAppletChanged: {
|
||
if (activeApplet === "battery" && !brightnessReadProc.running)
|
||
brightnessReadProc.running = true
|
||
}
|
||
|
||
// Keep one BlueZ CLI session alive so the selected pairing agent remains
|
||
// registered while scan/pair/connect requests complete.
|
||
Process {
|
||
id: btAgentProc
|
||
command: ["bluetoothctl", "--agent", "NoInputNoOutput"]
|
||
stdinEnabled: true
|
||
stdout: SplitParser {
|
||
onRead: data => root.readBluetoothOutput(data)
|
||
}
|
||
stderr: SplitParser {
|
||
onRead: data => root.readBluetoothOutput(data)
|
||
}
|
||
onExited: {
|
||
root.btAgentReady = false
|
||
root.btQueuedCommands = []
|
||
root.btScanning = false
|
||
root.btActionPending = false
|
||
root.btActionAddress = ""
|
||
root.btActionType = ""
|
||
statusProc.running = true
|
||
}
|
||
}
|
||
|
||
Timer {
|
||
id: btScanTimer
|
||
interval: 15000
|
||
repeat: false
|
||
onTriggered: {
|
||
root.runBluetoothCommand("scan off")
|
||
root.btScanning = false
|
||
statusProc.running = true
|
||
}
|
||
}
|
||
|
||
Timer {
|
||
id: volumeOsdTimer
|
||
interval: 1600
|
||
repeat: false
|
||
onTriggered: root.volumeOsdVisible = false
|
||
}
|
||
|
||
Timer {
|
||
interval: 1000
|
||
repeat: true
|
||
running: true
|
||
onTriggered: if (!mprisPlayersProc.running) mprisPlayersProc.running = true
|
||
}
|
||
|
||
Timer {
|
||
interval: 4000
|
||
running: true
|
||
repeat: true
|
||
triggeredOnStart: true
|
||
onTriggered: statusProc.running = true
|
||
}
|
||
|
||
// Wi-Fi network scanner
|
||
Process {
|
||
id: wifiScanProc
|
||
command: ["nmcli", "-t", "-f", "SSID,SIGNAL,SECURITY,IN-USE", "dev", "wifi"]
|
||
stdout: StdioCollector {
|
||
onStreamFinished: {
|
||
const lines = text.trim().split("\n")
|
||
let results = []
|
||
for (let l of lines) {
|
||
if (!l) continue
|
||
const parts = l.split(":")
|
||
if (parts[0] && !results.some(r => r.ssid === parts[0])) {
|
||
results.push({
|
||
ssid: parts[0],
|
||
signal: parseInt(parts[1]) || 0,
|
||
security: parts[2] || "Open",
|
||
active: parts[3] === "*"
|
||
})
|
||
}
|
||
}
|
||
results.sort((a, b) => (b.active ? 1 : 0) - (a.active ? 1 : 0) || b.signal - a.signal)
|
||
root.wifiNetworks = results
|
||
}
|
||
}
|
||
}
|
||
|
||
// ───────────── VERTICAL BAR ─────────────
|
||
Variants {
|
||
model: Quickshell.screens
|
||
|
||
PanelWindow {
|
||
id: bar
|
||
required property var modelData
|
||
screen: modelData
|
||
|
||
anchors { top: true; bottom: true; left: true }
|
||
implicitWidth: root.barWidth
|
||
color: root.cBar
|
||
|
||
Rectangle {
|
||
anchors { top: parent.top; bottom: parent.bottom; right: parent.right }
|
||
width: 1
|
||
color: root.cBorder
|
||
}
|
||
|
||
// Top: Workspaces
|
||
ColumnLayout {
|
||
anchors {
|
||
top: parent.top
|
||
topMargin: 12
|
||
horizontalCenter: parent.horizontalCenter
|
||
horizontalCenterOffset: -0.5
|
||
}
|
||
spacing: 6
|
||
|
||
// Workspaces
|
||
Repeater {
|
||
model: root.wsCount
|
||
delegate: Rectangle {
|
||
required property int index
|
||
readonly property int wsId: index + 1
|
||
readonly property bool active: Hyprland.focusedWorkspace?.id === wsId
|
||
readonly property bool occupied: Hyprland.workspaces.values.some(w => w.id === wsId)
|
||
|
||
Layout.alignment: Qt.AlignHCenter
|
||
implicitWidth: 32; implicitHeight: 32
|
||
radius: 0
|
||
color: active ? root.cSelected : (wsHover.containsMouse ? root.cSurface : "transparent")
|
||
border.color: active ? root.cBorderFocus : "transparent"
|
||
|
||
Text {
|
||
anchors.centerIn: parent
|
||
text: parent.wsId
|
||
font.family: root.mono
|
||
font.pixelSize: 12
|
||
font.bold: parent.active
|
||
color: parent.active ? root.cBright : (parent.occupied ? root.cText : root.cDim)
|
||
}
|
||
|
||
MouseArea {
|
||
id: wsHover
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: Hyprland.dispatch('hl.dsp.focus({ workspace = "' + parent.wsId + '" })')
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Center: Clock (Clickable, opens Calendar)
|
||
Rectangle {
|
||
anchors.centerIn: parent
|
||
anchors.horizontalCenterOffset: -0.5
|
||
implicitWidth: 34
|
||
implicitHeight: 52
|
||
radius: 0
|
||
color: root.activeApplet === "calendar" ? root.cSelected : (clockHover.containsMouse ? root.cSurface : "transparent")
|
||
border.color: root.activeApplet === "calendar" ? root.cBorderFocus : "transparent"
|
||
|
||
ColumnLayout {
|
||
anchors.centerIn: parent
|
||
spacing: 1
|
||
|
||
Text {
|
||
Layout.alignment: Qt.AlignHCenter
|
||
text: Qt.formatDateTime(clock.date, "HH")
|
||
font.family: root.mono
|
||
font.pixelSize: 13
|
||
font.bold: true
|
||
color: root.activeApplet === "calendar" ? root.cBright : (clockHover.containsMouse ? root.cBright : root.cText)
|
||
}
|
||
|
||
Rectangle {
|
||
Layout.alignment: Qt.AlignHCenter
|
||
width: 4; height: 1
|
||
color: root.activeApplet === "calendar" ? root.cBorderFocus : root.cBorder
|
||
}
|
||
|
||
Text {
|
||
Layout.alignment: Qt.AlignHCenter
|
||
text: Qt.formatDateTime(clock.date, "mm")
|
||
font.family: root.mono
|
||
font.pixelSize: 13
|
||
font.bold: true
|
||
color: root.activeApplet === "calendar" ? root.cBright : (clockHover.containsMouse ? root.cBright : root.cDim)
|
||
}
|
||
}
|
||
|
||
MouseArea {
|
||
id: clockHover
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: root.activeApplet = (root.activeApplet === "calendar" ? "" : "calendar")
|
||
}
|
||
}
|
||
|
||
// Bottom: Battery, Network, Bluetooth, Audio Triggers
|
||
ColumnLayout {
|
||
anchors {
|
||
bottom: parent.bottom
|
||
bottomMargin: 12
|
||
horizontalCenter: parent.horizontalCenter
|
||
horizontalCenterOffset: -0.5
|
||
}
|
||
spacing: 6
|
||
|
||
// Expandable system tray. The arrow stays visible while the
|
||
// registered application icons are tucked away.
|
||
Repeater {
|
||
model: root.trayExpanded ? SystemTray.items : []
|
||
|
||
delegate: Item {
|
||
id: trayItem
|
||
required property var modelData
|
||
Layout.alignment: Qt.AlignHCenter
|
||
implicitWidth: 32
|
||
implicitHeight: 28
|
||
|
||
Image {
|
||
anchors.centerIn: parent
|
||
width: 18
|
||
height: 18
|
||
source: trayItem.modelData.icon
|
||
sourceSize.width: 36
|
||
sourceSize.height: 36
|
||
fillMode: Image.PreserveAspectFit
|
||
smooth: true
|
||
}
|
||
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: mouse => {
|
||
const item = trayItem.modelData
|
||
if (mouse.button === Qt.LeftButton) {
|
||
if (item.onlyMenu)
|
||
openMenu()
|
||
else
|
||
item.activate()
|
||
} else if (mouse.button === Qt.MiddleButton) {
|
||
item.secondaryActivate()
|
||
} else if (mouse.button === Qt.RightButton) {
|
||
openMenu()
|
||
}
|
||
}
|
||
|
||
onWheel: wheel => {
|
||
const horizontal = wheel.angleDelta.x !== 0
|
||
trayItem.modelData.scroll(horizontal ? wheel.angleDelta.x : wheel.angleDelta.y, horizontal)
|
||
}
|
||
|
||
function openMenu(): void {
|
||
const item = trayItem.modelData
|
||
if (!item.hasMenu) return
|
||
const position = trayItem.mapToItem(bar.contentItem, 0, trayItem.height / 2)
|
||
root.openTrayMenu(item, bar.screen, position.y)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
Layout.alignment: Qt.AlignHCenter
|
||
implicitWidth: 32; implicitHeight: 32
|
||
visible: SystemTray.items.values.length > 0
|
||
color: root.trayExpanded ? root.cSelected : (trayHover.containsMouse ? root.cSurface : "transparent")
|
||
border.color: root.trayExpanded ? root.cBorderFocus : "transparent"
|
||
|
||
Text {
|
||
anchors.centerIn: parent
|
||
text: root.trayExpanded ? "" : ""
|
||
font.family: root.mono
|
||
font.pixelSize: 14
|
||
color: root.trayExpanded ? root.cBright : (trayHover.containsMouse ? root.cBright : root.cText)
|
||
}
|
||
|
||
MouseArea {
|
||
id: trayHover
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: root.trayExpanded = !root.trayExpanded
|
||
}
|
||
}
|
||
|
||
// Charge glyph with a distinct external-power glyph. The bar
|
||
// stays compact and deliberately omits a numeric percentage.
|
||
Rectangle {
|
||
Layout.alignment: Qt.AlignHCenter
|
||
implicitWidth: 32; implicitHeight: 32
|
||
color: root.activeApplet === "battery" ? root.cSelected : (batteryHover.containsMouse ? root.cSurface : "transparent")
|
||
border.color: root.activeApplet === "battery" ? root.cBorderFocus : "transparent"
|
||
|
||
Text {
|
||
anchors.centerIn: parent
|
||
anchors.verticalCenterOffset: root.onExternalPower ? -3 : 0
|
||
text: {
|
||
if (root.batteryPercent < 0) return ""
|
||
if (root.batteryPercent >= 95) return ""
|
||
if (root.batteryPercent >= 85) return ""
|
||
if (root.batteryPercent >= 75) return ""
|
||
if (root.batteryPercent >= 65) return ""
|
||
if (root.batteryPercent >= 55) return ""
|
||
if (root.batteryPercent >= 45) return ""
|
||
if (root.batteryPercent >= 35) return ""
|
||
if (root.batteryPercent >= 25) return ""
|
||
if (root.batteryPercent >= 15) return ""
|
||
return ""
|
||
}
|
||
font.family: root.mono
|
||
font.pixelSize: 14
|
||
color: root.batteryPercent >= 0 && root.batteryPercent <= 15 ? root.cDanger
|
||
: root.onExternalPower ? root.cSuccess : root.cText
|
||
}
|
||
|
||
Text {
|
||
visible: root.onExternalPower
|
||
anchors.horizontalCenter: parent.horizontalCenter
|
||
anchors.bottom: parent.bottom
|
||
text: ""
|
||
font.family: root.mono; font.pixelSize: 9
|
||
color: root.cSuccess
|
||
}
|
||
|
||
MouseArea {
|
||
id: batteryHover
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: root.activeApplet = root.activeApplet === "battery" ? "" : "battery"
|
||
}
|
||
}
|
||
|
||
Rectangle { Layout.alignment: Qt.AlignHCenter; width: 16; height: 1; color: root.cBorder }
|
||
|
||
// Network Button
|
||
Rectangle {
|
||
Layout.alignment: Qt.AlignHCenter
|
||
implicitWidth: 32; implicitHeight: 32
|
||
radius: 0
|
||
color: root.activeApplet === "wifi" ? root.cSelected : (netHover.containsMouse ? root.cSurface : "transparent")
|
||
border.color: root.activeApplet === "wifi" ? root.cBorderFocus : "transparent"
|
||
|
||
Text {
|
||
anchors.centerIn: parent
|
||
text: root.activeSsid ? "" : (root.ethernetConnected ? "" : (root.simEnabled ? "" : ""))
|
||
font.family: root.mono; font.pixelSize: 14
|
||
color: root.activeApplet === "wifi" ? root.cBright : (root.netUp ? root.cText : root.cDim)
|
||
}
|
||
|
||
MouseArea {
|
||
id: netHover
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: {
|
||
if (root.activeApplet === "wifi") {
|
||
root.activeApplet = ""
|
||
} else {
|
||
root.activeApplet = "wifi"
|
||
wifiScanProc.running = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Rectangle { Layout.alignment: Qt.AlignHCenter; width: 16; height: 1; color: root.cBorder }
|
||
|
||
// Bluetooth Button
|
||
Rectangle {
|
||
Layout.alignment: Qt.AlignHCenter
|
||
implicitWidth: 32; implicitHeight: 32
|
||
radius: 0
|
||
color: root.activeApplet === "bluetooth" ? root.cSelected : (btHover.containsMouse ? root.cSurface : "transparent")
|
||
border.color: root.activeApplet === "bluetooth" ? root.cBorderFocus : "transparent"
|
||
|
||
Text {
|
||
anchors.centerIn: parent
|
||
text: root.btConnected ? "" : (root.btOn ? "" : "")
|
||
font.family: root.mono; font.pixelSize: 14
|
||
color: root.activeApplet === "bluetooth" ? root.cBright : (root.btOn ? root.cText : root.cDim)
|
||
}
|
||
|
||
MouseArea {
|
||
id: btHover
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: root.activeApplet = (root.activeApplet === "bluetooth" ? "" : "bluetooth")
|
||
}
|
||
}
|
||
|
||
Rectangle { Layout.alignment: Qt.AlignHCenter; width: 16; height: 1; color: root.cBorder }
|
||
|
||
// Audio Button
|
||
Rectangle {
|
||
Layout.alignment: Qt.AlignHCenter
|
||
implicitWidth: 32; implicitHeight: 32
|
||
radius: 0
|
||
color: root.activeApplet === "sound" ? root.cSelected : (soundHover.containsMouse ? root.cSurface : "transparent")
|
||
border.color: root.activeApplet === "sound" ? root.cBorderFocus : "transparent"
|
||
|
||
Text {
|
||
anchors.centerIn: parent
|
||
text: {
|
||
if (Pipewire.defaultAudioSink?.audio?.muted) return ""
|
||
const vol = Pipewire.defaultAudioSink?.audio?.volume ?? 0
|
||
if (vol > 0.5) return ""
|
||
if (vol > 0) return ""
|
||
return ""
|
||
}
|
||
font.family: root.mono; font.pixelSize: 14
|
||
color: {
|
||
if (Pipewire.defaultAudioSink?.audio?.muted) return root.cDanger
|
||
if (root.activeApplet === "sound") return root.cBright
|
||
return soundHover.containsMouse ? root.cBright : root.cText
|
||
}
|
||
}
|
||
|
||
MouseArea {
|
||
id: soundHover
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: root.activeApplet = (root.activeApplet === "sound" ? "" : "sound")
|
||
}
|
||
}
|
||
|
||
}
|
||
}
|
||
}
|
||
|
||
// ───────────── MENU BACKDROP BLUR ─────────────
|
||
// Blur the active monitor behind open shell surfaces while preserving the bar.
|
||
Variants {
|
||
model: Quickshell.screens
|
||
|
||
PanelWindow {
|
||
id: menuBackdrop
|
||
required property var modelData
|
||
screen: modelData
|
||
visible: (root.activeApplet !== "" || root.launcherOpen || root.powerMenuOpen || root.trayMenuOpen)
|
||
&& modelData.name === (root.trayMenuOpen
|
||
? (root.trayMenuScreen?.name ?? Quickshell.screens[0]?.name)
|
||
: (Hyprland.focusedMonitor?.name ?? Quickshell.screens[0]?.name))
|
||
|
||
anchors { top: true; bottom: true; left: true; right: true }
|
||
color: "transparent"
|
||
mask: Region {}
|
||
|
||
WlrLayershell.layer: WlrLayer.Top
|
||
WlrLayershell.namespace: "menu-backdrop"
|
||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||
exclusionMode: ExclusionMode.Ignore
|
||
|
||
BackgroundEffect.blurRegion: Region {
|
||
item: menuBackdrop.contentItem
|
||
|
||
// The vertical bar occupies the leftmost 42 logical pixels.
|
||
Region {
|
||
x: 0
|
||
y: 0
|
||
width: root.barWidth
|
||
height: menuBackdrop.height
|
||
intersection: Intersection.Subtract
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Application-provided DBusMenu rendered inside a layer-shell panel.
|
||
// Native Qt platform menus cannot be parented directly to the bar surface.
|
||
PanelWindow {
|
||
id: trayMenuWindow
|
||
visible: root.trayMenuOpen
|
||
screen: root.trayMenuScreen ?? Quickshell.screens[0]
|
||
|
||
anchors { top: true; bottom: true; left: true; right: true }
|
||
color: "transparent"
|
||
exclusionMode: ExclusionMode.Ignore
|
||
|
||
WlrLayershell.layer: WlrLayer.Overlay
|
||
WlrLayershell.namespace: "tray-menu"
|
||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
|
||
aboveWindows: true
|
||
|
||
QsMenuOpener {
|
||
id: trayMenuOpener
|
||
menu: root.trayMenuHandle
|
||
}
|
||
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
|
||
onClicked: root.closeMenus()
|
||
}
|
||
|
||
Item {
|
||
anchors.fill: parent
|
||
focus: root.trayMenuOpen
|
||
Keys.onEscapePressed: root.closeMenus()
|
||
|
||
Rectangle {
|
||
id: trayMenuCard
|
||
x: root.barWidth + 14
|
||
y: Math.max(8, Math.min(root.trayMenuY - height / 2, trayMenuWindow.height - height - 8))
|
||
width: 300
|
||
height: Math.min(trayMenuWindow.height - 16, trayMenuContent.implicitHeight)
|
||
color: root.cPanel
|
||
border.color: root.cBorder
|
||
border.width: 1
|
||
|
||
ColumnLayout {
|
||
id: trayMenuContent
|
||
anchors.fill: parent
|
||
anchors.margins: 16
|
||
spacing: 10
|
||
|
||
RowLayout {
|
||
Layout.fillWidth: true
|
||
Layout.preferredHeight: 30
|
||
spacing: 8
|
||
|
||
Rectangle {
|
||
visible: root.trayMenuStack.length > 0
|
||
Layout.preferredWidth: visible ? 28 : 0
|
||
Layout.preferredHeight: 26
|
||
color: backHover.containsMouse ? root.cSurfaceHover : "transparent"
|
||
|
||
Text {
|
||
anchors.centerIn: parent
|
||
text: "‹"
|
||
font.family: root.mono
|
||
font.pixelSize: 18
|
||
color: root.cText
|
||
}
|
||
|
||
MouseArea {
|
||
id: backHover
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: root.returnFromTraySubmenu()
|
||
}
|
||
}
|
||
|
||
Text {
|
||
Layout.fillWidth: true
|
||
text: (root.trayMenuStack.length > 0
|
||
? (root.trayMenuHandle?.text ?? "Menu")
|
||
: (root.trayMenuItem?.tooltipTitle || root.trayMenuItem?.title || root.trayMenuItem?.id || "Tray")).toUpperCase()
|
||
font.family: root.mono
|
||
font.pixelSize: 13
|
||
font.bold: true
|
||
color: root.cBright
|
||
elide: Text.ElideRight
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
Layout.fillWidth: true
|
||
height: 1
|
||
color: root.cBorder
|
||
}
|
||
|
||
Flickable {
|
||
Layout.fillWidth: true
|
||
Layout.fillHeight: true
|
||
implicitHeight: Math.min(trayMenuEntries.implicitHeight, 480)
|
||
contentHeight: trayMenuEntries.implicitHeight
|
||
clip: true
|
||
boundsBehavior: Flickable.StopAtBounds
|
||
|
||
ColumnLayout {
|
||
id: trayMenuEntries
|
||
width: parent.width
|
||
spacing: 0
|
||
|
||
Repeater {
|
||
model: trayMenuOpener.children
|
||
|
||
delegate: Item {
|
||
id: trayMenuEntry
|
||
required property var modelData
|
||
Layout.fillWidth: true
|
||
Layout.preferredHeight: modelData.isSeparator ? 9 : 32
|
||
|
||
Rectangle {
|
||
visible: trayMenuEntry.modelData.isSeparator
|
||
anchors { left: parent.left; right: parent.right; verticalCenter: parent.verticalCenter }
|
||
height: 1
|
||
color: root.cBorder
|
||
}
|
||
|
||
Rectangle {
|
||
visible: !trayMenuEntry.modelData.isSeparator
|
||
anchors.fill: parent
|
||
color: entryHover.containsMouse && trayMenuEntry.modelData.enabled ? root.cSurfaceHover : "transparent"
|
||
|
||
RowLayout {
|
||
anchors { fill: parent; leftMargin: 8; rightMargin: 8 }
|
||
spacing: 8
|
||
|
||
Text {
|
||
Layout.preferredWidth: 16
|
||
horizontalAlignment: Text.AlignHCenter
|
||
text: {
|
||
const entry = trayMenuEntry.modelData
|
||
if (entry.buttonType === QsMenuButtonType.CheckBox)
|
||
return entry.checkState === Qt.Checked ? "✓" : ""
|
||
if (entry.buttonType === QsMenuButtonType.RadioButton)
|
||
return entry.checkState === Qt.Checked ? "●" : "○"
|
||
return ""
|
||
}
|
||
font.family: root.mono
|
||
font.pixelSize: 12
|
||
color: root.cAccent
|
||
}
|
||
|
||
Image {
|
||
visible: trayMenuEntry.modelData.icon !== ""
|
||
Layout.preferredWidth: visible ? 16 : 0
|
||
Layout.preferredHeight: 16
|
||
source: trayMenuEntry.modelData.icon
|
||
sourceSize.width: 32
|
||
sourceSize.height: 32
|
||
fillMode: Image.PreserveAspectFit
|
||
}
|
||
|
||
Text {
|
||
Layout.fillWidth: true
|
||
text: trayMenuEntry.modelData.text
|
||
font.family: root.mono
|
||
font.pixelSize: 11
|
||
color: trayMenuEntry.modelData.enabled ? root.cText : root.cDim
|
||
elide: Text.ElideRight
|
||
}
|
||
|
||
Text {
|
||
visible: trayMenuEntry.modelData.hasChildren
|
||
text: "›"
|
||
font.family: root.mono
|
||
font.pixelSize: 16
|
||
color: root.cSubtle
|
||
}
|
||
}
|
||
}
|
||
|
||
MouseArea {
|
||
id: entryHover
|
||
anchors.fill: parent
|
||
visible: !trayMenuEntry.modelData.isSeparator
|
||
enabled: trayMenuEntry.modelData.enabled
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: {
|
||
const entry = trayMenuEntry.modelData
|
||
if (entry.hasChildren)
|
||
root.enterTraySubmenu(entry)
|
||
else {
|
||
entry.triggered()
|
||
root.closeMenus()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ───────────── APP LAUNCHER OVERLAY ─────────────
|
||
PanelWindow {
|
||
id: launcherWindow
|
||
visible: root.launcherOpen
|
||
|
||
screen: Quickshell.screens.find(s => s.name === Hyprland.focusedMonitor?.name) ?? Quickshell.screens[0]
|
||
|
||
// Full-screen overlay surface; the launcher itself stays centered.
|
||
anchors { top: true; bottom: true; left: true; right: true }
|
||
color: "transparent"
|
||
|
||
WlrLayershell.layer: WlrLayer.Overlay
|
||
WlrLayershell.namespace: "launcher"
|
||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
|
||
exclusionMode: ExclusionMode.Ignore
|
||
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
onClicked: root.launcherOpen = false
|
||
}
|
||
|
||
Rectangle {
|
||
anchors.centerIn: parent
|
||
width: 340
|
||
height: 480
|
||
color: root.cPanel
|
||
border.color: root.cBorder
|
||
border.width: 1
|
||
radius: 0
|
||
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
}
|
||
|
||
FocusScope {
|
||
id: launcherScope
|
||
anchors.fill: parent
|
||
focus: launcherWindow.visible
|
||
|
||
Keys.onEscapePressed: root.closeMenus()
|
||
|
||
ColumnLayout {
|
||
anchors.fill: parent
|
||
anchors.margins: 12
|
||
spacing: 10
|
||
|
||
// Search Input Box
|
||
Rectangle {
|
||
Layout.fillWidth: true
|
||
implicitHeight: 36
|
||
color: root.cSurface
|
||
border.color: root.cBorderFocus
|
||
border.width: 1
|
||
radius: 0
|
||
|
||
RowLayout {
|
||
anchors.fill: parent
|
||
anchors.leftMargin: 10
|
||
anchors.rightMargin: 10
|
||
spacing: 8
|
||
|
||
Text {
|
||
text: ""
|
||
font.family: root.mono
|
||
font.pixelSize: 13
|
||
color: root.cSubtle
|
||
}
|
||
|
||
TextInput {
|
||
id: searchInput
|
||
Layout.fillWidth: true
|
||
font.family: root.mono
|
||
font.pixelSize: 12
|
||
color: root.cBright
|
||
focus: true
|
||
selectByMouse: true
|
||
|
||
Text {
|
||
text: "Search applications..."
|
||
font.family: root.mono
|
||
font.pixelSize: 12
|
||
color: root.cDim
|
||
visible: searchInput.text === ""
|
||
}
|
||
|
||
Keys.onDownPressed: appListView.incrementCurrentIndex()
|
||
Keys.onUpPressed: appListView.decrementCurrentIndex()
|
||
Keys.onReturnPressed: {
|
||
if (appListView.currentItem) {
|
||
appListView.currentItem.launch()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Application List
|
||
ListView {
|
||
id: appListView
|
||
Layout.fillWidth: true
|
||
Layout.fillHeight: true
|
||
clip: true
|
||
spacing: 3
|
||
currentIndex: 0
|
||
|
||
model: appListProc.apps
|
||
|
||
delegate: Rectangle {
|
||
id: appItem
|
||
required property var modelData
|
||
required property int index
|
||
|
||
width: appListView.width
|
||
height: 38
|
||
radius: 0
|
||
color: ListView.isCurrentItem ? root.cSelected : (itemArea.containsMouse ? root.cSurfaceHover : "transparent")
|
||
border.color: ListView.isCurrentItem ? root.cBorderFocus : "transparent"
|
||
|
||
function launch() {
|
||
Quickshell.execDetached(["sh", "-c", modelData.exec])
|
||
root.launcherOpen = false
|
||
searchInput.text = ""
|
||
}
|
||
|
||
RowLayout {
|
||
anchors.fill: parent
|
||
anchors.leftMargin: 10
|
||
anchors.rightMargin: 10
|
||
spacing: 10
|
||
|
||
Text {
|
||
text: ""
|
||
font.family: root.mono
|
||
font.pixelSize: 13
|
||
color: ListView.isCurrentItem ? root.cAccent : root.cSubtle
|
||
}
|
||
|
||
Text {
|
||
text: modelData.name
|
||
font.family: root.mono
|
||
font.pixelSize: 12
|
||
color: ListView.isCurrentItem ? root.cBright : root.cText
|
||
Layout.fillWidth: true
|
||
elide: Text.ElideRight
|
||
}
|
||
}
|
||
|
||
MouseArea {
|
||
id: itemArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: appItem.launch()
|
||
}
|
||
}
|
||
|
||
// Empty search state
|
||
Item {
|
||
anchors.centerIn: parent
|
||
visible: appListView.count === 0
|
||
ColumnLayout {
|
||
anchors.centerIn: parent
|
||
spacing: 4
|
||
Text { Layout.alignment: Qt.AlignHCenter; text: "No applications found"; font.family: root.mono; font.pixelSize: 12; color: root.cDim }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Desktop applications scanner with flatpak and standard fallbacks
|
||
Process {
|
||
id: appListProc
|
||
property var rawApps: []
|
||
property var apps: {
|
||
const query = searchInput.text.toLowerCase().trim()
|
||
if (!query) return rawApps
|
||
return rawApps.filter(a => a.name.toLowerCase().includes(query))
|
||
}
|
||
|
||
command: ["python3", "-c", `
|
||
import os, glob
|
||
|
||
dirs = [
|
||
'/usr/share/applications',
|
||
'/usr/local/share/applications',
|
||
os.path.expanduser('~/.local/share/applications'),
|
||
os.path.expanduser('~/.local/share/flatpak/exports/share/applications'),
|
||
'/var/lib/flatpak/exports/share/applications'
|
||
]
|
||
xdg = os.environ.get('XDG_DATA_DIRS', '')
|
||
if xdg:
|
||
for p in xdg.split(':'):
|
||
app_dir = os.path.join(p, 'applications')
|
||
if app_dir not in dirs:
|
||
dirs.append(app_dir)
|
||
|
||
apps = []
|
||
seen = set()
|
||
for d in dirs:
|
||
if not os.path.isdir(d):
|
||
continue
|
||
for path in glob.glob(os.path.join(d, '*.desktop')):
|
||
try:
|
||
with open(path, 'r', encoding='utf-8', errors='ignore') as f:
|
||
name, exec_cmd, nodisplay = '', '', False
|
||
for line in f:
|
||
line = line.strip()
|
||
if line.startswith('Name=') and not name:
|
||
name = line.split('=', 1)[1].strip()
|
||
elif line.startswith('Exec=') and not exec_cmd:
|
||
exec_cmd = line.split('=', 1)[1].strip().split('%')[0].strip()
|
||
elif line.startswith('NoDisplay=true'):
|
||
nodisplay = True
|
||
if name and exec_cmd and not nodisplay:
|
||
if name.lower() not in seen:
|
||
seen.add(name.lower())
|
||
apps.append((name, exec_cmd))
|
||
except:
|
||
pass
|
||
|
||
# Fallbacks for essential desktop tools
|
||
fallbacks = [
|
||
('Terminal (Foot)', 'foot'),
|
||
('Fish Shell', 'foot -e fish'),
|
||
]
|
||
for name, cmd in fallbacks:
|
||
if name.lower() not in seen:
|
||
apps.append((name, cmd))
|
||
|
||
apps.sort(key=lambda x: x[0].lower())
|
||
for name, cmd in apps:
|
||
print(f"{name}|||{cmd}")
|
||
`]
|
||
stdout: StdioCollector {
|
||
onStreamFinished: {
|
||
const lines = text.trim().split("\n")
|
||
let parsed = []
|
||
for (let line of lines) {
|
||
const parts = line.split("|||")
|
||
if (parts.length === 2 && parts[0]) {
|
||
parsed.push({ name: parts[0], exec: parts[1] })
|
||
}
|
||
}
|
||
appListProc.rawApps = parsed
|
||
}
|
||
}
|
||
}
|
||
|
||
Component.onCompleted: appListProc.running = true
|
||
onVisibleChanged: {
|
||
if (visible) {
|
||
searchInput.text = ""
|
||
searchInput.forceActiveFocus()
|
||
appListProc.running = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ───────────── CALENDAR POPUP WINDOW ─────────────
|
||
PanelWindow {
|
||
id: calendarWindow
|
||
visible: root.activeApplet === "calendar"
|
||
|
||
screen: Quickshell.screens.find(s => s.name === Hyprland.focusedMonitor?.name) ?? Quickshell.screens[0]
|
||
|
||
// Vertically centered beside the bar; this overlay reserves no space from app windows.
|
||
anchors { left: true; top: true }
|
||
margins.left: root.barWidth + 14
|
||
margins.top: Math.max(8, (screen.height - implicitHeight) / 2)
|
||
|
||
implicitWidth: 320
|
||
implicitHeight: 390
|
||
color: "transparent"
|
||
|
||
WlrLayershell.layer: WlrLayer.Overlay
|
||
WlrLayershell.namespace: "calendar"
|
||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
|
||
aboveWindows: true
|
||
exclusionMode: ExclusionMode.Ignore
|
||
|
||
Rectangle {
|
||
anchors.fill: parent
|
||
color: root.cPanel
|
||
border.color: root.cBorder
|
||
border.width: 1
|
||
radius: 0
|
||
|
||
FocusScope {
|
||
id: calendarScope
|
||
anchors.fill: parent
|
||
focus: calendarWindow.visible
|
||
Keys.onEscapePressed: root.closeMenus()
|
||
|
||
property date viewDate: new Date()
|
||
|
||
function getCellData(index) {
|
||
const y = calendarScope.viewDate.getFullYear()
|
||
const m = calendarScope.viewDate.getMonth()
|
||
const firstDay = new Date(y, m, 1)
|
||
// Monday as 0, Sunday as 6
|
||
const startDay = (firstDay.getDay() + 6) % 7
|
||
const date = new Date(y, m, 1 - startDay + index)
|
||
const now = clock.date
|
||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||
const offset = Math.round((date.getTime() - today.getTime()) / 86400000)
|
||
|
||
return {
|
||
day: date.getDate(),
|
||
isCurrentMonth: date.getMonth() === m,
|
||
isToday: offset === 0,
|
||
isWeekend: index % 7 >= 5,
|
||
dayOffset: offset
|
||
}
|
||
}
|
||
|
||
ColumnLayout {
|
||
anchors.fill: parent
|
||
anchors.margins: 16
|
||
spacing: 12
|
||
|
||
// Header: Full Date & Clock
|
||
RowLayout {
|
||
Layout.fillWidth: true
|
||
|
||
ColumnLayout {
|
||
spacing: 2
|
||
Text {
|
||
text: Qt.formatDateTime(clock.date, "dddd, MMMM d, yyyy")
|
||
font.family: root.mono; font.pixelSize: 12; font.bold: true; color: root.cBright
|
||
}
|
||
Text {
|
||
text: Qt.formatDateTime(clock.date, "HH:mm:ss")
|
||
font.family: root.mono; font.pixelSize: 11; color: root.cAccent
|
||
}
|
||
}
|
||
|
||
Item { Layout.fillWidth: true }
|
||
}
|
||
|
||
Rectangle { Layout.fillWidth: true; height: 1; color: root.cBorder }
|
||
|
||
// Month Navigation Row
|
||
RowLayout {
|
||
Layout.fillWidth: true
|
||
|
||
Text {
|
||
text: Qt.formatDate(calendarScope.viewDate, "MMMM yyyy")
|
||
font.family: root.mono; font.pixelSize: 13; font.bold: true; color: root.cBright
|
||
Layout.fillWidth: true
|
||
}
|
||
|
||
// Prev Month Button
|
||
Rectangle {
|
||
width: 26; height: 26; radius: 0
|
||
color: prevHover.containsMouse ? root.cSurfaceHover : root.cSurface
|
||
border.color: root.cBorder
|
||
|
||
Text { anchors.centerIn: parent; text: ""; font.family: root.mono; font.pixelSize: 12; color: root.cText }
|
||
MouseArea {
|
||
id: prevHover
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: {
|
||
const v = calendarScope.viewDate
|
||
calendarScope.viewDate = new Date(v.getFullYear(), v.getMonth() - 1, 1)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Today Button
|
||
Rectangle {
|
||
implicitWidth: 46; implicitHeight: 26; radius: 0
|
||
color: todayHover.containsMouse ? root.cSurfaceHover : root.cSurface
|
||
border.color: root.cBorder
|
||
|
||
Text { anchors.centerIn: parent; text: "Today"; font.family: root.mono; font.pixelSize: 10; color: root.cAccent }
|
||
MouseArea {
|
||
id: todayHover
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: calendarScope.viewDate = new Date()
|
||
}
|
||
}
|
||
|
||
// Next Month Button
|
||
Rectangle {
|
||
width: 26; height: 26; radius: 0
|
||
color: nextHover.containsMouse ? root.cSurfaceHover : root.cSurface
|
||
border.color: root.cBorder
|
||
|
||
Text { anchors.centerIn: parent; text: ""; font.family: root.mono; font.pixelSize: 12; color: root.cText }
|
||
MouseArea {
|
||
id: nextHover
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: {
|
||
const v = calendarScope.viewDate
|
||
calendarScope.viewDate = new Date(v.getFullYear(), v.getMonth() + 1, 1)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Weekdays Header (Mon..Sun)
|
||
RowLayout {
|
||
Layout.fillWidth: true
|
||
spacing: 2
|
||
|
||
Repeater {
|
||
model: ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]
|
||
delegate: Item {
|
||
Layout.fillWidth: true
|
||
height: 20
|
||
Text {
|
||
anchors.centerIn: parent
|
||
text: modelData
|
||
font.family: root.mono; font.pixelSize: 11; font.bold: true
|
||
color: (index >= 5) ? root.cDanger : root.cSubtle
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 42-day Calendar Grid
|
||
GridLayout {
|
||
id: calendarGrid
|
||
Layout.fillWidth: true
|
||
Layout.fillHeight: true
|
||
columns: 7
|
||
rows: 6
|
||
rowSpacing: 3
|
||
columnSpacing: 2
|
||
|
||
Repeater {
|
||
model: 42
|
||
delegate: Rectangle {
|
||
required property int index
|
||
readonly property var cell: calendarScope.getCellData(index)
|
||
readonly property string dayOffsetText: {
|
||
if (cell.dayOffset > 0)
|
||
return cell.dayOffset === 1 ? "In 1 day" : "In " + cell.dayOffset + " days"
|
||
const daysAgo = Math.abs(cell.dayOffset)
|
||
return daysAgo === 1 ? "1 day ago" : daysAgo + " days ago"
|
||
}
|
||
|
||
Layout.fillWidth: true
|
||
Layout.fillHeight: true
|
||
radius: 0
|
||
z: dayHover.containsMouse ? 2 : 0
|
||
color: cell.isToday ? root.cAccent : (dayHover.containsMouse ? root.cSurfaceHover : "transparent")
|
||
border.color: (cell.isToday) ? root.cAccent : "transparent"
|
||
|
||
Text {
|
||
anchors.centerIn: parent
|
||
text: parent.cell.day
|
||
font.family: root.mono
|
||
font.pixelSize: 11
|
||
font.bold: parent.cell.isToday
|
||
color: {
|
||
if (parent.cell.isToday) return root.cBar
|
||
if (parent.cell.isWeekend) return root.cDanger
|
||
if (parent.cell.isCurrentMonth) return root.cText
|
||
return root.cDim
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
visible: dayHover.containsMouse && !cell.isToday
|
||
z: 3
|
||
anchors.bottom: parent.top
|
||
anchors.bottomMargin: 4
|
||
anchors.horizontalCenter: parent.horizontalCenter
|
||
implicitWidth: dayOffsetLabel.implicitWidth + 14
|
||
implicitHeight: 24
|
||
radius: 0
|
||
color: root.cSurface
|
||
border.color: root.cBorder
|
||
|
||
Text {
|
||
id: dayOffsetLabel
|
||
anchors.centerIn: parent
|
||
text: parent.parent.dayOffsetText
|
||
font.family: root.mono
|
||
font.pixelSize: 10
|
||
color: root.cText
|
||
}
|
||
}
|
||
|
||
MouseArea {
|
||
id: dayHover
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ───────────── APPLETS POPUP WINDOW (NETWORK / BLUETOOTH / AUDIO) ─────────────
|
||
PanelWindow {
|
||
id: appletWindow
|
||
visible: root.activeApplet !== "" && root.activeApplet !== "calendar"
|
||
|
||
screen: Quickshell.screens.find(s => s.name === Hyprland.focusedMonitor?.name) ?? Quickshell.screens[0]
|
||
|
||
// Keep a clear 14px gutter from the vertical bar, anchored to bottom
|
||
anchors { left: true; bottom: true }
|
||
margins.left: root.barWidth + 14
|
||
margins.bottom: 8
|
||
|
||
implicitWidth: 360
|
||
implicitHeight: 520
|
||
color: "transparent"
|
||
|
||
WlrLayershell.layer: WlrLayer.Overlay
|
||
WlrLayershell.namespace: "applet"
|
||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
|
||
aboveWindows: true
|
||
exclusionMode: ExclusionMode.Ignore
|
||
|
||
Rectangle {
|
||
anchors.fill: parent
|
||
color: root.cPanel
|
||
border.color: root.cBorder
|
||
border.width: 1
|
||
radius: 0
|
||
|
||
FocusScope {
|
||
id: appletScope
|
||
anchors.fill: parent
|
||
focus: appletWindow.visible
|
||
Keys.onEscapePressed: root.closeMenus()
|
||
onVisibleChanged: if (visible) forceActiveFocus()
|
||
|
||
ColumnLayout {
|
||
anchors.fill: parent
|
||
anchors.margins: 16
|
||
spacing: 12
|
||
|
||
// Each bar control opens its own view; keep this header informational.
|
||
RowLayout {
|
||
Layout.fillWidth: true
|
||
Text {
|
||
text: root.activeApplet === "wifi" ? "NETWORK"
|
||
: root.activeApplet === "bluetooth" ? "BLUETOOTH"
|
||
: root.activeApplet === "battery" ? "BATTERY" : "AUDIO"
|
||
font.family: root.mono
|
||
font.pixelSize: 12
|
||
font.bold: true
|
||
color: root.cBright
|
||
}
|
||
Item { Layout.fillWidth: true }
|
||
}
|
||
|
||
Rectangle { Layout.fillWidth: true; height: 1; color: root.cBorder }
|
||
|
||
// Container for the 3 Applet Views
|
||
Item {
|
||
Layout.fillWidth: true
|
||
Layout.fillHeight: true
|
||
|
||
// ══════════════════ BATTERY VIEW ══════════════════
|
||
ScrollView {
|
||
anchors.fill: parent
|
||
visible: root.activeApplet === "battery"
|
||
clip: true
|
||
contentWidth: availableWidth
|
||
|
||
ColumnLayout {
|
||
width: parent.width
|
||
spacing: 12
|
||
|
||
Rectangle {
|
||
Layout.fillWidth: true
|
||
implicitHeight: 126
|
||
color: root.cSurface
|
||
border.color: root.cBorder
|
||
|
||
ColumnLayout {
|
||
anchors.fill: parent
|
||
anchors.margins: 14
|
||
spacing: 12
|
||
|
||
RowLayout {
|
||
Layout.fillWidth: true
|
||
spacing: 12
|
||
|
||
Text {
|
||
text: root.batteryPercent < 0 ? ""
|
||
: root.batteryPercent >= 95 ? ""
|
||
: root.batteryPercent >= 85 ? ""
|
||
: root.batteryPercent >= 75 ? ""
|
||
: root.batteryPercent >= 65 ? ""
|
||
: root.batteryPercent >= 55 ? ""
|
||
: root.batteryPercent >= 45 ? ""
|
||
: root.batteryPercent >= 35 ? ""
|
||
: root.batteryPercent >= 25 ? ""
|
||
: root.batteryPercent >= 15 ? "" : ""
|
||
font.family: root.mono; font.pixelSize: 30
|
||
color: root.batteryChargeColor()
|
||
}
|
||
|
||
ColumnLayout {
|
||
Layout.fillWidth: true
|
||
spacing: 3
|
||
Text {
|
||
text: root.batteryPercent >= 0 ? root.batteryPercent + "%" : "Unavailable"
|
||
font.family: root.mono; font.pixelSize: 20; font.bold: true
|
||
color: root.batteryChargeColor()
|
||
}
|
||
Text {
|
||
text: root.onExternalPower ? "Connected to external power"
|
||
: root.batteryStatus === "Discharging" ? "Discharging"
|
||
: root.batteryStatus === "Full" ? "Fully charged" : root.batteryStatus
|
||
font.family: root.mono; font.pixelSize: 10
|
||
color: root.batteryChargeColor()
|
||
}
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
Layout.fillWidth: true
|
||
height: 4
|
||
color: root.cBorder
|
||
Rectangle {
|
||
width: parent.width * Math.max(0, Math.min(100, root.batteryPercent)) / 100
|
||
height: parent.height
|
||
color: root.batteryChargeColor()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
Layout.fillWidth: true
|
||
implicitHeight: 74
|
||
color: root.cSurface
|
||
border.color: root.cBorder
|
||
|
||
RowLayout {
|
||
anchors.fill: parent
|
||
anchors.margins: 14
|
||
spacing: 10
|
||
Text {
|
||
text: root.onExternalPower ? "" : ""
|
||
font.family: root.mono; font.pixelSize: 18
|
||
color: root.cSubtle
|
||
}
|
||
ColumnLayout {
|
||
Layout.fillWidth: true
|
||
spacing: 3
|
||
Text {
|
||
text: root.batteryStatus === "Charging" ? "TIME TO FULL" : "TIME REMAINING"
|
||
font.family: root.mono; font.pixelSize: 10; font.bold: true
|
||
color: root.cSubtle
|
||
}
|
||
Text {
|
||
text: root.batteryStatus === "Full" ? "Fully charged"
|
||
: root.batteryTimeSeconds >= 0 ? root.formatBatteryTime(root.batteryTimeSeconds)
|
||
: "Time estimate unavailable"
|
||
font.family: root.mono; font.pixelSize: 13
|
||
color: root.cBright
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
Layout.fillWidth: true
|
||
implicitHeight: 92
|
||
color: root.cSurface
|
||
border.color: root.cBorder
|
||
|
||
ColumnLayout {
|
||
anchors.fill: parent
|
||
anchors.margins: 14
|
||
spacing: 8
|
||
|
||
RowLayout {
|
||
Layout.fillWidth: true
|
||
Text {
|
||
text: "DISPLAY BRIGHTNESS"
|
||
font.family: root.mono; font.pixelSize: 10; font.bold: true
|
||
color: root.cSubtle
|
||
}
|
||
Item { Layout.fillWidth: true }
|
||
Text {
|
||
text: root.brightnessLevel >= 0 ? root.brightnessLevel + "%" : "Unavailable"
|
||
font.family: root.mono; font.pixelSize: 10
|
||
color: root.cText
|
||
}
|
||
}
|
||
|
||
Item {
|
||
Layout.fillWidth: true
|
||
implicitHeight: brightnessSlider.implicitHeight
|
||
Slider {
|
||
id: brightnessSlider
|
||
HoverHandler { cursorShape: Qt.PointingHandCursor }
|
||
anchors.fill: parent
|
||
enabled: root.brightnessLevel >= 0
|
||
from: 0; to: 100; stepSize: 1
|
||
value: Math.max(0, root.brightnessLevel)
|
||
live: true
|
||
onMoved: root.setScreenBrightness(value)
|
||
background: Rectangle {
|
||
x: brightnessSlider.leftPadding
|
||
y: brightnessSlider.topPadding + brightnessSlider.availableHeight / 2 - height / 2
|
||
width: brightnessSlider.availableWidth; height: 3
|
||
color: root.cBorder
|
||
Rectangle {
|
||
width: brightnessSlider.visualPosition * parent.width
|
||
height: parent.height
|
||
color: brightnessSlider.enabled ? root.cAccent : root.cDim
|
||
}
|
||
}
|
||
handle: Rectangle {
|
||
x: brightnessSlider.leftPadding + brightnessSlider.visualPosition * (brightnessSlider.availableWidth - width)
|
||
y: brightnessSlider.topPadding + brightnessSlider.availableHeight / 2 - height / 2
|
||
width: 10; height: 10
|
||
color: brightnessSlider.enabled ? root.cBright : root.cDim
|
||
border.color: root.cBorderFocus; border.width: 1
|
||
}
|
||
}
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
z: 10
|
||
preventStealing: true
|
||
acceptedButtons: Qt.LeftButton
|
||
enabled: false
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
function applyAt(x: real): void {
|
||
root.setScreenBrightness(root.sliderValueFromPointer(brightnessSlider, x))
|
||
}
|
||
onPressed: mouse => { brightnessSlider.forceActiveFocus(); applyAt(mouse.x) }
|
||
onPositionChanged: mouse => { if (pressed) applyAt(mouse.x) }
|
||
onReleased: mouse => applyAt(mouse.x)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
Layout.fillWidth: true
|
||
implicitHeight: 58
|
||
color: root.cSurface
|
||
border.color: root.cBorder
|
||
RowLayout {
|
||
anchors.fill: parent
|
||
anchors.margins: 14
|
||
Text {
|
||
text: ""
|
||
font.family: root.mono; font.pixelSize: 16
|
||
color: root.nightLightEnabled ? root.cAccent : root.cSubtle
|
||
}
|
||
ColumnLayout {
|
||
Layout.fillWidth: true
|
||
spacing: 3
|
||
Text { text: "NIGHT LIGHT"; font.family: root.mono; font.pixelSize: 10; font.bold: true; color: root.cText }
|
||
Text { text: root.nightLightEnabled ? "Warm color filter · 4500 K" : "Blue light filter"; font.family: root.mono; font.pixelSize: 9; color: root.cSubtle }
|
||
}
|
||
Rectangle {
|
||
implicitWidth: 42; implicitHeight: 24
|
||
color: root.nightLightEnabled ? root.cAccent : root.cBorder
|
||
border.color: nightLightToggle.activeFocus ? root.cBright : root.cSubtle
|
||
Text { anchors.centerIn: parent; text: root.nightLightEnabled ? "ON" : "OFF"; font.family: root.mono; font.pixelSize: 8; color: root.cBright }
|
||
MouseArea {
|
||
id: nightLightToggle
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
activeFocusOnTab: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
Keys.onReturnPressed: toggleNightLight()
|
||
Keys.onSpacePressed: toggleNightLight()
|
||
function toggleNightLight() {
|
||
root.nightLightEnabled = !root.nightLightEnabled
|
||
if (root.nightLightEnabled)
|
||
Quickshell.execDetached(["hyprctl", "hyprsunset", "temperature", "4500"])
|
||
else
|
||
Quickshell.execDetached(["hyprctl", "hyprsunset", "identity"])
|
||
}
|
||
onClicked: toggleNightLight()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
Layout.fillWidth: true
|
||
implicitHeight: 58
|
||
color: root.cSurface
|
||
border.color: root.cBorder
|
||
RowLayout {
|
||
anchors.fill: parent
|
||
anchors.margins: 14
|
||
Text { text: ""; font.family: root.mono; font.pixelSize: 16; color: root.cAccent }
|
||
ColumnLayout {
|
||
Layout.fillWidth: true
|
||
spacing: 3
|
||
Text { text: "SYSTEM LOAD"; font.family: root.mono; font.pixelSize: 10; font.bold: true; color: root.cText }
|
||
Text { text: "1 min 5 min 15 min"; font.family: root.mono; font.pixelSize: 8; color: root.cSubtle }
|
||
}
|
||
Text { text: root.systemLoad; font.family: root.mono; font.pixelSize: 11; color: root.cBright }
|
||
}
|
||
}
|
||
|
||
Text {
|
||
Layout.fillWidth: true
|
||
text: "Time is estimated from the battery’s current charge and power rate."
|
||
wrapMode: Text.WordWrap
|
||
font.family: root.mono; font.pixelSize: 9
|
||
color: root.cDim
|
||
}
|
||
}
|
||
}
|
||
|
||
// ══════════════════ NETWORK VIEW ══════════════════
|
||
ColumnLayout {
|
||
anchors.fill: parent
|
||
visible: root.activeApplet === "wifi"
|
||
spacing: 12
|
||
|
||
// WireGuard profile and connection switch.
|
||
Rectangle {
|
||
visible: root.wireguardAvailable
|
||
Layout.fillWidth: true
|
||
implicitHeight: 64
|
||
color: root.cSurface
|
||
border.color: root.cBorder
|
||
radius: 0
|
||
|
||
RowLayout {
|
||
anchors.fill: parent
|
||
anchors.margins: 12
|
||
spacing: 10
|
||
|
||
Item {
|
||
Layout.preferredWidth: 32
|
||
Layout.minimumWidth: 32
|
||
Layout.maximumWidth: 32
|
||
Layout.preferredHeight: 32
|
||
Layout.minimumHeight: 32
|
||
Layout.maximumHeight: 32
|
||
Layout.alignment: Qt.AlignVCenter
|
||
|
||
Text {
|
||
anchors.centerIn: parent
|
||
text: ""
|
||
font.family: root.mono; font.pixelSize: 22
|
||
color: root.wireguardEnabled ? root.cSuccess : root.cDim
|
||
}
|
||
}
|
||
|
||
ColumnLayout {
|
||
Layout.fillWidth: true
|
||
spacing: 2
|
||
Text {
|
||
Layout.fillWidth: true
|
||
text: "Wireguard (" + (root.wireguardProfile || "connection name") + ")"
|
||
font.family: root.mono; font.pixelSize: 13; font.bold: true
|
||
color: root.cBright
|
||
elide: Text.ElideRight
|
||
}
|
||
Text {
|
||
text: root.wireguardInterface === root.wireguardProfile ? "Connected"
|
||
: root.wireguardInterface ? "Switching from " + root.wireguardInterface
|
||
: "Disconnected"
|
||
font.family: root.mono; font.pixelSize: 10
|
||
color: root.wireguardInterface === root.wireguardProfile ? root.cSuccess : root.cSubtle
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
implicitWidth: 36; implicitHeight: 20; radius: 0
|
||
color: root.wireguardEnabled ? root.cAccent : root.cSurfaceHover
|
||
border.color: root.wireguardEnabled ? root.cAccent : root.cBorder
|
||
border.width: 1
|
||
|
||
Rectangle {
|
||
x: root.wireguardEnabled ? 18 : 2; y: 2; width: 16; height: 16; radius: 0
|
||
color: root.wireguardEnabled ? root.cBar : root.cDim
|
||
Behavior on x { NumberAnimation { duration: 150 } }
|
||
}
|
||
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: {
|
||
const newState = !root.wireguardEnabled
|
||
root.wireguardEnabled = newState
|
||
root.wireguardAutoTarget = root.wireguardProfile
|
||
if (newState) {
|
||
Quickshell.execDetached(["doas", "/usr/bin/wg-quick", "up", root.wireguardProfile])
|
||
} else {
|
||
Quickshell.execDetached(["doas", "/usr/bin/wg-quick", "down", root.wireguardInterface || root.wireguardProfile])
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
visible: root.wireguardAvailable
|
||
Layout.fillWidth: true
|
||
height: 1
|
||
color: root.cBorder
|
||
opacity: 0.7
|
||
}
|
||
|
||
// SIM card status and WWAN switch (shown only with a detected SIM).
|
||
Rectangle {
|
||
visible: root.simPresent
|
||
Layout.fillWidth: true
|
||
implicitHeight: 64
|
||
color: root.cSurface
|
||
border.color: root.cBorder
|
||
radius: 0
|
||
|
||
RowLayout {
|
||
anchors.fill: parent
|
||
anchors.margins: 12
|
||
spacing: 10
|
||
|
||
Item {
|
||
Layout.preferredWidth: 32
|
||
Layout.minimumWidth: 32
|
||
Layout.maximumWidth: 32
|
||
Layout.preferredHeight: 32
|
||
Layout.minimumHeight: 32
|
||
Layout.maximumHeight: 32
|
||
Layout.alignment: Qt.AlignVCenter
|
||
|
||
Text {
|
||
anchors.centerIn: parent
|
||
text: ""
|
||
font.family: root.mono; font.pixelSize: 22
|
||
color: root.simEnabled ? root.cSuccess : root.cDim
|
||
}
|
||
}
|
||
|
||
ColumnLayout {
|
||
spacing: 2
|
||
Layout.fillWidth: true
|
||
RowLayout {
|
||
Layout.fillWidth: true
|
||
spacing: 5
|
||
Text {
|
||
Layout.maximumWidth: 180
|
||
text: root.simOperator || "Cellular"
|
||
font.family: root.mono; font.pixelSize: 13; font.bold: true; color: root.cBright
|
||
elide: Text.ElideRight
|
||
}
|
||
Text {
|
||
text: root.simEnabled ? (root.simAccessMode || "—") + " " + (root.simSignalPercent >= 0 ? root.simSignalPercent + "%" : "—") : "OFF"
|
||
font.family: root.mono; font.pixelSize: 10; color: root.cSubtle
|
||
}
|
||
}
|
||
Text {
|
||
Layout.fillWidth: true
|
||
text: "IP: " + (root.simEnabled && root.simIpAddress ? root.simIpAddress : "—")
|
||
font.family: root.mono; font.pixelSize: 10; color: root.cSubtle
|
||
elide: Text.ElideRight
|
||
}
|
||
}
|
||
|
||
// Cellular connection toggle.
|
||
Rectangle {
|
||
implicitWidth: 36; implicitHeight: 20; radius: 0
|
||
color: root.simEnabled ? root.cAccent : root.cSurfaceHover
|
||
border.color: root.simEnabled ? root.cAccent : root.cBorder
|
||
border.width: 1
|
||
|
||
Rectangle {
|
||
x: root.simEnabled ? 18 : 2; y: 2; width: 16; height: 16; radius: 0
|
||
color: root.simEnabled ? root.cBar : root.cDim
|
||
Behavior on x { NumberAnimation { duration: 150 } }
|
||
}
|
||
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: {
|
||
const newState = !root.simEnabled
|
||
root.simEnabled = newState
|
||
Quickshell.execDetached(["nmcli", "connection", newState ? "up" : "down", "mobile"])
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
visible: root.simPresent
|
||
Layout.fillWidth: true
|
||
height: 1
|
||
color: root.cBorder
|
||
opacity: 0.7
|
||
}
|
||
|
||
// Wi-Fi status and toggle card.
|
||
Rectangle {
|
||
Layout.fillWidth: true
|
||
implicitHeight: 64
|
||
color: root.cSurface
|
||
border.color: root.cBorder
|
||
radius: 0
|
||
|
||
RowLayout {
|
||
anchors.fill: parent
|
||
anchors.margins: 12
|
||
spacing: 10
|
||
|
||
Item {
|
||
Layout.preferredWidth: 32
|
||
Layout.minimumWidth: 32
|
||
Layout.maximumWidth: 32
|
||
Layout.preferredHeight: 32
|
||
Layout.minimumHeight: 32
|
||
Layout.maximumHeight: 32
|
||
Layout.alignment: Qt.AlignVCenter
|
||
|
||
Text {
|
||
anchors.centerIn: parent
|
||
anchors.horizontalCenterOffset: -2
|
||
text: root.activeSsid ? "" : (root.ethernetConnected ? "" : (root.simEnabled ? "" : ""))
|
||
font.family: root.mono; font.pixelSize: 22
|
||
color: root.netUp ? root.cSuccess : root.cWarn
|
||
}
|
||
}
|
||
|
||
ColumnLayout {
|
||
spacing: 2
|
||
Text {
|
||
text: root.activeSsid ? root.activeSsid
|
||
: root.ethernetConnected ? "Wired Connection"
|
||
: root.simEnabled ? "Mobile Connection"
|
||
: root.netUp ? "Connected" : "Disconnected"
|
||
font.family: root.mono; font.pixelSize: 13; font.bold: true; color: root.cBright
|
||
elide: Text.ElideRight
|
||
}
|
||
Text {
|
||
text: root.ipAddress ? ("IP: " + root.ipAddress) : (root.wifiEnabled ? "Wi-Fi Enabled" : "Wi-Fi Disabled")
|
||
font.family: root.mono; font.pixelSize: 10; color: root.cSubtle
|
||
}
|
||
}
|
||
|
||
Item { Layout.fillWidth: true }
|
||
|
||
// Wi-Fi Toggle Pill
|
||
Rectangle {
|
||
implicitWidth: 36; implicitHeight: 20; radius: 0
|
||
color: root.wifiEnabled ? root.cAccent : root.cSurfaceHover
|
||
border.color: wifiToggleMouse.activeFocus ? root.cBorderFocus : (root.wifiEnabled ? root.cAccent : root.cBorder)
|
||
border.width: 1
|
||
|
||
Rectangle {
|
||
x: root.wifiEnabled ? 18 : 2; y: 2; width: 16; height: 16; radius: 0
|
||
color: root.wifiEnabled ? root.cBar : root.cDim
|
||
Behavior on x { NumberAnimation { duration: 150 } }
|
||
}
|
||
|
||
MouseArea {
|
||
id: wifiToggleMouse
|
||
anchors.fill: parent
|
||
activeFocusOnTab: true
|
||
Keys.onReturnPressed: toggleWifi()
|
||
Keys.onSpacePressed: toggleWifi()
|
||
function toggleWifi() {
|
||
const newState = !root.wifiEnabled
|
||
root.wifiEnabled = newState
|
||
Quickshell.execDetached(["nmcli", "radio", "wifi", newState ? "on" : "off"])
|
||
if (newState) wifiScanProc.running = true
|
||
}
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: {
|
||
const newState = !root.wifiEnabled
|
||
root.wifiEnabled = newState
|
||
Quickshell.execDetached(["nmcli", "radio", "wifi", newState ? "on" : "off"])
|
||
if (newState) wifiScanProc.running = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Keep the network action in the panel; no terminal launcher.
|
||
RowLayout {
|
||
Layout.fillWidth: true
|
||
spacing: 0
|
||
|
||
Rectangle {
|
||
Layout.fillWidth: true; implicitHeight: 28; radius: 0
|
||
color: rescanHover.containsMouse ? root.cSurfaceHover : root.cSurface
|
||
border.color: root.cBorder
|
||
|
||
RowLayout {
|
||
anchors.centerIn: parent; spacing: 6
|
||
Text { text: ""; font.family: root.mono; font.pixelSize: 11; color: root.cAccent }
|
||
Text { text: "Rescan"; font.family: root.mono; font.pixelSize: 11; color: root.cText }
|
||
}
|
||
MouseArea {
|
||
id: rescanHover
|
||
anchors.fill: parent
|
||
activeFocusOnTab: true
|
||
Keys.onReturnPressed: wifiScanProc.running = true
|
||
Keys.onSpacePressed: wifiScanProc.running = true
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: wifiScanProc.running = true
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
// Available Networks Header
|
||
Text {
|
||
text: "AVAILABLE NETWORKS"
|
||
font.family: root.mono; font.pixelSize: 10; font.bold: true; color: root.cSubtle
|
||
Layout.topMargin: 2
|
||
}
|
||
|
||
// Wi-Fi Networks List
|
||
ListView {
|
||
id: wifiNetworkList
|
||
Layout.fillWidth: true
|
||
Layout.fillHeight: true
|
||
clip: true
|
||
spacing: 4
|
||
model: root.wifiNetworks
|
||
|
||
delegate: Rectangle {
|
||
width: ListView.view.width
|
||
height: 36
|
||
activeFocusOnTab: true
|
||
radius: 0
|
||
color: modelData.active ? root.cSelected : (wifiItemHover.containsMouse ? root.cSurfaceHover : root.cSurface)
|
||
border.color: modelData.active ? root.cAccent : (activeFocus ? root.cBorderFocus : "transparent")
|
||
Keys.onUpPressed: if (ListView.view.currentIndex > 0) ListView.view.decrementCurrentIndex()
|
||
Keys.onDownPressed: if (ListView.view.currentIndex < ListView.view.count - 1) ListView.view.incrementCurrentIndex()
|
||
Keys.onPressed: event => {
|
||
if (event.key === Qt.Key_J && ListView.view.currentIndex < ListView.view.count - 1) { ListView.view.incrementCurrentIndex(); event.accepted = true }
|
||
else if (event.key === Qt.Key_K && ListView.view.currentIndex > 0) { ListView.view.decrementCurrentIndex(); event.accepted = true }
|
||
else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_Space) {
|
||
if (modelData.active) Quickshell.execDetached(["nmcli", "connection", "down", "id", modelData.ssid])
|
||
else Quickshell.execDetached(["foot", "-e", "nmtui-connect", modelData.ssid])
|
||
root.activeApplet = ""; event.accepted = true
|
||
}
|
||
}
|
||
|
||
RowLayout {
|
||
anchors.fill: parent
|
||
anchors.leftMargin: 10
|
||
anchors.rightMargin: 10
|
||
spacing: 10
|
||
|
||
Text {
|
||
text: ""
|
||
font.family: root.mono; font.pixelSize: 12
|
||
color: modelData.active ? root.cAccent : (modelData.signal > 50 ? root.cText : root.cDim)
|
||
}
|
||
|
||
Text {
|
||
text: modelData.ssid
|
||
font.family: root.mono; font.pixelSize: 12
|
||
color: modelData.active ? root.cBright : root.cText
|
||
Layout.fillWidth: true
|
||
elide: Text.ElideRight
|
||
}
|
||
|
||
// Security lock
|
||
Text {
|
||
visible: modelData.security && !modelData.security.includes("Open")
|
||
text: ""
|
||
font.family: root.mono; font.pixelSize: 10; color: root.cDim
|
||
}
|
||
|
||
// Signal percentage
|
||
Text {
|
||
text: modelData.signal + "%"
|
||
font.family: root.mono; font.pixelSize: 10; color: root.cSubtle
|
||
}
|
||
|
||
// Active badge
|
||
Rectangle {
|
||
visible: modelData.active
|
||
width: 8; height: 8; radius: 0
|
||
color: root.cSuccess
|
||
}
|
||
}
|
||
|
||
MouseArea {
|
||
id: wifiItemHover
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: {
|
||
if (modelData.active) {
|
||
Quickshell.execDetached(["nmcli", "connection", "down", "id", modelData.ssid])
|
||
} else {
|
||
Quickshell.execDetached(["foot", "-e", "nmtui-connect", modelData.ssid])
|
||
}
|
||
root.activeApplet = ""
|
||
}
|
||
}
|
||
}
|
||
|
||
// Empty state
|
||
Item {
|
||
anchors.centerIn: parent
|
||
visible: root.wifiNetworks.length === 0
|
||
ColumnLayout {
|
||
anchors.centerIn: parent
|
||
spacing: 4
|
||
Text { Layout.alignment: Qt.AlignHCenter; text: root.wifiEnabled ? "Scanning for networks..." : "Wi-Fi is currently disabled"; font.family: root.mono; font.pixelSize: 12; color: root.cDim }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ══════════════════ BLUETOOTH VIEW ══════════════════
|
||
ColumnLayout {
|
||
anchors.fill: parent
|
||
visible: root.activeApplet === "bluetooth"
|
||
spacing: 12
|
||
|
||
// Status & Toggle Card
|
||
Rectangle {
|
||
Layout.fillWidth: true
|
||
implicitHeight: 64
|
||
color: root.cSurface
|
||
border.color: root.cBorder
|
||
radius: 0
|
||
|
||
RowLayout {
|
||
anchors.fill: parent
|
||
anchors.margins: 12
|
||
spacing: 10
|
||
|
||
Text {
|
||
text: root.btConnected ? "" : (root.btOn ? "" : "")
|
||
font.family: root.mono; font.pixelSize: 22
|
||
color: root.btOn ? (root.btConnected ? root.cSuccess : root.cAccent) : root.cDim
|
||
}
|
||
|
||
ColumnLayout {
|
||
spacing: 2
|
||
Text {
|
||
text: root.btOn ? (root.btConnected ? "Device Connected" : "Bluetooth Enabled") : "Bluetooth Disabled"
|
||
font.family: root.mono; font.pixelSize: 13; font.bold: true; color: root.cBright
|
||
}
|
||
Text {
|
||
text: root.btActionMessage || (root.btDevices.length > 0 ? (root.btDevices.length + " known devices") : "No devices")
|
||
font.family: root.mono; font.pixelSize: 10; color: root.cSubtle
|
||
elide: Text.ElideRight
|
||
Layout.maximumWidth: 190
|
||
}
|
||
}
|
||
|
||
Item { Layout.fillWidth: true }
|
||
|
||
// Bluetooth Power Toggle Pill
|
||
Rectangle {
|
||
implicitWidth: 36; implicitHeight: 20; radius: 0
|
||
color: root.btOn ? root.cAccent : root.cSurfaceHover
|
||
border.color: btToggleMouse.activeFocus ? root.cBorderFocus : (root.btOn ? root.cAccent : root.cBorder)
|
||
border.width: 1
|
||
|
||
Rectangle {
|
||
x: root.btOn ? 18 : 2; y: 2; width: 16; height: 16; radius: 0
|
||
color: root.btOn ? root.cBar : root.cDim
|
||
Behavior on x { NumberAnimation { duration: 150 } }
|
||
}
|
||
|
||
MouseArea {
|
||
id: btToggleMouse
|
||
anchors.fill: parent
|
||
activeFocusOnTab: true
|
||
Keys.onReturnPressed: toggleBluetooth()
|
||
Keys.onSpacePressed: toggleBluetooth()
|
||
function toggleBluetooth() {
|
||
const newState = !root.btOn
|
||
root.btOn = newState
|
||
if (!newState && root.btScanning) { btScanTimer.stop(); root.btScanning = false; root.runBluetoothCommand("scan off") }
|
||
if (Bluetooth.defaultAdapter) Bluetooth.defaultAdapter.enabled = newState
|
||
statusProc.running = true
|
||
}
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: {
|
||
const newState = !root.btOn
|
||
root.btOn = newState
|
||
if (!newState && root.btScanning) {
|
||
btScanTimer.stop()
|
||
root.btScanning = false
|
||
root.runBluetoothCommand("scan off")
|
||
}
|
||
if (Bluetooth.defaultAdapter)
|
||
Bluetooth.defaultAdapter.enabled = newState
|
||
statusProc.running = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Start a bounded discovery from this panel. Pairing and
|
||
// connect actions remain here, without opening a terminal.
|
||
Rectangle {
|
||
Layout.fillWidth: true
|
||
implicitHeight: 30
|
||
radius: 0
|
||
color: btScanHover.containsMouse ? root.cSurfaceHover : root.cSurface
|
||
border.color: root.cBorder
|
||
|
||
RowLayout {
|
||
anchors.centerIn: parent
|
||
spacing: 7
|
||
Text {
|
||
text: root.btScanning ? "◌" : (root.btOn ? "" : "")
|
||
font.family: root.mono; font.pixelSize: 11
|
||
color: root.cAccent
|
||
}
|
||
Text {
|
||
text: root.btScanning ? "Scanning nearby devices…" : (root.btOn ? "Scan nearby devices" : "Enable Bluetooth")
|
||
font.family: root.mono; font.pixelSize: 11
|
||
color: root.cText
|
||
}
|
||
}
|
||
MouseArea {
|
||
id: btScanHover
|
||
anchors.fill: parent
|
||
activeFocusOnTab: true
|
||
Keys.onReturnPressed: root.startBluetoothScan()
|
||
Keys.onSpacePressed: root.startBluetoothScan()
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
enabled: !root.btScanning
|
||
onClicked: root.startBluetoothScan()
|
||
}
|
||
}
|
||
|
||
// Paired Devices Header
|
||
Text {
|
||
text: "NEARBY & PAIRED DEVICES"
|
||
font.family: root.mono; font.pixelSize: 10; font.bold: true; color: root.cSubtle
|
||
Layout.topMargin: 2
|
||
}
|
||
|
||
// Bluetooth Devices List
|
||
ListView {
|
||
id: bluetoothDeviceList
|
||
Layout.fillWidth: true
|
||
Layout.fillHeight: true
|
||
clip: true
|
||
spacing: 4
|
||
model: root.btDevices
|
||
|
||
delegate: Rectangle {
|
||
width: ListView.view.width
|
||
height: 50
|
||
activeFocusOnTab: true
|
||
radius: 0
|
||
color: modelData.connected ? root.cSelected : root.cSurface
|
||
border.color: modelData.connected || activeFocus ? root.cBorderFocus : "transparent"
|
||
Keys.onUpPressed: if (ListView.view.currentIndex > 0) ListView.view.decrementCurrentIndex()
|
||
Keys.onDownPressed: if (ListView.view.currentIndex < ListView.view.count - 1) ListView.view.incrementCurrentIndex()
|
||
Keys.onPressed: event => {
|
||
if (event.key === Qt.Key_J && ListView.view.currentIndex < ListView.view.count - 1) { ListView.view.incrementCurrentIndex(); event.accepted = true }
|
||
else if (event.key === Qt.Key_K && ListView.view.currentIndex > 0) { ListView.view.decrementCurrentIndex(); event.accepted = true }
|
||
else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_Space) { root.runBluetoothAction(modelData.connected ? "disconnect" : (modelData.paired ? "connect" : "pair"), modelData.mac); event.accepted = true }
|
||
}
|
||
|
||
RowLayout {
|
||
anchors.fill: parent
|
||
anchors.leftMargin: 10
|
||
anchors.rightMargin: 10
|
||
spacing: 10
|
||
|
||
Text {
|
||
text: ""
|
||
font.family: root.mono; font.pixelSize: 14
|
||
color: modelData.connected ? root.cSuccess : root.cSubtle
|
||
}
|
||
|
||
ColumnLayout {
|
||
Layout.fillWidth: true
|
||
Layout.minimumWidth: 0
|
||
spacing: 1
|
||
Text {
|
||
text: modelData.name
|
||
font.family: root.mono; font.pixelSize: 12
|
||
color: modelData.connected ? root.cBright : root.cText
|
||
elide: Text.ElideRight
|
||
}
|
||
Text {
|
||
text: modelData.mac
|
||
font.family: root.mono; font.pixelSize: 9
|
||
color: root.cDim
|
||
}
|
||
}
|
||
|
||
// Connect / Disconnect Action Button
|
||
Rectangle {
|
||
Layout.preferredWidth: 84
|
||
Layout.minimumWidth: 84
|
||
Layout.maximumWidth: 84
|
||
Layout.alignment: Qt.AlignRight
|
||
implicitHeight: 22
|
||
radius: 0
|
||
color: modelData.connected ? root.cDanger : (btActionHover.containsMouse ? root.cSelected : root.cSurfaceHover)
|
||
border.color: root.cBorder
|
||
|
||
Text {
|
||
anchors.centerIn: parent
|
||
text: root.btActionPending && root.btActionAddress === modelData.mac
|
||
? "Working…"
|
||
: (modelData.connected ? "Disconnect" : (modelData.paired ? "Connect" : "Pair"))
|
||
font.family: root.mono; font.pixelSize: 9
|
||
color: modelData.connected ? root.cBright : root.cText
|
||
}
|
||
|
||
MouseArea {
|
||
id: btActionHover
|
||
anchors.fill: parent
|
||
activeFocusOnTab: true
|
||
Keys.onReturnPressed: activateBtAction()
|
||
Keys.onSpacePressed: activateBtAction()
|
||
function activateBtAction() {
|
||
const action = modelData.connected ? "disconnect" : (modelData.paired ? "connect" : "pair")
|
||
root.runBluetoothAction(action, modelData.mac)
|
||
}
|
||
hoverEnabled: true
|
||
enabled: !root.btActionPending
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: {
|
||
const action = modelData.connected ? "disconnect" : (modelData.paired ? "connect" : "pair")
|
||
root.runBluetoothAction(action, modelData.mac)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Empty state
|
||
Item {
|
||
anchors.centerIn: parent
|
||
visible: root.btDevices.length === 0
|
||
ColumnLayout {
|
||
anchors.centerIn: parent
|
||
spacing: 4
|
||
Text {
|
||
Layout.alignment: Qt.AlignHCenter
|
||
text: root.btOn ? (root.btScanning ? "Looking for nearby devices…" : "No devices found yet") : "Bluetooth is powered off"
|
||
font.family: root.mono; font.pixelSize: 12; color: root.cDim
|
||
}
|
||
Text {
|
||
Layout.alignment: Qt.AlignHCenter
|
||
text: "Scan nearby devices, then choose Pair or Connect"
|
||
font.family: root.mono; font.pixelSize: 10; color: root.cSubtle
|
||
visible: root.btOn && !root.btScanning
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ══════════════════ AUDIO VIEW ══════════════════
|
||
ScrollView {
|
||
anchors.fill: parent
|
||
contentWidth: availableWidth
|
||
clip: true
|
||
visible: root.activeApplet === "sound"
|
||
|
||
ColumnLayout {
|
||
width: parent.width
|
||
spacing: 14
|
||
|
||
Text {
|
||
text: "PLAYING NOW"
|
||
font.family: root.mono; font.pixelSize: 10; font.bold: true
|
||
color: root.cSubtle
|
||
}
|
||
Repeater {
|
||
model: root.pulsePlaybackStreams
|
||
delegate: RowLayout {
|
||
id: playbackRow
|
||
required property var modelData
|
||
Layout.fillWidth: true
|
||
Layout.leftMargin: 8
|
||
Layout.rightMargin: 8
|
||
Layout.topMargin: 4
|
||
Layout.bottomMargin: 4
|
||
spacing: 10
|
||
|
||
ColumnLayout {
|
||
Layout.fillWidth: true
|
||
spacing: 4
|
||
Text {
|
||
text: modelData.applicationLabel || modelData.application
|
||
font.family: root.mono; font.pixelSize: 10
|
||
color: modelData.applicationLabel === "Apple Music" ? root.cAccent : root.cText
|
||
elide: Text.ElideRight
|
||
Layout.fillWidth: true
|
||
}
|
||
Text {
|
||
text: modelData.artist && modelData.title
|
||
? modelData.artist + " — " + modelData.title
|
||
: (modelData.title || modelData.media || "")
|
||
font.family: root.mono; font.pixelSize: 9
|
||
color: root.cDim
|
||
elide: Text.ElideRight
|
||
Layout.fillWidth: true
|
||
visible: text.length > 0
|
||
}
|
||
}
|
||
Image {
|
||
source: modelData.artUrl || ""
|
||
visible: source.toString().length > 0
|
||
Layout.preferredWidth: 36
|
||
Layout.preferredHeight: 36
|
||
fillMode: Image.PreserveAspectCrop
|
||
asynchronous: true
|
||
Rectangle {
|
||
anchors.fill: parent
|
||
color: "transparent"
|
||
border.color: root.cBorder
|
||
}
|
||
}
|
||
RowLayout {
|
||
spacing: 2
|
||
Repeater {
|
||
model: [
|
||
{ action: "previous", icon: "", label: "Previous track" },
|
||
{ action: "play-pause", icon: "", label: "Play or pause" },
|
||
{ action: "next", icon: "", label: "Next track" }
|
||
]
|
||
delegate: Rectangle {
|
||
required property var modelData
|
||
width: 28; height: 26
|
||
color: playerControlHover.containsMouse ? root.cSelected : "transparent"
|
||
border.color: playerControlHover.containsMouse ? root.cBorderFocus : root.cBorder
|
||
Accessible.name: modelData.label
|
||
Text {
|
||
anchors.centerIn: parent
|
||
text: modelData.icon
|
||
font.family: root.mono
|
||
font.pixelSize: 12
|
||
color: playerControlHover.containsMouse ? root.cBright : root.cSubtle
|
||
}
|
||
MouseArea {
|
||
id: playerControlHover
|
||
anchors.fill: parent
|
||
activeFocusOnTab: true
|
||
Keys.onReturnPressed: activatePlayerControl()
|
||
Keys.onSpacePressed: activatePlayerControl()
|
||
function activatePlayerControl() {
|
||
if (playbackRow.modelData.player)
|
||
Quickshell.execDetached(["playerctl", "--player=" + playbackRow.modelData.player, modelData.action])
|
||
}
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: {
|
||
if (playbackRow.modelData.player)
|
||
Quickshell.execDetached(["playerctl", "--player=" + playbackRow.modelData.player, modelData.action])
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Text {
|
||
text: "No active playback"
|
||
visible: root.pulsePlaybackStreams.length === 0
|
||
font.family: root.mono; font.pixelSize: 10
|
||
color: root.cDim
|
||
}
|
||
|
||
|
||
Rectangle { Layout.fillWidth: true; height: 1; color: root.cBorder }
|
||
|
||
RowLayout {
|
||
Layout.fillWidth: true
|
||
Text {
|
||
text: "OUTPUT VOLUME"
|
||
font.family: root.mono; font.pixelSize: 11; font.bold: true
|
||
color: root.cAccent
|
||
}
|
||
Item { Layout.fillWidth: true }
|
||
Text {
|
||
text: Pipewire.defaultAudioSink?.description || Pipewire.defaultAudioSink?.name || "No output device"
|
||
font.family: root.mono; font.pixelSize: 10
|
||
color: root.cDim
|
||
elide: Text.ElideRight
|
||
Layout.maximumWidth: 190
|
||
}
|
||
}
|
||
|
||
RowLayout {
|
||
Layout.fillWidth: true
|
||
Layout.topMargin: 2
|
||
Layout.bottomMargin: 2
|
||
spacing: 10
|
||
Item {
|
||
Layout.preferredWidth: 20
|
||
Layout.minimumWidth: 20
|
||
Layout.maximumWidth: 20
|
||
implicitHeight: 20
|
||
Text {
|
||
anchors.centerIn: parent
|
||
text: Pipewire.defaultAudioSink?.audio?.muted ? "" : ""
|
||
font.family: root.mono; font.pixelSize: 15
|
||
color: Pipewire.defaultAudioSink?.audio?.muted ? root.cDanger : root.cText
|
||
}
|
||
}
|
||
Item {
|
||
Layout.fillWidth: true
|
||
implicitHeight: 24
|
||
Slider {
|
||
id: masterVolSlider
|
||
activeFocusOnTab: true
|
||
anchors.fill: parent
|
||
implicitHeight: 24
|
||
from: 0; to: 1.0; stepSize: 0.01
|
||
value: Pipewire.defaultAudioSink?.audio?.volume ?? 0
|
||
live: true
|
||
Keys.onLeftPressed: root.setOutputVolume(Math.max(0, value - stepSize))
|
||
Keys.onRightPressed: root.setOutputVolume(Math.min(1, value + stepSize))
|
||
Keys.onPressed: event => {
|
||
if (event.key === Qt.Key_H) { root.setOutputVolume(Math.max(0, value - stepSize)); event.accepted = true }
|
||
else if (event.key === Qt.Key_L) { root.setOutputVolume(Math.min(1, value + stepSize)); event.accepted = true }
|
||
}
|
||
onMoved: root.setOutputVolume(value)
|
||
background: Rectangle {
|
||
x: masterVolSlider.leftPadding
|
||
y: masterVolSlider.topPadding + masterVolSlider.availableHeight / 2 - height / 2
|
||
width: masterVolSlider.availableWidth; height: 3
|
||
color: root.cBorder
|
||
Rectangle {
|
||
width: masterVolSlider.visualPosition * parent.width; height: parent.height
|
||
color: Pipewire.defaultAudioSink?.audio?.muted ? root.cDim : root.cAccent
|
||
}
|
||
}
|
||
handle: Rectangle {
|
||
x: masterVolSlider.leftPadding + masterVolSlider.visualPosition * (masterVolSlider.availableWidth - width)
|
||
y: masterVolSlider.topPadding + masterVolSlider.availableHeight / 2 - height / 2
|
||
width: 10; height: 10; color: root.cBright
|
||
border.color: root.cBorderFocus; border.width: 1
|
||
}
|
||
}
|
||
MouseArea {
|
||
enabled: false
|
||
anchors.fill: parent
|
||
z: 10
|
||
preventStealing: true
|
||
acceptedButtons: Qt.LeftButton
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
function applyAt(x: real): void {
|
||
root.setOutputVolume(root.sliderValueFromPointer(masterVolSlider, x))
|
||
}
|
||
onPressed: mouse => { masterVolSlider.forceActiveFocus(); applyAt(mouse.x) }
|
||
onPositionChanged: mouse => { if (pressed) applyAt(mouse.x) }
|
||
onReleased: mouse => applyAt(mouse.x)
|
||
}
|
||
}
|
||
Text {
|
||
text: Pipewire.defaultAudioSink?.audio?.muted ? "Muted" : Math.round((Pipewire.defaultAudioSink?.audio?.volume ?? 0) * 100) + "%"
|
||
font.family: root.mono; font.pixelSize: 10; color: root.cText
|
||
horizontalAlignment: Text.AlignRight
|
||
Layout.preferredWidth: 38
|
||
}
|
||
Rectangle {
|
||
implicitWidth: 30; implicitHeight: 28
|
||
color: muteHover.containsMouse ? root.cSelected : "transparent"
|
||
border.color: muteHover.activeFocus ? root.cBorderFocus : (muteHover.containsMouse ? root.cBorderFocus : root.cBorder)
|
||
Text {
|
||
anchors.centerIn: parent
|
||
text: Pipewire.defaultAudioSink?.audio?.muted ? "" : ""
|
||
font.family: root.mono; font.pixelSize: 12; color: root.cText
|
||
}
|
||
MouseArea {
|
||
id: muteHover
|
||
anchors.fill: parent; hoverEnabled: true
|
||
activeFocusOnTab: true
|
||
Keys.onReturnPressed: toggleOutputMute()
|
||
Keys.onSpacePressed: toggleOutputMute()
|
||
function toggleOutputMute() {
|
||
if (Pipewire.defaultAudioSink?.audio) Pipewire.defaultAudioSink.audio.muted = !Pipewire.defaultAudioSink.audio.muted
|
||
root.showVolumeOsd()
|
||
}
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: {
|
||
if (Pipewire.defaultAudioSink?.audio)
|
||
Pipewire.defaultAudioSink.audio.muted = !Pipewire.defaultAudioSink.audio.muted
|
||
root.showVolumeOsd()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
RowLayout {
|
||
Layout.fillWidth: true
|
||
Layout.topMargin: 2
|
||
Text {
|
||
text: "INPUT VOLUME"
|
||
font.family: root.mono; font.pixelSize: 11; font.bold: true
|
||
color: root.cAccent
|
||
}
|
||
Item { Layout.fillWidth: true }
|
||
Text {
|
||
text: Pipewire.defaultAudioSource?.description || Pipewire.defaultAudioSource?.name || "No input device"
|
||
font.family: root.mono; font.pixelSize: 10
|
||
color: root.cDim
|
||
elide: Text.ElideRight
|
||
Layout.maximumWidth: 190
|
||
}
|
||
}
|
||
|
||
RowLayout {
|
||
Layout.fillWidth: true
|
||
Layout.topMargin: 2
|
||
Layout.bottomMargin: 2
|
||
spacing: 10
|
||
Item {
|
||
Layout.preferredWidth: 20
|
||
Layout.minimumWidth: 20
|
||
Layout.maximumWidth: 20
|
||
implicitHeight: 20
|
||
Text {
|
||
anchors.centerIn: parent
|
||
text: Pipewire.defaultAudioSource?.audio?.muted ? "" : ""
|
||
font.family: root.mono; font.pixelSize: 15
|
||
color: Pipewire.defaultAudioSource?.audio?.muted ? root.cDanger : root.cText
|
||
}
|
||
}
|
||
Item {
|
||
Layout.fillWidth: true
|
||
Layout.preferredHeight: 24
|
||
implicitHeight: 24
|
||
Slider {
|
||
id: masterInputSlider
|
||
activeFocusOnTab: true
|
||
anchors.fill: parent
|
||
implicitHeight: 24
|
||
from: 0; to: 1.0; stepSize: 0.01
|
||
value: Pipewire.defaultAudioSource?.audio?.volume ?? 0
|
||
live: true
|
||
Keys.onLeftPressed: root.setInputVolume(Math.max(0, value - stepSize))
|
||
Keys.onRightPressed: root.setInputVolume(Math.min(1, value + stepSize))
|
||
Keys.onPressed: event => {
|
||
if (event.key === Qt.Key_H) { root.setInputVolume(Math.max(0, value - stepSize)); event.accepted = true }
|
||
else if (event.key === Qt.Key_L) { root.setInputVolume(Math.min(1, value + stepSize)); event.accepted = true }
|
||
}
|
||
onMoved: root.setInputVolume(value)
|
||
background: Rectangle {
|
||
x: masterInputSlider.leftPadding
|
||
y: masterInputSlider.topPadding + masterInputSlider.availableHeight / 2 - height / 2
|
||
width: masterInputSlider.availableWidth; height: 3
|
||
color: root.cBorder
|
||
Rectangle {
|
||
width: masterInputSlider.visualPosition * parent.width; height: parent.height
|
||
color: Pipewire.defaultAudioSource?.audio?.muted ? root.cDim : root.cAccent
|
||
}
|
||
}
|
||
handle: Rectangle {
|
||
implicitWidth: 10; implicitHeight: 10
|
||
x: masterInputSlider.leftPadding + masterInputSlider.visualPosition * (masterInputSlider.availableWidth - width)
|
||
y: masterInputSlider.topPadding + masterInputSlider.availableHeight / 2 - height / 2
|
||
width: 10; height: 10; color: root.cBright
|
||
border.color: root.cBorderFocus; border.width: 1
|
||
}
|
||
}
|
||
MouseArea {
|
||
enabled: false
|
||
anchors.fill: parent
|
||
z: 10
|
||
preventStealing: true
|
||
acceptedButtons: Qt.LeftButton
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
function applyAt(x: real): void {
|
||
root.setInputVolume(root.sliderValueFromPointer(masterInputSlider, x))
|
||
}
|
||
onPressed: mouse => { masterInputSlider.forceActiveFocus(); applyAt(mouse.x) }
|
||
onPositionChanged: mouse => { if (pressed) applyAt(mouse.x) }
|
||
onReleased: mouse => applyAt(mouse.x)
|
||
}
|
||
}
|
||
Text {
|
||
text: Pipewire.defaultAudioSource?.audio?.muted ? "Muted" : Math.round((Pipewire.defaultAudioSource?.audio?.volume ?? 0) * 100) + "%"
|
||
font.family: root.mono; font.pixelSize: 10; color: root.cText
|
||
horizontalAlignment: Text.AlignRight
|
||
Layout.preferredWidth: 38
|
||
}
|
||
Rectangle {
|
||
implicitWidth: 30; implicitHeight: 28
|
||
color: inputMuteHover.containsMouse ? root.cSelected : "transparent"
|
||
border.color: inputMuteHover.activeFocus ? root.cBorderFocus : (inputMuteHover.containsMouse ? root.cBorderFocus : root.cBorder)
|
||
Text {
|
||
anchors.centerIn: parent
|
||
text: Pipewire.defaultAudioSource?.audio?.muted ? "" : ""
|
||
font.family: root.mono; font.pixelSize: 12; color: root.cText
|
||
}
|
||
MouseArea {
|
||
id: inputMuteHover
|
||
anchors.fill: parent; hoverEnabled: true
|
||
activeFocusOnTab: true
|
||
Keys.onReturnPressed: toggleInputMute()
|
||
Keys.onSpacePressed: toggleInputMute()
|
||
function toggleInputMute() {
|
||
if (Pipewire.defaultAudioSource?.audio) Pipewire.defaultAudioSource.audio.muted = !Pipewire.defaultAudioSource.audio.muted
|
||
}
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: {
|
||
if (Pipewire.defaultAudioSource?.audio)
|
||
Pipewire.defaultAudioSource.audio.muted = !Pipewire.defaultAudioSource.audio.muted
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Rectangle { Layout.fillWidth: true; height: 1; color: root.cBorder; Layout.topMargin: 2 }
|
||
|
||
Rectangle {
|
||
id: outputDevicesHeader
|
||
Layout.fillWidth: true
|
||
height: 32
|
||
color: outputHeaderHover.containsMouse ? root.cSurfaceHover : root.cSurface
|
||
border.color: outputHeaderHover.activeFocus || outputHeaderHover.containsMouse ? root.cBorderFocus : root.cBorder
|
||
RowLayout {
|
||
anchors.fill: parent
|
||
anchors.leftMargin: 10; anchors.rightMargin: 8
|
||
spacing: 8
|
||
Text {
|
||
text: ""
|
||
font.family: root.mono; font.pixelSize: 12; color: root.cAccent
|
||
}
|
||
Text {
|
||
text: "OUTPUT DEVICES"
|
||
font.family: root.mono; font.pixelSize: 10; font.bold: true; color: root.cText
|
||
Layout.fillWidth: true
|
||
}
|
||
Text {
|
||
text: root.pulseOutputs.length
|
||
font.family: root.mono; font.pixelSize: 9; color: root.cSubtle
|
||
}
|
||
Text {
|
||
text: root.outputDevicesExpanded ? "" : ""
|
||
font.family: root.mono; font.pixelSize: 10; color: root.cAccent
|
||
}
|
||
}
|
||
MouseArea {
|
||
id: outputHeaderHover
|
||
anchors.fill: parent
|
||
activeFocusOnTab: true
|
||
Keys.onReturnPressed: toggleOutputDevices()
|
||
Keys.onSpacePressed: toggleOutputDevices()
|
||
function toggleOutputDevices() {
|
||
root.outputDevicesExpanded = !root.outputDevicesExpanded
|
||
if (!root.outputDevicesExpanded) forceActiveFocus()
|
||
}
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: toggleOutputDevices()
|
||
}
|
||
}
|
||
Repeater {
|
||
model: root.outputDevicesExpanded ? root.pulseOutputs : []
|
||
delegate: Rectangle {
|
||
required property var modelData
|
||
property real deviceVolume: modelData.volume
|
||
onModelDataChanged: deviceVolume = modelData.volume
|
||
readonly property bool isDefault: Pipewire.defaultAudioSink?.name === modelData.name
|
||
Layout.fillWidth: true
|
||
implicitHeight: 62
|
||
color: isDefault ? root.cSelected : (deviceHover.containsMouse ? root.cSurfaceHover : root.cSurface)
|
||
border.color: isDefault ? root.cAccent : (deviceHover.containsMouse ? root.cBorderFocus : root.cBorder)
|
||
border.width: 1
|
||
MouseArea {
|
||
id: deviceHover
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: Quickshell.execDetached(["pactl", "set-default-sink", modelData.name])
|
||
}
|
||
ColumnLayout {
|
||
anchors.fill: parent
|
||
anchors.leftMargin: 10; anchors.rightMargin: 10
|
||
anchors.topMargin: 5; anchors.bottomMargin: 3
|
||
spacing: 1
|
||
z: 1
|
||
RowLayout {
|
||
Layout.fillWidth: true
|
||
spacing: 8
|
||
Text {
|
||
text: ""
|
||
font.family: root.mono; font.pixelSize: 12
|
||
color: isDefault ? root.cAccent : root.cSubtle
|
||
}
|
||
Text {
|
||
text: modelData.description
|
||
font.family: root.mono; font.pixelSize: 10
|
||
color: isDefault ? root.cBright : root.cText
|
||
Layout.fillWidth: true; elide: Text.ElideRight
|
||
}
|
||
Text {
|
||
text: isDefault ? "DEFAULT" : ""
|
||
font.family: root.mono; font.pixelSize: 8
|
||
color: root.cAccent
|
||
}
|
||
Text {
|
||
text: Math.round(modelData.volume * 100) + "%"
|
||
font.family: root.mono; font.pixelSize: 9; color: root.cSubtle
|
||
}
|
||
Text {
|
||
text: modelData.muted ? "" : ""
|
||
font.family: root.mono; font.pixelSize: 11
|
||
color: modelData.muted ? root.cDanger : root.cText
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
anchors.margins: -4
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: root.togglePulseDeviceMute("sink", modelData.name, !modelData.muted)
|
||
}
|
||
}
|
||
}
|
||
Item {
|
||
Layout.fillWidth: true
|
||
Layout.preferredHeight: 24
|
||
Layout.leftMargin: 20
|
||
Layout.bottomMargin: 4
|
||
implicitHeight: 24
|
||
Slider {
|
||
id: sinkSlider
|
||
activeFocusOnTab: true
|
||
anchors.fill: parent
|
||
implicitHeight: 24
|
||
from: 0; to: 1.0; stepSize: 0.01
|
||
value: deviceVolume
|
||
live: true
|
||
Keys.onLeftPressed: { deviceVolume = Math.max(0, value - stepSize); root.setPulseDeviceVolume("sink", modelData.name, deviceVolume) }
|
||
Keys.onRightPressed: { deviceVolume = Math.min(1, value + stepSize); root.setPulseDeviceVolume("sink", modelData.name, deviceVolume) }
|
||
Keys.onPressed: event => {
|
||
if (event.key === Qt.Key_H) { deviceVolume = Math.max(0, value - stepSize); root.setPulseDeviceVolume("sink", modelData.name, deviceVolume); event.accepted = true }
|
||
else if (event.key === Qt.Key_L) { deviceVolume = Math.min(1, value + stepSize); root.setPulseDeviceVolume("sink", modelData.name, deviceVolume); event.accepted = true }
|
||
}
|
||
onMoved: sinkUpdateTimer.restart()
|
||
onPressedChanged: if (!pressed) {
|
||
sinkUpdateTimer.stop()
|
||
root.setPulseDeviceVolume("sink", modelData.name, value)
|
||
}
|
||
background: Rectangle {
|
||
x: sinkSlider.leftPadding
|
||
y: sinkSlider.topPadding + sinkSlider.availableHeight / 2 - height / 2
|
||
width: sinkSlider.availableWidth; height: 2; color: root.cBorder
|
||
Rectangle { width: sinkSlider.visualPosition * parent.width; height: parent.height; color: isDefault ? root.cAccent : root.cDim }
|
||
}
|
||
handle: Rectangle {
|
||
implicitWidth: 10; implicitHeight: 10
|
||
x: sinkSlider.leftPadding + sinkSlider.visualPosition * (sinkSlider.availableWidth - width)
|
||
y: sinkSlider.topPadding + sinkSlider.availableHeight / 2 - height / 2
|
||
width: 10; height: 10; color: sinkSlider.hovered || sinkSlider.pressed ? root.cBright : root.cText
|
||
border.color: root.cBorderFocus; border.width: 1
|
||
}
|
||
Timer {
|
||
id: sinkUpdateTimer
|
||
interval: 100
|
||
onTriggered: root.setPulseDeviceVolume("sink", modelData.name, sinkSlider.value)
|
||
}
|
||
}
|
||
MouseArea {
|
||
enabled: false
|
||
id: sinkPointerArea
|
||
anchors.fill: parent
|
||
z: 10
|
||
preventStealing: true
|
||
acceptedButtons: Qt.LeftButton
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
function applyAt(x: real): void {
|
||
deviceVolume = root.sliderValueFromPointer(sinkSlider, x)
|
||
sinkUpdateTimer.restart()
|
||
}
|
||
onPressed: mouse => { sinkSlider.forceActiveFocus(); applyAt(mouse.x) }
|
||
onPositionChanged: mouse => { if (pressed) applyAt(mouse.x) }
|
||
onReleased: mouse => {
|
||
sinkUpdateTimer.stop()
|
||
root.setPulseDeviceVolume("sink", modelData.name, deviceVolume)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Text {
|
||
text: "No output devices found"
|
||
visible: root.outputDevicesExpanded && root.pulseOutputs.length === 0
|
||
font.family: root.mono; font.pixelSize: 10; color: root.cDim
|
||
}
|
||
|
||
Rectangle { Layout.fillWidth: true; height: 1; color: root.cBorder; Layout.topMargin: 2 }
|
||
|
||
Rectangle {
|
||
id: inputDevicesHeader
|
||
Layout.fillWidth: true
|
||
height: 32
|
||
color: inputHeaderHover.containsMouse ? root.cSurfaceHover : root.cSurface
|
||
border.color: inputHeaderHover.activeFocus || inputHeaderHover.containsMouse ? root.cBorderFocus : root.cBorder
|
||
RowLayout {
|
||
anchors.fill: parent
|
||
anchors.leftMargin: 10; anchors.rightMargin: 8
|
||
spacing: 8
|
||
Text {
|
||
text: ""
|
||
font.family: root.mono; font.pixelSize: 12; color: root.cAccent
|
||
}
|
||
Text {
|
||
text: "INPUT DEVICES"
|
||
font.family: root.mono; font.pixelSize: 10; font.bold: true; color: root.cText
|
||
Layout.fillWidth: true
|
||
}
|
||
Text {
|
||
text: root.pulseInputs.length
|
||
font.family: root.mono; font.pixelSize: 9; color: root.cSubtle
|
||
}
|
||
Text {
|
||
text: root.inputDevicesExpanded ? "" : ""
|
||
font.family: root.mono; font.pixelSize: 10; color: root.cAccent
|
||
}
|
||
}
|
||
MouseArea {
|
||
id: inputHeaderHover
|
||
anchors.fill: parent
|
||
activeFocusOnTab: true
|
||
Keys.onReturnPressed: toggleInputDevices()
|
||
Keys.onSpacePressed: toggleInputDevices()
|
||
function toggleInputDevices() {
|
||
root.inputDevicesExpanded = !root.inputDevicesExpanded
|
||
if (!root.inputDevicesExpanded) forceActiveFocus()
|
||
}
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: toggleInputDevices()
|
||
}
|
||
}
|
||
Repeater {
|
||
model: root.inputDevicesExpanded ? root.pulseInputs : []
|
||
delegate: Rectangle {
|
||
required property var modelData
|
||
property real deviceVolume: modelData.volume
|
||
onModelDataChanged: deviceVolume = modelData.volume
|
||
readonly property bool isDefault: Pipewire.defaultAudioSource?.name === modelData.name
|
||
Layout.fillWidth: true
|
||
implicitHeight: 62
|
||
color: isDefault ? root.cSelected : (deviceHover.containsMouse ? root.cSurfaceHover : root.cSurface)
|
||
border.color: isDefault ? root.cAccent : (deviceHover.containsMouse ? root.cBorderFocus : root.cBorder)
|
||
border.width: 1
|
||
MouseArea {
|
||
id: deviceHover
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: Quickshell.execDetached(["pactl", "set-default-source", modelData.name])
|
||
}
|
||
ColumnLayout {
|
||
anchors.fill: parent
|
||
anchors.leftMargin: 10; anchors.rightMargin: 10
|
||
anchors.topMargin: 5; anchors.bottomMargin: 3
|
||
spacing: 1
|
||
z: 1
|
||
RowLayout {
|
||
Layout.fillWidth: true
|
||
spacing: 8
|
||
Text {
|
||
text: ""
|
||
font.family: root.mono; font.pixelSize: 12
|
||
color: isDefault ? root.cAccent : root.cSubtle
|
||
}
|
||
Text {
|
||
text: modelData.description
|
||
font.family: root.mono; font.pixelSize: 10
|
||
color: isDefault ? root.cBright : root.cText
|
||
Layout.fillWidth: true; elide: Text.ElideRight
|
||
}
|
||
Text {
|
||
text: isDefault ? "DEFAULT" : ""
|
||
font.family: root.mono; font.pixelSize: 8
|
||
color: root.cAccent
|
||
}
|
||
Text {
|
||
text: Math.round(modelData.volume * 100) + "%"
|
||
font.family: root.mono; font.pixelSize: 9; color: root.cSubtle
|
||
}
|
||
Text {
|
||
text: modelData.muted ? "" : ""
|
||
font.family: root.mono; font.pixelSize: 11
|
||
color: modelData.muted ? root.cDanger : root.cText
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
anchors.margins: -4
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: root.togglePulseDeviceMute("source", modelData.name, !modelData.muted)
|
||
}
|
||
}
|
||
}
|
||
Item {
|
||
Layout.fillWidth: true
|
||
Layout.preferredHeight: 24
|
||
Layout.leftMargin: 20
|
||
Layout.bottomMargin: 4
|
||
implicitHeight: 24
|
||
Slider {
|
||
id: srcSlider
|
||
activeFocusOnTab: true
|
||
anchors.fill: parent
|
||
implicitHeight: 24
|
||
from: 0; to: 1.0; stepSize: 0.01
|
||
value: deviceVolume
|
||
live: true
|
||
Keys.onLeftPressed: { deviceVolume = Math.max(0, value - stepSize); root.setPulseDeviceVolume("source", modelData.name, deviceVolume) }
|
||
Keys.onRightPressed: { deviceVolume = Math.min(1, value + stepSize); root.setPulseDeviceVolume("source", modelData.name, deviceVolume) }
|
||
Keys.onPressed: event => {
|
||
if (event.key === Qt.Key_H) { deviceVolume = Math.max(0, value - stepSize); root.setPulseDeviceVolume("source", modelData.name, deviceVolume); event.accepted = true }
|
||
else if (event.key === Qt.Key_L) { deviceVolume = Math.min(1, value + stepSize); root.setPulseDeviceVolume("source", modelData.name, deviceVolume); event.accepted = true }
|
||
}
|
||
onMoved: srcUpdateTimer.restart()
|
||
onPressedChanged: if (!pressed) {
|
||
srcUpdateTimer.stop()
|
||
root.setPulseDeviceVolume("source", modelData.name, value)
|
||
}
|
||
background: Rectangle {
|
||
x: srcSlider.leftPadding
|
||
y: srcSlider.topPadding + srcSlider.availableHeight / 2 - height / 2
|
||
width: srcSlider.availableWidth; height: 2; color: root.cBorder
|
||
Rectangle { width: srcSlider.visualPosition * parent.width; height: parent.height; color: isDefault ? root.cAccent : root.cDim }
|
||
}
|
||
handle: Rectangle {
|
||
implicitWidth: 10; implicitHeight: 10
|
||
x: srcSlider.leftPadding + srcSlider.visualPosition * (srcSlider.availableWidth - width)
|
||
y: srcSlider.topPadding + srcSlider.availableHeight / 2 - height / 2
|
||
width: 10; height: 10; color: srcSlider.hovered || srcSlider.pressed ? root.cBright : root.cText
|
||
border.color: root.cBorderFocus; border.width: 1
|
||
}
|
||
Timer {
|
||
id: srcUpdateTimer
|
||
interval: 100
|
||
onTriggered: root.setPulseDeviceVolume("source", modelData.name, srcSlider.value)
|
||
}
|
||
}
|
||
MouseArea {
|
||
enabled: false
|
||
id: srcPointerArea
|
||
anchors.fill: parent
|
||
z: 10
|
||
preventStealing: true
|
||
acceptedButtons: Qt.LeftButton
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
function applyAt(x: real): void {
|
||
deviceVolume = root.sliderValueFromPointer(srcSlider, x)
|
||
srcUpdateTimer.restart()
|
||
}
|
||
onPressed: mouse => { srcSlider.forceActiveFocus(); applyAt(mouse.x) }
|
||
onPositionChanged: mouse => { if (pressed) applyAt(mouse.x) }
|
||
onReleased: mouse => {
|
||
srcUpdateTimer.stop()
|
||
root.setPulseDeviceVolume("source", modelData.name, deviceVolume)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Text {
|
||
text: "No input devices found"
|
||
visible: root.inputDevicesExpanded && root.pulseInputs.length === 0
|
||
font.family: root.mono; font.pixelSize: 10; color: root.cDim
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ───────────── VOLUME OSD ─────────────
|
||
PanelWindow {
|
||
id: volumeOsdWindow
|
||
visible: root.volumeOsdVisible
|
||
screen: Quickshell.screens.find(s => s.name === Hyprland.focusedMonitor?.name) ?? Quickshell.screens[0]
|
||
|
||
anchors { left: true; right: true; bottom: true }
|
||
implicitHeight: root.osdKind === "track" ? 102 : 78
|
||
color: "transparent"
|
||
mask: Region {}
|
||
|
||
WlrLayershell.layer: WlrLayer.Overlay
|
||
WlrLayershell.namespace: "volume-osd"
|
||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||
exclusionMode: ExclusionMode.Ignore
|
||
aboveWindows: true
|
||
|
||
Rectangle {
|
||
id: volumeOsdCard
|
||
width: root.osdKind === "track" ? 360 : 300
|
||
height: root.osdKind === "track" ? 82 : 58
|
||
anchors.horizontalCenter: parent.horizontalCenter
|
||
anchors.bottom: parent.bottom
|
||
anchors.bottomMargin: 18
|
||
color: root.cPanel
|
||
border.color: root.cBorder
|
||
border.width: 1
|
||
radius: 0
|
||
|
||
ColumnLayout {
|
||
anchors.fill: parent
|
||
anchors.leftMargin: root.osdKind === "track" ? 10 : 14
|
||
anchors.rightMargin: root.osdKind === "track" ? 10 : 14
|
||
anchors.topMargin: root.osdKind === "track" ? 10 : 9
|
||
anchors.bottomMargin: root.osdKind === "track" ? 10 : 9
|
||
spacing: 7
|
||
|
||
RowLayout {
|
||
visible: root.osdKind === "track"
|
||
Layout.fillWidth: true
|
||
Layout.fillHeight: true
|
||
spacing: 10
|
||
Image {
|
||
source: root.osdTrackArtUrl
|
||
Layout.preferredWidth: 54
|
||
Layout.preferredHeight: 54
|
||
fillMode: Image.PreserveAspectCrop
|
||
asynchronous: true
|
||
Rectangle {
|
||
anchors.fill: parent
|
||
color: "transparent"
|
||
border.color: root.cBorder
|
||
}
|
||
}
|
||
ColumnLayout {
|
||
Layout.fillWidth: true
|
||
spacing: 3
|
||
Text {
|
||
text: "APPLE MUSIC"
|
||
font.family: root.mono; font.pixelSize: 9; font.bold: true
|
||
color: root.cAccent
|
||
}
|
||
Text {
|
||
text: root.osdTrackTitle
|
||
font.family: root.mono; font.pixelSize: 12; font.bold: true
|
||
color: root.cBright
|
||
elide: Text.ElideRight
|
||
Layout.fillWidth: true
|
||
}
|
||
Text {
|
||
text: root.osdTrackArtist
|
||
font.family: root.mono; font.pixelSize: 10
|
||
color: root.cSubtle
|
||
elide: Text.ElideRight
|
||
Layout.fillWidth: true
|
||
}
|
||
}
|
||
}
|
||
|
||
RowLayout {
|
||
visible: root.osdKind !== "track"
|
||
Layout.fillWidth: true
|
||
Text {
|
||
text: root.osdKind === "brightness" ? ""
|
||
: root.osdMuted ? "" : (root.osdVolume > 0.5 ? "" : (root.osdVolume > 0 ? "" : ""))
|
||
font.family: root.mono
|
||
font.pixelSize: 13
|
||
color: root.osdKind === "brightness" ? root.cBright : (root.osdMuted ? root.cSubtle : root.cBright)
|
||
}
|
||
Text {
|
||
text: root.osdKind === "brightness" ? "Brightness" : (root.osdMuted ? "Muted" : "Volume")
|
||
font.family: root.mono
|
||
font.pixelSize: 10
|
||
color: root.cText
|
||
}
|
||
Item { Layout.fillWidth: true }
|
||
Text {
|
||
text: root.osdKind === "brightness"
|
||
? (root.brightnessLevel >= 0 ? root.brightnessLevel + "%" : "--")
|
||
: root.osdMuted ? "—" : Math.round(root.osdVolume * 100) + "%"
|
||
font.family: root.mono
|
||
font.pixelSize: 10
|
||
color: root.cSubtle
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
visible: root.osdKind !== "track"
|
||
Layout.fillWidth: true
|
||
height: 3
|
||
color: root.cBorder
|
||
Rectangle {
|
||
width: root.osdKind === "brightness"
|
||
? parent.width * Math.max(0, root.brightnessLevel) / 100
|
||
: root.osdMuted ? 0 : parent.width * root.osdVolume
|
||
height: parent.height
|
||
color: root.osdKind === "brightness" ? root.cBright : (root.osdMuted ? root.cDim : root.cBright)
|
||
Behavior on width { NumberAnimation { duration: 100 } }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ───────────── POWER MENU ─────────────
|
||
PanelWindow {
|
||
id: powerWindow
|
||
visible: root.powerMenuOpen
|
||
screen: Quickshell.screens.find(s => s.name === Hyprland.focusedMonitor?.name) ?? Quickshell.screens[0]
|
||
|
||
anchors { top: true; bottom: true; left: true; right: true }
|
||
color: "transparent"
|
||
|
||
WlrLayershell.layer: WlrLayer.Overlay
|
||
WlrLayershell.namespace: "power-menu"
|
||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
|
||
exclusionMode: ExclusionMode.Ignore
|
||
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
onClicked: root.closeMenus()
|
||
}
|
||
|
||
Rectangle {
|
||
anchors.centerIn: parent
|
||
width: 330
|
||
height: root.powerConfirmAction === "" ? 310 : 190
|
||
color: root.cPanel
|
||
border.color: root.cBorder
|
||
border.width: 1
|
||
radius: 0
|
||
|
||
MouseArea { anchors.fill: parent }
|
||
|
||
FocusScope {
|
||
anchors.fill: parent
|
||
focus: powerWindow.visible
|
||
Keys.onEscapePressed: {
|
||
if (root.powerConfirmAction !== "") root.powerConfirmAction = ""
|
||
else root.closeMenus()
|
||
}
|
||
|
||
ColumnLayout {
|
||
anchors.fill: parent
|
||
anchors.margins: 18
|
||
spacing: 8
|
||
|
||
Text {
|
||
text: root.powerConfirmAction === "" ? "POWER" : "CONFIRM ACTION"
|
||
font.family: root.mono
|
||
font.pixelSize: 11
|
||
font.bold: true
|
||
color: root.cBright
|
||
}
|
||
|
||
Text {
|
||
Layout.fillWidth: true
|
||
text: root.powerConfirmAction === "" ? "Choose what to do with this session." :
|
||
(root.powerConfirmAction === "reboot" ? "Restart the computer now?" : "Shut down the computer now?")
|
||
font.family: root.mono
|
||
font.pixelSize: 10
|
||
color: root.cSubtle
|
||
wrapMode: Text.WordWrap
|
||
}
|
||
|
||
Rectangle { Layout.fillWidth: true; height: 1; color: root.cBorder }
|
||
|
||
ColumnLayout {
|
||
Layout.fillWidth: true
|
||
Layout.fillHeight: true
|
||
spacing: 4
|
||
visible: root.powerConfirmAction === ""
|
||
|
||
Repeater {
|
||
model: [
|
||
{ label: "Log out", icon: "", action: "logout", confirm: false },
|
||
{ label: "Restart…", icon: "", action: "reboot", confirm: true },
|
||
{ label: "Shut down…", icon: "", action: "poweroff", confirm: true },
|
||
]
|
||
|
||
delegate: Rectangle {
|
||
required property var modelData
|
||
Layout.fillWidth: true
|
||
implicitHeight: 42
|
||
color: powerActionHover.containsMouse ? root.cSurfaceHover : "transparent"
|
||
border.color: powerActionHover.containsMouse ? root.cBorderFocus : "transparent"
|
||
border.width: 1
|
||
|
||
RowLayout {
|
||
anchors.fill: parent
|
||
anchors.leftMargin: 10
|
||
anchors.rightMargin: 10
|
||
spacing: 12
|
||
|
||
Text {
|
||
text: modelData.icon
|
||
font.family: root.mono
|
||
font.pixelSize: 14
|
||
color: root.cSubtle
|
||
}
|
||
Text {
|
||
Layout.fillWidth: true
|
||
text: modelData.label
|
||
font.family: root.mono
|
||
font.pixelSize: 11
|
||
color: root.cText
|
||
}
|
||
Text {
|
||
text: modelData.confirm ? "›" : ""
|
||
font.family: root.mono
|
||
font.pixelSize: 14
|
||
color: root.cSubtle
|
||
}
|
||
}
|
||
|
||
MouseArea {
|
||
id: powerActionHover
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: {
|
||
if (modelData.confirm) root.powerConfirmAction = modelData.action
|
||
else root.runPowerAction(modelData.action)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
RowLayout {
|
||
Layout.fillWidth: true
|
||
visible: root.powerConfirmAction !== ""
|
||
Item { Layout.fillWidth: true }
|
||
|
||
Rectangle {
|
||
implicitWidth: 88
|
||
implicitHeight: 34
|
||
color: cancelPowerHover.containsMouse ? root.cSurfaceHover : "transparent"
|
||
border.color: root.cBorder
|
||
border.width: 1
|
||
Text { anchors.centerIn: parent; text: "Cancel"; font.family: root.mono; font.pixelSize: 10; color: root.cText }
|
||
MouseArea {
|
||
id: cancelPowerHover
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: root.powerConfirmAction = ""
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
implicitWidth: 104
|
||
implicitHeight: 34
|
||
color: confirmPowerHover.containsMouse ? root.cSurfaceHover : root.cSelected
|
||
border.color: root.cBorderFocus
|
||
border.width: 1
|
||
Text {
|
||
anchors.centerIn: parent
|
||
text: root.powerConfirmAction === "reboot" ? "Restart" : "Shut down"
|
||
font.family: root.mono
|
||
font.pixelSize: 10
|
||
color: root.cBright
|
||
}
|
||
MouseArea {
|
||
id: confirmPowerHover
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: root.runPowerAction(root.powerConfirmAction)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|