1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-01-24 13:32:50 -05:00

Modularlize the shell

This commit is contained in:
bbedward
2025-07-10 16:40:04 -04:00
parent 7cdeba1625
commit 40b2a3af1e
28 changed files with 5260 additions and 4906 deletions

1
.gitignore vendored
View File

@@ -52,3 +52,4 @@ compile_commands.json
*creator.user*
*_qmlcache.qrc
UNUSED

329
CLAUDE.md
View File

@@ -6,6 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
This is a Quickshell-based desktop shell implementation with Material Design 3 dark theme. The shell provides a complete desktop environment experience with panels, widgets, and system integration services.
**Architecture**: Modular design with clean separation between UI components (Widgets), system services (Services), and shared utilities (Common).
## Technology Stack
- **QML (Qt Modeling Language)** - Primary language for all UI components
@@ -28,62 +30,199 @@ qs -p .
## Architecture Overview
### Modular Structure
The shell follows a clean modular architecture reduced from 4,830 lines to ~250 lines in shell.qml:
```
shell.qml # Main entry point (minimal orchestration)
├── Common/ # Shared resources
│ ├── Theme.qml # Material Design 3 theme singleton
│ └── Utilities.js # Shared utility functions
├── Services/ # System integration singletons
│ ├── AudioService.qml
│ ├── NetworkService.qml
│ ├── BrightnessService.qml
│ └── [9 total services]
└── Widgets/ # UI components
├── TopBar.qml
├── AppLauncher.qml
├── ControlCenterPopup.qml
└── [18 total widgets]
```
### Component Organization
1. **Shell Entry Point** (root directory)
- `shell.qml` - Main shell implementation with multi-monitor support
1. **Shell Entry Point** (`shell.qml`)
- Minimal orchestration layer (~250 lines)
- Imports and instantiates components
- Handles global state and property bindings
- Multi-monitor support using Quickshell's `Variants`
2. **Widgets/** - Reusable UI components
- Each widget is a self-contained QML module with its own `qmldir`
- Examples: TopBar, ClockWidget, SystemTrayWidget, NotificationWidget
- Components follow Material Design 3 principles
2. **Common/** - Shared resources
- `Theme.qml` - Material Design 3 theme singleton with consistent colors, spacing, fonts
- `Utilities.js` - Shared functions for workspace parsing, notifications, menu handling
3. **Services/** - Backend services and controllers
- `MprisController.qml` - Media player integration
- `OSDetectionService.qml` - Operating system detection
- `WeatherService.qml` - Weather data fetching
- Services handle system integration and data management
3. **Services/** - System integration singletons
- **Pattern**: All services use `Singleton` type with `id: root`
- **Independence**: No cross-service dependencies
- **Examples**: AudioService, NetworkService, BrightnessService, WeatherService
- Services handle system commands, state management, and hardware integration
4. **Widgets/** - Reusable UI components
- **Full-screen components**: AppLauncher, ClipboardHistory, ControlCenterPopup
- **Panel components**: TopBar, SystemTrayWidget, NotificationPopup
- **Reusable controls**: CustomSlider, WorkspaceSwitcher
- Each widget directory contains `qmldir` for module registration
### Key Architectural Patterns
1. **Module System**: Each component directory contains a `qmldir` file defining the module exports
2. **Property Bindings**: Heavy use of Qt property bindings for reactive UI updates
3. **Singleton Services**: Services are typically instantiated once and shared across components
4. **Material Design Theming**: Consistent use of Material Design 3 color properties throughout
### Important Components
- **ControlCenter**: Central hub for system controls (WiFi, Bluetooth, brightness, volume)
- **ApplicationLauncher**: App grid and search functionality
- **NotificationSystem**: Notification display and management
- **ClipboardHistory**: Clipboard manager with history
- **WorkspaceSwitcher**: Per-display virtual desktop switching with Niri integration
## Code Conventions
1. **QML Style**:
- Use 4-space indentation
- Properties before signal handlers
- ID should be the first property
- Prefer property bindings over imperative code
2. **Component Structure**:
1. **Singleton Services Pattern**:
```qml
Item {
import QtQuick
import Quickshell
import Quickshell.Io
pragma Singleton
pragma ComponentBehavior: Bound
Singleton {
id: root
// Properties
property type name: value
property type value: defaultValue
// Signal handlers
onSignal: { }
// Child components
Component { }
function performAction() { /* implementation */ }
}
```
3. **Service Integration**: Components should communicate with services through properties and signals rather than direct method calls
2. **Module Registration**: Each directory contains `qmldir` file:
```
singleton ServiceName 1.0 ServiceName.qml
ComponentName 1.0 ComponentName.qml
```
3. **Smart Feature Detection**: Services detect system capabilities:
```qml
property bool featureAvailable: false
// Auto-hide UI elements when features unavailable
visible: ServiceName.featureAvailable
```
4. **Property Bindings**: Reactive UI updates through property binding
5. **Material Design Theming**: Consistent use of Theme singleton throughout
### Important Components
- **ControlCenterPopup**: System controls (WiFi, Bluetooth, brightness, volume, night mode)
- **AppLauncher**: Full-featured app grid/list with 93+ applications, search, categories
- **ClipboardHistory**: Complete clipboard management with cliphist integration
- **TopBar**: Per-monitor panels with workspace switching, clock, system tray
- **CustomSlider**: Reusable enhanced slider with animations and smart detection
## Code Conventions
### QML Style Guidelines
1. **Structure and Formatting**:
- Use 4-space indentation
- `id` should be the first property
- Properties before signal handlers before child components
- Prefer property bindings over imperative code
2. **Naming Conventions**:
- **Services**: Use `Singleton` type with `id: root`
- **Components**: Use descriptive names (e.g., `CustomSlider`, `TopBar`)
- **Properties**: camelCase for properties, PascalCase for types
3. **Component Structure**:
```qml
// For regular components
Item {
id: root
property type name: value
signal customSignal(type param)
onSignal: { /* handler */ }
Component { /* children */ }
}
// For services (singletons)
Singleton {
id: root
property bool featureAvailable: false
property type currentValue: defaultValue
function performAction(param) { /* implementation */ }
}
```
### Import Guidelines
1. **Standard Import Order**:
```qml
import QtQuick
import QtQuick.Controls // If needed
import Quickshell
import Quickshell.Widgets
import Quickshell.Io // For Process, FileView
import "../Common" // For Theme, utilities
import "../Services" // For service access
```
2. **Service Dependencies**:
- Services should NOT import other services
- Widgets can import and use services via property bindings
- Use `Theme.propertyName` for consistent styling
### Component Development Patterns
1. **Smart Feature Detection**:
```qml
// In services - detect capabilities
property bool brightnessAvailable: false
// In widgets - adapt UI accordingly
CustomSlider {
visible: BrightnessService.brightnessAvailable
enabled: BrightnessService.brightnessAvailable
value: BrightnessService.brightnessLevel
}
```
2. **Reusable Components**:
- Create reusable widgets for common patterns (like CustomSlider)
- Use configurable properties for different use cases
- Include proper signal handling with unique names (avoid `valueChanged`)
3. **Service Integration**:
- Services expose properties and functions
- Widgets bind to service properties for reactive updates
- Use service functions for actions: `ServiceName.performAction(value)`
### Error Handling and Debugging
1. **Console Logging**:
```qml
// Use appropriate log levels
console.log("Info message") // General info
console.warn("Warning message") // Warnings
console.error("Error message") // Errors
// Include context in service operations
onExited: (exitCode) => {
if (exitCode !== 0) {
console.warn("Service failed:", serviceName, "exit code:", exitCode)
}
}
```
2. **Graceful Degradation**:
- Always check feature availability before showing UI
- Provide fallbacks for missing system tools
- Use `visible` and `enabled` properties appropriately
## Multi-Monitor Support
@@ -93,18 +232,100 @@ The shell uses Quickshell's `Variants` pattern for multi-monitor support:
- Monitors are automatically detected by screen name (DP-1, DP-2, etc.)
- Workspaces are dynamically synchronized with Niri's per-output workspaces
## Common Tasks
## Common Development Tasks
### Testing and Validation
When modifying the shell:
1. Test changes with `qs -p .`
2. Check that animations remain smooth (60 FPS target)
3. Ensure Material Design 3 color consistency
4. Test on Wayland session
5. Verify multi-monitor behavior if applicable
1. **Test changes**: `qs -p .` (automatic reload on file changes)
2. **Performance**: Ensure animations remain smooth (60 FPS target)
3. **Theming**: Use `Theme.propertyName` for Material Design 3 consistency
4. **Wayland compatibility**: Test on Wayland session
5. **Multi-monitor**: Verify behavior with multiple displays
6. **Feature detection**: Test on systems with/without required tools
When adding new widgets:
1. Create directory under `Widgets/`
2. Add `qmldir` file with module definition
3. Follow existing widget patterns for property exposure
4. Integrate with relevant services as needed
5. Consider whether the widget should be per-screen or global
### Adding New Widgets
1. **Create component**:
```bash
# Create new widget file
touch Widgets/NewWidget.qml
```
2. **Register in qmldir**:
```
# Add to Widgets/qmldir
NewWidget 1.0 NewWidget.qml
```
3. **Follow widget patterns**:
- Use `Theme.propertyName` for styling
- Import `"../Common"` and `"../Services"` as needed
- Bind to service properties for reactive updates
- Consider per-screen vs global behavior
4. **Integration in shell.qml**:
```qml
NewWidget {
id: newWidget
// Configure properties
}
```
### Adding New Services
1. **Create service**:
```qml
// Services/NewService.qml
import QtQuick
import Quickshell
import Quickshell.Io
pragma Singleton
pragma ComponentBehavior: Bound
Singleton {
id: root
property bool featureAvailable: false
property type currentValue: defaultValue
function performAction(param) {
// Implementation
}
}
```
2. **Register in qmldir**:
```
# Add to Services/qmldir
singleton NewService 1.0 NewService.qml
```
3. **Use in widgets**:
```qml
// In widget files
property alias serviceValue: NewService.currentValue
SomeControl {
visible: NewService.featureAvailable
enabled: NewService.featureAvailable
onTriggered: NewService.performAction(value)
}
```
### Debugging Common Issues
1. **Import errors**: Check `qmldir` registration and import paths
2. **Singleton conflicts**: Ensure services use `Singleton` type with `id: root`
3. **Property binding issues**: Use property aliases for reactive updates
4. **Process failures**: Check system tool availability and command syntax
5. **Theme inconsistencies**: Always use `Theme.propertyName` instead of hardcoded values
### Best Practices Summary
- **Modularity**: Keep components focused and independent
- **Reusability**: Create reusable components for common patterns
- **Responsiveness**: Use property bindings for reactive UI
- **Robustness**: Implement feature detection and graceful degradation
- **Consistency**: Follow Material Design 3 principles via Theme singleton
- **Performance**: Minimize expensive operations and use appropriate data structures

66
Common/Theme.qml Normal file
View File

@@ -0,0 +1,66 @@
import QtQuick
pragma Singleton
pragma ComponentBehavior: Bound
QtObject {
id: root
property color primary: "#D0BCFF"
property color primaryText: "#381E72"
property color primaryContainer: "#4F378B"
property color secondary: "#CCC2DC"
property color surface: "#10121E"
property color surfaceText: "#E6E0E9"
property color surfaceVariant: "#49454F"
property color surfaceVariantText: "#CAC4D0"
property color surfaceTint: "#D0BCFF"
property color background: "#10121E"
property color backgroundText: "#E6E0E9"
property color outline: "#938F99"
property color surfaceContainer: "#1D1B20"
property color surfaceContainerHigh: "#2B2930"
property color archBlue: "#1793D1"
property color success: "#4CAF50"
property color warning: "#FF9800"
property color info: "#2196F3"
property color error: "#F2B8B5"
property int shortDuration: 150
property int mediumDuration: 300
property int longDuration: 500
property int extraLongDuration: 1000
property int standardEasing: Easing.OutCubic
property int emphasizedEasing: Easing.OutQuart
property real cornerRadius: 12
property real cornerRadiusSmall: 8
property real cornerRadiusLarge: 16
property real cornerRadiusXLarge: 24
property real spacingXS: 4
property real spacingS: 8
property real spacingM: 12
property real spacingL: 16
property real spacingXL: 24
property real fontSizeSmall: 12
property real fontSizeMedium: 14
property real fontSizeLarge: 16
property real fontSizeXLarge: 20
property real barHeight: 48
property real iconSize: 24
property real iconSizeSmall: 16
property real iconSizeLarge: 32
property real opacityDisabled: 0.38
property real opacityMedium: 0.60
property real opacityHigh: 0.87
property real opacityFull: 1.0
property string iconFont: "Material Symbols Rounded"
property string iconFontFilled: "Material Symbols Rounded"
property int iconFontWeight: Font.Normal
property int iconFontFilledWeight: Font.Medium
}

101
Common/Utilities.js Normal file
View File

@@ -0,0 +1,101 @@
function parseWorkspaceOutput(data) {
const lines = data.split('\n')
let currentOutputName = ""
let focusedOutput = ""
let focusedWorkspace = 1
let outputWorkspaces = {}
for (const line of lines) {
if (line.startsWith('Output "')) {
const outputMatch = line.match(/Output "(.+)"/)
if (outputMatch) {
currentOutputName = outputMatch[1]
outputWorkspaces[currentOutputName] = []
}
continue
}
if (line.trim() && line.match(/^\s*\*?\s*(\d+)$/)) {
const wsMatch = line.match(/^\s*(\*?)\s*(\d+)$/)
if (wsMatch) {
const isActive = wsMatch[1] === '*'
const wsNum = parseInt(wsMatch[2])
if (currentOutputName && outputWorkspaces[currentOutputName]) {
outputWorkspaces[currentOutputName].push(wsNum)
}
if (isActive) {
focusedOutput = currentOutputName
focusedWorkspace = wsNum
}
}
}
}
// Show workspaces for THIS screen only
if (topBar.screenName && outputWorkspaces[topBar.screenName]) {
workspaceList = outputWorkspaces[topBar.screenName]
// Always track the active workspace for this display
// Parse all lines to find which workspace is active on this display
let thisDisplayActiveWorkspace = 1
let inThisOutput = false
for (const line of lines) {
if (line.startsWith('Output "')) {
const outputMatch = line.match(/Output "(.+)"/)
inThisOutput = outputMatch && outputMatch[1] === topBar.screenName
continue
}
if (inThisOutput && line.trim() && line.match(/^\s*\*\s*(\d+)$/)) {
const wsMatch = line.match(/^\s*\*\s*(\d+)$/)
if (wsMatch) {
thisDisplayActiveWorkspace = parseInt(wsMatch[1])
break
}
}
}
currentWorkspace = thisDisplayActiveWorkspace
// console.log("Monitor", topBar.screenName, "active workspace:", thisDisplayActiveWorkspace)
} else {
// Fallback if screen name not found
workspaceList = [1, 2]
currentWorkspace = 1
}
}
function showMenu(x, y) {
root.currentTrayMenu = customTrayMenu
root.currentTrayItem = trayItem
// Simple positioning: right side of screen, below the panel
root.trayMenuX = rightSection.x + rightSection.width - 180 - theme.spacingL
root.trayMenuY = theme.barHeight + theme.spacingS
console.log("Showing menu at:", root.trayMenuX, root.trayMenuY)
menuVisible = true
root.showTrayMenu = true
}
function hideMenu() {
menuVisible = false
root.showTrayMenu = false
root.currentTrayMenu = null
root.currentTrayItem = null
}
function showNotificationPopup(notification) {
root.activeNotification = notification
root.showNotificationPopup = true
notificationTimer.restart()
}
function hideNotificationPopup() {
root.showNotificationPopup = false
notificationTimer.stop()
clearNotificationTimer.restart()
}

4
Common/qmldir Normal file
View File

@@ -0,0 +1,4 @@
module Common
singleton Theme 1.0 Theme.qml
Utilities 1.0 Utilities.js

127
Services/AudioService.qml Normal file
View File

@@ -0,0 +1,127 @@
import QtQuick
import Quickshell
import Quickshell.Io
pragma Singleton
pragma ComponentBehavior: Bound
Singleton {
id: root
property int volumeLevel: 50
property var audioSinks: []
property string currentAudioSink: ""
// Real Audio Control
Process {
id: volumeChecker
command: ["bash", "-c", "pactl get-sink-volume @DEFAULT_SINK@ | grep -o '[0-9]*%' | head -1 | tr -d '%'"]
running: true
stdout: SplitParser {
splitMarker: "\n"
onRead: (data) => {
if (data.trim()) {
root.volumeLevel = Math.min(100, parseInt(data.trim()) || 50)
}
}
}
}
Process {
id: audioSinkLister
command: ["bash", "-c", "pactl list sinks | grep -E '^Sink #|device.description|Name:' | paste - - - | sed 's/Sink #//g' | sed 's/Name: //g' | sed 's/device.description = //g' | sed 's/\"//g'"]
running: true
stdout: StdioCollector {
onStreamFinished: {
if (text.trim()) {
let sinks = []
let lines = text.trim().split('\n')
for (let line of lines) {
let parts = line.split('\t')
if (parts.length >= 3) {
let id = parts[0].trim()
let name = parts[1].trim()
let description = parts[2].trim()
// Use description as display name if available, fallback to name processing
let displayName = description
if (!description || description === name) {
if (name.includes("analog-stereo")) displayName = "Built-in Speakers"
else if (name.includes("bluez")) displayName = "Bluetooth Audio"
else if (name.includes("usb")) displayName = "USB Audio"
else if (name.includes("hdmi")) displayName = "HDMI Audio"
else if (name.includes("easyeffects")) displayName = "EasyEffects"
else displayName = name
}
sinks.push({
id: id,
name: name,
displayName: displayName,
active: false // Will be determined by default sink
})
}
}
root.audioSinks = sinks
defaultSinkChecker.running = true
}
}
}
}
Process {
id: defaultSinkChecker
command: ["pactl", "get-default-sink"]
running: false
stdout: SplitParser {
splitMarker: "\n"
onRead: (data) => {
if (data.trim()) {
root.currentAudioSink = data.trim()
console.log("Default audio sink:", root.currentAudioSink)
// Update active status in audioSinks
let updatedSinks = []
for (let sink of root.audioSinks) {
updatedSinks.push({
id: sink.id,
name: sink.name,
displayName: sink.displayName,
active: sink.name === root.currentAudioSink
})
}
root.audioSinks = updatedSinks
}
}
}
}
function setVolume(percentage) {
let volumeSetProcess = Qt.createQmlObject('
import Quickshell.Io
Process {
command: ["pactl", "set-sink-volume", "@DEFAULT_SINK@", "' + percentage + '%"]
running: true
onExited: volumeChecker.running = true
}
', root)
}
function setAudioSink(sinkName) {
let sinkSetProcess = Qt.createQmlObject('
import Quickshell.Io
Process {
command: ["pactl", "set-default-sink", "' + sinkName + '"]
running: true
onExited: {
defaultSinkChecker.running = true
audioSinkLister.running = true
}
}
', root)
}
}

View File

@@ -0,0 +1,121 @@
import QtQuick
import Quickshell
import Quickshell.Io
pragma Singleton
pragma ComponentBehavior: Bound
Singleton {
id: root
property bool bluetoothEnabled: false
property bool bluetoothAvailable: false
property var bluetoothDevices: []
// Real Bluetooth Management
Process {
id: bluetoothStatusChecker
command: ["bluetoothctl", "show"]
running: true
stdout: StdioCollector {
onStreamFinished: {
root.bluetoothAvailable = text.trim() !== "" && !text.includes("No default controller")
root.bluetoothEnabled = text.includes("Powered: yes")
console.log("Bluetooth available:", root.bluetoothAvailable, "enabled:", root.bluetoothEnabled)
if (root.bluetoothEnabled && root.bluetoothAvailable) {
bluetoothDeviceScanner.running = true
} else {
root.bluetoothDevices = []
}
}
}
}
Process {
id: bluetoothDeviceScanner
command: ["bash", "-c", "bluetoothctl devices | while read -r line; do if [[ $line =~ Device\\ ([0-9A-F:]+)\\ (.+) ]]; then mac=\"${BASH_REMATCH[1]}\"; name=\"${BASH_REMATCH[2]}\"; if [[ ! $name =~ ^/org/bluez ]]; then info=$(bluetoothctl info $mac); connected=$(echo \"$info\" | grep 'Connected:' | grep -q 'yes' && echo 'true' || echo 'false'); battery=$(echo \"$info\" | grep 'Battery Percentage' | grep -o '([0-9]*)' | tr -d '()'); echo \"$mac|$name|$connected|${battery:-}\"; 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 >= 3) {
let mac = parts[0].trim()
let name = parts[1].trim()
let connected = parts[2].trim() === 'true'
let battery = parts[3] ? parseInt(parts[3]) : -1
// 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")) 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")) type = "phone"
else if (nameLower.includes("watch")) type = "watch"
else if (nameLower.includes("speaker")) type = "speaker"
devices.push({
mac: mac,
name: name,
type: type,
connected: connected,
battery: battery
})
}
}
}
root.bluetoothDevices = devices
console.log("Found", devices.length, "Bluetooth devices")
}
}
}
}
function scanDevices() {
if (root.bluetoothEnabled && root.bluetoothAvailable) {
bluetoothDeviceScanner.running = true
}
}
function toggleBluetoothDevice(mac) {
console.log("Toggling Bluetooth device:", mac)
let device = root.bluetoothDevices.find(d => d.mac === mac)
if (device) {
let action = device.connected ? "disconnect" : "connect"
let toggleProcess = Qt.createQmlObject('
import Quickshell.Io
Process {
command: ["bluetoothctl", "' + action + '", "' + mac + '"]
running: true
onExited: bluetoothDeviceScanner.running = true
}
', root)
}
}
function toggleBluetooth() {
let action = root.bluetoothEnabled ? "off" : "on"
let toggleProcess = Qt.createQmlObject('
import Quickshell.Io
Process {
command: ["bluetoothctl", "power", "' + action + '"]
running: true
onExited: bluetoothStatusChecker.running = true
}
', root)
}
}

View File

@@ -0,0 +1,107 @@
import QtQuick
import Quickshell
import Quickshell.Io
pragma Singleton
pragma ComponentBehavior: Bound
Singleton {
id: root
property int brightnessLevel: 75
property bool brightnessAvailable: false
// Check if brightness control is available
Process {
id: brightnessAvailabilityChecker
command: ["bash", "-c", "if command -v brightnessctl > /dev/null; then echo 'brightnessctl'; elif command -v xbacklight > /dev/null; then echo 'xbacklight'; else echo 'none'; fi"]
running: true
stdout: SplitParser {
splitMarker: "\n"
onRead: (data) => {
if (data.trim()) {
let method = data.trim()
if (method === "brightnessctl" || method === "xbacklight") {
root.brightnessAvailable = true
brightnessChecker.running = true
} else {
root.brightnessAvailable = false
console.log("Brightness control not available - no brightnessctl or xbacklight found")
}
}
}
}
}
// Brightness Control
Process {
id: brightnessChecker
command: ["bash", "-c", "if command -v brightnessctl > /dev/null; then brightnessctl get; elif command -v xbacklight > /dev/null; then xbacklight -get | cut -d. -f1; else echo 75; fi"]
running: false
stdout: SplitParser {
splitMarker: "\n"
onRead: (data) => {
if (data.trim()) {
let brightness = parseInt(data.trim()) || 75
// brightnessctl returns absolute value, need to convert to percentage
if (brightness > 100) {
brightnessMaxChecker.running = true
} else {
root.brightnessLevel = brightness
}
}
}
}
}
Process {
id: brightnessMaxChecker
command: ["brightnessctl", "max"]
running: false
stdout: SplitParser {
splitMarker: "\n"
onRead: (data) => {
if (data.trim()) {
let maxBrightness = parseInt(data.trim()) || 100
brightnessCurrentChecker.property("maxBrightness", maxBrightness)
brightnessCurrentChecker.running = true
}
}
}
}
Process {
id: brightnessCurrentChecker
property int maxBrightness: 100
command: ["brightnessctl", "get"]
running: false
stdout: SplitParser {
splitMarker: "\n"
onRead: (data) => {
if (data.trim()) {
let currentBrightness = parseInt(data.trim()) || 75
root.brightnessLevel = Math.round((currentBrightness / maxBrightness) * 100)
}
}
}
}
function setBrightness(percentage) {
if (!root.brightnessAvailable) {
console.warn("Brightness control not available")
return
}
let brightnessSetProcess = Qt.createQmlObject('
import Quickshell.Io
Process {
command: ["bash", "-c", "if command -v brightnessctl > /dev/null; then brightnessctl set ' + percentage + '%; elif command -v xbacklight > /dev/null; then xbacklight -set ' + percentage + '; fi"]
running: true
onExited: brightnessChecker.running = true
}
', root)
}
}

View File

@@ -0,0 +1,26 @@
import QtQuick
import Quickshell
import Quickshell.Io
pragma Singleton
pragma ComponentBehavior: Bound
Singleton {
id: root
// Color Picker Process
Process {
id: colorPickerProcess
command: ["hyprpicker", "-a"]
running: false
onExited: (exitCode) => {
if (exitCode !== 0) {
console.warn("Color picker failed. Make sure hyprpicker is installed: yay -S hyprpicker")
}
}
}
function pickColor() {
colorPickerProcess.running = true
}
}

View File

@@ -1,11 +1,10 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQml.Models
import QtQuick
import QtQml.Models
import Quickshell
import Quickshell.Io
import Quickshell.Services.Mpris
pragma Singleton
pragma ComponentBehavior: Bound
/**
* A service that provides easy access to the active Mpris player.

159
Services/NetworkService.qml Normal file
View File

@@ -0,0 +1,159 @@
import QtQuick
import Quickshell
import Quickshell.Io
pragma Singleton
pragma ComponentBehavior: Bound
Singleton {
id: root
property string networkStatus: "disconnected" // "ethernet", "wifi", "disconnected"
property string ethernetIP: ""
property string wifiIP: ""
property bool wifiAvailable: false
property bool wifiEnabled: true
// Real Network Management
Process {
id: networkStatusChecker
command: ["bash", "-c", "nmcli -t -f DEVICE,TYPE,STATE device | grep -E '(ethernet|wifi)' && echo '---' && ip link show | grep -E '^[0-9]+:.*ethernet.*state UP'"]
running: true
stdout: StdioCollector {
onStreamFinished: {
if (text.trim()) {
console.log("Network status full output:", text.trim())
let hasEthernet = text.includes("ethernet:connected")
let hasWifi = text.includes("wifi:connected")
let ethernetCableUp = text.includes("state UP")
// Check if ethernet cable is physically connected but not managed
if (hasEthernet || ethernetCableUp) {
root.networkStatus = "ethernet"
ethernetIPChecker.running = true
console.log("Setting network status to ethernet (cable connected)")
} else if (hasWifi) {
root.networkStatus = "wifi"
wifiIPChecker.running = true
console.log("Setting network status to wifi")
} else {
root.networkStatus = "disconnected"
root.ethernetIP = ""
root.wifiIP = ""
console.log("Setting network status to disconnected")
}
// Always check WiFi radio status
wifiRadioChecker.running = true
} else {
root.networkStatus = "disconnected"
root.ethernetIP = ""
root.wifiIP = ""
console.log("No network output, setting to disconnected")
}
}
}
}
Process {
id: wifiRadioChecker
command: ["nmcli", "radio", "wifi"]
running: false
stdout: SplitParser {
splitMarker: "\n"
onRead: (data) => {
let response = data.trim()
root.wifiAvailable = response === "enabled" || response === "disabled"
root.wifiEnabled = response === "enabled"
console.log("WiFi available:", root.wifiAvailable, "enabled:", root.wifiEnabled)
}
}
}
Process {
id: ethernetIPChecker
command: ["bash", "-c", "ip route get 1.1.1.1 | grep -oP 'src \\K\\S+' | head -1"]
running: false
stdout: SplitParser {
splitMarker: "\n"
onRead: (data) => {
if (data.trim()) {
root.ethernetIP = data.trim()
console.log("Ethernet IP:", root.ethernetIP)
}
}
}
}
Process {
id: wifiIPChecker
command: ["bash", "-c", "nmcli -t -f IP4.ADDRESS dev show $(nmcli -t -f DEVICE,TYPE device | grep wifi | cut -d: -f1 | head -1) | cut -d: -f2 | cut -d/ -f1"]
running: false
stdout: SplitParser {
splitMarker: "\n"
onRead: (data) => {
if (data.trim()) {
root.wifiIP = data.trim()
console.log("WiFi IP:", root.wifiIP)
}
}
}
}
function toggleNetworkConnection(type) {
if (type === "ethernet") {
// Toggle ethernet connection
if (root.networkStatus === "ethernet") {
// Disconnect ethernet
let disconnectProcess = Qt.createQmlObject('
import Quickshell.Io
Process {
command: ["bash", "-c", "nmcli device disconnect $(nmcli -t -f DEVICE,TYPE device | grep ethernet | cut -d: -f1 | head -1)"]
running: true
onExited: networkStatusChecker.running = true
}
', root)
} else {
// Connect ethernet with proper nmcli device connect
let connectProcess = Qt.createQmlObject('
import Quickshell.Io
Process {
command: ["bash", "-c", "nmcli device connect $(nmcli -t -f DEVICE,TYPE device | grep ethernet | cut -d: -f1 | head -1)"]
running: true
onExited: networkStatusChecker.running = true
}
', root)
}
} else if (type === "wifi") {
// Connect to WiFi if disconnected
if (root.networkStatus !== "wifi" && root.wifiEnabled) {
let connectProcess = Qt.createQmlObject('
import Quickshell.Io
Process {
command: ["bash", "-c", "nmcli device connect $(nmcli -t -f DEVICE,TYPE device | grep wifi | cut -d: -f1 | head -1)"]
running: true
onExited: networkStatusChecker.running = true
}
', root)
}
}
}
function toggleWifiRadio() {
let action = root.wifiEnabled ? "off" : "on"
let toggleProcess = Qt.createQmlObject('
import Quickshell.Io
Process {
command: ["nmcli", "radio", "wifi", "' + action + '"]
running: true
onExited: {
networkStatusChecker.running = true
}
}
', root)
}
}

View File

@@ -1,85 +0,0 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
QtObject {
id: osService
property string osLogo: ""
property string osName: ""
Process {
id: osDetector
command: ["lsb_release", "-i", "-s"]
running: true
stdout: SplitParser {
splitMarker: "\n"
onRead: (data) => {
if (data.trim()) {
let osId = data.trim().toLowerCase()
console.log("Detected OS:", osId)
setOSInfo(osId)
}
}
}
onExited: (exitCode) => {
if (exitCode !== 0) {
osDetectorFallback.running = true
}
}
}
Process {
id: osDetectorFallback
command: ["sh", "-c", "cat /etc/os-release | grep '^ID=' | cut -d'=' -f2 | tr -d '\"'"]
running: false
stdout: SplitParser {
splitMarker: "\n"
onRead: (data) => {
if (data.trim()) {
let osId = data.trim().toLowerCase()
console.log("Detected OS (fallback):", osId)
setOSInfo(osId)
}
}
}
onExited: (exitCode) => {
if (exitCode !== 0) {
osService.osLogo = ""
osService.osName = "Linux"
console.log("OS detection failed, using generic icon")
}
}
}
function setOSInfo(osId) {
if (osId.includes("arch")) {
osService.osLogo = "\uf303"
osService.osName = "Arch Linux"
} else if (osId.includes("ubuntu")) {
osService.osLogo = "\uf31b"
osService.osName = "Ubuntu"
} else if (osId.includes("fedora")) {
osService.osLogo = "\uf30a"
osService.osName = "Fedora"
} else if (osId.includes("debian")) {
osService.osLogo = "\uf306"
osService.osName = "Debian"
} else if (osId.includes("opensuse")) {
osService.osLogo = "\uef6d"
osService.osName = "openSUSE"
} else if (osId.includes("manjaro")) {
osService.osLogo = "\uf312"
osService.osName = "Manjaro"
} else {
osService.osLogo = "\uf033"
osService.osName = "Linux"
}
}
}

View File

@@ -0,0 +1,110 @@
import QtQuick
import Quickshell
import Quickshell.Io
pragma Singleton
pragma ComponentBehavior: Bound
Singleton {
id: root
property string osLogo: ""
property string osName: ""
// OS Detection
Process {
id: osDetector
command: ["lsb_release", "-i", "-s"]
running: true
stdout: SplitParser {
splitMarker: "\n"
onRead: (data) => {
if (data.trim()) {
let osId = data.trim().toLowerCase()
console.log("Detected OS:", osId)
// Set OS-specific Nerd Font icons and names
if (osId.includes("arch")) {
root.osLogo = "\uf303" // Arch Linux Nerd Font icon
root.osName = "Arch Linux"
console.log("Set Arch logo:", root.osLogo)
} else if (osId.includes("ubuntu")) {
root.osLogo = "\uf31b" // Ubuntu Nerd Font icon
root.osName = "Ubuntu"
} else if (osId.includes("fedora")) {
root.osLogo = "\uf30a" // Fedora Nerd Font icon
root.osName = "Fedora"
} else if (osId.includes("debian")) {
root.osLogo = "\uf306" // Debian Nerd Font icon
root.osName = "Debian"
} else if (osId.includes("opensuse")) {
root.osLogo = "\uef6d" // openSUSE Nerd Font icon
root.osName = "openSUSE"
} else if (osId.includes("manjaro")) {
root.osLogo = "\uf312" // Manjaro Nerd Font icon
root.osName = "Manjaro"
} else {
root.osLogo = "\uf033" // Generic Linux Nerd Font icon
root.osName = "Linux"
}
}
}
}
onExited: (exitCode) => {
if (exitCode !== 0) {
// Fallback: try checking /etc/os-release
osDetectorFallback.running = true
}
}
}
// Fallback OS detection
Process {
id: osDetectorFallback
command: ["sh", "-c", "grep '^ID=' /etc/os-release | cut -d'=' -f2 | tr -d '\"'"]
running: false
stdout: SplitParser {
splitMarker: "\n"
onRead: (data) => {
if (data.trim()) {
let osId = data.trim().toLowerCase()
console.log("Detected OS (fallback):", osId)
if (osId.includes("arch")) {
root.osLogo = "\uf303"
root.osName = "Arch Linux"
} else if (osId.includes("ubuntu")) {
root.osLogo = "\uf31b"
root.osName = "Ubuntu"
} else if (osId.includes("fedora")) {
root.osLogo = "\uf30a"
root.osName = "Fedora"
} else if (osId.includes("debian")) {
root.osLogo = "\uf306"
root.osName = "Debian"
} else if (osId.includes("opensuse")) {
root.osLogo = "\uef6d"
root.osName = "openSUSE"
} else if (osId.includes("manjaro")) {
root.osLogo = "\uf312"
root.osName = "Manjaro"
} else {
root.osLogo = "\uf033"
root.osName = "Linux"
}
}
}
}
onExited: (exitCode) => {
if (exitCode !== 0) {
// Ultimate fallback - use generic apps icon (empty logo means fallback to "apps")
root.osLogo = ""
root.osName = "Linux"
console.log("OS detection failed, using generic icon")
}
}
}
}

View File

@@ -1,11 +1,11 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
pragma Singleton
pragma ComponentBehavior: Bound
QtObject {
id: weatherService
Singleton {
id: root
property var weather: ({
available: false,
@@ -32,7 +32,7 @@ QtObject {
try {
let parsedData = JSON.parse(text.trim())
if (parsedData.current && parsedData.location) {
weatherService.weather = {
root.weather = {
available: true,
temp: parseInt(parsedData.current.temp_C || 0),
tempF: parseInt(parsedData.current.temp_F || 0),
@@ -45,15 +45,15 @@ QtObject {
uv: parseInt(parsedData.current.uvIndex || 0),
pressure: parseInt(parsedData.current.pressure || 0)
}
console.log("Weather updated:", weatherService.weather.city, weatherService.weather.temp + "°C")
console.log("Weather updated:", root.weather.city, root.weather.temp + "°C")
}
} catch (e) {
console.warn("Failed to parse weather data:", e.message)
weatherService.weather.available = false
root.weather.available = false
}
} else {
console.warn("No valid weather data received")
weatherService.weather.available = false
root.weather.available = false
}
}
}
@@ -61,7 +61,7 @@ QtObject {
onExited: (exitCode) => {
if (exitCode !== 0) {
console.warn("Weather fetch failed with exit code:", exitCode)
weatherService.weather.available = false
root.weather.available = false
}
}
}

172
Services/WifiService.qml Normal file
View File

@@ -0,0 +1,172 @@
import QtQuick
import Quickshell
import Quickshell.Io
pragma Singleton
pragma ComponentBehavior: Bound
Singleton {
id: root
property string currentWifiSSID: ""
property string wifiSignalStrength: "excellent" // "excellent", "good", "fair", "poor"
property var wifiNetworks: []
property var savedWifiNetworks: []
Process {
id: currentWifiInfo
command: ["bash", "-c", "nmcli -t -f ssid,signal connection show --active | grep -v '^--' | grep -v '^$'"]
running: false
stdout: SplitParser {
splitMarker: "\n"
onRead: (data) => {
if (data.trim()) {
let parts = data.split(":")
if (parts.length >= 2 && parts[0].trim() !== "") {
root.currentWifiSSID = parts[0].trim()
let signal = parseInt(parts[1]) || 100
if (signal >= 75) root.wifiSignalStrength = "excellent"
else if (signal >= 50) root.wifiSignalStrength = "good"
else if (signal >= 25) root.wifiSignalStrength = "fair"
else root.wifiSignalStrength = "poor"
console.log("Active WiFi:", root.currentWifiSSID, "Signal:", signal + "%")
}
}
}
}
}
Process {
id: wifiScanner
command: ["nmcli", "-t", "-f", "SSID,SIGNAL,SECURITY", "dev", "wifi"]
running: false
stdout: StdioCollector {
onStreamFinished: {
if (text.trim()) {
let networks = []
let lines = text.trim().split('\n')
for (let line of lines) {
let parts = line.split(':')
if (parts.length >= 3 && parts[0].trim() !== "") {
let ssid = parts[0].trim()
let signal = parseInt(parts[1]) || 0
let security = parts[2].trim()
// Skip duplicates
if (!networks.find(n => n.ssid === ssid)) {
networks.push({
ssid: ssid,
signal: signal,
secured: security !== "",
connected: ssid === root.currentWifiSSID,
signalStrength: signal >= 75 ? "excellent" :
signal >= 50 ? "good" :
signal >= 25 ? "fair" : "poor"
})
}
}
}
// Sort by signal strength
networks.sort((a, b) => b.signal - a.signal)
root.wifiNetworks = networks
console.log("Found", networks.length, "WiFi networks")
}
}
}
}
Process {
id: savedWifiScanner
command: ["nmcli", "-t", "-f", "NAME", "connection", "show"]
running: false
stdout: StdioCollector {
onStreamFinished: {
if (text.trim()) {
let saved = []
let lines = text.trim().split('\n')
for (let line of lines) {
if (line.trim() && !line.includes("ethernet") && !line.includes("lo")) {
saved.push({
ssid: line.trim(),
saved: true
})
}
}
root.savedWifiNetworks = saved
console.log("Found", saved.length, "saved WiFi networks")
}
}
}
}
function scanWifi() {
wifiScanner.running = true
savedWifiScanner.running = true
currentWifiInfo.running = true
}
function connectToWifi(ssid) {
console.log("Connecting to WiFi:", ssid)
let connectProcess = Qt.createQmlObject('
import Quickshell.Io
Process {
command: ["nmcli", "dev", "wifi", "connect", "' + ssid + '"]
running: true
onExited: (exitCode) => {
console.log("WiFi connection result:", exitCode)
if (exitCode === 0) {
console.log("Connected to WiFi successfully")
} else {
console.log("WiFi connection failed")
}
scanWifi()
}
}
', root)
}
function connectToWifiWithPassword(ssid, password) {
console.log("Connecting to WiFi with password:", ssid)
let connectProcess = Qt.createQmlObject('
import Quickshell.Io
Process {
command: ["nmcli", "dev", "wifi", "connect", "' + ssid + '", "password", "' + password + '"]
running: true
onExited: (exitCode) => {
console.log("WiFi connection with password result:", exitCode)
if (exitCode === 0) {
console.log("Connected to WiFi with password successfully")
} else {
console.log("WiFi connection with password failed")
}
scanWifi()
}
}
', root)
}
function forgetWifiNetwork(ssid) {
console.log("Forgetting WiFi network:", ssid)
let forgetProcess = Qt.createQmlObject('
import Quickshell.Io
Process {
command: ["nmcli", "connection", "delete", "' + ssid + '"]
running: true
onExited: (exitCode) => {
console.log("WiFi forget result:", exitCode)
scanWifi()
}
}
', root)
}
}

View File

@@ -1 +1,9 @@
singleton MprisController 1.0 MprisController.qml
singleton MprisController 1.0 MprisController.qml
singleton OSDetectorService 1.0 OSDetectorService.qml
singleton ColorPickerService 1.0 ColorPickerService.qml
singleton WeatherService 1.0 WeatherService.qml
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

View File

@@ -4,6 +4,7 @@ import Quickshell
import Quickshell.Widgets
import Quickshell.Wayland
import Quickshell.Io
import "../Common"
PanelWindow {
id: launcher

562
Widgets/CalendarPopup.qml Normal file
View File

@@ -0,0 +1,562 @@
import QtQuick
import QtQuick.Controls
import Qt5Compat.GraphicalEffects
import Quickshell
import Quickshell.Widgets
import Quickshell.Wayland
import Quickshell.Services.Mpris
import "../Common"
PanelWindow {
id: calendarPopup
visible: root.calendarVisible
implicitWidth: 320
implicitHeight: 400
WlrLayershell.layer: WlrLayershell.Overlay
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
color: "transparent"
anchors {
top: true
left: true
right: true
bottom: true
}
property date displayDate: new Date()
property date selectedDate: new Date()
Rectangle {
width: 400
height: root.hasActiveMedia ? 580 : (root.weather.available ? 480 : 400)
x: (parent.width - width) / 2
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.calendarVisible ? 1.0 : 0.0
scale: root.calendarVisible ? 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
}
}
Column {
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingM
// Media Player (when active)
Rectangle {
visible: root.hasActiveMedia
width: parent.width
height: 180
radius: Theme.cornerRadius
color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08)
border.color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.2)
border.width: 1
Column {
anchors.fill: parent
anchors.margins: Theme.spacingM
spacing: Theme.spacingS
Row {
width: parent.width
height: 100
spacing: Theme.spacingM
Rectangle {
width: 100
height: 100
radius: Theme.cornerRadius
color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3)
Item {
anchors.fill: parent
clip: true
Image {
anchors.fill: parent
source: root.activePlayer?.trackArtUrl || ""
fillMode: Image.PreserveAspectCrop
smooth: true
}
Rectangle {
anchors.fill: parent
visible: parent.children[0].status !== Image.Ready
color: "transparent"
Text {
anchors.centerIn: parent
text: "album"
font.family: Theme.iconFont
font.pixelSize: 48
color: Theme.surfaceVariantText
}
}
}
}
Column {
width: parent.width - 100 - Theme.spacingM
spacing: Theme.spacingXS
anchors.verticalCenter: parent.verticalCenter
Text {
text: root.activePlayer?.trackTitle || "Unknown Track"
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Bold
color: Theme.surfaceText
width: parent.width
elide: Text.ElideRight
}
Text {
text: root.activePlayer?.trackArtist || "Unknown Artist"
font.pixelSize: Theme.fontSizeMedium
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.8)
width: parent.width
elide: Text.ElideRight
}
Text {
text: root.activePlayer?.trackAlbum || ""
font.pixelSize: Theme.fontSizeSmall
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.6)
width: parent.width
elide: Text.ElideRight
visible: text.length > 0
}
}
}
// Progress bar
Rectangle {
width: parent.width
height: 6
radius: 3
color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3)
Rectangle {
width: parent.width * (root.activePlayer?.position / Math.max(root.activePlayer?.length || 1, 1))
height: parent.height
radius: parent.radius
color: Theme.primary
Behavior on width {
NumberAnimation {
duration: 200
easing.type: Easing.OutQuad
}
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: (mouse) => {
if (root.activePlayer && root.activePlayer.length > 0) {
const ratio = mouse.x / width
const newPosition = ratio * root.activePlayer.length
console.log("Seeking to position:", newPosition, "ratio:", ratio, "canSeek:", root.activePlayer.canSeek)
if (root.activePlayer.canSeek) {
root.activePlayer.position = newPosition
} else {
console.log("Player does not support seeking")
}
}
}
}
}
// Control buttons
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.spacingL
Rectangle {
width: 36
height: 36
radius: 18
color: prevBtnAreaCal.containsMouse ? Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.12) : "transparent"
Text {
anchors.centerIn: parent
text: "skip_previous"
font.family: Theme.iconFont
font.pixelSize: 20
color: Theme.surfaceText
}
MouseArea {
id: prevBtnAreaCal
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.activePlayer?.previous()
}
}
Rectangle {
width: 40
height: 40
radius: 20
color: Theme.primary
Text {
anchors.centerIn: parent
text: root.activePlayer?.playbackState === MprisPlaybackState.Playing ? "pause" : "play_arrow"
font.family: Theme.iconFont
font.pixelSize: 24
color: Theme.background
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.activePlayer?.togglePlaying()
}
}
Rectangle {
width: 36
height: 36
radius: 18
color: nextBtnAreaCal.containsMouse ? Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.12) : "transparent"
Text {
anchors.centerIn: parent
text: "skip_next"
font.family: Theme.iconFont
font.pixelSize: 20
color: Theme.surfaceText
}
MouseArea {
id: nextBtnAreaCal
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.activePlayer?.next()
}
}
}
}
}
// Weather header (when available and no media)
Rectangle {
visible: root.weather.available && !root.hasActiveMedia
width: parent.width
height: 80
radius: Theme.cornerRadius
color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08)
border.color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.2)
border.width: 1
Row {
anchors.centerIn: parent
spacing: Theme.spacingL
// Weather icon and temp
Column {
spacing: 2
anchors.verticalCenter: parent.verticalCenter
Text {
text: root.weatherIcons[root.weather.wCode] || "clear_day"
font.family: Theme.iconFont
font.pixelSize: Theme.iconSize + 4
color: Theme.primary
anchors.horizontalCenter: parent.horizontalCenter
}
Text {
text: (root.useFahrenheit ? root.weather.tempF : root.weather.temp) + "°" + (root.useFahrenheit ? "F" : "C")
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Bold
anchors.horizontalCenter: parent.horizontalCenter
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.useFahrenheit = !root.useFahrenheit
}
}
Text {
text: root.weather.city
font.pixelSize: Theme.fontSizeSmall
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
anchors.horizontalCenter: parent.horizontalCenter
}
}
// Weather details grid
Grid {
columns: 2
spacing: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
Row {
spacing: Theme.spacingXS
Text {
text: "humidity_low"
font.family: Theme.iconFont
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: root.weather.humidity + "%"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
}
Row {
spacing: Theme.spacingXS
Text {
text: "air"
font.family: Theme.iconFont
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: root.weather.wind
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
}
Row {
spacing: Theme.spacingXS
Text {
text: "wb_twilight"
font.family: Theme.iconFont
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: root.weather.sunrise
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
}
Row {
spacing: Theme.spacingXS
Text {
text: "bedtime"
font.family: Theme.iconFont
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: root.weather.sunset
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
}
}
}
}
Row {
width: parent.width
height: 40
Rectangle {
width: 40
height: 40
radius: Theme.cornerRadius
color: prevMonthArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
Text {
anchors.centerIn: parent
text: "chevron_left"
font.family: Theme.iconFont
font.pixelSize: Theme.iconSize
color: Theme.primary
font.weight: Theme.iconFontWeight
}
MouseArea {
id: prevMonthArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
let newDate = new Date(calendarPopup.displayDate)
newDate.setMonth(newDate.getMonth() - 1)
calendarPopup.displayDate = newDate
}
}
}
Text {
width: parent.width - 80
height: 40
text: Qt.formatDate(calendarPopup.displayDate, "MMMM yyyy")
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
Rectangle {
width: 40
height: 40
radius: Theme.cornerRadius
color: nextMonthArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
Text {
anchors.centerIn: parent
text: "chevron_right"
font.family: Theme.iconFont
font.pixelSize: Theme.iconSize
color: Theme.primary
font.weight: Theme.iconFontWeight
}
MouseArea {
id: nextMonthArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
let newDate = new Date(calendarPopup.displayDate)
newDate.setMonth(newDate.getMonth() + 1)
calendarPopup.displayDate = newDate
}
}
}
}
Row {
width: parent.width
height: 32
Repeater {
model: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
Rectangle {
width: parent.width / 7
height: 32
color: "transparent"
Text {
anchors.centerIn: parent
text: modelData
font.pixelSize: Theme.fontSizeSmall
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.6)
font.weight: Font.Medium
}
}
}
}
Grid {
width: parent.width
height: root.hasActiveMedia ? parent.height - 300 : (root.weather.available ? parent.height - 200 : parent.height - 120)
columns: 7
rows: 6
property date firstDay: {
let date = new Date(calendarPopup.displayDate.getFullYear(), calendarPopup.displayDate.getMonth(), 1)
let dayOfWeek = date.getDay()
date.setDate(date.getDate() - dayOfWeek)
return date
}
Repeater {
model: 42
Rectangle {
width: parent.width / 7
height: parent.height / 6
property date dayDate: {
let date = new Date(parent.firstDay)
date.setDate(date.getDate() + index)
return date
}
property bool isCurrentMonth: dayDate.getMonth() === calendarPopup.displayDate.getMonth()
property bool isToday: dayDate.toDateString() === new Date().toDateString()
property bool isSelected: dayDate.toDateString() === calendarPopup.selectedDate.toDateString()
color: isSelected ? Theme.primary :
isToday ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) :
dayArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : "transparent"
radius: Theme.cornerRadiusSmall
Text {
anchors.centerIn: parent
text: dayDate.getDate()
font.pixelSize: Theme.fontSizeMedium
color: isSelected ? Theme.surface :
isToday ? Theme.primary :
isCurrentMonth ? Theme.surfaceText :
Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.4)
font.weight: isToday || isSelected ? Font.Medium : Font.Normal
}
MouseArea {
id: dayArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
calendarPopup.selectedDate = dayDate
}
}
}
}
}
}
}
MouseArea {
anchors.fill: parent
z: -1
onClicked: {
root.calendarVisible = false
}
}
}

View File

@@ -4,6 +4,7 @@ import Quickshell
import Quickshell.Widgets
import Quickshell.Wayland
import Quickshell.Io
import "../Common"
PanelWindow {
id: clipboardHistory
@@ -563,7 +564,6 @@ PanelWindow {
if (line.trim()) {
clipboardHistory.clipboardEntries.push(line)
clipboardModel.append({"entry": line})
console.log("ClipboardHistory: Adding entry:", line.substring(0, 50) + "...")
}
}
}
@@ -576,7 +576,6 @@ PanelWindow {
onExited: (exitCode) => {
if (exitCode === 0) {
console.log("ClipboardHistory: Loaded", clipboardModel.count, "entries")
updateFilteredModel()
} else {
console.warn("ClipboardHistory: Failed to load clipboard history")

File diff suppressed because it is too large Load Diff

150
Widgets/CustomSlider.qml Normal file
View File

@@ -0,0 +1,150 @@
import QtQuick
import "../Common"
Item {
id: slider
property int value: 50
property int minimum: 0
property int maximum: 100
property string leftIcon: ""
property string rightIcon: ""
property bool enabled: true
property string unit: "%"
property bool showValue: true
signal sliderValueChanged(int newValue)
height: 80
Column {
anchors.fill: parent
spacing: Theme.spacingM
// Value display
Text {
text: slider.value + slider.unit
font.pixelSize: Theme.fontSizeMedium
color: slider.enabled ? Theme.surfaceText : Theme.surfaceVariantText
font.weight: Font.Medium
visible: slider.showValue
anchors.horizontalCenter: parent.horizontalCenter
}
// Slider row
Row {
width: parent.width
spacing: Theme.spacingM
// Left icon
Text {
text: slider.leftIcon
font.family: Theme.iconFont
font.pixelSize: Theme.iconSize
color: slider.enabled ? Theme.surfaceText : Theme.surfaceVariantText
anchors.verticalCenter: parent.verticalCenter
visible: slider.leftIcon.length > 0
}
// Slider track
Rectangle {
id: sliderTrack
width: parent.width - (leftIconWidth + rightIconWidth + (slider.leftIcon.length > 0 ? Theme.spacingM : 0) + (slider.rightIcon.length > 0 ? Theme.spacingM : 0))
height: 6
radius: 3
color: slider.enabled ?
Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3) :
Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.1)
anchors.verticalCenter: parent.verticalCenter
property int leftIconWidth: slider.leftIcon.length > 0 ? Theme.iconSize : 0
property int rightIconWidth: slider.rightIcon.length > 0 ? Theme.iconSize : 0
// Fill
Rectangle {
id: sliderFill
width: parent.width * ((slider.value - slider.minimum) / (slider.maximum - slider.minimum))
height: parent.height
radius: parent.radius
color: slider.enabled ? Theme.primary : Theme.surfaceVariantText
Behavior on width {
NumberAnimation { duration: 150; easing.type: Easing.OutCubic }
}
}
// Draggable handle
Rectangle {
id: sliderHandle
width: 18
height: 18
radius: 9
color: slider.enabled ? Theme.primary : Theme.surfaceVariantText
border.color: slider.enabled ? Qt.lighter(Theme.primary, 1.3) : Qt.lighter(Theme.surfaceVariantText, 1.3)
border.width: 2
x: Math.max(0, Math.min(parent.width - width, sliderFill.width - width/2))
anchors.verticalCenter: parent.verticalCenter
scale: sliderMouseArea.containsMouse || sliderMouseArea.pressed ? 1.2 : 1.0
Behavior on scale {
NumberAnimation { duration: 150 }
}
// Handle glow effect when active
Rectangle {
anchors.centerIn: parent
width: parent.width + 4
height: parent.height + 4
radius: width / 2
color: "transparent"
border.color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.3)
border.width: 2
visible: sliderMouseArea.containsMouse && slider.enabled
Behavior on opacity {
NumberAnimation { duration: 150 }
}
}
}
MouseArea {
id: sliderMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: slider.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
enabled: slider.enabled
onClicked: (mouse) => {
if (slider.enabled) {
let ratio = Math.max(0, Math.min(1, mouse.x / width))
let newValue = Math.round(slider.minimum + ratio * (slider.maximum - slider.minimum))
slider.value = newValue
slider.sliderValueChanged(newValue)
}
}
onPositionChanged: (mouse) => {
if (pressed && slider.enabled) {
let ratio = Math.max(0, Math.min(1, mouse.x / width))
let newValue = Math.round(slider.minimum + ratio * (slider.maximum - slider.minimum))
slider.value = newValue
slider.sliderValueChanged(newValue)
}
}
}
}
// Right icon
Text {
text: slider.rightIcon
font.family: Theme.iconFont
font.pixelSize: Theme.iconSize
color: slider.enabled ? Theme.surfaceText : Theme.surfaceVariantText
anchors.verticalCenter: parent.verticalCenter
visible: slider.rightIcon.length > 0
}
}
}
}

View File

@@ -0,0 +1,354 @@
import QtQuick
import QtQuick.Controls
import Qt5Compat.GraphicalEffects
import Quickshell
import Quickshell.Widgets
import Quickshell.Wayland
import "../Common"
PanelWindow {
id: notificationHistoryPopup
visible: root.notificationHistoryVisible
implicitWidth: 400
implicitHeight: 500
WlrLayershell.layer: WlrLayershell.Overlay
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
color: "transparent"
anchors {
top: true
left: true
right: true
bottom: true
}
Rectangle {
width: 400
height: 500
x: 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.notificationHistoryVisible ? 1.0 : 0.0
scale: root.notificationHistoryVisible ? 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
}
}
Column {
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingM
// Header
Column {
width: parent.width
spacing: Theme.spacingM
Row {
width: parent.width
height: 32
Text {
text: "Notifications"
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
Item { width: parent.width - 200; height: 1 }
}
Rectangle {
width: parent.width
height: 36
radius: Theme.cornerRadius
color: clearArea.containsMouse ? Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.16) : Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12)
border.color: Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.5)
border.width: 1
visible: notificationHistory.count > 0
Row {
anchors.centerIn: parent
spacing: Theme.spacingS
Text {
text: "delete_sweep"
font.family: Theme.iconFont
font.pixelSize: Theme.iconSizeSmall + 2
color: Theme.error
font.weight: Theme.iconFontWeight
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: "Clear All Notifications"
font.pixelSize: Theme.fontSizeMedium
color: Theme.error
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: clearArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
notificationHistory.clear()
}
}
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
Behavior on border.color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
}
// Notification List
ScrollView {
width: parent.width
height: parent.height - 120
clip: true
ListView {
id: notificationListView
model: notificationHistory
spacing: Theme.spacingS
delegate: Rectangle {
width: notificationListView.width
height: 80
radius: Theme.cornerRadius
color: notifArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08)
Row {
anchors.fill: parent
anchors.margins: Theme.spacingM
spacing: Theme.spacingM
// Notification icon using reference pattern
Rectangle {
width: 32
height: 32
radius: Theme.cornerRadius
color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12)
anchors.verticalCenter: parent.verticalCenter
// Fallback material icon 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
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
// App icon when no notification image
Loader {
active: model.appIcon && model.appIcon !== "" && (!model.image || model.image === "")
anchors.fill: parent
anchors.margins: 3
sourceComponent: IconImage {
anchors.fill: parent
anchors.margins: 4
asynchronous: true
source: {
if (!model.appIcon) return ""
// Skip file:// URLs as they're usually screenshots/images, not icons
if (model.appIcon.startsWith("file://")) return ""
return Quickshell.iconPath(model.appIcon, "image-missing")
}
}
}
// Notification image with rounded corners
Loader {
active: model.image && model.image !== ""
anchors.fill: parent
sourceComponent: Item {
anchors.fill: parent
Image {
id: historyNotifImage
anchors.fill: parent
source: model.image || ""
fillMode: Image.PreserveAspectCrop
cache: false
antialiasing: true
asynchronous: true
layer.enabled: true
layer.effect: OpacityMask {
maskSource: Rectangle {
width: historyNotifImage.width
height: historyNotifImage.height
radius: Theme.cornerRadius
}
}
}
// Small app icon overlay when showing notification image
Loader {
active: model.appIcon && model.appIcon !== ""
anchors.bottom: parent.bottom
anchors.right: parent.right
anchors.margins: 2
sourceComponent: IconImage {
width: 12
height: 12
asynchronous: true
source: model.appIcon ? Quickshell.iconPath(model.appIcon, "image-missing") : ""
}
}
}
}
}
// Content
Column {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - 80
spacing: Theme.spacingXS
Text {
text: model.appName || "App"
font.pixelSize: Theme.fontSizeSmall
color: Theme.primary
font.weight: Font.Medium
}
Text {
text: model.summary || ""
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
font.weight: Font.Medium
width: parent.width
elide: Text.ElideRight
visible: text.length > 0
}
Text {
text: model.body || ""
font.pixelSize: Theme.fontSizeSmall
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
width: parent.width
wrapMode: Text.WordWrap
maximumLineCount: 2
elide: Text.ElideRight
visible: text.length > 0
}
}
}
MouseArea {
id: notifArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
notificationHistory.remove(index)
}
}
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
}
// Empty state - properly centered
Rectangle {
anchors.fill: parent
visible: notificationHistory.count === 0
color: "transparent"
Column {
anchors.centerIn: parent
spacing: Theme.spacingM
width: parent.width * 0.8
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "notifications_none"
font.family: Theme.iconFont
font.pixelSize: Theme.iconSizeLarge + 16
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.3)
font.weight: Theme.iconFontWeight
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "No notifications"
font.pixelSize: Theme.fontSizeLarge
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.6)
font.weight: Font.Medium
horizontalAlignment: Text.AlignHCenter
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "Notifications will appear here"
font.pixelSize: Theme.fontSizeMedium
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.4)
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
width: parent.width
}
}
}
}
}
}
// Click outside to close
MouseArea {
anchors.fill: parent
z: -1
onClicked: {
root.notificationHistoryVisible = false
}
}
}

View File

@@ -0,0 +1,203 @@
import QtQuick
import QtQuick.Controls
import Qt5Compat.GraphicalEffects
import Quickshell
import Quickshell.Widgets
import Quickshell.Wayland
import "../Common"
PanelWindow {
id: notificationPopup
visible: root.showNotificationPopup && root.activeNotification
WlrLayershell.layer: WlrLayershell.Overlay
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
color: "transparent"
anchors {
top: true
right: true
bottom: true
}
implicitWidth: 400
Rectangle {
id: popupContainer
width: 380
height: 100
anchors.top: parent.top
anchors.right: parent.right
anchors.topMargin: Theme.barHeight + 16
anchors.rightMargin: 16
color: Theme.surfaceContainer
radius: Theme.cornerRadiusLarge
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
border.width: 1
opacity: root.showNotificationPopup ? 1.0 : 0.0
Behavior on opacity {
NumberAnimation { duration: 200; easing.type: Easing.OutQuad }
}
MouseArea {
anchors.fill: parent
onClicked: Utils.hideNotificationPopup()
}
// Close button with cursor pointer
Text {
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 8
text: "×"
font.pixelSize: 16
color: Theme.surfaceText
MouseArea {
anchors.fill: parent
anchors.margins: -4
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: Utils.hideNotificationPopup()
}
}
// Content layout
Row {
anchors.fill: parent
anchors.margins: 12
anchors.rightMargin: 32
spacing: 12
// Notification icon using reference pattern
Rectangle {
width: 40
height: 40
radius: 8
color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1)
anchors.verticalCenter: parent.verticalCenter
// Fallback material icon when no app icon
Loader {
active: !root.activeNotification || root.activeNotification.appIcon === ""
anchors.fill: parent
sourceComponent: Text {
anchors.centerIn: parent
text: "notifications"
font.family: Theme.iconFont
font.pixelSize: 20
color: Theme.primary
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
// 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
sourceComponent: IconImage {
anchors.fill: parent
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") : ""
}
}
}
// Notification image with rounded corners
Loader {
active: root.activeNotification && root.activeNotification.image !== ""
anchors.fill: parent
sourceComponent: Item {
anchors.fill: parent
clip: true
Rectangle {
anchors.fill: parent
radius: 8
color: "transparent"
clip: true
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)
}
}
}
}
// Small app icon overlay when showing notification image
Loader {
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") : ""
}
}
}
}
}
// Text content
Column {
width: parent.width - 52
anchors.verticalCenter: parent.verticalCenter
spacing: 4
Text {
text: root.activeNotification ? (root.activeNotification.summary || "") : ""
font.pixelSize: 14
color: Theme.surfaceText
font.weight: Font.Medium
width: parent.width
elide: Text.ElideRight
visible: text.length > 0
}
Text {
text: root.activeNotification ? (root.activeNotification.body || "") : ""
font.pixelSize: 12
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
width: parent.width
wrapMode: Text.WordWrap
maximumLineCount: 2
elide: Text.ElideRight
visible: text.length > 0
}
}
}
}
}

View File

@@ -1,16 +1,25 @@
import QtQuick
import QtQuick.Controls
import Qt5Compat.GraphicalEffects
import Quickshell
import Quickshell.Widgets
import Quickshell.Wayland
import Quickshell.Io
import Quickshell.Services.SystemTray
import Quickshell.Services.Notifications
import Quickshell.Services.Mpris
import "../Common"
import "../Services"
PanelWindow {
id: topBar
property var theme
property var root
// modelData contains the screen from Quickshell.screens
property var modelData
screen: modelData
// Get the screen name (e.g., "DP-1", "DP-2")
property string screenName: modelData.name
anchors {
top: true
@@ -18,100 +27,779 @@ PanelWindow {
right: true
}
implicitHeight: theme.barHeight
implicitHeight: Theme.barHeight
color: "transparent"
Rectangle {
anchors.fill: parent
color: Qt.rgba(theme.surfaceContainer.r, theme.surfaceContainer.g, theme.surfaceContainer.b, 0.95)
Rectangle {
anchors.fill: parent
color: "transparent"
border.color: Qt.rgba(theme.outline.r, theme.outline.g, theme.outline.b, 0.12)
border.width: 1
color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.95)
Rectangle {
anchors.fill: parent
color: "transparent"
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
border.width: 1
}
Rectangle {
anchors.fill: parent
color: Qt.rgba(Theme.surfaceTint.r, Theme.surfaceTint.g, Theme.surfaceTint.b, 0.08)
SequentialAnimation on opacity {
running: true
loops: Animation.Infinite
NumberAnimation {
to: 0.12
duration: Theme.extraLongDuration
easing.type: Theme.standardEasing
}
NumberAnimation {
to: 0.06
duration: Theme.extraLongDuration
easing.type: Theme.standardEasing
}
}
}
}
Rectangle {
Item {
anchors.fill: parent
color: Qt.rgba(theme.surfaceTint.r, theme.surfaceTint.g, theme.surfaceTint.b, 0.08)
anchors.leftMargin: Theme.spacingL
anchors.rightMargin: Theme.spacingL
SequentialAnimation on opacity {
running: true
loops: Animation.Infinite
NumberAnimation {
to: 0.12
duration: theme.extraLongDuration
easing.type: theme.standardEasing
Row {
id: leftSection
height: parent.height
spacing: Theme.spacingL
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
Rectangle {
id: archLauncher
width: Math.max(120, launcherRow.implicitWidth + Theme.spacingM * 2)
height: 32
radius: Theme.cornerRadius
color: launcherArea.containsMouse ? Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.12) : Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08)
anchors.verticalCenter: parent.verticalCenter
Row {
id: launcherRow
anchors.centerIn: parent
spacing: Theme.spacingS
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.osLogo || "apps" // Use OS logo if detected, fallback to apps icon
font.family: root.osLogo ? "NerdFont" : Theme.iconFont
font.pixelSize: root.osLogo ? Theme.iconSize - 2 : Theme.iconSize - 2
font.weight: Theme.iconFontWeight
color: Theme.surfaceText
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.isSmallScreen ? "Apps" : "Applications"
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
visible: !root.isSmallScreen || width > 60
}
}
MouseArea {
id: launcherArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
appLauncher.toggle()
}
}
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
NumberAnimation {
to: 0.06
duration: theme.extraLongDuration
easing.type: theme.standardEasing
Rectangle {
id: workspaceSwitcher
width: Math.max(120, workspaceRow.implicitWidth + Theme.spacingL * 2)
height: 32
radius: Theme.cornerRadiusLarge
color: Qt.rgba(Theme.surfaceContainerHigh.r, Theme.surfaceContainerHigh.g, Theme.surfaceContainerHigh.b, 0.8)
anchors.verticalCenter: parent.verticalCenter
property int currentWorkspace: 1
property var workspaceList: []
Process {
id: workspaceQuery
command: ["niri", "msg", "workspaces"]
running: true
stdout: StdioCollector {
onStreamFinished: {
if (text && text.trim()) {
workspaceSwitcher.parseWorkspaceOutput(text.trim())
}
}
}
}
function parseWorkspaceOutput(data) {
const lines = data.split('\n')
let currentOutputName = ""
let focusedOutput = ""
let focusedWorkspace = 1
let outputWorkspaces = {}
for (const line of lines) {
if (line.startsWith('Output "')) {
const outputMatch = line.match(/Output "(.+)"/)
if (outputMatch) {
currentOutputName = outputMatch[1]
outputWorkspaces[currentOutputName] = []
}
continue
}
if (line.trim() && line.match(/^\s*\*?\s*(\d+)$/)) {
const wsMatch = line.match(/^\s*(\*?)\s*(\d+)$/)
if (wsMatch) {
const isActive = wsMatch[1] === '*'
const wsNum = parseInt(wsMatch[2])
if (currentOutputName && outputWorkspaces[currentOutputName]) {
outputWorkspaces[currentOutputName].push(wsNum)
}
if (isActive) {
focusedOutput = currentOutputName
focusedWorkspace = wsNum
}
}
}
}
// Show workspaces for THIS screen only
if (topBar.screenName && outputWorkspaces[topBar.screenName]) {
workspaceList = outputWorkspaces[topBar.screenName]
// Always track the active workspace for this display
// Parse all lines to find which workspace is active on this display
let thisDisplayActiveWorkspace = 1
let inThisOutput = false
for (const line of lines) {
if (line.startsWith('Output "')) {
const outputMatch = line.match(/Output "(.+)"/)
inThisOutput = outputMatch && outputMatch[1] === topBar.screenName
continue
}
if (inThisOutput && line.trim() && line.match(/^\s*\*\s*(\d+)$/)) {
const wsMatch = line.match(/^\s*\*\s*(\d+)$/)
if (wsMatch) {
thisDisplayActiveWorkspace = parseInt(wsMatch[1])
break
}
}
}
currentWorkspace = thisDisplayActiveWorkspace
// console.log("Monitor", topBar.screenName, "active workspace:", thisDisplayActiveWorkspace)
} else {
// Fallback if screen name not found
workspaceList = [1, 2]
currentWorkspace = 1
}
}
Timer {
interval: 500
running: true
repeat: true
onTriggered: {
workspaceQuery.running = true
}
}
Row {
id: workspaceRow
anchors.centerIn: parent
spacing: Theme.spacingS
Repeater {
model: workspaceSwitcher.workspaceList
Rectangle {
property bool isActive: modelData === workspaceSwitcher.currentWorkspace
property bool isHovered: mouseArea.containsMouse
width: isActive ? Theme.spacingXL + Theme.spacingS : Theme.spacingL
height: Theme.spacingS
radius: height / 2
color: isActive ? Theme.primary :
isHovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.5) :
Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.3)
Behavior on width {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
Behavior on color {
ColorAnimation {
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
// Set target workspace and focus monitor first
console.log("Clicking workspace", modelData, "on monitor", topBar.screenName)
workspaceSwitcher.targetWorkspace = modelData
focusMonitorProcess.command = ["niri", "msg", "action", "focus-monitor", topBar.screenName]
focusMonitorProcess.running = true
}
}
}
}
}
Process {
id: switchProcess
running: false
onExited: {
// Update current workspace and refresh query
workspaceSwitcher.currentWorkspace = workspaceSwitcher.targetWorkspace
Qt.callLater(() => {
workspaceQuery.running = true
})
}
}
Process {
id: focusMonitorProcess
running: false
onExited: {
// After focusing the monitor, switch to the workspace
Qt.callLater(() => {
switchProcess.command = ["niri", "msg", "action", "focus-workspace", workspaceSwitcher.targetWorkspace.toString()]
switchProcess.running = true
})
}
}
property int targetWorkspace: 1
}
}
Rectangle {
id: clockContainer
width: {
let baseWidth = 200
if (root.hasActiveMedia) {
// Calculate width needed for media info + time/date + spacing + padding
let mediaWidth = 24 + Theme.spacingXS + mediaTitleText.implicitWidth + Theme.spacingM + 180
return Math.min(Math.max(mediaWidth, 300), parent.width - Theme.spacingL * 2)
} else if (root.weather.available) {
return Math.min(280, parent.width - Theme.spacingL * 2)
} else {
return Math.min(baseWidth, parent.width - Theme.spacingL * 2)
}
}
height: 32
radius: Theme.cornerRadius
color: clockMouseArea.containsMouse ?
Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) :
Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08)
anchors.centerIn: parent
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
property date currentDate: new Date()
Row {
anchors.centerIn: parent
spacing: Theme.spacingM
// Media info or Weather info
Row {
spacing: Theme.spacingXS
visible: root.hasActiveMedia || root.weather.available
anchors.verticalCenter: parent.verticalCenter
// Music icon when media is playing
Text {
text: "music_note"
font.family: Theme.iconFont
font.pixelSize: Theme.iconSize - 2
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
visible: root.hasActiveMedia
SequentialAnimation on scale {
running: root.activePlayer?.playbackState === MprisPlaybackState.Playing
loops: Animation.Infinite
NumberAnimation { to: 1.1; duration: 500 }
NumberAnimation { to: 1.0; duration: 500 }
}
}
// Song title when media is playing
Text {
id: mediaTitleText
text: root.activePlayer?.trackTitle || "Unknown Track"
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
visible: root.hasActiveMedia
width: Math.min(implicitWidth, clockContainer.width - 100)
elide: Text.ElideRight
}
// Weather icon when no media but weather available
Text {
text: root.weatherIcons[root.weather.wCode] || "clear_day"
font.family: Theme.iconFont
font.pixelSize: Theme.iconSize - 2
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
visible: !root.hasActiveMedia && root.weather.available
}
// Weather temp when no media but weather available
Text {
text: (root.useFahrenheit ? root.weather.tempF : root.weather.temp) + "°" + (root.useFahrenheit ? "F" : "C")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
visible: !root.hasActiveMedia && root.weather.available
}
}
// Separator
Text {
text: "•"
font.pixelSize: Theme.fontSizeMedium
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.5)
anchors.verticalCenter: parent.verticalCenter
visible: root.hasActiveMedia || root.weather.available
}
// Time and date
Row {
spacing: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
Text {
text: Qt.formatTime(clockContainer.currentDate, "h:mm AP")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: "•"
font.pixelSize: Theme.fontSizeMedium
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.5)
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: Qt.formatDate(clockContainer.currentDate, "ddd d")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
}
}
Timer {
interval: 1000
running: true
repeat: true
onTriggered: {
clockContainer.currentDate = new Date()
}
}
MouseArea {
id: clockMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.calendarVisible = !root.calendarVisible
}
}
}
Row {
id: rightSection
height: parent.height
spacing: Theme.spacingXS
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
Rectangle {
width: Math.max(40, systemTrayRow.implicitWidth + Theme.spacingS * 2)
height: 32
radius: Theme.cornerRadius
color: Qt.rgba(Theme.secondary.r, Theme.secondary.g, Theme.secondary.b, 0.08)
anchors.verticalCenter: parent.verticalCenter
visible: systemTrayRow.children.length > 0
Row {
id: systemTrayRow
anchors.centerIn: parent
spacing: Theme.spacingXS
Repeater {
model: SystemTray.items
delegate: Rectangle {
width: 24
height: 24
radius: Theme.cornerRadiusSmall
color: trayItemArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
property var trayItem: modelData
Image {
anchors.centerIn: parent
width: 18
height: 18
source: {
let icon = trayItem?.icon || "";
if (!icon) return "";
if (icon.includes("?path=")) {
const [name, path] = icon.split("?path=");
const fileName = name.substring(name.lastIndexOf("/") + 1);
return `file://${path}/${fileName}`;
}
return icon;
}
asynchronous: true
smooth: true
fillMode: Image.PreserveAspectFit
}
MouseArea {
id: trayItemArea
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: (mouse) => {
if (!trayItem) return;
if (mouse.button === Qt.LeftButton) {
if (!trayItem.onlyMenu) {
trayItem.activate()
}
} else if (mouse.button === Qt.RightButton) {
if (trayItem.hasMenu) {
console.log("Right-click detected, showing menu for:", trayItem.title || "Unknown")
customTrayMenu.showMenu(mouse.x, mouse.y)
} else {
console.log("No menu available for:", trayItem.title || "Unknown")
}
}
}
}
// Custom Material 3 styled menu
QtObject {
id: customTrayMenu
property bool menuVisible: false
function showMenu(x, y) {
root.currentTrayMenu = customTrayMenu
root.currentTrayItem = trayItem
// Simple positioning: right side of screen, below the panel
root.trayMenuX = rightSection.x + rightSection.width - 180 - Theme.spacingL
root.trayMenuY = Theme.barHeight + Theme.spacingS
console.log("Showing menu at:", root.trayMenuX, root.trayMenuY)
menuVisible = true
root.showTrayMenu = true
}
function hideMenu() {
menuVisible = false
root.showTrayMenu = false
root.currentTrayMenu = null
root.currentTrayItem = null
}
}
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
}
}
}
// Clipboard History Button
Rectangle {
width: 40
height: 32
radius: Theme.cornerRadius
color: clipboardArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : Qt.rgba(Theme.secondary.r, Theme.secondary.g, Theme.secondary.b, 0.08)
anchors.verticalCenter: parent.verticalCenter
Text {
anchors.centerIn: parent
text: "content_paste" // Material icon for clipboard
font.family: Theme.iconFont
font.pixelSize: Theme.iconSize - 6
font.weight: Theme.iconFontWeight
color: Theme.surfaceText
}
MouseArea {
id: clipboardArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
clipboardHistoryPopup.toggle()
}
}
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
// Color Picker Button
Rectangle {
width: 40
height: 32
radius: Theme.cornerRadius
color: colorPickerArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : Qt.rgba(Theme.secondary.r, Theme.secondary.g, Theme.secondary.b, 0.08)
anchors.verticalCenter: parent.verticalCenter
Text {
anchors.centerIn: parent
text: "colorize" // Material icon for color picker
font.family: Theme.iconFont
font.pixelSize: Theme.iconSize - 6
font.weight: Theme.iconFontWeight
color: Theme.surfaceText
}
MouseArea {
id: colorPickerArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
ColorPickerService.pickColor()
}
}
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
// Notification Center Button
Rectangle {
width: 40
height: 32
radius: Theme.cornerRadius
color: notificationArea.containsMouse || root.notificationHistoryVisible ?
Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.16) :
Qt.rgba(Theme.secondary.r, Theme.secondary.g, Theme.secondary.b, 0.08)
anchors.verticalCenter: parent.verticalCenter
property bool hasUnread: notificationHistory.count > 0
Text {
anchors.centerIn: parent
text: "notifications" // Material icon for notifications
font.family: Theme.iconFont
font.pixelSize: Theme.iconSize - 6
font.weight: Theme.iconFontWeight
color: notificationArea.containsMouse || root.notificationHistoryVisible ?
Theme.primary : Theme.surfaceText
}
// Notification dot indicator
Rectangle {
width: 8
height: 8
radius: 4
color: Theme.error
anchors.right: parent.right
anchors.top: parent.top
anchors.rightMargin: 6
anchors.topMargin: 6
visible: parent.hasUnread
}
MouseArea {
id: notificationArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.notificationHistoryVisible = !root.notificationHistoryVisible
}
}
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
// Control Center Indicators
Rectangle {
width: Math.max(80, controlIndicators.implicitWidth + Theme.spacingS * 2)
height: 32
radius: Theme.cornerRadius
color: controlCenterArea.containsMouse || root.controlCenterVisible ?
Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.16) :
Qt.rgba(Theme.secondary.r, Theme.secondary.g, Theme.secondary.b, 0.08)
anchors.verticalCenter: parent.verticalCenter
Row {
id: controlIndicators
anchors.centerIn: parent
spacing: Theme.spacingXS
// Network Status Icon
Text {
text: {
if (root.networkStatus === "ethernet") return "lan"
else if (root.networkStatus === "wifi") {
switch (root.wifiSignalStrength) {
case "excellent": return "wifi"
case "good": return "wifi_2_bar"
case "fair": return "wifi_1_bar"
case "poor": return "wifi_calling_3"
default: return "wifi"
}
}
else return "wifi_off"
}
font.family: Theme.iconFont
font.pixelSize: Theme.iconSize - 8
font.weight: Theme.iconFontWeight
color: root.networkStatus !== "disconnected" ? Theme.primary : Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.5)
anchors.verticalCenter: parent.verticalCenter
visible: true
}
// Audio Icon
Text {
text: root.volumeLevel === 0 ? "volume_off" :
root.volumeLevel < 33 ? "volume_down" : "volume_up"
font.family: Theme.iconFont
font.pixelSize: Theme.iconSize - 8
font.weight: Theme.iconFontWeight
color: controlCenterArea.containsMouse || root.controlCenterVisible ?
Theme.primary : Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
// Microphone Icon (when active)
Text {
text: "mic"
font.family: Theme.iconFont
font.pixelSize: Theme.iconSize - 8
font.weight: Theme.iconFontWeight
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
visible: false // TODO: Add mic detection
}
// Bluetooth Icon (when available and enabled)
Text {
text: "bluetooth"
font.family: Theme.iconFont
font.pixelSize: Theme.iconSize - 8
font.weight: Theme.iconFontWeight
color: root.bluetoothEnabled ? Theme.primary : Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.5)
anchors.verticalCenter: parent.verticalCenter
visible: root.bluetoothAvailable && root.bluetoothEnabled
}
}
MouseArea {
id: controlCenterArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.controlCenterVisible = !root.controlCenterVisible
if (root.controlCenterVisible) {
// Refresh data when opening control center
WifiService.scanWifi()
BluetoothService.scanDevices()
// Audio sink info is automatically refreshed by AudioService
}
}
}
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
}
}
}
Item {
anchors.fill: parent
anchors.leftMargin: theme.spacingL
anchors.rightMargin: theme.spacingL
// Left section - Apps and Workspace Switcher
Row {
id: leftSection
height: parent.height
spacing: theme.spacingL
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
AppLauncherButton {
theme: topBar.theme
root: topBar.root
}
WorkspaceSwitcher {
theme: topBar.theme
root: topBar.root
}
}
// Center section - Clock/Media Player
ClockWidget {
id: clockWidget
theme: topBar.theme
root: topBar.root
anchors.centerIn: parent
}
// Right section - System controls
Row {
id: rightSection
height: parent.height
spacing: theme.spacingXS
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
SystemTrayWidget {
theme: topBar.theme
root: topBar.root
}
ClipboardButton {
theme: topBar.theme
root: topBar.root
}
ColorPickerButton {
theme: topBar.theme
root: topBar.root
}
NotificationButton {
theme: topBar.theme
root: topBar.root
}
}
}
}

154
Widgets/TrayMenuPopup.qml Normal file
View File

@@ -0,0 +1,154 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Widgets
import Quickshell.Wayland
import "../Common"
PanelWindow {
id: trayMenuPopup
visible: root.showTrayMenu
WlrLayershell.layer: WlrLayershell.Overlay
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
color: "transparent"
anchors {
top: true
left: true
right: true
bottom: true
}
Rectangle {
id: menuContainer
x: root.trayMenuX
y: root.trayMenuY
width: 180
height: Math.max(60, menuList.contentHeight + Theme.spacingS * 2)
color: Theme.surfaceContainer
radius: Theme.cornerRadiusLarge
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
border.width: 1
// Material 3 drop shadow
Rectangle {
anchors.fill: parent
anchors.topMargin: 4
anchors.leftMargin: 2
anchors.rightMargin: -2
anchors.bottomMargin: -4
radius: parent.radius
color: Qt.rgba(0, 0, 0, 0.15)
z: parent.z - 1
}
// Material 3 animations
opacity: root.showTrayMenu ? 1.0 : 0.0
scale: root.showTrayMenu ? 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
}
}
Item {
anchors.fill: parent
anchors.margins: Theme.spacingS
QsMenuOpener {
id: menuOpener
menu: root.currentTrayItem?.menu
}
// Custom menu styling using ListView
ListView {
id: menuList
anchors.fill: parent
spacing: 1
model: ScriptModel {
values: menuOpener.children ? [...menuOpener.children.values].filter(item => {
// Filter out empty items and separators
return item && item.text && item.text.trim().length > 0 && !item.isSeparator
}) : []
}
delegate: Rectangle {
width: ListView.view.width
height: modelData.isSeparator ? 5 : 28
radius: modelData.isSeparator ? 0 : Theme.cornerRadiusSmall
color: modelData.isSeparator ? "transparent" :
(menuItemArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent")
// Separator line
Rectangle {
visible: modelData.isSeparator
anchors.centerIn: parent
width: parent.width - Theme.spacingS * 2
height: 1
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
}
// Menu item content
Row {
visible: !modelData.isSeparator
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXS
Text {
text: modelData.text || ""
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
font.weight: Font.Normal
}
}
MouseArea {
id: menuItemArea
anchors.fill: parent
hoverEnabled: true
cursorShape: modelData.isSeparator ? Qt.ArrowCursor : Qt.PointingHandCursor
enabled: !modelData.isSeparator
onClicked: {
if (modelData.triggered) {
modelData.triggered()
}
root.showTrayMenu = false
}
}
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
}
}
}
// Click outside to close
MouseArea {
anchors.fill: parent
z: -1
onClicked: {
root.showTrayMenu = false
}
}
}

View File

@@ -0,0 +1,308 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Widgets
import Quickshell.Wayland
import "../Common"
import "../Services"
PanelWindow {
id: wifiPasswordDialog
visible: root.wifiPasswordDialogVisible
anchors {
top: true
left: true
right: true
bottom: true
}
WlrLayershell.layer: WlrLayershell.Overlay
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: root.wifiPasswordDialogVisible ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
color: "transparent"
onVisibleChanged: {
if (visible) {
passwordInput.forceActiveFocus()
}
}
Rectangle {
anchors.fill: parent
color: Qt.rgba(0, 0, 0, 0.5)
opacity: root.wifiPasswordDialogVisible ? 1.0 : 0.0
Behavior on opacity {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.standardEasing
}
}
MouseArea {
anchors.fill: parent
onClicked: {
root.wifiPasswordDialogVisible = false
root.wifiPasswordInput = ""
}
}
}
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: root.wifiPasswordDialogVisible ? 1.0 : 0.0
scale: root.wifiPasswordDialogVisible ? 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: "Connect to Wi-Fi"
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
}
Text {
text: "Enter password for \"" + root.wifiPasswordSSID + "\""
font.pixelSize: Theme.fontSizeMedium
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
width: parent.width
elide: Text.ElideRight
}
}
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: {
root.wifiPasswordDialogVisible = false
root.wifiPasswordInput = ""
}
}
}
}
// Password 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: passwordInput.activeFocus ? Theme.primary : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
border.width: passwordInput.activeFocus ? 2 : 1
TextInput {
id: passwordInput
anchors.fill: parent
anchors.margins: Theme.spacingM
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
echoMode: showPasswordCheckbox.checked ? TextInput.Normal : TextInput.Password
verticalAlignment: TextInput.AlignVCenter
cursorVisible: activeFocus
selectByMouse: true
Text {
anchors.fill: parent
text: "Enter password"
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: {
root.wifiPasswordInput = text
}
onAccepted: {
WifiService.connectToWifiWithPassword(root.wifiPasswordSSID, root.wifiPasswordInput)
}
Component.onCompleted: {
if (root.wifiPasswordDialogVisible) {
forceActiveFocus()
}
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.IBeamCursor
onClicked: {
passwordInput.forceActiveFocus()
}
}
}
// Show password checkbox
Row {
spacing: Theme.spacingS
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: "Cancel"
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
font.weight: Font.Medium
}
MouseArea {
id: cancelArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.wifiPasswordDialogVisible = false
root.wifiPasswordInput = ""
}
}
}
Rectangle {
width: Math.max(80, connectText.contentWidth + Theme.spacingM * 2)
height: 36
radius: Theme.cornerRadius
color: connectArea.containsMouse ? Qt.darker(Theme.primary, 1.1) : Theme.primary
enabled: root.wifiPasswordInput.length > 0
opacity: enabled ? 1.0 : 0.5
Text {
id: connectText
anchors.centerIn: parent
text: "Connect"
font.pixelSize: Theme.fontSizeMedium
color: Theme.background
font.weight: Font.Medium
}
MouseArea {
id: connectArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: parent.enabled
onClicked: {
WifiService.connectToWifiWithPassword(root.wifiPasswordSSID, root.wifiPasswordInput)
}
}
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
}
}
}
}
}

View File

@@ -6,4 +6,13 @@ ClockWidget 1.0 ClockWidget.qml
SystemTrayWidget 1.0 SystemTrayWidget.qml
ClipboardButton 1.0 ClipboardButton.qml
ColorPickerButton 1.0 ColorPickerButton.qml
NotificationButton 1.0 NotificationButton.qml
NotificationButton 1.0 NotificationButton.qml
CalendarPopup 1.0 CalendarPopup.qml
TrayMenuPopup 1.0 TrayMenuPopup.qml
NotificationPopup 1.0 NotificationPopup.qml
NotificationHistoryPopup 1.0 NotificationHistoryPopup.qml
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

4753
shell.qml

File diff suppressed because it is too large Load Diff