mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-01-24 21:42:51 -05:00
Modularlize the shell
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -52,3 +52,4 @@ compile_commands.json
|
|||||||
*creator.user*
|
*creator.user*
|
||||||
|
|
||||||
*_qmlcache.qrc
|
*_qmlcache.qrc
|
||||||
|
UNUSED
|
||||||
|
|||||||
329
CLAUDE.md
329
CLAUDE.md
@@ -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.
|
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
|
## Technology Stack
|
||||||
|
|
||||||
- **QML (Qt Modeling Language)** - Primary language for all UI components
|
- **QML (Qt Modeling Language)** - Primary language for all UI components
|
||||||
@@ -28,62 +30,199 @@ qs -p .
|
|||||||
|
|
||||||
## Architecture Overview
|
## 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
|
### Component Organization
|
||||||
|
|
||||||
1. **Shell Entry Point** (root directory)
|
1. **Shell Entry Point** (`shell.qml`)
|
||||||
- `shell.qml` - Main shell implementation with multi-monitor support
|
- 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
|
2. **Common/** - Shared resources
|
||||||
- Each widget is a self-contained QML module with its own `qmldir`
|
- `Theme.qml` - Material Design 3 theme singleton with consistent colors, spacing, fonts
|
||||||
- Examples: TopBar, ClockWidget, SystemTrayWidget, NotificationWidget
|
- `Utilities.js` - Shared functions for workspace parsing, notifications, menu handling
|
||||||
- Components follow Material Design 3 principles
|
|
||||||
|
|
||||||
3. **Services/** - Backend services and controllers
|
3. **Services/** - System integration singletons
|
||||||
- `MprisController.qml` - Media player integration
|
- **Pattern**: All services use `Singleton` type with `id: root`
|
||||||
- `OSDetectionService.qml` - Operating system detection
|
- **Independence**: No cross-service dependencies
|
||||||
- `WeatherService.qml` - Weather data fetching
|
- **Examples**: AudioService, NetworkService, BrightnessService, WeatherService
|
||||||
- Services handle system integration and data management
|
- 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
|
### Key Architectural Patterns
|
||||||
|
|
||||||
1. **Module System**: Each component directory contains a `qmldir` file defining the module exports
|
1. **Singleton Services Pattern**:
|
||||||
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**:
|
|
||||||
```qml
|
```qml
|
||||||
Item {
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
pragma Singleton
|
||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
// Properties
|
property type value: defaultValue
|
||||||
property type name: value
|
|
||||||
|
|
||||||
// Signal handlers
|
function performAction() { /* implementation */ }
|
||||||
onSignal: { }
|
|
||||||
|
|
||||||
// Child components
|
|
||||||
Component { }
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
## 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.)
|
- Monitors are automatically detected by screen name (DP-1, DP-2, etc.)
|
||||||
- Workspaces are dynamically synchronized with Niri's per-output workspaces
|
- Workspaces are dynamically synchronized with Niri's per-output workspaces
|
||||||
|
|
||||||
## Common Tasks
|
## Common Development Tasks
|
||||||
|
|
||||||
|
### Testing and Validation
|
||||||
|
|
||||||
When modifying the shell:
|
When modifying the shell:
|
||||||
1. Test changes with `qs -p .`
|
1. **Test changes**: `qs -p .` (automatic reload on file changes)
|
||||||
2. Check that animations remain smooth (60 FPS target)
|
2. **Performance**: Ensure animations remain smooth (60 FPS target)
|
||||||
3. Ensure Material Design 3 color consistency
|
3. **Theming**: Use `Theme.propertyName` for Material Design 3 consistency
|
||||||
4. Test on Wayland session
|
4. **Wayland compatibility**: Test on Wayland session
|
||||||
5. Verify multi-monitor behavior if applicable
|
5. **Multi-monitor**: Verify behavior with multiple displays
|
||||||
|
6. **Feature detection**: Test on systems with/without required tools
|
||||||
|
|
||||||
When adding new widgets:
|
### Adding New Widgets
|
||||||
1. Create directory under `Widgets/`
|
|
||||||
2. Add `qmldir` file with module definition
|
1. **Create component**:
|
||||||
3. Follow existing widget patterns for property exposure
|
```bash
|
||||||
4. Integrate with relevant services as needed
|
# Create new widget file
|
||||||
5. Consider whether the widget should be per-screen or global
|
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
66
Common/Theme.qml
Normal 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
101
Common/Utilities.js
Normal 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
4
Common/qmldir
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
module Common
|
||||||
|
|
||||||
|
singleton Theme 1.0 Theme.qml
|
||||||
|
Utilities 1.0 Utilities.js
|
||||||
127
Services/AudioService.qml
Normal file
127
Services/AudioService.qml
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
121
Services/BluetoothService.qml
Normal file
121
Services/BluetoothService.qml
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
107
Services/BrightnessService.qml
Normal file
107
Services/BrightnessService.qml
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
26
Services/ColorPickerService.qml
Normal file
26
Services/ColorPickerService.qml
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
pragma Singleton
|
|
||||||
pragma ComponentBehavior: Bound
|
|
||||||
|
|
||||||
import QtQml.Models
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
|
import QtQml.Models
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
import Quickshell.Services.Mpris
|
import Quickshell.Services.Mpris
|
||||||
|
pragma Singleton
|
||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A service that provides easy access to the active Mpris player.
|
* A service that provides easy access to the active Mpris player.
|
||||||
|
|||||||
159
Services/NetworkService.qml
Normal file
159
Services/NetworkService.qml
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
110
Services/OSDetectorService.qml
Normal file
110
Services/OSDetectorService.qml
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
pragma Singleton
|
|
||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
|
pragma Singleton
|
||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
QtObject {
|
Singleton {
|
||||||
id: weatherService
|
id: root
|
||||||
|
|
||||||
property var weather: ({
|
property var weather: ({
|
||||||
available: false,
|
available: false,
|
||||||
@@ -32,7 +32,7 @@ QtObject {
|
|||||||
try {
|
try {
|
||||||
let parsedData = JSON.parse(text.trim())
|
let parsedData = JSON.parse(text.trim())
|
||||||
if (parsedData.current && parsedData.location) {
|
if (parsedData.current && parsedData.location) {
|
||||||
weatherService.weather = {
|
root.weather = {
|
||||||
available: true,
|
available: true,
|
||||||
temp: parseInt(parsedData.current.temp_C || 0),
|
temp: parseInt(parsedData.current.temp_C || 0),
|
||||||
tempF: parseInt(parsedData.current.temp_F || 0),
|
tempF: parseInt(parsedData.current.temp_F || 0),
|
||||||
@@ -45,15 +45,15 @@ QtObject {
|
|||||||
uv: parseInt(parsedData.current.uvIndex || 0),
|
uv: parseInt(parsedData.current.uvIndex || 0),
|
||||||
pressure: parseInt(parsedData.current.pressure || 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) {
|
} catch (e) {
|
||||||
console.warn("Failed to parse weather data:", e.message)
|
console.warn("Failed to parse weather data:", e.message)
|
||||||
weatherService.weather.available = false
|
root.weather.available = false
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn("No valid weather data received")
|
console.warn("No valid weather data received")
|
||||||
weatherService.weather.available = false
|
root.weather.available = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,7 +61,7 @@ QtObject {
|
|||||||
onExited: (exitCode) => {
|
onExited: (exitCode) => {
|
||||||
if (exitCode !== 0) {
|
if (exitCode !== 0) {
|
||||||
console.warn("Weather fetch failed with exit code:", exitCode)
|
console.warn("Weather fetch failed with exit code:", exitCode)
|
||||||
weatherService.weather.available = false
|
root.weather.available = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
172
Services/WifiService.qml
Normal file
172
Services/WifiService.qml
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -4,6 +4,7 @@ import Quickshell
|
|||||||
import Quickshell.Widgets
|
import Quickshell.Widgets
|
||||||
import Quickshell.Wayland
|
import Quickshell.Wayland
|
||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
|
import "../Common"
|
||||||
|
|
||||||
PanelWindow {
|
PanelWindow {
|
||||||
id: launcher
|
id: launcher
|
||||||
562
Widgets/CalendarPopup.qml
Normal file
562
Widgets/CalendarPopup.qml
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import Quickshell
|
|||||||
import Quickshell.Widgets
|
import Quickshell.Widgets
|
||||||
import Quickshell.Wayland
|
import Quickshell.Wayland
|
||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
|
import "../Common"
|
||||||
|
|
||||||
PanelWindow {
|
PanelWindow {
|
||||||
id: clipboardHistory
|
id: clipboardHistory
|
||||||
@@ -563,7 +564,6 @@ PanelWindow {
|
|||||||
if (line.trim()) {
|
if (line.trim()) {
|
||||||
clipboardHistory.clipboardEntries.push(line)
|
clipboardHistory.clipboardEntries.push(line)
|
||||||
clipboardModel.append({"entry": line})
|
clipboardModel.append({"entry": line})
|
||||||
console.log("ClipboardHistory: Adding entry:", line.substring(0, 50) + "...")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -576,7 +576,6 @@ PanelWindow {
|
|||||||
|
|
||||||
onExited: (exitCode) => {
|
onExited: (exitCode) => {
|
||||||
if (exitCode === 0) {
|
if (exitCode === 0) {
|
||||||
console.log("ClipboardHistory: Loaded", clipboardModel.count, "entries")
|
|
||||||
updateFilteredModel()
|
updateFilteredModel()
|
||||||
} else {
|
} else {
|
||||||
console.warn("ClipboardHistory: Failed to load clipboard history")
|
console.warn("ClipboardHistory: Failed to load clipboard history")
|
||||||
1366
Widgets/ControlCenterPopup.qml
Normal file
1366
Widgets/ControlCenterPopup.qml
Normal file
File diff suppressed because it is too large
Load Diff
150
Widgets/CustomSlider.qml
Normal file
150
Widgets/CustomSlider.qml
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
354
Widgets/NotificationHistoryPopup.qml
Normal file
354
Widgets/NotificationHistoryPopup.qml
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
203
Widgets/NotificationPopup.qml
Normal file
203
Widgets/NotificationPopup.qml
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,25 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
|
import Qt5Compat.GraphicalEffects
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Widgets
|
import Quickshell.Widgets
|
||||||
import Quickshell.Wayland
|
import Quickshell.Wayland
|
||||||
|
import Quickshell.Io
|
||||||
import Quickshell.Services.SystemTray
|
import Quickshell.Services.SystemTray
|
||||||
|
import Quickshell.Services.Notifications
|
||||||
|
import Quickshell.Services.Mpris
|
||||||
|
import "../Common"
|
||||||
import "../Services"
|
import "../Services"
|
||||||
|
|
||||||
PanelWindow {
|
PanelWindow {
|
||||||
id: topBar
|
id: topBar
|
||||||
|
|
||||||
property var theme
|
// modelData contains the screen from Quickshell.screens
|
||||||
property var root
|
property var modelData
|
||||||
|
screen: modelData
|
||||||
|
|
||||||
|
// Get the screen name (e.g., "DP-1", "DP-2")
|
||||||
|
property string screenName: modelData.name
|
||||||
|
|
||||||
anchors {
|
anchors {
|
||||||
top: true
|
top: true
|
||||||
@@ -18,100 +27,779 @@ PanelWindow {
|
|||||||
right: true
|
right: true
|
||||||
}
|
}
|
||||||
|
|
||||||
implicitHeight: theme.barHeight
|
implicitHeight: Theme.barHeight
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
anchors.fill: parent
|
|
||||||
color: Qt.rgba(theme.surfaceContainer.r, theme.surfaceContainer.g, theme.surfaceContainer.b, 0.95)
|
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
color: "transparent"
|
color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.95)
|
||||||
border.color: Qt.rgba(theme.outline.r, theme.outline.g, theme.outline.b, 0.12)
|
|
||||||
border.width: 1
|
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
|
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 {
|
Row {
|
||||||
running: true
|
id: leftSection
|
||||||
loops: Animation.Infinite
|
height: parent.height
|
||||||
NumberAnimation {
|
spacing: Theme.spacingL
|
||||||
to: 0.12
|
anchors.left: parent.left
|
||||||
duration: theme.extraLongDuration
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
easing.type: theme.standardEasing
|
|
||||||
|
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
|
Rectangle {
|
||||||
duration: theme.extraLongDuration
|
id: workspaceSwitcher
|
||||||
easing.type: theme.standardEasing
|
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
154
Widgets/TrayMenuPopup.qml
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
308
Widgets/WifiPasswordDialog.qml
Normal file
308
Widgets/WifiPasswordDialog.qml
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,4 +6,13 @@ ClockWidget 1.0 ClockWidget.qml
|
|||||||
SystemTrayWidget 1.0 SystemTrayWidget.qml
|
SystemTrayWidget 1.0 SystemTrayWidget.qml
|
||||||
ClipboardButton 1.0 ClipboardButton.qml
|
ClipboardButton 1.0 ClipboardButton.qml
|
||||||
ColorPickerButton 1.0 ColorPickerButton.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
|
||||||
Reference in New Issue
Block a user