1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2025-12-05 21:15:38 -05:00

Bluetooth improvements, battery widget, notification fixes

This commit is contained in:
bbedward
2025-07-10 17:44:51 -04:00
parent 40b2a3af1e
commit c4975019e7
14 changed files with 1775 additions and 141 deletions

252
Services/BatteryService.qml Normal file
View File

@@ -0,0 +1,252 @@
import QtQuick
import Quickshell
import Quickshell.Io
pragma Singleton
pragma ComponentBehavior: Bound
Singleton {
id: root
property bool batteryAvailable: false
property int batteryLevel: 0
property string batteryStatus: "Unknown" // "Charging", "Discharging", "Full", "Not charging", "Unknown"
property int timeRemaining: 0 // minutes
property bool isCharging: false
property bool isLowBattery: false
property int batteryHealth: 100 // percentage
property string batteryTechnology: "Unknown"
property int cycleCount: 0
property int batteryCapacity: 0 // mAh
property var powerProfiles: []
property string activePowerProfile: "balanced"
// Check if battery is available
Process {
id: batteryAvailabilityChecker
command: ["bash", "-c", "ls /sys/class/power_supply/ | grep -E '^BAT' | head -1"]
running: true
stdout: SplitParser {
splitMarker: "\n"
onRead: (data) => {
if (data.trim()) {
root.batteryAvailable = true
console.log("Battery found:", data.trim())
batteryStatusChecker.running = true
} else {
root.batteryAvailable = false
console.log("No battery found - this appears to be a desktop system")
}
}
}
}
// Battery status checker
Process {
id: batteryStatusChecker
command: ["bash", "-c", "if [ -d /sys/class/power_supply/BAT0 ] || [ -d /sys/class/power_supply/BAT1 ]; then upower -i $(upower -e | grep 'BAT') | grep -E 'state|percentage|time to|energy|technology|cycle-count' || acpi -b 2>/dev/null || echo 'fallback'; else echo 'no-battery'; fi"]
running: false
stdout: StdioCollector {
onStreamFinished: {
if (text.trim() === "no-battery") {
root.batteryAvailable = false
return
}
if (text.trim() && text.trim() !== "fallback") {
parseBatteryInfo(text.trim())
} else {
// Fallback to simple methods
fallbackBatteryChecker.running = true
}
}
}
onExited: (exitCode) => {
if (exitCode !== 0) {
console.warn("Battery status check failed, trying fallback methods")
fallbackBatteryChecker.running = true
}
}
}
// Fallback battery checker using /sys files
Process {
id: fallbackBatteryChecker
command: ["bash", "-c", "if [ -f /sys/class/power_supply/BAT0/capacity ]; then BAT=BAT0; elif [ -f /sys/class/power_supply/BAT1/capacity ]; then BAT=BAT1; else echo 'no-battery'; exit 1; fi; echo \"percentage: $(cat /sys/class/power_supply/$BAT/capacity)%\"; echo \"state: $(cat /sys/class/power_supply/$BAT/status 2>/dev/null || echo Unknown)\"; if [ -f /sys/class/power_supply/$BAT/technology ]; then echo \"technology: $(cat /sys/class/power_supply/$BAT/technology)\"; fi; if [ -f /sys/class/power_supply/$BAT/cycle_count ]; then echo \"cycle-count: $(cat /sys/class/power_supply/$BAT/cycle_count)\"; fi"]
running: false
stdout: StdioCollector {
onStreamFinished: {
if (text.trim() !== "no-battery") {
parseBatteryInfo(text.trim())
}
}
}
}
// Power profiles checker (for systems with power-profiles-daemon)
Process {
id: powerProfilesChecker
command: ["bash", "-c", "if command -v powerprofilesctl > /dev/null; then powerprofilesctl list 2>/dev/null; else echo 'not-available'; fi"]
running: false
stdout: StdioCollector {
onStreamFinished: {
if (text.trim() !== "not-available") {
parsePowerProfiles(text.trim())
}
}
}
}
function parseBatteryInfo(batteryText) {
let lines = batteryText.split('\n')
for (let line of lines) {
line = line.trim().toLowerCase()
if (line.includes('percentage:') || line.includes('capacity:')) {
let match = line.match(/(\d+)%?/)
if (match) {
root.batteryLevel = parseInt(match[1])
root.isLowBattery = root.batteryLevel <= 20
}
} else if (line.includes('state:') || line.includes('status:')) {
if (line.includes('charging')) {
root.batteryStatus = "Charging"
root.isCharging = true
} else if (line.includes('discharging')) {
root.batteryStatus = "Discharging"
root.isCharging = false
} else if (line.includes('full')) {
root.batteryStatus = "Full"
root.isCharging = false
} else if (line.includes('not charging')) {
root.batteryStatus = "Not charging"
root.isCharging = false
} else {
root.batteryStatus = "Unknown"
root.isCharging = false
}
} else if (line.includes('time to')) {
let match = line.match(/(\d+):(\d+)/)
if (match) {
root.timeRemaining = parseInt(match[1]) * 60 + parseInt(match[2])
}
} else if (line.includes('technology:')) {
let tech = line.split(':')[1]?.trim() || "Unknown"
root.batteryTechnology = tech.charAt(0).toUpperCase() + tech.slice(1)
} else if (line.includes('cycle-count:')) {
let match = line.match(/(\d+)/)
if (match) {
root.cycleCount = parseInt(match[1])
}
} else if (line.includes('energy-full:') || line.includes('capacity:')) {
let match = line.match(/([\d.]+)\s*wh/i)
if (match) {
root.batteryCapacity = Math.round(parseFloat(match[1]) * 1000) // Convert to mWh
}
}
}
console.log("Battery status updated:", root.batteryLevel + "%", root.batteryStatus)
}
function parsePowerProfiles(profileText) {
let lines = profileText.split('\n')
let profiles = []
for (let line of lines) {
line = line.trim()
if (line.includes('*')) {
// Active profile
let profileName = line.replace('*', '').trim()
if (profileName.includes(':')) {
profileName = profileName.split(':')[0].trim()
}
root.activePowerProfile = profileName
profiles.push(profileName)
} else if (line && !line.includes(':') && line.length > 0) {
profiles.push(line)
}
}
root.powerProfiles = profiles
console.log("Power profiles available:", profiles, "Active:", root.activePowerProfile)
}
function setBatteryProfile(profileName) {
if (!root.powerProfiles.includes(profileName)) {
console.warn("Invalid power profile:", profileName)
return
}
console.log("Setting power profile to:", profileName)
let profileProcess = Qt.createQmlObject(`
import Quickshell.Io
Process {
command: ["powerprofilesctl", "set", "${profileName}"]
running: true
onExited: (exitCode) => {
if (exitCode === 0) {
console.log("Power profile changed to:", "${profileName}")
root.activePowerProfile = "${profileName}"
} else {
console.warn("Failed to change power profile")
}
}
}
`, root)
}
function getBatteryIcon() {
if (!root.batteryAvailable) return "power"
let level = root.batteryLevel
let charging = root.isCharging
if (charging) {
if (level >= 90) return "battery_charging_full"
if (level >= 60) return "battery_charging_90"
if (level >= 30) return "battery_charging_60"
if (level >= 20) return "battery_charging_30"
return "battery_charging_20"
} else {
if (level >= 90) return "battery_full"
if (level >= 60) return "battery_6_bar"
if (level >= 50) return "battery_5_bar"
if (level >= 40) return "battery_4_bar"
if (level >= 30) return "battery_3_bar"
if (level >= 20) return "battery_2_bar"
if (level >= 10) return "battery_1_bar"
return "battery_alert"
}
}
function formatTimeRemaining() {
if (root.timeRemaining <= 0) return "Unknown"
let hours = Math.floor(root.timeRemaining / 60)
let minutes = root.timeRemaining % 60
if (hours > 0) {
return hours + "h " + minutes + "m"
} else {
return minutes + "m"
}
}
// Update battery status every 30 seconds
Timer {
interval: 30000
running: root.batteryAvailable
repeat: true
triggeredOnStart: false
onTriggered: {
batteryStatusChecker.running = true
powerProfilesChecker.running = true
}
}
}

View File

@@ -10,6 +10,9 @@ Singleton {
property bool bluetoothEnabled: false
property bool bluetoothAvailable: false
property var bluetoothDevices: []
property var availableDevices: []
property bool scanning: false
property bool discoverable: false
// Real Bluetooth Management
Process {
@@ -91,6 +94,91 @@ Singleton {
}
}
function startDiscovery() {
console.log("Starting Bluetooth discovery...")
let discoveryProcess = Qt.createQmlObject('
import Quickshell.Io
Process {
command: ["bluetoothctl", "scan", "on"]
running: true
onExited: {
root.scanning = true
// Scan for 10 seconds then get discovered devices
discoveryScanTimer.start()
}
}
', root)
}
function stopDiscovery() {
console.log("Stopping Bluetooth discovery...")
let stopDiscoveryProcess = Qt.createQmlObject('
import Quickshell.Io
Process {
command: ["bluetoothctl", "scan", "off"]
running: true
onExited: {
root.scanning = false
}
}
', root)
}
function pairDevice(mac) {
console.log("Pairing device:", mac)
let pairProcess = Qt.createQmlObject('
import Quickshell.Io
Process {
command: ["bluetoothctl", "pair", "' + mac + '"]
running: true
onExited: (exitCode) => {
if (exitCode === 0) {
console.log("Pairing successful")
connectDevice("' + mac + '")
} else {
console.warn("Pairing failed with exit code:", exitCode)
}
availableDeviceScanner.running = true
bluetoothDeviceScanner.running = true
}
}
', root)
}
function connectDevice(mac) {
console.log("Connecting to device:", mac)
let connectProcess = Qt.createQmlObject('
import Quickshell.Io
Process {
command: ["bluetoothctl", "connect", "' + mac + '"]
running: true
onExited: (exitCode) => {
if (exitCode === 0) {
console.log("Connection successful")
} else {
console.warn("Connection failed with exit code:", exitCode)
}
bluetoothDeviceScanner.running = true
}
}
', root)
}
function removeDevice(mac) {
console.log("Removing device:", mac)
let removeProcess = Qt.createQmlObject('
import Quickshell.Io
Process {
command: ["bluetoothctl", "remove", "' + mac + '"]
running: true
onExited: {
bluetoothDeviceScanner.running = true
availableDeviceScanner.running = true
}
}
', root)
}
function toggleBluetoothDevice(mac) {
console.log("Toggling Bluetooth device:", mac)
let device = root.bluetoothDevices.find(d => d.mac === mac)
@@ -118,4 +206,82 @@ Singleton {
}
', root)
}
// Timer for discovery scanning
Timer {
id: discoveryScanTimer
interval: 8000 // 8 seconds
repeat: false
onTriggered: {
availableDeviceScanner.running = true
}
}
// Scan for available/discoverable devices
Process {
id: availableDeviceScanner
command: ["bash", "-c", "timeout 5 bluetoothctl devices | grep -v 'Device.*/' | while read -r line; do if [[ $line =~ Device\ ([0-9A-F:]+)\ (.+) ]]; then mac=\"${BASH_REMATCH[1]}\"; name=\"${BASH_REMATCH[2]}\"; if [[ ! $name =~ ^/org/bluez ]] && [[ ! $name =~ hci0 ]]; then info=$(timeout 3 bluetoothctl info $mac 2>/dev/null); paired=$(echo \"$info\" | grep 'Paired:' | grep -q 'yes' && echo 'true' || echo 'false'); connected=$(echo \"$info\" | grep 'Connected:' | grep -q 'yes' && echo 'true' || echo 'false'); rssi=$(echo \"$info\" | grep 'RSSI:' | awk '{print $2}' | head -n1); echo \"$mac|$name|$paired|$connected|${rssi:-}\"; fi; fi; done"]
running: false
stdout: StdioCollector {
onStreamFinished: {
if (text.trim()) {
let devices = []
let lines = text.trim().split('\n')
for (let line of lines) {
if (line.trim()) {
let parts = line.split('|')
if (parts.length >= 4) {
let mac = parts[0].trim()
let name = parts[1].trim()
let paired = parts[2].trim() === 'true'
let connected = parts[3].trim() === 'true'
let rssi = parts[4] ? parseInt(parts[4]) : 0
// Skip if name is still a technical path
if (name.startsWith('/org/bluez') || name.includes('hci0')) {
continue
}
// Determine device type from name
let type = "bluetooth"
let nameLower = name.toLowerCase()
if (nameLower.includes("headphone") || nameLower.includes("airpod") || nameLower.includes("headset") || nameLower.includes("arctis") || nameLower.includes("audio")) type = "headset"
else if (nameLower.includes("mouse")) type = "mouse"
else if (nameLower.includes("keyboard")) type = "keyboard"
else if (nameLower.includes("phone") || nameLower.includes("iphone") || nameLower.includes("samsung") || nameLower.includes("android")) type = "phone"
else if (nameLower.includes("watch")) type = "watch"
else if (nameLower.includes("speaker")) type = "speaker"
else if (nameLower.includes("tv") || nameLower.includes("display")) type = "tv"
// Signal strength assessment
let signalStrength = "unknown"
if (rssi !== 0) {
if (rssi >= -50) signalStrength = "excellent"
else if (rssi >= -60) signalStrength = "good"
else if (rssi >= -70) signalStrength = "fair"
else signalStrength = "weak"
}
devices.push({
mac: mac,
name: name,
type: type,
paired: paired,
connected: connected,
rssi: rssi,
signalStrength: signalStrength,
canPair: !paired
})
}
}
}
root.availableDevices = devices
console.log("Found", devices.length, "available Bluetooth devices")
}
}
}
}
}

View File

@@ -21,6 +21,62 @@ Singleton {
pressure: 0
})
// Weather icon mapping (based on wttr.in weather codes)
property var weatherIcons: ({
"113": "clear_day",
"116": "partly_cloudy_day",
"119": "cloud",
"122": "cloud",
"143": "foggy",
"176": "rainy",
"179": "rainy",
"182": "rainy",
"185": "rainy",
"200": "thunderstorm",
"227": "cloudy_snowing",
"230": "snowing_heavy",
"248": "foggy",
"260": "foggy",
"263": "rainy",
"266": "rainy",
"281": "rainy",
"284": "rainy",
"293": "rainy",
"296": "rainy",
"299": "rainy",
"302": "weather_hail",
"305": "rainy",
"308": "weather_hail",
"311": "rainy",
"314": "rainy",
"317": "rainy",
"320": "cloudy_snowing",
"323": "cloudy_snowing",
"326": "cloudy_snowing",
"329": "snowing_heavy",
"332": "snowing_heavy",
"335": "snowing_heavy",
"338": "snowing_heavy",
"350": "rainy",
"353": "rainy",
"356": "weather_hail",
"359": "weather_hail",
"362": "rainy",
"365": "weather_hail",
"368": "cloudy_snowing",
"371": "snowing_heavy",
"374": "weather_hail",
"377": "weather_hail",
"386": "thunderstorm",
"389": "thunderstorm",
"392": "snowing_heavy",
"395": "snowing_heavy"
})
function getWeatherIcon(code) {
return weatherIcons[code] || "cloud"
}
Process {
id: weatherFetcher
command: ["bash", "-c", "curl -s 'wttr.in/?format=j1' | jq '{current: .current_condition[0], location: .nearest_area[0], astronomy: .weather[0].astronomy[0]}'"]

View File

@@ -6,4 +6,5 @@ singleton NetworkService 1.0 NetworkService.qml
singleton WifiService 1.0 WifiService.qml
singleton AudioService 1.0 AudioService.qml
singleton BluetoothService 1.0 BluetoothService.qml
singleton BrightnessService 1.0 BrightnessService.qml
singleton BrightnessService 1.0 BrightnessService.qml
singleton BatteryService 1.0 BatteryService.qml

View File

@@ -0,0 +1,367 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Widgets
import Quickshell.Wayland
import "../Common"
import "../Services"
PanelWindow {
id: batteryControlPopup
visible: root.batteryPopupVisible && BatteryService.batteryAvailable
implicitWidth: 400
implicitHeight: 300
WlrLayershell.layer: WlrLayershell.Overlay
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
color: "transparent"
anchors {
top: true
left: true
right: true
bottom: true
}
Rectangle {
width: Math.min(380, parent.width - Theme.spacingL * 2)
height: Math.min(450, parent.height - Theme.barHeight - Theme.spacingS * 2)
x: Math.max(Theme.spacingL, parent.width - width - Theme.spacingL)
y: Theme.barHeight + Theme.spacingS
color: Theme.surfaceContainer
radius: Theme.cornerRadiusLarge
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
border.width: 1
opacity: root.batteryPopupVisible ? 1.0 : 0.0
scale: root.batteryPopupVisible ? 1.0 : 0.85
Behavior on opacity {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
Behavior on scale {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
ScrollView {
anchors.fill: parent
anchors.margins: Theme.spacingL
clip: true
Column {
width: parent.width
spacing: Theme.spacingL
// Header
Row {
width: parent.width
Text {
text: "Battery Information"
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
Item { width: parent.width - 200; height: 1 }
Rectangle {
width: 32
height: 32
radius: 16
color: closeBatteryArea.containsMouse ? Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12) : "transparent"
Text {
anchors.centerIn: parent
text: "close"
font.family: Theme.iconFont
font.pixelSize: Theme.iconSize - 4
color: closeBatteryArea.containsMouse ? Theme.error : Theme.surfaceText
}
MouseArea {
id: closeBatteryArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.batteryPopupVisible = false
}
}
}
}
// Battery status card
Rectangle {
width: parent.width
height: 120
radius: Theme.cornerRadius
color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.5)
border.color: BatteryService.isCharging ? Theme.primary : (BatteryService.isLowBattery ? Theme.error : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12))
border.width: BatteryService.isCharging || BatteryService.isLowBattery ? 2 : 1
Row {
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingL
// Large battery icon
Text {
text: BatteryService.getBatteryIcon()
font.family: Theme.iconFont
font.pixelSize: 48
color: {
if (BatteryService.isLowBattery && !BatteryService.isCharging) return Theme.error
if (BatteryService.isCharging) return Theme.primary
return Theme.surfaceText
}
anchors.verticalCenter: parent.verticalCenter
}
Column {
spacing: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
Text {
text: BatteryService.batteryLevel + "%"
font.pixelSize: Theme.fontSizeXLarge
color: {
if (BatteryService.isLowBattery && !BatteryService.isCharging) return Theme.error
if (BatteryService.isCharging) return Theme.primary
return Theme.surfaceText
}
font.weight: Font.Bold
}
Text {
text: BatteryService.batteryStatus
font.pixelSize: Theme.fontSizeLarge
color: {
if (BatteryService.isLowBattery && !BatteryService.isCharging) return Theme.error
if (BatteryService.isCharging) return Theme.primary
return Theme.surfaceText
}
font.weight: Font.Medium
}
Text {
text: {
let time = BatteryService.formatTimeRemaining()
if (time !== "Unknown") {
return BatteryService.isCharging ? "Time until full: " + time : "Time remaining: " + time
}
return ""
}
font.pixelSize: Theme.fontSizeMedium
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
visible: text.length > 0
}
}
}
}
// Battery details
Column {
width: parent.width
spacing: Theme.spacingM
Text {
text: "Battery Details"
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
}
Grid {
width: parent.width
columns: 2
columnSpacing: Theme.spacingL
rowSpacing: Theme.spacingM
// Technology
Column {
spacing: 2
Text {
text: "Technology"
font.pixelSize: Theme.fontSizeSmall
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
font.weight: Font.Medium
}
Text {
text: BatteryService.batteryTechnology
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
}
}
// Cycle count
Column {
spacing: 2
Text {
text: "Cycle Count"
font.pixelSize: Theme.fontSizeSmall
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
font.weight: Font.Medium
}
Text {
text: BatteryService.cycleCount > 0 ? BatteryService.cycleCount.toString() : "Unknown"
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
}
}
// Health
Column {
spacing: 2
Text {
text: "Health"
font.pixelSize: Theme.fontSizeSmall
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
font.weight: Font.Medium
}
Text {
text: BatteryService.batteryHealth + "%"
font.pixelSize: Theme.fontSizeMedium
color: BatteryService.batteryHealth < 80 ? Theme.error : Theme.surfaceText
}
}
// Capacity
Column {
spacing: 2
Text {
text: "Capacity"
font.pixelSize: Theme.fontSizeSmall
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
font.weight: Font.Medium
}
Text {
text: BatteryService.batteryCapacity > 0 ? BatteryService.batteryCapacity + " mWh" : "Unknown"
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
}
}
}
}
// Power profiles (if available)
Column {
width: parent.width
spacing: Theme.spacingM
visible: BatteryService.powerProfiles.length > 0
Text {
text: "Power Profile"
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
}
Column {
width: parent.width
spacing: Theme.spacingS
Repeater {
model: BatteryService.powerProfiles
Rectangle {
width: parent.width
height: 50
radius: Theme.cornerRadius
color: profileArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) :
(modelData === BatteryService.activePowerProfile ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.16) : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08))
border.color: modelData === BatteryService.activePowerProfile ? Theme.primary : "transparent"
border.width: 2
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingL
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingM
Text {
text: {
switch (modelData) {
case "power-saver": return "battery_saver"
case "balanced": return "battery_std"
case "performance": return "flash_on"
default: return "settings"
}
}
font.family: Theme.iconFont
font.pixelSize: Theme.iconSize
color: modelData === BatteryService.activePowerProfile ? Theme.primary : Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
Column {
spacing: 2
anchors.verticalCenter: parent.verticalCenter
Text {
text: {
switch (modelData) {
case "power-saver": return "Power Saver"
case "balanced": return "Balanced"
case "performance": return "Performance"
default: return modelData.charAt(0).toUpperCase() + modelData.slice(1)
}
}
font.pixelSize: Theme.fontSizeMedium
color: modelData === BatteryService.activePowerProfile ? Theme.primary : Theme.surfaceText
font.weight: modelData === BatteryService.activePowerProfile ? Font.Medium : Font.Normal
}
Text {
text: {
switch (modelData) {
case "power-saver": return "Extend battery life"
case "balanced": return "Balance power and performance"
case "performance": return "Prioritize performance"
default: return "Custom power profile"
}
}
font.pixelSize: Theme.fontSizeSmall
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
}
}
}
MouseArea {
id: profileArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
BatteryService.setBatteryProfile(modelData)
}
}
}
}
}
}
}
}
}
}

129
Widgets/BatteryWidget.qml Normal file
View File

@@ -0,0 +1,129 @@
import QtQuick
import QtQuick.Controls
import "../Common"
import "../Services"
Rectangle {
id: batteryWidget
property bool batteryPopupVisible: false
width: Theme.barHeight - Theme.spacingS
height: Theme.barHeight - Theme.spacingS
radius: Theme.cornerRadiusSmall
color: batteryArea.containsMouse ? Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : "transparent"
visible: BatteryService.batteryAvailable
Row {
anchors.centerIn: parent
spacing: Theme.spacingXS
// Battery icon
Text {
text: BatteryService.getBatteryIcon()
font.family: Theme.iconFont
font.pixelSize: Theme.iconSize
color: {
if (!BatteryService.batteryAvailable) return Theme.surfaceText
if (BatteryService.isLowBattery && !BatteryService.isCharging) return Theme.error
if (BatteryService.isCharging) return Theme.primary
return Theme.surfaceText
}
anchors.verticalCenter: parent.verticalCenter
// Subtle animation for charging
RotationAnimation on rotation {
running: BatteryService.isCharging
loops: Animation.Infinite
from: 0
to: 360
duration: 8000
easing.type: Easing.Linear
}
}
// Battery percentage
Text {
text: BatteryService.batteryLevel + "%"
font.pixelSize: Theme.fontSizeSmall
color: {
if (!BatteryService.batteryAvailable) return Theme.surfaceText
if (BatteryService.isLowBattery && !BatteryService.isCharging) return Theme.error
if (BatteryService.isCharging) return Theme.primary
return Theme.surfaceText
}
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
visible: BatteryService.batteryAvailable
}
}
MouseArea {
id: batteryArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
batteryPopupVisible = !batteryPopupVisible
root.batteryPopupVisible = batteryPopupVisible
}
}
// Tooltip on hover
Rectangle {
id: batteryTooltip
width: Math.max(120, tooltipText.contentWidth + Theme.spacingM * 2)
height: tooltipText.contentHeight + Theme.spacingS * 2
radius: Theme.cornerRadius
color: Theme.surfaceContainer
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
border.width: 1
visible: batteryArea.containsMouse && !batteryPopupVisible && BatteryService.batteryAvailable
anchors.bottom: parent.top
anchors.bottomMargin: Theme.spacingS
anchors.horizontalCenter: parent.horizontalCenter
opacity: batteryArea.containsMouse ? 1.0 : 0.0
Behavior on opacity {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
Column {
anchors.centerIn: parent
spacing: 2
Text {
id: tooltipText
text: {
if (!BatteryService.batteryAvailable) return "No battery"
let status = BatteryService.batteryStatus
let level = BatteryService.batteryLevel + "%"
let time = BatteryService.formatTimeRemaining()
if (time !== "Unknown") {
return status + " • " + level + " • " + time
} else {
return status + " • " + level
}
}
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
horizontalAlignment: Text.AlignHCenter
}
}
}
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}

View File

@@ -6,6 +6,7 @@ import Quickshell.Widgets
import Quickshell.Wayland
import Quickshell.Services.Mpris
import "../Common"
import "../Services"
PanelWindow {
id: calendarPopup
@@ -285,7 +286,7 @@ PanelWindow {
anchors.verticalCenter: parent.verticalCenter
Text {
text: root.weatherIcons[root.weather.wCode] || "clear_day"
text: WeatherService.getWeatherIcon(root.weather.wCode)
font.family: Theme.iconFont
font.pixelSize: Theme.iconSize + 4
color: Theme.primary

View File

@@ -1217,6 +1217,221 @@ PanelWindow {
}
}
}
// Available devices for pairing (when enabled)
Column {
width: parent.width
spacing: Theme.spacingM
visible: root.bluetoothEnabled
Row {
width: parent.width
spacing: Theme.spacingM
Text {
text: "Available Devices"
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
Item { width: 1; height: 1 }
Rectangle {
width: Math.max(100, scanText.contentWidth + Theme.spacingM * 2)
height: 32
radius: Theme.cornerRadius
color: scanArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08)
border.color: Theme.primary
border.width: 1
Row {
anchors.centerIn: parent
spacing: Theme.spacingXS
Text {
text: BluetoothService.scanning ? "search" : "bluetooth_searching"
font.family: Theme.iconFont
font.pixelSize: Theme.iconSize - 4
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
RotationAnimation on rotation {
running: BluetoothService.scanning
loops: Animation.Infinite
from: 0
to: 360
duration: 2000
}
}
Text {
id: scanText
text: BluetoothService.scanning ? "Scanning..." : "Scan"
font.pixelSize: Theme.fontSizeMedium
color: Theme.primary
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: scanArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: !BluetoothService.scanning
onClicked: {
BluetoothService.startDiscovery()
}
}
}
}
// Available devices list
Repeater {
model: BluetoothService.availableDevices
Rectangle {
width: parent.width
height: 70
radius: Theme.cornerRadius
color: availableDeviceArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) :
(modelData.paired ? Qt.rgba(Theme.secondary.r, Theme.secondary.g, Theme.secondary.b, 0.12) : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08))
border.color: modelData.paired ? Theme.secondary : (modelData.canPair ? Theme.primary : "transparent")
border.width: 1
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingM
Text {
text: {
switch (modelData.type) {
case "headset": return "headset"
case "mouse": return "mouse"
case "keyboard": return "keyboard"
case "phone": return "smartphone"
case "watch": return "watch"
case "speaker": return "speaker"
case "tv": return "tv"
default: return "bluetooth"
}
}
font.family: Theme.iconFont
font.pixelSize: Theme.iconSize
color: modelData.paired ? Theme.secondary : Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
Column {
spacing: 2
anchors.verticalCenter: parent.verticalCenter
Text {
text: modelData.name
font.pixelSize: Theme.fontSizeMedium
color: modelData.paired ? Theme.secondary : Theme.surfaceText
font.weight: modelData.paired ? Font.Medium : Font.Normal
}
Row {
spacing: Theme.spacingXS
Text {
text: {
if (modelData.paired && modelData.connected) return "Connected"
if (modelData.paired) return "Paired"
return "Signal: " + modelData.signalStrength
}
font.pixelSize: Theme.fontSizeSmall
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
}
Text {
text: modelData.rssi !== 0 ? "• " + modelData.rssi + " dBm" : ""
font.pixelSize: Theme.fontSizeSmall
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.5)
visible: modelData.rssi !== 0
}
}
}
}
// Action button on the right
Rectangle {
width: 80
height: 28
radius: Theme.cornerRadiusSmall
anchors.right: parent.right
anchors.rightMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
color: actionButtonArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
border.color: Theme.primary
border.width: 1
visible: modelData.canPair || modelData.paired
Text {
anchors.centerIn: parent
text: modelData.paired ? (modelData.connected ? "Disconnect" : "Connect") : "Pair"
font.pixelSize: Theme.fontSizeSmall
color: Theme.primary
font.weight: Font.Medium
}
MouseArea {
id: actionButtonArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (modelData.paired) {
if (modelData.connected) {
BluetoothService.toggleBluetoothDevice(modelData.mac)
} else {
BluetoothService.connectDevice(modelData.mac)
}
} else {
BluetoothService.pairDevice(modelData.mac)
}
}
}
}
MouseArea {
id: availableDeviceArea
anchors.fill: parent
anchors.rightMargin: 90 // Don't overlap with action button
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (modelData.paired) {
BluetoothService.toggleBluetoothDevice(modelData.mac)
} else {
BluetoothService.pairDevice(modelData.mac)
}
}
}
}
}
// No devices message
Text {
text: "No devices found. Put your device in pairing mode and click Scan."
font.pixelSize: Theme.fontSizeMedium
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
visible: BluetoothService.availableDevices.length === 0 && !BluetoothService.scanning
wrapMode: Text.WordWrap
width: parent.width
horizontalAlignment: Text.AlignHCenter
}
}
}
}

341
Widgets/InputDialog.qml Normal file
View File

@@ -0,0 +1,341 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Widgets
import Quickshell.Wayland
import "../Common"
PanelWindow {
id: inputDialog
property bool dialogVisible: false
property string dialogTitle: "Input Required"
property string dialogSubtitle: "Please enter the required information"
property string inputPlaceholder: "Enter text"
property string inputValue: ""
property bool isPassword: false
property string confirmButtonText: "Confirm"
property string cancelButtonText: "Cancel"
signal confirmed(string value)
signal cancelled()
visible: dialogVisible
anchors {
top: true
left: true
right: true
bottom: true
}
WlrLayershell.layer: WlrLayershell.Overlay
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: dialogVisible ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
color: "transparent"
onVisibleChanged: {
if (visible) {
textInput.forceActiveFocus()
textInput.text = inputValue
}
}
function showDialog(title, subtitle, placeholder, isPass, confirmText, cancelText) {
dialogTitle = title || "Input Required"
dialogSubtitle = subtitle || "Please enter the required information"
inputPlaceholder = placeholder || "Enter text"
isPassword = isPass || false
confirmButtonText = confirmText || "Confirm"
cancelButtonText = cancelText || "Cancel"
inputValue = ""
dialogVisible = true
}
function hideDialog() {
dialogVisible = false
inputValue = ""
}
Rectangle {
anchors.fill: parent
color: Qt.rgba(0, 0, 0, 0.5)
opacity: dialogVisible ? 1.0 : 0.0
Behavior on opacity {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.standardEasing
}
}
MouseArea {
anchors.fill: parent
onClicked: {
inputDialog.cancelled()
hideDialog()
}
}
}
Rectangle {
width: Math.min(400, parent.width - Theme.spacingL * 2)
height: Math.min(250, parent.height - Theme.spacingL * 2)
anchors.centerIn: parent
color: Theme.surfaceContainer
radius: Theme.cornerRadiusLarge
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
border.width: 1
opacity: dialogVisible ? 1.0 : 0.0
scale: dialogVisible ? 1.0 : 0.9
Behavior on opacity {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
Behavior on scale {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
Column {
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingL
// Header
Row {
width: parent.width
Column {
width: parent.width - 40
spacing: Theme.spacingXS
Text {
text: dialogTitle
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
}
Text {
text: dialogSubtitle
font.pixelSize: Theme.fontSizeMedium
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
width: parent.width
elide: Text.ElideRight
wrapMode: Text.WordWrap
maximumLineCount: 2
}
}
Rectangle {
width: 32
height: 32
radius: 16
color: closeDialogArea.containsMouse ? Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12) : "transparent"
Text {
anchors.centerIn: parent
text: "close"
font.family: Theme.iconFont
font.pixelSize: Theme.iconSize - 4
color: closeDialogArea.containsMouse ? Theme.error : Theme.surfaceText
}
MouseArea {
id: closeDialogArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
inputDialog.cancelled()
hideDialog()
}
}
}
}
// Text input
Rectangle {
width: parent.width
height: 50
radius: Theme.cornerRadius
color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08)
border.color: textInput.activeFocus ? Theme.primary : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
border.width: textInput.activeFocus ? 2 : 1
TextInput {
id: textInput
anchors.fill: parent
anchors.margins: Theme.spacingM
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
echoMode: isPassword && !showPasswordCheckbox.checked ? TextInput.Password : TextInput.Normal
verticalAlignment: TextInput.AlignVCenter
cursorVisible: activeFocus
selectByMouse: true
Text {
anchors.fill: parent
text: inputPlaceholder
font: parent.font
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.5)
verticalAlignment: Text.AlignVCenter
visible: parent.text.length === 0
}
onTextChanged: {
inputValue = text
}
onAccepted: {
inputDialog.confirmed(inputValue)
hideDialog()
}
Component.onCompleted: {
if (dialogVisible) {
forceActiveFocus()
}
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.IBeamCursor
onClicked: {
textInput.forceActiveFocus()
}
}
}
// Show password checkbox (only visible for password inputs)
Row {
spacing: Theme.spacingS
visible: isPassword
Rectangle {
id: showPasswordCheckbox
property bool checked: false
width: 20
height: 20
radius: 4
color: checked ? Theme.primary : "transparent"
border.color: checked ? Theme.primary : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.5)
border.width: 2
Text {
anchors.centerIn: parent
text: "check"
font.family: Theme.iconFont
font.pixelSize: 12
color: Theme.background
visible: parent.checked
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
showPasswordCheckbox.checked = !showPasswordCheckbox.checked
}
}
}
Text {
text: "Show password"
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
}
// Buttons
Item {
width: parent.width
height: 40
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingM
Rectangle {
width: Math.max(70, cancelText.contentWidth + Theme.spacingM * 2)
height: 36
radius: Theme.cornerRadius
color: cancelArea.containsMouse ? Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : "transparent"
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
border.width: 1
Text {
id: cancelText
anchors.centerIn: parent
text: cancelButtonText
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
font.weight: Font.Medium
}
MouseArea {
id: cancelArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
inputDialog.cancelled()
hideDialog()
}
}
}
Rectangle {
width: Math.max(80, confirmText.contentWidth + Theme.spacingM * 2)
height: 36
radius: Theme.cornerRadius
color: confirmArea.containsMouse ? Qt.darker(Theme.primary, 1.1) : Theme.primary
enabled: inputValue.length > 0
opacity: enabled ? 1.0 : 0.5
Text {
id: confirmText
anchors.centerIn: parent
text: confirmButtonText
font.pixelSize: Theme.fontSizeMedium
color: Theme.background
font.weight: Font.Medium
}
MouseArea {
id: confirmArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: parent.enabled
onClicked: {
inputDialog.confirmed(inputValue)
hideDialog()
}
}
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
}
}
}
}
}

View File

@@ -159,69 +159,88 @@ PanelWindow {
anchors.margins: Theme.spacingM
spacing: Theme.spacingM
// Notification icon using reference pattern
// Notification icon based on EXAMPLE NotificationAppIcon pattern
Rectangle {
width: 32
height: 32
radius: Theme.cornerRadius
color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12)
width: 48
height: 48
radius: width / 2 // Fully rounded like EXAMPLE
color: Theme.primaryContainer
anchors.verticalCenter: parent.verticalCenter
// Fallback material icon when no app icon
// Material icon fallback (when no app icon)
Loader {
active: !model.appIcon || model.appIcon === ""
anchors.fill: parent
sourceComponent: Text {
anchors.centerIn: parent
text: model.appName ? model.appName.charAt(0).toUpperCase() : "notifications"
font.family: model.appName ? "Roboto" : Theme.iconFont
font.pixelSize: model.appName ? Theme.fontSizeMedium : 16
color: Theme.primary
font.weight: Font.Medium
text: "notifications"
font.family: Theme.iconFont
font.pixelSize: 20
color: Theme.primaryText
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
// App icon when no notification image
// App icon (when no notification image)
Loader {
active: model.appIcon && model.appIcon !== "" && (!model.image || model.image === "")
anchors.fill: parent
anchors.margins: 3
anchors.centerIn: parent
sourceComponent: IconImage {
anchors.fill: parent
anchors.margins: 4
width: 32
height: 32
asynchronous: true
source: {
if (!model.appIcon) return ""
// Skip file:// URLs as they're usually screenshots/images, not icons
if (model.appIcon.startsWith("file://")) return ""
// Handle file:// URLs directly
if (model.appIcon.startsWith("file://") || model.appIcon.startsWith("/")) {
return model.appIcon
}
// Otherwise treat as icon name
return Quickshell.iconPath(model.appIcon, "image-missing")
}
}
}
// Notification image with rounded corners
// Notification image (like Discord user avatar) - PRIORITY
Loader {
active: model.image && model.image !== ""
anchors.fill: parent
sourceComponent: Item {
anchors.fill: parent
Image {
id: historyNotifImage
anchors.fill: parent
readonly property int size: parent.width
source: model.image || ""
fillMode: Image.PreserveAspectCrop
cache: false
antialiasing: true
asynchronous: true
smooth: true
// Proper sizing like EXAMPLE
width: size
height: size
sourceSize.width: size
sourceSize.height: size
layer.enabled: true
layer.effect: OpacityMask {
maskSource: Rectangle {
width: historyNotifImage.width
height: historyNotifImage.height
radius: Theme.cornerRadius
width: historyNotifImage.size
height: historyNotifImage.size
radius: historyNotifImage.size / 2 // Fully rounded
}
}
onStatusChanged: {
if (status === Image.Error) {
console.warn("Failed to load notification image:", source)
} else if (status === Image.Ready) {
console.log("Notification image loaded:", source, "size:", sourceSize.width + "x" + sourceSize.height)
}
}
}
@@ -231,12 +250,17 @@ PanelWindow {
active: model.appIcon && model.appIcon !== ""
anchors.bottom: parent.bottom
anchors.right: parent.right
anchors.margins: 2
sourceComponent: IconImage {
width: 12
height: 12
width: 16
height: 16
asynchronous: true
source: model.appIcon ? Quickshell.iconPath(model.appIcon, "image-missing") : ""
source: {
if (!model.appIcon) return ""
if (model.appIcon.startsWith("file://") || model.appIcon.startsWith("/")) {
return model.appIcon
}
return Quickshell.iconPath(model.appIcon, "image-missing")
}
}
}
}
@@ -286,6 +310,11 @@ PanelWindow {
cursorShape: Qt.PointingHandCursor
onClicked: {
// Try to handle notification click if it has actions
if (model && root.handleNotificationClick) {
root.handleNotificationClick(model)
}
// Remove from history after handling
notificationHistory.remove(index)
}
}
@@ -300,10 +329,9 @@ PanelWindow {
}
// Empty state - properly centered
Rectangle {
Item {
anchors.fill: parent
visible: notificationHistory.count === 0
color: "transparent"
Column {
anchors.centerIn: parent

View File

@@ -5,6 +5,7 @@ import Quickshell
import Quickshell.Widgets
import Quickshell.Wayland
import "../Common"
import "../Common/Utilities.js" as Utils
PanelWindow {
id: notificationPopup
@@ -36,8 +37,7 @@ PanelWindow {
color: Theme.surfaceContainer
radius: Theme.cornerRadiusLarge
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
border.width: 1
border.width: 0 // Remove border completely
opacity: root.showNotificationPopup ? 1.0 : 0.0
@@ -47,25 +47,59 @@ PanelWindow {
MouseArea {
anchors.fill: parent
onClicked: Utils.hideNotificationPopup()
anchors.rightMargin: 36 // Don't overlap with close button
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
console.log("Popup clicked!")
if (root.activeNotification) {
root.handleNotificationClick(root.activeNotification)
// Remove notification from history entirely
for (let i = 0; i < notificationHistory.count; i++) {
if (notificationHistory.get(i).id === root.activeNotification.id) {
notificationHistory.remove(i)
break
}
}
}
// Always hide popup after click
Utils.hideNotificationPopup()
}
}
// Close button with cursor pointer
Text {
// Close button with hover styling
Rectangle {
width: 28
height: 28
radius: 14
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 8
text: "×"
font.pixelSize: 16
color: Theme.surfaceText
color: closeButtonArea.containsMouse ? Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12) : "transparent"
Text {
anchors.centerIn: parent
text: "close"
font.family: Theme.iconFont
font.pixelSize: 16
color: closeButtonArea.containsMouse ? Theme.error : Theme.surfaceText
}
MouseArea {
id: closeButtonArea
anchors.fill: parent
anchors.margins: -4
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: Utils.hideNotificationPopup()
}
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
// Content layout
@@ -75,81 +109,89 @@ PanelWindow {
anchors.rightMargin: 32
spacing: 12
// Notification icon using reference pattern
// Notification icon based on EXAMPLE NotificationAppIcon pattern
Rectangle {
width: 40
height: 40
radius: 8
color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1)
width: 48
height: 48
radius: width / 2 // Fully rounded like EXAMPLE
color: Theme.primaryContainer
anchors.verticalCenter: parent.verticalCenter
// Fallback material icon when no app icon
// Material icon fallback (when no app icon)
Loader {
active: !root.activeNotification || root.activeNotification.appIcon === ""
active: !root.activeNotification || !root.activeNotification.appIcon || root.activeNotification.appIcon === ""
anchors.fill: parent
sourceComponent: Text {
anchors.centerIn: parent
text: "notifications"
font.family: Theme.iconFont
font.pixelSize: 20
color: Theme.primary
color: Theme.primaryText
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
// App icon when no notification image
// App icon (when no notification image)
Loader {
active: root.activeNotification && root.activeNotification.appIcon !== "" && (root.activeNotification.image === "" || !root.activeNotification.image)
anchors.fill: parent
anchors.margins: 4
active: root.activeNotification && root.activeNotification.appIcon !== "" && (!root.activeNotification.image || root.activeNotification.image === "")
anchors.centerIn: parent
sourceComponent: IconImage {
anchors.fill: parent
width: 32
height: 32
asynchronous: true
source: {
if (!root.activeNotification) return ""
let iconPath = root.activeNotification.appIcon
// Skip file:// URLs as they're usually screenshots/images, not icons
if (iconPath && iconPath.startsWith("file://")) return ""
return iconPath ? Quickshell.iconPath(iconPath, "image-missing") : ""
if (!root.activeNotification || !root.activeNotification.appIcon) return ""
let appIcon = root.activeNotification.appIcon
// Handle file:// URLs directly
if (appIcon.startsWith("file://") || appIcon.startsWith("/")) {
return appIcon
}
// Otherwise treat as icon name
return Quickshell.iconPath(appIcon, "image-missing")
}
}
}
// Notification image with rounded corners
// Notification image (like Discord user avatar) - PRIORITY
Loader {
active: root.activeNotification && root.activeNotification.image !== ""
anchors.fill: parent
sourceComponent: Item {
anchors.fill: parent
clip: true
Rectangle {
Image {
id: notifImage
anchors.fill: parent
radius: 8
color: "transparent"
clip: true
readonly property int size: parent.width
Image {
id: notifImage
anchors.fill: parent
source: root.activeNotification ? root.activeNotification.image : ""
fillMode: Image.PreserveAspectCrop
cache: false
antialiasing: true
asynchronous: true
smooth: true
// Ensure minimum size and proper scaling
sourceSize.width: 64
sourceSize.height: 64
onStatusChanged: {
if (status === Image.Error) {
console.warn("Failed to load notification image:", source)
} else if (status === Image.Ready) {
console.log("Notification image loaded:", source, "size:", sourceSize)
}
source: root.activeNotification ? root.activeNotification.image : ""
fillMode: Image.PreserveAspectCrop
cache: false
antialiasing: true
asynchronous: true
smooth: true
// Proper sizing like EXAMPLE
width: size
height: size
sourceSize.width: size
sourceSize.height: size
layer.enabled: true
layer.effect: OpacityMask {
maskSource: Rectangle {
width: notifImage.size
height: notifImage.size
radius: notifImage.size / 2 // Fully rounded
}
}
onStatusChanged: {
if (status === Image.Error) {
console.warn("Failed to load notification image:", source)
} else if (status === Image.Ready) {
console.log("Notification image loaded:", source, "size:", sourceSize.width + "x" + sourceSize.height)
}
}
}
@@ -159,12 +201,18 @@ PanelWindow {
active: root.activeNotification && root.activeNotification.appIcon !== ""
anchors.bottom: parent.bottom
anchors.right: parent.right
anchors.margins: 2
sourceComponent: IconImage {
width: 16
height: 16
asynchronous: true
source: root.activeNotification ? Quickshell.iconPath(root.activeNotification.appIcon, "image-missing") : ""
source: {
if (!root.activeNotification || !root.activeNotification.appIcon) return ""
let appIcon = root.activeNotification.appIcon
if (appIcon.startsWith("file://") || appIcon.startsWith("/")) {
return appIcon
}
return Quickshell.iconPath(appIcon, "image-missing")
}
}
}
}
@@ -173,7 +221,7 @@ PanelWindow {
// Text content
Column {
width: parent.width - 52
width: parent.width - 68
anchors.verticalCenter: parent.verticalCenter
spacing: 4

View File

@@ -384,7 +384,7 @@ PanelWindow {
// Weather icon when no media but weather available
Text {
text: root.weatherIcons[root.weather.wCode] || "clear_day"
text: WeatherService.getWeatherIcon(root.weather.wCode)
font.family: Theme.iconFont
font.pixelSize: Theme.iconSize - 2
color: Theme.surfaceText
@@ -702,6 +702,11 @@ PanelWindow {
}
}
// Battery Widget
BatteryWidget {
anchors.verticalCenter: parent.verticalCenter
}
// Control Center Indicators
Rectangle {
width: Math.max(80, controlIndicators.implicitWidth + Theme.spacingS * 2)

View File

@@ -15,4 +15,7 @@ ControlCenterPopup 1.0 ControlCenterPopup.qml
WifiPasswordDialog 1.0 WifiPasswordDialog.qml
AppLauncher 1.0 AppLauncher.qml
ClipboardHistory 1.0 ClipboardHistory.qml
CustomSlider 1.0 CustomSlider.qml
CustomSlider 1.0 CustomSlider.qml
InputDialog 1.0 InputDialog.qml
BatteryWidget 1.0 BatteryWidget.qml
BatteryControlPopup 1.0 BatteryControlPopup.qml

128
shell.qml
View File

@@ -33,6 +33,7 @@ ShellRoot {
property MprisPlayer activePlayer: MprisController.activePlayer
property bool hasActiveMedia: activePlayer && (activePlayer.trackTitle || activePlayer.trackArtist)
property bool controlCenterVisible: false
property bool batteryPopupVisible: false
// Network properties from NetworkService
property string networkStatus: NetworkService.networkStatus
@@ -67,6 +68,65 @@ ShellRoot {
property string wifiConnectionStatus: ""
property bool wifiAutoRefreshEnabled: false
// Notification action handling - ALWAYS invoke action if exists
function handleNotificationClick(notifObj) {
console.log("Handling notification click for:", notifObj.appName)
// ALWAYS try to invoke the action first (this is what real notifications do)
if (notifObj.notification && notifObj.actions && notifObj.actions.length > 0) {
// Look for "default" action first, then fallback to first action
let defaultAction = notifObj.actions.find(action => action.identifier === "default") || notifObj.actions[0]
if (defaultAction) {
console.log("Invoking notification action:", defaultAction.text, "identifier:", defaultAction.identifier)
attemptInvokeAction(notifObj.id, defaultAction.identifier)
return
}
}
// If no action exists, check for URLs in notification text
let notificationText = (notifObj.summary || "") + " " + (notifObj.body || "")
let urlRegex = /(https?:\/\/[^\s]+)/g
let urls = notificationText.match(urlRegex)
if (urls && urls.length > 0) {
console.log("Opening URL from notification:", urls[0])
Qt.openUrlExternally(urls[0])
return
}
console.log("No action or URL found, notification will just dismiss")
}
// Helper function to invoke notification actions (based on EXAMPLE)
function attemptInvokeAction(notifId, actionIdentifier) {
console.log("Attempting to invoke action:", actionIdentifier, "for notification:", notifId)
// Find the notification in the server's tracked notifications
let trackedNotifications = notificationServer.trackedNotifications.values
let serverNotification = trackedNotifications.find(notif => notif.id === notifId)
if (serverNotification) {
let action = serverNotification.actions.find(action => action.identifier === actionIdentifier)
if (action) {
console.log("Invoking action:", action.text)
action.invoke()
} else {
console.warn("Action not found:", actionIdentifier)
}
} else {
console.warn("Notification not found in server:", notifId, "Available IDs:", trackedNotifications.map(n => n.id))
// Try to find by any available action
if (trackedNotifications.length > 0) {
let latestNotif = trackedNotifications[trackedNotifications.length - 1]
let action = latestNotif.actions.find(action => action.identifier === actionIdentifier)
if (action) {
console.log("Using latest notification for action")
action.invoke()
}
}
}
}
// Screen size breakpoints for responsive design
property real screenWidth: Screen.width
property bool isSmallScreen: screenWidth < 1200
@@ -79,57 +139,6 @@ ShellRoot {
// Weather configuration
property bool useFahrenheit: true // Default to Fahrenheit
// Weather icon mapping (based on wttr.in weather codes)
property var weatherIcons: ({
"113": "clear_day",
"116": "partly_cloudy_day",
"119": "cloud",
"122": "cloud",
"143": "foggy",
"176": "rainy",
"179": "rainy",
"182": "rainy",
"185": "rainy",
"200": "thunderstorm",
"227": "cloudy_snowing",
"230": "snowing_heavy",
"248": "foggy",
"260": "foggy",
"263": "rainy",
"266": "rainy",
"281": "rainy",
"284": "rainy",
"293": "rainy",
"296": "rainy",
"299": "rainy",
"302": "weather_hail",
"305": "rainy",
"308": "weather_hail",
"311": "rainy",
"314": "rainy",
"317": "rainy",
"320": "cloudy_snowing",
"323": "cloudy_snowing",
"326": "cloudy_snowing",
"329": "snowing_heavy",
"332": "snowing_heavy",
"335": "snowing_heavy",
"338": "snowing_heavy",
"350": "rainy",
"353": "rainy",
"356": "weather_hail",
"359": "weather_hail",
"362": "rainy",
"365": "weather_hail",
"368": "cloudy_snowing",
"371": "snowing_heavy",
"374": "weather_hail",
"377": "weather_hail",
"386": "thunderstorm",
"389": "thunderstorm",
"392": "snowing_heavy",
"395": "snowing_heavy"
})
// WiFi Auto-refresh Timer
Timer {
@@ -172,7 +181,10 @@ ShellRoot {
console.log("New notification from:", notification.appName || "Unknown", "Summary:", notification.summary || "No summary")
// Create notification object with correct properties
// CRITICAL: Mark notification as tracked so it stays in server list for actions
notification.tracked = true
// Create notification object with correct properties (based on EXAMPLE)
var notifObj = {
"id": notification.id,
"appName": notification.appName || "App",
@@ -181,7 +193,13 @@ ShellRoot {
"timestamp": new Date(),
"appIcon": notification.appIcon || notification.icon || "",
"icon": notification.icon || "",
"image": notification.image || ""
"image": notification.image || "",
"actions": notification.actions ? notification.actions.map(action => ({
"identifier": action.identifier,
"text": action.text
})) : [],
"urgency": notification.urgency ? notification.urgency.toString() : "normal",
"notification": notification // Keep reference for action handling
}
// Add to history (prepend to show newest first)
@@ -237,6 +255,10 @@ ShellRoot {
NotificationHistoryPopup {}
ControlCenterPopup {}
WifiPasswordDialog {}
InputDialog {
id: globalInputDialog
}
BatteryControlPopup {}
// Application and clipboard components
AppLauncher {