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

Fix multi-monitor

This commit is contained in:
bbedward
2025-07-10 14:14:16 -04:00
parent 53687266a1
commit 7cdeba1625
5 changed files with 195 additions and 2816 deletions

110
CLAUDE.md Normal file
View File

@@ -0,0 +1,110 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
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.
## Technology Stack
- **QML (Qt Modeling Language)** - Primary language for all UI components
- **Quickshell Framework** - QML-based framework for building desktop shells
- **Qt/QtQuick** - UI rendering and controls
- **Qt5Compat** - Graphical effects
- **Wayland** - Display server protocol
## Development Commands
Since this is a Quickshell-based project without traditional build configuration files, development typically involves:
```bash
# Run the shell (requires Quickshell to be installed)
quickshell -p shell.qml
# Or use the shorthand
qs -p .
```
## Architecture Overview
### Component Organization
1. **Shell Entry Point** (root directory)
- `shell.qml` - Main shell implementation with multi-monitor support
2. **Widgets/** - Reusable UI components
- Each widget is a self-contained QML module with its own `qmldir`
- Examples: TopBar, ClockWidget, SystemTrayWidget, NotificationWidget
- Components follow Material Design 3 principles
3. **Services/** - Backend services and controllers
- `MprisController.qml` - Media player integration
- `OSDetectionService.qml` - Operating system detection
- `WeatherService.qml` - Weather data fetching
- Services handle system integration and data management
### Key Architectural Patterns
1. **Module System**: Each component directory contains a `qmldir` file defining the module exports
2. **Property Bindings**: Heavy use of Qt property bindings for reactive UI updates
3. **Singleton Services**: Services are typically instantiated once and shared across components
4. **Material Design Theming**: Consistent use of Material Design 3 color properties throughout
### Important Components
- **ControlCenter**: Central hub for system controls (WiFi, Bluetooth, brightness, volume)
- **ApplicationLauncher**: App grid and search functionality
- **NotificationSystem**: Notification display and management
- **ClipboardHistory**: Clipboard manager with history
- **WorkspaceSwitcher**: Per-display virtual desktop switching with Niri integration
## Code Conventions
1. **QML Style**:
- Use 4-space indentation
- Properties before signal handlers
- ID should be the first property
- Prefer property bindings over imperative code
2. **Component Structure**:
```qml
Item {
id: root
// Properties
property type name: value
// Signal handlers
onSignal: { }
// Child components
Component { }
}
```
3. **Service Integration**: Components should communicate with services through properties and signals rather than direct method calls
## Multi-Monitor Support
The shell uses Quickshell's `Variants` pattern for multi-monitor support:
- Each connected monitor gets its own top bar instance
- Workspace switchers are per-display and Niri-aware
- Monitors are automatically detected by screen name (DP-1, DP-2, etc.)
- Workspaces are dynamically synchronized with Niri's per-output workspaces
## Common Tasks
When modifying the shell:
1. Test changes with `qs -p .`
2. Check that animations remain smooth (60 FPS target)
3. Ensure Material Design 3 color consistency
4. Test on Wayland session
5. Verify multi-monitor behavior if applicable
When adding new widgets:
1. Create directory under `Widgets/`
2. Add `qmldir` file with module definition
3. Follow existing widget patterns for property exposure
4. Integrate with relevant services as needed
5. Consider whether the widget should be per-screen or global

View File

@@ -1,274 +0,0 @@
//@ pragma UseQApplication
import QtQuick
import QtQuick.Controls
import Qt5Compat.GraphicalEffects
import Quickshell
import Quickshell.Widgets
import Quickshell.Wayland
import Quickshell.Io
import Quickshell.Services.SystemTray
import Quickshell.Services.Notifications
import Quickshell.Services.Mpris
import "Services"
import "Widgets"
ShellRoot {
id: root
property bool calendarVisible: false
property bool showTrayMenu: false
property real trayMenuX: 0
property real trayMenuY: 0
property var currentTrayMenu: null
property var currentTrayItem: null
property bool notificationHistoryVisible: false
property var activeNotification: null
property bool showNotificationPopup: false
property bool mediaPlayerVisible: false
property MprisPlayer activePlayer: MprisController.activePlayer
property bool hasActiveMedia: MprisController.isPlaying && (activePlayer?.trackTitle || activePlayer?.trackArtist)
property bool useFahrenheit: true
property var weather: WeatherService.weather
property string osLogo: OSDetectionService.osLogo
property string osName: OSDetectionService.osName
property var notificationHistory: notificationHistoryModel
property var appLauncher: appLauncherPopup
property var clipboardHistoryPopup: clipboardHistoryPopupInstance
property var colorPickerProcess: colorPickerProcessInstance
property var weatherIcons: ({
"113": "clear_day",
"116": "partly_cloudy_day",
"119": "cloud",
"122": "cloud",
"143": "foggy",
"176": "rainy",
"179": "rainy",
"182": "rainy",
"185": "rainy",
"200": "thunderstorm",
"227": "cloudy_snowing",
"230": "snowing_heavy",
"248": "foggy",
"260": "foggy",
"263": "rainy",
"266": "rainy",
"281": "rainy",
"284": "rainy",
"293": "rainy",
"296": "rainy",
"299": "rainy",
"302": "weather_hail",
"305": "rainy",
"308": "weather_hail",
"311": "rainy",
"314": "rainy",
"317": "rainy",
"320": "cloudy_snowing",
"323": "cloudy_snowing",
"326": "cloudy_snowing",
"329": "snowing_heavy",
"332": "snowing_heavy",
"335": "snowing",
"338": "snowing_heavy",
"350": "rainy",
"353": "rainy",
"356": "rainy",
"359": "weather_hail",
"362": "rainy",
"365": "rainy",
"368": "cloudy_snowing",
"371": "snowing",
"374": "rainy",
"377": "rainy",
"386": "thunderstorm",
"389": "thunderstorm",
"392": "thunderstorm",
"395": "snowing"
})
QtObject {
id: theme
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
}
TopBar {
id: topBar
theme: root.theme
root: root
}
AppLauncher {
id: appLauncherPopup
theme: root.theme
}
ClipboardHistory {
id: clipboardHistoryPopupInstance
theme: root.theme
}
MediaPlayer {
id: mediaPlayer
theme: root.theme
isVisible: root.mediaPlayerVisible
}
Process {
id: colorPickerProcessInstance
command: ["hyprpicker", "-a"]
running: false
onExited: (exitCode) => {
if (exitCode !== 0) {
console.warn("Color picker failed. Make sure hyprpicker is installed: yay -S hyprpicker")
}
}
}
NotificationServer {
id: notificationServer
actionsSupported: true
bodyMarkupSupported: true
imageSupported: true
keepOnReload: false
persistenceSupported: true
onNotification: (notification) => {
if (!notification || !notification.id) return
if (!notification.appName && !notification.summary && !notification.body) {
return
}
console.log("New notification from:", notification.appName || "Unknown", "Summary:", notification.summary || "No summary")
var notifObj = {
"id": notification.id,
"appName": notification.appName || "App",
"summary": notification.summary || "",
"body": notification.body || "",
"timestamp": new Date(),
"appIcon": notification.appIcon || notification.icon || "",
"icon": notification.icon || "",
"image": notification.image || ""
}
notificationHistoryModel.insert(0, notifObj)
while (notificationHistoryModel.count > 50) {
notificationHistoryModel.remove(notificationHistoryModel.count - 1)
}
root.activeNotification = notifObj
root.showNotificationPopup = true
notificationTimer.restart()
}
}
ListModel {
id: notificationHistoryModel
}
Timer {
id: notificationTimer
interval: 5000
repeat: false
onTriggered: hideNotificationPopup()
}
Timer {
id: clearNotificationTimer
interval: theme.mediumDuration + 50
repeat: false
onTriggered: root.activeNotification = null
}
function showNotificationPopup(notification) {
root.activeNotification = notification
root.showNotificationPopup = true
notificationTimer.restart()
}
function hideNotificationPopup() {
root.showNotificationPopup = false
notificationTimer.stop()
clearNotificationTimer.restart()
}
Timer {
running: root.activePlayer?.playbackState === MprisPlaybackState.Playing
interval: 1000
repeat: true
onTriggered: {
if (root.activePlayer) {
root.activePlayer.positionChanged()
}
}
}
Component.onCompleted: {
console.log("DankMaterialDark shell loaded successfully!")
}
}

View File

@@ -1,269 +0,0 @@
//@ pragma UseQApplication
import QtQuick
import QtQuick.Controls
import Qt5Compat.GraphicalEffects
import Quickshell
import Quickshell.Widgets
import Quickshell.Wayland
import Quickshell.Io
import Quickshell.Services.SystemTray
import Quickshell.Services.Notifications
import Quickshell.Services.Mpris
import "Services"
import "Widgets"
ShellRoot {
id: root
property bool calendarVisible: false
property bool showTrayMenu: false
property real trayMenuX: 0
property real trayMenuY: 0
property var currentTrayMenu: null
property var currentTrayItem: null
property bool notificationHistoryVisible: false
property var activeNotification: null
property bool showNotificationPopup: false
property bool mediaPlayerVisible: false
property MprisPlayer activePlayer: MprisController.activePlayer
property bool hasActiveMedia: MprisController.isPlaying && (activePlayer?.trackTitle || activePlayer?.trackArtist)
property bool useFahrenheit: true
property var weather: WeatherService.weather
property string osLogo: OSDetectionService.osLogo
property string osName: OSDetectionService.osName
property var notificationHistory: notificationHistoryModel
property var appLauncher: appLauncherPopup
property var clipboardHistoryPopup: clipboardHistoryPopupInstance
property var colorPickerProcess: colorPickerProcessInstance
property var weatherIcons: ({
"113": "clear_day",
"116": "partly_cloudy_day",
"119": "cloud",
"122": "cloud",
"143": "foggy",
"176": "rainy",
"179": "rainy",
"182": "rainy",
"185": "rainy",
"200": "thunderstorm",
"227": "cloudy_snowing",
"230": "snowing_heavy",
"248": "foggy",
"260": "foggy",
"263": "rainy",
"266": "rainy",
"281": "rainy",
"284": "rainy",
"293": "rainy",
"296": "rainy",
"299": "rainy",
"302": "weather_hail",
"305": "rainy",
"308": "weather_hail",
"311": "rainy",
"314": "rainy",
"317": "rainy",
"320": "cloudy_snowing",
"323": "cloudy_snowing",
"326": "cloudy_snowing",
"329": "snowing_heavy",
"332": "snowing_heavy",
"335": "snowing",
"338": "snowing_heavy",
"350": "rainy",
"353": "rainy",
"356": "rainy",
"359": "weather_hail",
"362": "rainy",
"365": "rainy",
"368": "cloudy_snowing",
"371": "snowing",
"374": "rainy",
"377": "rainy",
"386": "thunderstorm",
"389": "thunderstorm",
"392": "thunderstorm",
"395": "snowing"
})
QtObject {
id: theme
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
}
TopBarSimple {
id: topBar
theme: root.theme
root: root
}
AppLauncher {
id: appLauncherPopup
theme: root.theme
}
ClipboardHistory {
id: clipboardHistoryPopupInstance
theme: root.theme
}
Process {
id: colorPickerProcessInstance
command: ["hyprpicker", "-a"]
running: false
onExited: (exitCode) => {
if (exitCode !== 0) {
console.warn("Color picker failed. Make sure hyprpicker is installed: yay -S hyprpicker")
}
}
}
NotificationServer {
id: notificationServer
actionsSupported: true
bodyMarkupSupported: true
imageSupported: true
keepOnReload: false
persistenceSupported: true
onNotification: (notification) => {
if (!notification || !notification.id) return
if (!notification.appName && !notification.summary && !notification.body) {
return
}
console.log("New notification from:", notification.appName || "Unknown", "Summary:", notification.summary || "No summary")
var notifObj = {
"id": notification.id,
"appName": notification.appName || "App",
"summary": notification.summary || "",
"body": notification.body || "",
"timestamp": new Date(),
"appIcon": notification.appIcon || notification.icon || "",
"icon": notification.icon || "",
"image": notification.image || ""
}
notificationHistoryModel.insert(0, notifObj)
while (notificationHistoryModel.count > 50) {
notificationHistoryModel.remove(notificationHistoryModel.count - 1)
}
root.activeNotification = notifObj
root.showNotificationPopup = true
notificationTimer.restart()
}
}
ListModel {
id: notificationHistoryModel
}
Timer {
id: notificationTimer
interval: 5000
repeat: false
onTriggered: hideNotificationPopup()
}
Timer {
id: clearNotificationTimer
interval: theme.mediumDuration + 50
repeat: false
onTriggered: root.activeNotification = null
}
function showNotificationPopup(notification) {
root.activeNotification = notification
root.showNotificationPopup = true
notificationTimer.restart()
}
function hideNotificationPopup() {
root.showNotificationPopup = false
notificationTimer.stop()
clearNotificationTimer.restart()
}
Timer {
running: root.activePlayer?.playbackState === MprisPlaybackState.Playing
interval: 1000
repeat: true
onTriggered: {
if (root.activePlayer) {
root.activePlayer.positionChanged()
}
}
}
Component.onCompleted: {
console.log("DankMaterialDark shell loaded successfully!")
}
}

File diff suppressed because it is too large Load Diff

106
shell.qml
View File

@@ -215,18 +215,28 @@ ShellRoot {
"395": "snowing"
})
// Top bar
PanelWindow {
id: topBar
// Top bar - one instance per screen
Variants {
model: Quickshell.screens
anchors {
top: true
left: true
right: true
}
PanelWindow {
id: topBar
implicitHeight: theme.barHeight
color: "transparent"
// modelData contains the screen from Quickshell.screens
property var modelData
screen: modelData
// Get the screen name (e.g., "DP-1", "DP-2")
property string screenName: modelData.name
anchors {
top: true
left: true
right: true
}
implicitHeight: theme.barHeight
color: "transparent"
Rectangle {
anchors.fill: parent
@@ -341,11 +351,10 @@ ShellRoot {
command: ["niri", "msg", "workspaces"]
running: true
stdout: SplitParser {
splitMarker: "\n"
onRead: (data) => {
if (data.trim()) {
workspaceSwitcher.parseWorkspaceOutput(data.trim())
stdout: StdioCollector {
onStreamFinished: {
if (text && text.trim()) {
workspaceSwitcher.parseWorkspaceOutput(text.trim())
}
}
}
@@ -358,6 +367,7 @@ ShellRoot {
let focusedWorkspace = 1
let outputWorkspaces = {}
for (const line of lines) {
if (line.startsWith('Output "')) {
const outputMatch = line.match(/Output "(.+)"/)
@@ -386,12 +396,37 @@ ShellRoot {
}
}
currentWorkspace = focusedWorkspace
// Show workspaces for THIS screen only
if (topBar.screenName && outputWorkspaces[topBar.screenName]) {
workspaceList = outputWorkspaces[topBar.screenName]
if (focusedOutput && outputWorkspaces[focusedOutput]) {
workspaceList = outputWorkspaces[focusedOutput]
// 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
}
}
@@ -444,12 +479,11 @@ ShellRoot {
cursorShape: Qt.PointingHandCursor
onClicked: {
switchProcess.command = ["niri", "msg", "action", "focus-workspace", modelData.toString()]
switchProcess.running = true
workspaceSwitcher.currentWorkspace = modelData
Qt.callLater(() => {
workspaceQuery.running = true
})
// 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
}
}
}
@@ -459,7 +493,30 @@ ShellRoot {
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
}
}
@@ -954,6 +1011,7 @@ ShellRoot {
}
}
}
} // End of Variants for topBar
PanelWindow {
id: calendarPopup