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 bluetoothEnabled: false
property bool bluetoothAvailable: false property bool bluetoothAvailable: false
property var bluetoothDevices: [] property var bluetoothDevices: []
property var availableDevices: []
property bool scanning: false
property bool discoverable: false
// Real Bluetooth Management // Real Bluetooth Management
Process { 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) { function toggleBluetoothDevice(mac) {
console.log("Toggling Bluetooth device:", mac) console.log("Toggling Bluetooth device:", mac)
let device = root.bluetoothDevices.find(d => d.mac === mac) let device = root.bluetoothDevices.find(d => d.mac === mac)
@@ -118,4 +206,82 @@ Singleton {
} }
', root) ', 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 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 { Process {
id: weatherFetcher 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]}'"] 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 WifiService 1.0 WifiService.qml
singleton AudioService 1.0 AudioService.qml singleton AudioService 1.0 AudioService.qml
singleton BluetoothService 1.0 BluetoothService.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.Wayland
import Quickshell.Services.Mpris import Quickshell.Services.Mpris
import "../Common" import "../Common"
import "../Services"
PanelWindow { PanelWindow {
id: calendarPopup id: calendarPopup
@@ -285,7 +286,7 @@ PanelWindow {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
Text { Text {
text: root.weatherIcons[root.weather.wCode] || "clear_day" text: WeatherService.getWeatherIcon(root.weather.wCode)
font.family: Theme.iconFont font.family: Theme.iconFont
font.pixelSize: Theme.iconSize + 4 font.pixelSize: Theme.iconSize + 4
color: Theme.primary 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 anchors.margins: Theme.spacingM
spacing: Theme.spacingM spacing: Theme.spacingM
// Notification icon using reference pattern // Notification icon based on EXAMPLE NotificationAppIcon pattern
Rectangle { Rectangle {
width: 32 width: 48
height: 32 height: 48
radius: Theme.cornerRadius radius: width / 2 // Fully rounded like EXAMPLE
color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) color: Theme.primaryContainer
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
// Fallback material icon when no app icon // Material icon fallback (when no app icon)
Loader { Loader {
active: !model.appIcon || model.appIcon === "" active: !model.appIcon || model.appIcon === ""
anchors.fill: parent anchors.fill: parent
sourceComponent: Text { sourceComponent: Text {
anchors.centerIn: parent anchors.centerIn: parent
text: model.appName ? model.appName.charAt(0).toUpperCase() : "notifications" text: "notifications"
font.family: model.appName ? "Roboto" : Theme.iconFont font.family: Theme.iconFont
font.pixelSize: model.appName ? Theme.fontSizeMedium : 16 font.pixelSize: 20
color: Theme.primary color: Theme.primaryText
font.weight: Font.Medium
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter
} }
} }
// App icon when no notification image // App icon (when no notification image)
Loader { Loader {
active: model.appIcon && model.appIcon !== "" && (!model.image || model.image === "") active: model.appIcon && model.appIcon !== "" && (!model.image || model.image === "")
anchors.fill: parent anchors.centerIn: parent
anchors.margins: 3
sourceComponent: IconImage { sourceComponent: IconImage {
anchors.fill: parent width: 32
anchors.margins: 4 height: 32
asynchronous: true asynchronous: true
source: { source: {
if (!model.appIcon) return "" if (!model.appIcon) return ""
// Skip file:// URLs as they're usually screenshots/images, not icons // Handle file:// URLs directly
if (model.appIcon.startsWith("file://")) return "" if (model.appIcon.startsWith("file://") || model.appIcon.startsWith("/")) {
return model.appIcon
}
// Otherwise treat as icon name
return Quickshell.iconPath(model.appIcon, "image-missing") return Quickshell.iconPath(model.appIcon, "image-missing")
} }
} }
} }
// Notification image with rounded corners // Notification image (like Discord user avatar) - PRIORITY
Loader { Loader {
active: model.image && model.image !== "" active: model.image && model.image !== ""
anchors.fill: parent anchors.fill: parent
sourceComponent: Item { sourceComponent: Item {
anchors.fill: parent anchors.fill: parent
Image { Image {
id: historyNotifImage id: historyNotifImage
anchors.fill: parent anchors.fill: parent
readonly property int size: parent.width
source: model.image || "" source: model.image || ""
fillMode: Image.PreserveAspectCrop fillMode: Image.PreserveAspectCrop
cache: false cache: false
antialiasing: true antialiasing: true
asynchronous: true asynchronous: true
smooth: true
// Proper sizing like EXAMPLE
width: size
height: size
sourceSize.width: size
sourceSize.height: size
layer.enabled: true layer.enabled: true
layer.effect: OpacityMask { layer.effect: OpacityMask {
maskSource: Rectangle { maskSource: Rectangle {
width: historyNotifImage.width width: historyNotifImage.size
height: historyNotifImage.height height: historyNotifImage.size
radius: Theme.cornerRadius 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 !== "" active: model.appIcon && model.appIcon !== ""
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.right: parent.right anchors.right: parent.right
anchors.margins: 2
sourceComponent: IconImage { sourceComponent: IconImage {
width: 12 width: 16
height: 12 height: 16
asynchronous: true 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 cursorShape: Qt.PointingHandCursor
onClicked: { 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) notificationHistory.remove(index)
} }
} }
@@ -300,10 +329,9 @@ PanelWindow {
} }
// Empty state - properly centered // Empty state - properly centered
Rectangle { Item {
anchors.fill: parent anchors.fill: parent
visible: notificationHistory.count === 0 visible: notificationHistory.count === 0
color: "transparent"
Column { Column {
anchors.centerIn: parent anchors.centerIn: parent

View File

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

View File

@@ -384,7 +384,7 @@ PanelWindow {
// Weather icon when no media but weather available // Weather icon when no media but weather available
Text { Text {
text: root.weatherIcons[root.weather.wCode] || "clear_day" text: WeatherService.getWeatherIcon(root.weather.wCode)
font.family: Theme.iconFont font.family: Theme.iconFont
font.pixelSize: Theme.iconSize - 2 font.pixelSize: Theme.iconSize - 2
color: Theme.surfaceText color: Theme.surfaceText
@@ -702,6 +702,11 @@ PanelWindow {
} }
} }
// Battery Widget
BatteryWidget {
anchors.verticalCenter: parent.verticalCenter
}
// Control Center Indicators // Control Center Indicators
Rectangle { Rectangle {
width: Math.max(80, controlIndicators.implicitWidth + Theme.spacingS * 2) 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 WifiPasswordDialog 1.0 WifiPasswordDialog.qml
AppLauncher 1.0 AppLauncher.qml AppLauncher 1.0 AppLauncher.qml
ClipboardHistory 1.0 ClipboardHistory.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 MprisPlayer activePlayer: MprisController.activePlayer
property bool hasActiveMedia: activePlayer && (activePlayer.trackTitle || activePlayer.trackArtist) property bool hasActiveMedia: activePlayer && (activePlayer.trackTitle || activePlayer.trackArtist)
property bool controlCenterVisible: false property bool controlCenterVisible: false
property bool batteryPopupVisible: false
// Network properties from NetworkService // Network properties from NetworkService
property string networkStatus: NetworkService.networkStatus property string networkStatus: NetworkService.networkStatus
@@ -67,6 +68,65 @@ ShellRoot {
property string wifiConnectionStatus: "" property string wifiConnectionStatus: ""
property bool wifiAutoRefreshEnabled: false 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 // Screen size breakpoints for responsive design
property real screenWidth: Screen.width property real screenWidth: Screen.width
property bool isSmallScreen: screenWidth < 1200 property bool isSmallScreen: screenWidth < 1200
@@ -79,57 +139,6 @@ ShellRoot {
// Weather configuration // Weather configuration
property bool useFahrenheit: true // Default to Fahrenheit 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 // WiFi Auto-refresh Timer
Timer { Timer {
@@ -172,7 +181,10 @@ ShellRoot {
console.log("New notification from:", notification.appName || "Unknown", "Summary:", notification.summary || "No summary") 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 = { var notifObj = {
"id": notification.id, "id": notification.id,
"appName": notification.appName || "App", "appName": notification.appName || "App",
@@ -181,7 +193,13 @@ ShellRoot {
"timestamp": new Date(), "timestamp": new Date(),
"appIcon": notification.appIcon || notification.icon || "", "appIcon": notification.appIcon || notification.icon || "",
"icon": 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) // Add to history (prepend to show newest first)
@@ -237,6 +255,10 @@ ShellRoot {
NotificationHistoryPopup {} NotificationHistoryPopup {}
ControlCenterPopup {} ControlCenterPopup {}
WifiPasswordDialog {} WifiPasswordDialog {}
InputDialog {
id: globalInputDialog
}
BatteryControlPopup {}
// Application and clipboard components // Application and clipboard components
AppLauncher { AppLauncher {