From adbe4541cb7dfffed0ac2112a7259b25f88014a8 Mon Sep 17 00:00:00 2001
From: Aleksandr Lebedev ) and Redo (C-c
`);
+ return `\x00CODEBLOCK${blockIndex++}\x00`;
+ });
+
+ // Extract and replace inline code
+ html = html.replace(/`([^`]+)`/g, (match, code) => {
+ // Escape HTML entities in code
+ const escapedCode = code.replace(/&/g, '&')
+ .replace(//g, '>');
+ inlineCode.push(`${escapedCode}${escapedCode}`);
+ return `\x00INLINECODE${inlineIndex++}\x00`;
+ });
+
+ // Now process everything else
+ // Escape HTML entities (but not in code blocks)
+ html = html.replace(/&/g, '&')
+ .replace(//g, '>');
+
+ // Headers
+ html = html.replace(/^### (.*?)$/gm, '$1
');
+ html = html.replace(/^## (.*?)$/gm, '$1
');
+ html = html.replace(/^# (.*?)$/gm, '$1
');
+
+ // Bold and italic (order matters!)
+ html = html.replace(/\*\*\*(.*?)\*\*\*/g, '$1');
+ html = html.replace(/\*\*(.*?)\*\*/g, '$1');
+ html = html.replace(/\*(.*?)\*/g, '$1');
+ html = html.replace(/___(.*?)___/g, '$1');
+ html = html.replace(/__(.*?)__/g, '$1');
+ html = html.replace(/_(.*?)_/g, '$1');
+
+ // Links
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1');
+
+ // Lists
+ html = html.replace(/^\* (.*?)$/gm, '' + match + '
';
+ });
+
+ // Detect plain URLs and wrap them in anchor tags (but not inside existing or markdown links)
+ html = html.replace(/(^|[^"'>])((https?|file):\/\/[^\s<]+)/g, '$1$2');
+
+ // Restore code blocks and inline code BEFORE line break processing
+ html = html.replace(/\x00CODEBLOCK(\d+)\x00/g, (match, index) => {
+ return codeBlocks[parseInt(index)];
+ });
+
+ html = html.replace(/\x00INLINECODE(\d+)\x00/g, (match, index) => {
+ return inlineCode[parseInt(index)];
+ });
+
+ // Line breaks (after code blocks are restored)
+ html = html.replace(/\n\n/g, '
');
+ html = html.replace(/\n/g, '
');
+
+ // Wrap in paragraph tags if not already wrapped
+ if (!html.startsWith('<')) {
+ html = '
' + html + '
'; + } + + // Clean up the final HTML + // Remove/g, ''); + html = html.replace(/
\s*
\s*<\/p>/g, ''); + html = html.replace(/
\s*
\s*<\/p>/g, '');
+
+ // Remove excessive line breaks
+ html = html.replace(/(
){3,}/g, '
'); // Max 2 consecutive line breaks
+ html = html.replace(/(<\/p>)\s*(
)/g, '$1$2'); // Remove whitespace between paragraphs
+
+ // Remove leading/trailing whitespace
+ html = html.trim();
+
+ return html;
+}
\ No newline at end of file
diff --git a/quickshell/.config/quickshell/Modals/Clipboard/ClipboardConstants.qml b/quickshell/.config/quickshell/Modals/Clipboard/ClipboardConstants.qml
new file mode 100644
index 0000000..8850b95
--- /dev/null
+++ b/quickshell/.config/quickshell/Modals/Clipboard/ClipboardConstants.qml
@@ -0,0 +1,19 @@
+pragma Singleton
+
+import QtQuick
+import Quickshell
+
+Singleton {
+ id: root
+ readonly property int previewLength: 100
+ readonly property int longTextThreshold: 200
+ readonly property int modalWidth: 650
+ readonly property int modalHeight: 550
+ readonly property int itemHeight: 72
+ readonly property int thumbnailSize: 48
+ readonly property int retryInterval: 50
+ readonly property int viewportBuffer: 100
+ readonly property int extendedBuffer: 200
+ readonly property int keyboardHintsHeight: 80
+ readonly property int headerHeight: 40
+}
diff --git a/quickshell/.config/quickshell/Modals/Clipboard/ClipboardContent.qml b/quickshell/.config/quickshell/Modals/Clipboard/ClipboardContent.qml
new file mode 100644
index 0000000..8c5d196
--- /dev/null
+++ b/quickshell/.config/quickshell/Modals/Clipboard/ClipboardContent.qml
@@ -0,0 +1,169 @@
+import QtQuick
+import qs.Common
+import qs.Services
+import qs.Widgets
+import qs.Modals.Clipboard
+
+Item {
+ id: clipboardContent
+
+ required property var modal
+ required property var filteredModel
+ required property var clearConfirmDialog
+
+ property alias searchField: searchField
+ property alias clipboardListView: clipboardListView
+
+ anchors.fill: parent
+
+ Column {
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingL
+ focus: false
+
+ // Header
+ ClipboardHeader {
+ id: header
+ width: parent.width
+ totalCount: modal.totalCount
+ showKeyboardHints: modal.showKeyboardHints
+ onKeyboardHintsToggled: modal.showKeyboardHints = !modal.showKeyboardHints
+ onClearAllClicked: {
+ clearConfirmDialog.show("Clear All History?", "This will permanently delete all clipboard history.", function () {
+ modal.clearAll()
+ modal.hide()
+ }, function () {})
+ }
+ onCloseClicked: modal.hide()
+ }
+
+ // Search Field
+ DankTextField {
+ id: searchField
+ width: parent.width
+ placeholderText: ""
+ leftIconName: "search"
+ showClearButton: true
+ focus: true
+ ignoreLeftRightKeys: true
+ keyForwardTargets: [modal.modalFocusScope]
+ onTextChanged: {
+ modal.searchText = text
+ modal.updateFilteredModel()
+ }
+ Keys.onEscapePressed: function (event) {
+ modal.hide()
+ event.accepted = true
+ }
+ Component.onCompleted: {
+ Qt.callLater(function () {
+ forceActiveFocus()
+ })
+ }
+
+ Connections {
+ target: modal
+ function onOpened() {
+ Qt.callLater(function () {
+ searchField.forceActiveFocus()
+ })
+ }
+ }
+ }
+
+ // List Container
+ Rectangle {
+ width: parent.width
+ height: parent.height - ClipboardConstants.headerHeight - 70
+ radius: Theme.cornerRadius
+ color: Theme.surfaceLight
+ border.color: Theme.outlineLight
+ border.width: 1
+ clip: true
+
+ DankListView {
+ id: clipboardListView
+ anchors.fill: parent
+ anchors.margins: Theme.spacingS
+ model: filteredModel
+
+ currentIndex: clipboardContent.modal ? clipboardContent.modal.selectedIndex : 0
+ spacing: Theme.spacingXS
+ interactive: true
+ flickDeceleration: 1500
+ maximumFlickVelocity: 2000
+ boundsBehavior: Flickable.DragAndOvershootBounds
+ boundsMovement: Flickable.FollowBoundsBehavior
+ pressDelay: 0
+ flickableDirection: Flickable.VerticalFlick
+
+ function ensureVisible(index) {
+ if (index < 0 || index >= count) {
+ return
+ }
+ const itemHeight = ClipboardConstants.itemHeight + spacing
+ const itemY = index * itemHeight
+ const itemBottom = itemY + itemHeight
+ if (itemY < contentY) {
+ contentY = itemY
+ } else if (itemBottom > contentY + height) {
+ contentY = itemBottom - height
+ }
+ }
+
+ onCurrentIndexChanged: {
+ if (clipboardContent.modal && clipboardContent.modal.keyboardNavigationActive && currentIndex >= 0) {
+ ensureVisible(currentIndex)
+ }
+ }
+
+ StyledText {
+ text: "No clipboard entries found"
+ anchors.centerIn: parent
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceVariantText
+ visible: filteredModel.count === 0
+ }
+
+ delegate: ClipboardEntry {
+ required property int index
+ required property var model
+
+ width: clipboardListView.width
+ height: ClipboardConstants.itemHeight
+ entryData: model.entry
+ entryIndex: index + 1
+ itemIndex: index
+ isSelected: clipboardContent.modal && clipboardContent.modal.keyboardNavigationActive && index === clipboardContent.modal.selectedIndex
+ modal: clipboardContent.modal
+ listView: clipboardListView
+ onCopyRequested: clipboardContent.modal.copyEntry(model.entry)
+ onDeleteRequested: clipboardContent.modal.deleteEntry(model.entry)
+ }
+ }
+ }
+
+ // Spacer for keyboard hints
+ Item {
+ width: parent.width
+ height: modal.showKeyboardHints ? ClipboardConstants.keyboardHintsHeight + Theme.spacingL : 0
+
+ Behavior on height {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+ }
+ }
+
+ // Keyboard Hints Overlay
+ ClipboardKeyboardHints {
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.margins: Theme.spacingL
+ visible: modal.showKeyboardHints
+ }
+}
diff --git a/quickshell/.config/quickshell/Modals/Clipboard/ClipboardEntry.qml b/quickshell/.config/quickshell/Modals/Clipboard/ClipboardEntry.qml
new file mode 100644
index 0000000..0ce5c80
--- /dev/null
+++ b/quickshell/.config/quickshell/Modals/Clipboard/ClipboardEntry.qml
@@ -0,0 +1,137 @@
+import QtQuick
+import QtQuick.Effects
+import Quickshell.Io
+import qs.Common
+import qs.Widgets
+import qs.Modals.Clipboard
+
+Rectangle {
+ id: entry
+
+ required property string entryData
+ required property int entryIndex
+ required property int itemIndex
+ required property bool isSelected
+ required property var modal
+ required property var listView
+
+ signal copyRequested
+ signal deleteRequested
+
+ readonly property string entryType: modal ? modal.getEntryType(entryData) : "text"
+ readonly property string entryPreview: modal ? modal.getEntryPreview(entryData) : entryData
+
+ radius: Theme.cornerRadius
+ color: {
+ if (isSelected) {
+ return Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.2)
+ }
+ return mouseArea.containsMouse ? Theme.primaryHover : Theme.primaryBackground
+ }
+ border.color: {
+ if (isSelected) {
+ return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.5)
+ }
+ return Theme.outlineStrong
+ }
+ border.width: isSelected ? 1.5 : 1
+
+ Row {
+ anchors.fill: parent
+ anchors.margins: Theme.spacingM
+ anchors.rightMargin: Theme.spacingS
+ spacing: Theme.spacingL
+
+ // Index indicator
+ Rectangle {
+ width: 24
+ height: 24
+ radius: 12
+ color: Theme.primarySelected
+ anchors.verticalCenter: parent.verticalCenter
+
+ StyledText {
+ anchors.centerIn: parent
+ text: entryIndex.toString()
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Bold
+ color: Theme.primary
+ }
+ }
+
+ // Content area
+ Row {
+ anchors.verticalCenter: parent.verticalCenter
+ width: parent.width - 68
+ spacing: Theme.spacingM
+
+ // Thumbnail/Icon
+ ClipboardThumbnail {
+ width: entryType === "image" ? ClipboardConstants.thumbnailSize : Theme.iconSize
+ height: entryType === "image" ? ClipboardConstants.thumbnailSize : Theme.iconSize
+ anchors.verticalCenter: parent.verticalCenter
+ entryData: entry.entryData
+ entryType: entry.entryType
+ modal: entry.modal
+ listView: entry.listView
+ itemIndex: entry.itemIndex
+ }
+
+ // Text content
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ width: parent.width - (entryType === "image" ? ClipboardConstants.thumbnailSize : Theme.iconSize) - Theme.spacingM
+ spacing: Theme.spacingXS
+
+ StyledText {
+ text: {
+ switch (entryType) {
+ case "image":
+ return "Image • " + entryPreview
+ case "long_text":
+ return "Long Text"
+ default:
+ return "Text"
+ }
+ }
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.primary
+ font.weight: Font.Medium
+ width: parent.width
+ elide: Text.ElideRight
+ }
+
+ StyledText {
+ text: entryPreview
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ width: parent.width
+ wrapMode: Text.WordWrap
+ maximumLineCount: entryType === "long_text" ? 3 : 1
+ elide: Text.ElideRight
+ }
+ }
+ }
+ }
+
+ // Delete button
+ DankActionButton {
+ anchors.right: parent.right
+ anchors.rightMargin: Theme.spacingM
+ anchors.verticalCenter: parent.verticalCenter
+ iconName: "close"
+ iconSize: Theme.iconSize - 6
+ iconColor: Theme.surfaceText
+ onClicked: deleteRequested()
+ }
+
+ // Click area
+ MouseArea {
+ id: mouseArea
+ anchors.fill: parent
+ anchors.rightMargin: 40
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: copyRequested()
+ }
+}
diff --git a/quickshell/.config/quickshell/Modals/Clipboard/ClipboardHeader.qml b/quickshell/.config/quickshell/Modals/Clipboard/ClipboardHeader.qml
new file mode 100644
index 0000000..9c238c7
--- /dev/null
+++ b/quickshell/.config/quickshell/Modals/Clipboard/ClipboardHeader.qml
@@ -0,0 +1,65 @@
+import QtQuick
+import qs.Common
+import qs.Widgets
+import qs.Modals.Clipboard
+
+Item {
+ id: header
+
+ property int totalCount: 0
+ property bool showKeyboardHints: false
+
+ signal keyboardHintsToggled
+ signal clearAllClicked
+ signal closeClicked
+
+ height: ClipboardConstants.headerHeight
+
+ Row {
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "content_paste"
+ size: Theme.iconSize
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: `Clipboard History (${totalCount})`
+ font.pixelSize: Theme.fontSizeLarge
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ Row {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingS
+
+ DankActionButton {
+ iconName: "info"
+ iconSize: Theme.iconSize - 4
+ iconColor: showKeyboardHints ? Theme.primary : Theme.surfaceText
+ onClicked: keyboardHintsToggled()
+ }
+
+ DankActionButton {
+ iconName: "delete_sweep"
+ iconSize: Theme.iconSize
+ iconColor: Theme.surfaceText
+ onClicked: clearAllClicked()
+ }
+
+ DankActionButton {
+ iconName: "close"
+ iconSize: Theme.iconSize - 4
+ iconColor: Theme.surfaceText
+ onClicked: closeClicked()
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modals/Clipboard/ClipboardHistoryModal.qml b/quickshell/.config/quickshell/Modals/Clipboard/ClipboardHistoryModal.qml
new file mode 100644
index 0000000..d6477ea
--- /dev/null
+++ b/quickshell/.config/quickshell/Modals/Clipboard/ClipboardHistoryModal.qml
@@ -0,0 +1,216 @@
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import QtQuick.Controls
+import Quickshell
+import Quickshell.Io
+import qs.Common
+import qs.Modals.Common
+import qs.Services
+import qs.Widgets
+
+DankModal {
+ id: clipboardHistoryModal
+
+ property int totalCount: 0
+ property var clipboardEntries: []
+ property string searchText: ""
+ property int selectedIndex: 0
+ property bool keyboardNavigationActive: false
+ property bool showKeyboardHints: false
+ property Component clipboardContent
+ property int activeImageLoads: 0
+ readonly property int maxConcurrentLoads: 3
+
+ function updateFilteredModel() {
+ filteredClipboardModel.clear()
+ for (var i = 0; i < clipboardModel.count; i++) {
+ const entry = clipboardModel.get(i).entry
+ if (searchText.trim().length === 0) {
+ filteredClipboardModel.append({
+ "entry": entry
+ })
+ } else {
+ const content = getEntryPreview(entry).toLowerCase()
+ if (content.includes(searchText.toLowerCase())) {
+ filteredClipboardModel.append({
+ "entry": entry
+ })
+ }
+ }
+ }
+ clipboardHistoryModal.totalCount = filteredClipboardModel.count
+ if (filteredClipboardModel.count === 0) {
+ keyboardNavigationActive = false
+ selectedIndex = 0
+ } else if (selectedIndex >= filteredClipboardModel.count) {
+ selectedIndex = filteredClipboardModel.count - 1
+ }
+ }
+
+ function toggle() {
+ if (shouldBeVisible) {
+ hide()
+ } else {
+ show()
+ }
+ }
+
+ function show() {
+ open()
+ clipboardHistoryModal.searchText = ""
+ clipboardHistoryModal.activeImageLoads = 0
+ refreshClipboard()
+ keyboardController.reset()
+
+ Qt.callLater(function () {
+ if (contentLoader.item && contentLoader.item.searchField) {
+ contentLoader.item.searchField.text = ""
+ contentLoader.item.searchField.forceActiveFocus()
+ }
+ })
+ }
+
+ function hide() {
+ close()
+ clipboardHistoryModal.searchText = ""
+ clipboardHistoryModal.activeImageLoads = 0
+ updateFilteredModel()
+ keyboardController.reset()
+ cleanupTempFiles()
+ }
+
+ function cleanupTempFiles() {
+ Quickshell.execDetached(["sh", "-c", "rm -f /tmp/clipboard_*.png"])
+ }
+
+ function refreshClipboard() {
+ clipboardProcesses.refresh()
+ }
+
+ function copyEntry(entry) {
+ const entryId = entry.split('\t')[0]
+ Quickshell.execDetached(["sh", "-c", `cliphist decode ${entryId} | wl-copy`])
+ ToastService.showInfo("Copied to clipboard")
+ hide()
+ }
+
+ function deleteEntry(entry) {
+ clipboardProcesses.deleteEntry(entry)
+ }
+
+ function clearAll() {
+ clipboardProcesses.clearAll()
+ }
+
+ function getEntryPreview(entry) {
+ let content = entry.replace(/^\s*\d+\s+/, "")
+ if (content.includes("image/") || content.includes("binary data") || /\.(png|jpg|jpeg|gif|bmp|webp)/i.test(content)) {
+ const dimensionMatch = content.match(/(\d+)x(\d+)/)
+ if (dimensionMatch) {
+ return `Image ${dimensionMatch[1]}×${dimensionMatch[2]}`
+ }
+ const typeMatch = content.match(/\b(png|jpg|jpeg|gif|bmp|webp)\b/i)
+ if (typeMatch) {
+ return `Image (${typeMatch[1].toUpperCase()})`
+ }
+ return "Image"
+ }
+ if (content.length > ClipboardConstants.previewLength) {
+ return content.substring(0, ClipboardConstants.previewLength) + "..."
+ }
+ return content
+ }
+
+ function getEntryType(entry) {
+ if (entry.includes("image/") || entry.includes("binary data") || /\.(png|jpg|jpeg|gif|bmp|webp)/i.test(entry) || /\b(png|jpg|jpeg|gif|bmp|webp)\b/i.test(entry)) {
+ return "image"
+ }
+ if (entry.length > ClipboardConstants.longTextThreshold) {
+ return "long_text"
+ }
+ return "text"
+ }
+
+ visible: false
+ width: ClipboardConstants.modalWidth
+ height: ClipboardConstants.modalHeight
+ backgroundColor: Theme.popupBackground()
+ cornerRadius: Theme.cornerRadius
+ borderColor: Theme.outlineMedium
+ borderWidth: 1
+ enableShadow: true
+ onBackgroundClicked: hide()
+ modalFocusScope.Keys.onPressed: function (event) {
+ keyboardController.handleKey(event)
+ }
+ content: clipboardContent
+
+ ClipboardKeyboardController {
+ id: keyboardController
+ modal: clipboardHistoryModal
+ }
+
+ ConfirmModal {
+ id: clearConfirmDialog
+ confirmButtonText: "Clear All"
+ confirmButtonColor: Theme.primary
+ onVisibleChanged: {
+ if (visible) {
+ clipboardHistoryModal.shouldHaveFocus = false
+ } else if (clipboardHistoryModal.shouldBeVisible) {
+ clipboardHistoryModal.shouldHaveFocus = true
+ clipboardHistoryModal.modalFocusScope.forceActiveFocus()
+ if (clipboardHistoryModal.contentLoader.item && clipboardHistoryModal.contentLoader.item.searchField) {
+ clipboardHistoryModal.contentLoader.item.searchField.forceActiveFocus()
+ }
+ }
+ }
+ }
+
+ property alias filteredClipboardModel: filteredClipboardModel
+ property alias clipboardModel: clipboardModel
+ property var confirmDialog: clearConfirmDialog
+
+ ListModel {
+ id: clipboardModel
+ }
+
+ ListModel {
+ id: filteredClipboardModel
+ }
+
+ ClipboardProcesses {
+ id: clipboardProcesses
+ modal: clipboardHistoryModal
+ clipboardModel: clipboardModel
+ filteredClipboardModel: filteredClipboardModel
+ }
+
+ IpcHandler {
+ function open(): string {
+ clipboardHistoryModal.show()
+ return "CLIPBOARD_OPEN_SUCCESS"
+ }
+
+ function close(): string {
+ clipboardHistoryModal.hide()
+ return "CLIPBOARD_CLOSE_SUCCESS"
+ }
+
+ function toggle(): string {
+ clipboardHistoryModal.toggle()
+ return "CLIPBOARD_TOGGLE_SUCCESS"
+ }
+
+ target: "clipboard"
+ }
+
+ clipboardContent: Component {
+ ClipboardContent {
+ modal: clipboardHistoryModal
+ filteredModel: filteredClipboardModel
+ clearConfirmDialog: clipboardHistoryModal.confirmDialog
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modals/Clipboard/ClipboardKeyboardController.qml b/quickshell/.config/quickshell/Modals/Clipboard/ClipboardKeyboardController.qml
new file mode 100644
index 0000000..10db17c
--- /dev/null
+++ b/quickshell/.config/quickshell/Modals/Clipboard/ClipboardKeyboardController.qml
@@ -0,0 +1,95 @@
+import QtQuick
+import qs.Common
+
+QtObject {
+ id: keyboardController
+
+ required property var modal
+
+ function reset() {
+ modal.selectedIndex = 0
+ modal.keyboardNavigationActive = false
+ modal.showKeyboardHints = false
+ }
+
+ function selectNext() {
+ if (!modal.filteredClipboardModel || modal.filteredClipboardModel.count === 0) {
+ return
+ }
+ modal.keyboardNavigationActive = true
+ modal.selectedIndex = Math.min(modal.selectedIndex + 1, modal.filteredClipboardModel.count - 1)
+ }
+
+ function selectPrevious() {
+ if (!modal.filteredClipboardModel || modal.filteredClipboardModel.count === 0) {
+ return
+ }
+ modal.keyboardNavigationActive = true
+ modal.selectedIndex = Math.max(modal.selectedIndex - 1, 0)
+ }
+
+ function copySelected() {
+ if (!modal.filteredClipboardModel || modal.filteredClipboardModel.count === 0 || modal.selectedIndex < 0 || modal.selectedIndex >= modal.filteredClipboardModel.count) {
+ return
+ }
+ const selectedEntry = modal.filteredClipboardModel.get(modal.selectedIndex).entry
+ modal.copyEntry(selectedEntry)
+ }
+
+ function deleteSelected() {
+ if (!modal.filteredClipboardModel || modal.filteredClipboardModel.count === 0 || modal.selectedIndex < 0 || modal.selectedIndex >= modal.filteredClipboardModel.count) {
+ return
+ }
+ const selectedEntry = modal.filteredClipboardModel.get(modal.selectedIndex).entry
+ modal.deleteEntry(selectedEntry)
+ }
+
+ function handleKey(event) {
+ if (event.key === Qt.Key_Escape) {
+ if (modal.keyboardNavigationActive) {
+ modal.keyboardNavigationActive = false
+ event.accepted = true
+ } else {
+ modal.hide()
+ event.accepted = true
+ }
+ } else if (event.key === Qt.Key_Down) {
+ if (!modal.keyboardNavigationActive) {
+ modal.keyboardNavigationActive = true
+ modal.selectedIndex = 0
+ event.accepted = true
+ } else {
+ selectNext()
+ event.accepted = true
+ }
+ } else if (event.key === Qt.Key_Up) {
+ if (!modal.keyboardNavigationActive) {
+ modal.keyboardNavigationActive = true
+ modal.selectedIndex = 0
+ event.accepted = true
+ } else if (modal.selectedIndex === 0) {
+ modal.keyboardNavigationActive = false
+ event.accepted = true
+ } else {
+ selectPrevious()
+ event.accepted = true
+ }
+ } else if (event.key === Qt.Key_Delete && (event.modifiers & Qt.ShiftModifier)) {
+ modal.clearAll()
+ modal.hide()
+ event.accepted = true
+ } else if (modal.keyboardNavigationActive) {
+ if ((event.key === Qt.Key_C && (event.modifiers & Qt.ControlModifier)) || event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
+ copySelected()
+ event.accepted = true
+ } else if (event.key === Qt.Key_Delete) {
+ deleteSelected()
+ event.accepted = true
+ }
+ }
+ if (event.key === Qt.Key_F10) {
+ modal.showKeyboardHints = !modal.showKeyboardHints
+ event.accepted = true
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modals/Clipboard/ClipboardKeyboardHints.qml b/quickshell/.config/quickshell/Modals/Clipboard/ClipboardKeyboardHints.qml
new file mode 100644
index 0000000..a7aeb8a
--- /dev/null
+++ b/quickshell/.config/quickshell/Modals/Clipboard/ClipboardKeyboardHints.qml
@@ -0,0 +1,42 @@
+import QtQuick
+import qs.Common
+import qs.Widgets
+import qs.Modals.Clipboard
+
+Rectangle {
+ id: keyboardHints
+
+ height: ClipboardConstants.keyboardHintsHeight
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.95)
+ border.color: Theme.primary
+ border.width: 2
+ opacity: visible ? 1 : 0
+ z: 100
+
+ Column {
+ anchors.centerIn: parent
+ spacing: 2
+
+ StyledText {
+ text: "↑/↓: Navigate • Enter/Ctrl+C: Copy • Del: Delete • F10: Help"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+
+ StyledText {
+ text: "Shift+Del: Clear All • Esc: Close"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+ }
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modals/Clipboard/ClipboardProcesses.qml b/quickshell/.config/quickshell/Modals/Clipboard/ClipboardProcesses.qml
new file mode 100644
index 0000000..bb3c24d
--- /dev/null
+++ b/quickshell/.config/quickshell/Modals/Clipboard/ClipboardProcesses.qml
@@ -0,0 +1,94 @@
+import QtQuick
+import Quickshell.Io
+
+QtObject {
+ id: clipboardProcesses
+
+ required property var modal
+ required property var clipboardModel
+ required property var filteredClipboardModel
+
+ // Load clipboard entries
+ property var loadProcess: Process {
+ id: loadProcess
+ command: ["cliphist", "list"]
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ clipboardModel.clear()
+ const lines = text.trim().split('\n')
+ for (const line of lines) {
+ if (line.trim().length > 0) {
+ clipboardModel.append({
+ "entry": line
+ })
+ }
+ }
+ modal.updateFilteredModel()
+ }
+ }
+ }
+
+ // Delete single entry
+ property var deleteProcess: Process {
+ id: deleteProcess
+ property string deletedEntry: ""
+ running: false
+
+ onExited: exitCode => {
+ if (exitCode === 0) {
+ for (var i = 0; i < clipboardModel.count; i++) {
+ if (clipboardModel.get(i).entry === deleteProcess.deletedEntry) {
+ clipboardModel.remove(i)
+ break
+ }
+ }
+ for (var j = 0; j < filteredClipboardModel.count; j++) {
+ if (filteredClipboardModel.get(j).entry === deleteProcess.deletedEntry) {
+ filteredClipboardModel.remove(j)
+ break
+ }
+ }
+ modal.totalCount = filteredClipboardModel.count
+ if (filteredClipboardModel.count === 0) {
+ modal.keyboardNavigationActive = false
+ modal.selectedIndex = 0
+ } else if (modal.selectedIndex >= filteredClipboardModel.count) {
+ modal.selectedIndex = filteredClipboardModel.count - 1
+ }
+ } else {
+ console.warn("Failed to delete clipboard entry")
+ }
+ }
+ }
+
+ // Clear all entries
+ property var clearProcess: Process {
+ id: clearProcess
+ command: ["cliphist", "wipe"]
+ running: false
+
+ onExited: exitCode => {
+ if (exitCode === 0) {
+ clipboardModel.clear()
+ filteredClipboardModel.clear()
+ modal.totalCount = 0
+ }
+ }
+ }
+
+ function refresh() {
+ loadProcess.running = true
+ }
+
+ function deleteEntry(entry) {
+ deleteProcess.deletedEntry = entry
+ deleteProcess.command = ["sh", "-c", `echo '${entry.replace(/'/g, "'\\''")}' | cliphist delete`]
+ deleteProcess.running = true
+ }
+
+ function clearAll() {
+ clearProcess.running = true
+ }
+}
diff --git a/quickshell/.config/quickshell/Modals/Clipboard/ClipboardThumbnail.qml b/quickshell/.config/quickshell/Modals/Clipboard/ClipboardThumbnail.qml
new file mode 100644
index 0000000..bbed879
--- /dev/null
+++ b/quickshell/.config/quickshell/Modals/Clipboard/ClipboardThumbnail.qml
@@ -0,0 +1,174 @@
+import QtQuick
+import QtQuick.Effects
+import Quickshell.Io
+import qs.Common
+import qs.Widgets
+import qs.Modals.Clipboard
+
+Item {
+ id: thumbnail
+
+ required property string entryData
+ required property string entryType
+ required property var modal
+ required property var listView
+ required property int itemIndex
+
+ Image {
+ id: thumbnailImage
+
+ property string entryId: entryData.split('\t')[0]
+ property bool isVisible: false
+ property string cachedImageData: ""
+ property bool loadQueued: false
+
+ anchors.fill: parent
+ source: ""
+ fillMode: Image.PreserveAspectCrop
+ smooth: true
+ cache: false
+ visible: false
+ asynchronous: true
+ sourceSize.width: 128
+ sourceSize.height: 128
+
+ onCachedImageDataChanged: {
+ if (cachedImageData) {
+ source = ""
+ source = `data:image/png;base64,${cachedImageData}`
+ }
+ }
+
+ function tryLoadImage() {
+ if (!loadQueued && entryType === "image" && !cachedImageData) {
+ loadQueued = true
+ if (modal.activeImageLoads < modal.maxConcurrentLoads) {
+ modal.activeImageLoads++
+ imageLoader.running = true
+ } else {
+ retryTimer.restart()
+ }
+ }
+ }
+
+ Timer {
+ id: retryTimer
+ interval: ClipboardConstants.retryInterval
+ onTriggered: {
+ if (thumbnailImage.loadQueued && !imageLoader.running) {
+ if (modal.activeImageLoads < modal.maxConcurrentLoads) {
+ modal.activeImageLoads++
+ imageLoader.running = true
+ } else {
+ retryTimer.restart()
+ }
+ }
+ }
+ }
+
+ Component.onCompleted: {
+ if (entryType !== "image") {
+ return
+ }
+
+ // Check if item is visible on screen initially
+ const itemY = itemIndex * (ClipboardConstants.itemHeight + listView.spacing)
+ const viewTop = listView.contentY
+ const viewBottom = viewTop + listView.height
+ isVisible = (itemY + ClipboardConstants.itemHeight >= viewTop && itemY <= viewBottom)
+
+ if (isVisible) {
+ tryLoadImage()
+ }
+ }
+
+ Connections {
+ target: listView
+ function onContentYChanged() {
+ if (entryType !== "image") {
+ return
+ }
+
+ const itemY = itemIndex * (ClipboardConstants.itemHeight + listView.spacing)
+ const viewTop = listView.contentY - ClipboardConstants.viewportBuffer
+ const viewBottom = viewTop + listView.height + ClipboardConstants.extendedBuffer
+ const nowVisible = (itemY + ClipboardConstants.itemHeight >= viewTop && itemY <= viewBottom)
+
+ if (nowVisible && !thumbnailImage.isVisible) {
+ thumbnailImage.isVisible = true
+ thumbnailImage.tryLoadImage()
+ }
+ }
+ }
+
+ Process {
+ id: imageLoader
+ running: false
+ command: ["sh", "-c", `cliphist decode ${thumbnailImage.entryId} | base64 -w 0`]
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const imageData = text.trim()
+ if (imageData && imageData.length > 0) {
+ thumbnailImage.cachedImageData = imageData
+ }
+ }
+ }
+
+ onExited: exitCode => {
+ thumbnailImage.loadQueued = false
+ if (modal.activeImageLoads > 0) {
+ modal.activeImageLoads--
+ }
+ if (exitCode !== 0) {
+ console.warn("Failed to load clipboard image:", thumbnailImage.entryId)
+ }
+ }
+ }
+ }
+
+ // Rounded mask effect for images
+ MultiEffect {
+ anchors.fill: parent
+ anchors.margins: 2
+ source: thumbnailImage
+ maskEnabled: true
+ maskSource: clipboardCircularMask
+ visible: entryType === "image" && thumbnailImage.status === Image.Ready && thumbnailImage.source != ""
+ maskThresholdMin: 0.5
+ maskSpreadAtMin: 1
+ }
+
+ Item {
+ id: clipboardCircularMask
+ width: ClipboardConstants.thumbnailSize - 4
+ height: ClipboardConstants.thumbnailSize - 4
+ layer.enabled: true
+ layer.smooth: true
+ visible: false
+
+ Rectangle {
+ anchors.fill: parent
+ radius: width / 2
+ color: "black"
+ antialiasing: true
+ }
+ }
+
+ // Fallback icon
+ DankIcon {
+ visible: !(entryType === "image" && thumbnailImage.status === Image.Ready && thumbnailImage.source != "")
+ name: {
+ if (entryType === "image") {
+ return "image"
+ }
+ if (entryType === "long_text") {
+ return "subject"
+ }
+ return "content_copy"
+ }
+ size: Theme.iconSize
+ color: Theme.primary
+ anchors.centerIn: parent
+ }
+}
diff --git a/quickshell/.config/quickshell/Modals/Common/ConfirmModal.qml b/quickshell/.config/quickshell/Modals/Common/ConfirmModal.qml
new file mode 100644
index 0000000..9e35bee
--- /dev/null
+++ b/quickshell/.config/quickshell/Modals/Common/ConfirmModal.qml
@@ -0,0 +1,227 @@
+import QtQuick
+import qs.Common
+import qs.Modals.Common
+import qs.Widgets
+
+DankModal {
+ id: root
+
+ property string confirmTitle: ""
+ property string confirmMessage: ""
+ property string confirmButtonText: "Confirm"
+ property string cancelButtonText: "Cancel"
+ property color confirmButtonColor: Theme.primary
+ property var onConfirm: function () {}
+ property var onCancel: function () {}
+ property int selectedButton: -1
+ property bool keyboardNavigation: false
+
+ function show(title, message, onConfirmCallback, onCancelCallback) {
+ confirmTitle = title || ""
+ confirmMessage = message || ""
+ confirmButtonText = "Confirm"
+ cancelButtonText = "Cancel"
+ confirmButtonColor = Theme.primary
+ onConfirm = onConfirmCallback || (() => {})
+ onCancel = onCancelCallback || (() => {})
+ selectedButton = -1
+ keyboardNavigation = false
+ open()
+ }
+
+ function showWithOptions(options) {
+ confirmTitle = options.title || ""
+ confirmMessage = options.message || ""
+ confirmButtonText = options.confirmText || "Confirm"
+ cancelButtonText = options.cancelText || "Cancel"
+ confirmButtonColor = options.confirmColor || Theme.primary
+ onConfirm = options.onConfirm || (() => {})
+ onCancel = options.onCancel || (() => {})
+ selectedButton = -1
+ keyboardNavigation = false
+ open()
+ }
+
+ function selectButton() {
+ close()
+ if (selectedButton === 0) {
+ if (onCancel) {
+ onCancel()
+ }
+ } else {
+ if (onConfirm) {
+ onConfirm()
+ }
+ }
+ }
+
+ shouldBeVisible: false
+ allowStacking: true
+ width: 350
+ height: 160
+ enableShadow: true
+ shouldHaveFocus: true
+ onBackgroundClicked: {
+ close()
+ if (onCancel) {
+ onCancel()
+ }
+ }
+ onOpened: {
+ modalFocusScope.forceActiveFocus()
+ modalFocusScope.focus = true
+ shouldHaveFocus = true
+ }
+ modalFocusScope.Keys.onPressed: function (event) {
+ switch (event.key) {
+ case Qt.Key_Escape:
+ close()
+ if (onCancel) {
+ onCancel()
+ }
+ event.accepted = true
+ break
+ case Qt.Key_Left:
+ case Qt.Key_Up:
+ keyboardNavigation = true
+ selectedButton = 0
+ event.accepted = true
+ break
+ case Qt.Key_Right:
+ case Qt.Key_Down:
+ keyboardNavigation = true
+ selectedButton = 1
+ event.accepted = true
+ break
+ case Qt.Key_Tab:
+ keyboardNavigation = true
+ selectedButton = selectedButton === -1 ? 0 : (selectedButton + 1) % 2
+ event.accepted = true
+ break
+ case Qt.Key_Return:
+ case Qt.Key_Enter:
+ if (selectedButton !== -1) {
+ selectButton()
+ } else {
+ selectedButton = 1
+ selectButton()
+ }
+ event.accepted = true
+ break
+ }
+ }
+
+ content: Component {
+ Item {
+ anchors.fill: parent
+
+ Column {
+ anchors.centerIn: parent
+ width: parent.width - Theme.spacingM * 2
+ spacing: Theme.spacingM
+
+ StyledText {
+ text: confirmTitle
+ font.pixelSize: Theme.fontSizeLarge
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ width: parent.width
+ horizontalAlignment: Text.AlignHCenter
+ }
+
+ StyledText {
+ text: confirmMessage
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ width: parent.width
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.WordWrap
+ }
+
+ Item {
+ height: Theme.spacingS
+ }
+
+ Row {
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: Theme.spacingM
+
+ Rectangle {
+ width: 120
+ height: 40
+ radius: Theme.cornerRadius
+ color: {
+ if (keyboardNavigation && selectedButton === 0) {
+ return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12)
+ } else if (cancelButton.containsMouse) {
+ return Theme.surfacePressed
+ } else {
+ return Theme.surfaceVariantAlpha
+ }
+ }
+ border.color: (keyboardNavigation && selectedButton === 0) ? Theme.primary : "transparent"
+ border.width: (keyboardNavigation && selectedButton === 0) ? 1 : 0
+
+ StyledText {
+ text: cancelButtonText
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.centerIn: parent
+ }
+
+ MouseArea {
+ id: cancelButton
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ selectedButton = 0
+ selectButton()
+ }
+ }
+ }
+
+ Rectangle {
+ width: 120
+ height: 40
+ radius: Theme.cornerRadius
+ color: {
+ const baseColor = confirmButtonColor
+ if (keyboardNavigation && selectedButton === 1) {
+ return Qt.rgba(baseColor.r, baseColor.g, baseColor.b, 1)
+ } else if (confirmButton.containsMouse) {
+ return Qt.rgba(baseColor.r, baseColor.g, baseColor.b, 0.9)
+ } else {
+ return baseColor
+ }
+ }
+ border.color: (keyboardNavigation && selectedButton === 1) ? "white" : "transparent"
+ border.width: (keyboardNavigation && selectedButton === 1) ? 1 : 0
+
+ StyledText {
+ text: confirmButtonText
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.primaryText
+ font.weight: Font.Medium
+ anchors.centerIn: parent
+ }
+
+ MouseArea {
+ id: confirmButton
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ selectedButton = 1
+ selectButton()
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modals/Common/DankModal.qml b/quickshell/.config/quickshell/Modals/Common/DankModal.qml
new file mode 100644
index 0000000..025f7e3
--- /dev/null
+++ b/quickshell/.config/quickshell/Modals/Common/DankModal.qml
@@ -0,0 +1,234 @@
+import QtQuick
+import QtQuick.Effects
+import Quickshell
+import Quickshell.Wayland
+import qs.Common
+
+PanelWindow {
+ id: root
+
+ WlrLayershell.namespace: "quickshell:modal"
+
+ property alias content: contentLoader.sourceComponent
+ property alias contentLoader: contentLoader
+ property real width: 400
+ property real height: 300
+ readonly property real screenWidth: screen ? screen.width : 1920
+ readonly property real screenHeight: screen ? screen.height : 1080
+ property bool showBackground: true
+ property real backgroundOpacity: 0.5
+ property string positioning: "center"
+ property point customPosition: Qt.point(0, 0)
+ property bool closeOnEscapeKey: true
+ property bool closeOnBackgroundClick: true
+ property string animationType: "scale"
+ property int animationDuration: Theme.shorterDuration
+ property var animationEasing: Theme.emphasizedEasing
+ property color backgroundColor: Theme.surfaceContainer
+ property color borderColor: Theme.outlineMedium
+ property real borderWidth: 1
+ property real cornerRadius: Theme.cornerRadius
+ property bool enableShadow: false
+ property alias modalFocusScope: focusScope
+ property bool shouldBeVisible: false
+ property bool shouldHaveFocus: shouldBeVisible
+ property bool allowFocusOverride: false
+ property bool allowStacking: false
+
+ signal opened
+ signal dialogClosed
+ signal backgroundClicked
+
+ function open() {
+ ModalManager.openModal(root)
+ closeTimer.stop()
+ shouldBeVisible = true
+ visible = true
+ focusScope.forceActiveFocus()
+ }
+
+ function close() {
+ shouldBeVisible = false
+ closeTimer.restart()
+ }
+
+ function toggle() {
+ if (shouldBeVisible) {
+ close()
+ } else {
+ open()
+ }
+ }
+
+ visible: shouldBeVisible
+ color: "transparent"
+ WlrLayershell.layer: WlrLayershell.Top // if set to overlay -> virtual keyboards can be stuck under modal
+ WlrLayershell.exclusiveZone: -1
+ WlrLayershell.keyboardFocus: shouldHaveFocus ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
+ onVisibleChanged: {
+ if (root.visible) {
+ opened()
+ } else {
+ if (Qt.inputMethod) {
+ Qt.inputMethod.hide()
+ Qt.inputMethod.reset()
+ }
+ dialogClosed()
+ }
+ }
+
+ Connections {
+ function onCloseAllModalsExcept(excludedModal) {
+ if (excludedModal !== root && !allowStacking && shouldBeVisible) {
+ close()
+ }
+ }
+
+ target: ModalManager
+ }
+
+ Timer {
+ id: closeTimer
+
+ interval: animationDuration + 50
+ onTriggered: {
+ visible = false
+ }
+ }
+
+ anchors {
+ top: true
+ left: true
+ right: true
+ bottom: true
+ }
+
+ Rectangle {
+ id: background
+
+ anchors.fill: parent
+ color: "black"
+ opacity: root.showBackground ? (root.shouldBeVisible ? root.backgroundOpacity : 0) : 0
+ visible: root.showBackground
+
+ MouseArea {
+ anchors.fill: parent
+ enabled: root.closeOnBackgroundClick
+ onClicked: mouse => {
+ const localPos = mapToItem(contentContainer, mouse.x, mouse.y)
+ if (localPos.x < 0 || localPos.x > contentContainer.width || localPos.y < 0 || localPos.y > contentContainer.height) {
+ root.backgroundClicked()
+ }
+ }
+ }
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: root.animationDuration
+ easing.type: root.animationEasing
+ }
+ }
+ }
+
+ Rectangle {
+ id: contentContainer
+
+ width: root.width
+ height: root.height
+ anchors.centerIn: positioning === "center" ? parent : undefined
+ x: {
+ if (positioning === "top-right") {
+ return Math.max(Theme.spacingL, root.screenWidth - width - Theme.spacingL)
+ } else if (positioning === "custom") {
+ return root.customPosition.x
+ }
+ return 0
+ }
+ y: {
+ if (positioning === "top-right") {
+ return Theme.barHeight + Theme.spacingXS
+ } else if (positioning === "custom") {
+ return root.customPosition.y
+ }
+ return 0
+ }
+ color: root.backgroundColor
+ radius: root.cornerRadius
+ border.color: root.borderColor
+ border.width: root.borderWidth
+ layer.enabled: root.enableShadow
+ opacity: root.shouldBeVisible ? 1 : 0
+ scale: root.animationType === "scale" ? (root.shouldBeVisible ? 1 : 0.9) : 1
+ transform: root.animationType === "slide" ? slideTransform : null
+
+ Translate {
+ id: slideTransform
+
+ x: root.shouldBeVisible ? 0 : 15
+ y: root.shouldBeVisible ? 0 : -30
+ }
+
+ Loader {
+ id: contentLoader
+
+ anchors.fill: parent
+ active: root.visible
+ asynchronous: false
+ }
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: root.animationDuration
+ easing.type: root.animationEasing
+ }
+ }
+
+ Behavior on scale {
+ enabled: root.animationType === "scale"
+
+ NumberAnimation {
+ duration: root.animationDuration
+ easing.type: root.animationEasing
+ }
+ }
+
+ layer.effect: MultiEffect {
+ shadowEnabled: true
+ shadowHorizontalOffset: 0
+ shadowVerticalOffset: 8
+ shadowBlur: 1
+ shadowColor: Theme.shadowStrong
+ shadowOpacity: 0.3
+ }
+ }
+
+ FocusScope {
+ id: focusScope
+
+ objectName: "modalFocusScope"
+ anchors.fill: parent
+ visible: root.visible // Only active when the modal is visible
+ focus: root.visible
+ Keys.onEscapePressed: event => {
+ if (root.closeOnEscapeKey && shouldHaveFocus) {
+ root.close()
+ event.accepted = true
+ }
+ }
+ onVisibleChanged: {
+ if (visible && shouldHaveFocus) {
+ Qt.callLater(() => focusScope.forceActiveFocus())
+ }
+ }
+
+ Connections {
+ function onShouldHaveFocusChanged() {
+ if (shouldHaveFocus && visible) {
+ Qt.callLater(() => focusScope.forceActiveFocus())
+ }
+ }
+
+ target: root
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modals/FileBrowser/FileBrowserModal.qml b/quickshell/.config/quickshell/Modals/FileBrowser/FileBrowserModal.qml
new file mode 100644
index 0000000..c18a543
--- /dev/null
+++ b/quickshell/.config/quickshell/Modals/FileBrowser/FileBrowserModal.qml
@@ -0,0 +1,818 @@
+import Qt.labs.folderlistmodel
+import QtCore
+import QtQuick
+import QtQuick.Controls
+import Quickshell.Io
+import qs.Common
+import qs.Modals.Common
+import qs.Widgets
+
+DankModal {
+ id: fileBrowserModal
+
+ property string homeDir: StandardPaths.writableLocation(StandardPaths.HomeLocation)
+ property string currentPath: ""
+ property var fileExtensions: ["*.*"]
+ property alias filterExtensions: fileBrowserModal.fileExtensions
+ property string browserTitle: "Select File"
+ property string browserIcon: "folder_open"
+ property string browserType: "generic" // "wallpaper" or "profile" for last path memory
+ property bool showHiddenFiles: false
+ property int selectedIndex: -1
+ property bool keyboardNavigationActive: false
+ property bool backButtonFocused: false
+ property bool saveMode: false // Enable save functionality
+ property string defaultFileName: "" // Default filename for save mode
+ property int keyboardSelectionIndex: -1
+ property bool keyboardSelectionRequested: false
+ property bool showKeyboardHints: false
+ property bool showFileInfo: false
+ property string selectedFilePath: ""
+ property string selectedFileName: ""
+ property bool selectedFileIsDir: false
+ property bool showOverwriteConfirmation: false
+ property string pendingFilePath: ""
+
+ signal fileSelected(string path)
+
+ function isImageFile(fileName) {
+ if (!fileName) {
+ return false
+ }
+ const ext = fileName.toLowerCase().split('.').pop()
+ return ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(ext)
+ }
+
+ function getLastPath() {
+ const lastPath = browserType === "wallpaper" ? SessionData.wallpaperLastPath : browserType === "profile" ? SessionData.profileLastPath : ""
+ return (lastPath && lastPath !== "") ? lastPath : homeDir
+ }
+
+ function saveLastPath(path) {
+ if (browserType === "wallpaper") {
+ SessionData.setWallpaperLastPath(path)
+ } else if (browserType === "profile") {
+ SessionData.setProfileLastPath(path)
+ }
+ }
+
+ function setSelectedFileData(path, name, isDir) {
+ selectedFilePath = path
+ selectedFileName = name
+ selectedFileIsDir = isDir
+ }
+
+ function navigateUp() {
+ const path = currentPath
+ if (path === homeDir)
+ return
+
+ const lastSlash = path.lastIndexOf('/')
+ if (lastSlash > 0) {
+ const newPath = path.substring(0, lastSlash)
+ if (newPath.length < homeDir.length) {
+ currentPath = homeDir
+ saveLastPath(homeDir)
+ } else {
+ currentPath = newPath
+ saveLastPath(newPath)
+ }
+ }
+ }
+
+ function navigateTo(path) {
+ currentPath = path
+ saveLastPath(path)
+ selectedIndex = -1
+ backButtonFocused = false
+ }
+
+ function keyboardFileSelection(index) {
+ if (index >= 0) {
+ keyboardSelectionTimer.targetIndex = index
+ keyboardSelectionTimer.start()
+ }
+ }
+
+ function executeKeyboardSelection(index) {
+ keyboardSelectionIndex = index
+ keyboardSelectionRequested = true
+ }
+
+ function handleSaveFile(filePath) {
+ // Ensure the filePath has the correct file:// protocol format
+ var normalizedPath = filePath
+ if (!normalizedPath.startsWith("file://")) {
+ normalizedPath = "file://" + filePath
+ }
+
+ // Check if file exists by looking through the folder model
+ var exists = false
+ var fileName = filePath.split('/').pop()
+
+ for (var i = 0; i < folderModel.count; i++) {
+ if (folderModel.get(i, "fileName") === fileName && !folderModel.get(i, "fileIsDir")) {
+ exists = true
+ break
+ }
+ }
+
+ if (exists) {
+ pendingFilePath = normalizedPath
+ showOverwriteConfirmation = true
+ } else {
+ fileSelected(normalizedPath)
+ fileBrowserModal.close()
+ }
+ }
+
+ objectName: "fileBrowserModal"
+ allowStacking: true
+ Component.onCompleted: {
+ currentPath = getLastPath()
+ }
+ width: 800
+ height: 600
+ enableShadow: true
+ visible: false
+ onBackgroundClicked: close()
+ onOpened: {
+ modalFocusScope.forceActiveFocus()
+ }
+ modalFocusScope.Keys.onPressed: function (event) {
+ keyboardController.handleKey(event)
+ }
+ onVisibleChanged: {
+ if (visible) {
+ currentPath = getLastPath()
+ selectedIndex = -1
+ keyboardNavigationActive = false
+ backButtonFocused = false
+ }
+ }
+ onCurrentPathChanged: {
+ selectedFilePath = ""
+ selectedFileName = ""
+ selectedFileIsDir = false
+ }
+ onSelectedIndexChanged: {
+ if (selectedIndex >= 0 && folderModel && selectedIndex < folderModel.count) {
+ selectedFilePath = ""
+ selectedFileName = ""
+ selectedFileIsDir = false
+ }
+ }
+
+ FolderListModel {
+ id: folderModel
+
+ showDirsFirst: true
+ showDotAndDotDot: false
+ showHidden: fileBrowserModal.showHiddenFiles
+ nameFilters: fileExtensions
+ showFiles: true
+ showDirs: true
+ folder: currentPath ? "file://" + currentPath : "file://" + homeDir
+ }
+
+ QtObject {
+ id: keyboardController
+
+ property int totalItems: folderModel.count
+ property int gridColumns: 5
+
+ function handleKey(event) {
+ if (event.key === Qt.Key_Escape) {
+ close()
+ event.accepted = true
+ return
+ }
+ // F10 toggles keyboard hints
+ if (event.key === Qt.Key_F10) {
+ showKeyboardHints = !showKeyboardHints
+ event.accepted = true
+ return
+ }
+ // F1 or I key for file information
+ if (event.key === Qt.Key_F1 || event.key === Qt.Key_I) {
+ showFileInfo = !showFileInfo
+ event.accepted = true
+ return
+ }
+ // Alt+Left or Backspace to go back
+ if ((event.modifiers & Qt.AltModifier && event.key === Qt.Key_Left) || event.key === Qt.Key_Backspace) {
+ if (currentPath !== homeDir) {
+ navigateUp()
+ event.accepted = true
+ }
+ return
+ }
+ if (!keyboardNavigationActive) {
+ if (event.key === Qt.Key_Tab || event.key === Qt.Key_Down || event.key === Qt.Key_Right) {
+ keyboardNavigationActive = true
+ if (currentPath !== homeDir) {
+ backButtonFocused = true
+ selectedIndex = -1
+ } else {
+ backButtonFocused = false
+ selectedIndex = 0
+ }
+ event.accepted = true
+ }
+ return
+ }
+ switch (event.key) {
+ case Qt.Key_Tab:
+ if (backButtonFocused) {
+ backButtonFocused = false
+ selectedIndex = 0
+ } else if (selectedIndex < totalItems - 1) {
+ selectedIndex++
+ } else if (currentPath !== homeDir) {
+ backButtonFocused = true
+ selectedIndex = -1
+ } else {
+ selectedIndex = 0
+ }
+ event.accepted = true
+ break
+ case Qt.Key_Backtab:
+ if (backButtonFocused) {
+ backButtonFocused = false
+ selectedIndex = totalItems - 1
+ } else if (selectedIndex > 0) {
+ selectedIndex--
+ } else if (currentPath !== homeDir) {
+ backButtonFocused = true
+ selectedIndex = -1
+ } else {
+ selectedIndex = totalItems - 1
+ }
+ event.accepted = true
+ break
+ case Qt.Key_Left:
+ if (backButtonFocused)
+ return
+
+ if (selectedIndex > 0) {
+ selectedIndex--
+ } else if (currentPath !== homeDir) {
+ backButtonFocused = true
+ selectedIndex = -1
+ }
+ event.accepted = true
+ break
+ case Qt.Key_Right:
+ if (backButtonFocused) {
+ backButtonFocused = false
+ selectedIndex = 0
+ } else if (selectedIndex < totalItems - 1) {
+ selectedIndex++
+ }
+ event.accepted = true
+ break
+ case Qt.Key_Up:
+ if (backButtonFocused) {
+ backButtonFocused = false
+ // Go to first row, appropriate column
+ var col = selectedIndex % gridColumns
+ selectedIndex = Math.min(col, totalItems - 1)
+ } else if (selectedIndex >= gridColumns) {
+ // Move up one row
+ selectedIndex -= gridColumns
+ } else if (currentPath !== homeDir) {
+ // At top row, go to back button
+ backButtonFocused = true
+ selectedIndex = -1
+ }
+ event.accepted = true
+ break
+ case Qt.Key_Down:
+ if (backButtonFocused) {
+ backButtonFocused = false
+ selectedIndex = 0
+ } else {
+ // Move down one row if possible
+ var newIndex = selectedIndex + gridColumns
+ if (newIndex < totalItems) {
+ selectedIndex = newIndex
+ } else {
+ // If can't go down a full row, go to last item in the column if exists
+ var lastRowStart = Math.floor((totalItems - 1) / gridColumns) * gridColumns
+ var col = selectedIndex % gridColumns
+ var targetIndex = lastRowStart + col
+ if (targetIndex < totalItems && targetIndex > selectedIndex) {
+ selectedIndex = targetIndex
+ }
+ }
+ }
+ event.accepted = true
+ break
+ case Qt.Key_Return:
+ case Qt.Key_Enter:
+ case Qt.Key_Space:
+ if (backButtonFocused)
+ navigateUp()
+ else if (selectedIndex >= 0 && selectedIndex < totalItems)
+ // Trigger selection by setting the grid's current index and using signal
+ fileBrowserModal.keyboardFileSelection(selectedIndex)
+ event.accepted = true
+ break
+ }
+ }
+ }
+
+ Timer {
+ id: keyboardSelectionTimer
+
+ property int targetIndex: -1
+
+ interval: 1
+ onTriggered: {
+ // Access the currently selected item through model role names
+ // This will work because QML models expose role data
+ executeKeyboardSelection(targetIndex)
+ }
+ }
+
+ content: Component {
+ Item {
+ anchors.fill: parent
+
+ Column {
+ anchors.fill: parent
+ anchors.margins: Theme.spacingM
+ spacing: Theme.spacingS
+
+ Item {
+ width: parent.width
+ height: 40
+
+ Row {
+ spacing: Theme.spacingM
+ anchors.verticalCenter: parent.verticalCenter
+
+ DankIcon {
+ name: browserIcon
+ size: Theme.iconSizeLarge
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: browserTitle
+ font.pixelSize: Theme.fontSizeXLarge
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ Row {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingS
+
+ DankActionButton {
+ circular: false
+ iconName: "info"
+ iconSize: Theme.iconSize - 4
+ iconColor: Theme.surfaceText
+ onClicked: fileBrowserModal.showKeyboardHints = !fileBrowserModal.showKeyboardHints
+ }
+
+ DankActionButton {
+ circular: false
+ iconName: "close"
+ iconSize: Theme.iconSize - 4
+ iconColor: Theme.surfaceText
+ onClicked: fileBrowserModal.close()
+ }
+ }
+ }
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingS
+
+ StyledRect {
+ width: 32
+ height: 32
+ radius: Theme.cornerRadius
+ color: (backButtonMouseArea.containsMouse || (backButtonFocused && keyboardNavigationActive)) && currentPath !== homeDir ? Theme.surfaceVariant : "transparent"
+ opacity: currentPath !== homeDir ? 1 : 0
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "arrow_back"
+ size: Theme.iconSizeSmall
+ color: Theme.surfaceText
+ }
+
+ MouseArea {
+ id: backButtonMouseArea
+
+ anchors.fill: parent
+ hoverEnabled: currentPath !== homeDir
+ cursorShape: currentPath !== homeDir ? Qt.PointingHandCursor : Qt.ArrowCursor
+ enabled: currentPath !== homeDir
+ onClicked: navigateUp()
+ }
+ }
+
+ StyledText {
+ text: fileBrowserModal.currentPath.replace("file://", "")
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ width: parent.width - 40 - Theme.spacingS
+ elide: Text.ElideMiddle
+ anchors.verticalCenter: parent.verticalCenter
+ maximumLineCount: 1
+ wrapMode: Text.NoWrap
+ }
+ }
+
+ DankGridView {
+ id: fileGrid
+
+ width: parent.width
+ height: parent.height - 80
+ clip: true
+ cellWidth: 150
+ cellHeight: 130
+ cacheBuffer: 260
+ model: folderModel
+ currentIndex: selectedIndex
+ onCurrentIndexChanged: {
+ if (keyboardNavigationActive && currentIndex >= 0)
+ positionViewAtIndex(currentIndex, GridView.Contain)
+ }
+
+ ScrollBar.vertical: ScrollBar {
+ policy: ScrollBar.AsNeeded
+ }
+
+ ScrollBar.horizontal: ScrollBar {
+ policy: ScrollBar.AlwaysOff
+ }
+
+ delegate: StyledRect {
+ id: delegateRoot
+
+ required property bool fileIsDir
+ required property string filePath
+ required property string fileName
+ required property int index
+
+ width: 140
+ height: 120
+ radius: Theme.cornerRadius
+ color: {
+ if (keyboardNavigationActive && delegateRoot.index === selectedIndex)
+ return Theme.surfacePressed
+
+ return mouseArea.containsMouse ? Theme.surfaceVariant : "transparent"
+ }
+ border.color: keyboardNavigationActive && delegateRoot.index === selectedIndex ? Theme.primary : Theme.outline
+ border.width: (mouseArea.containsMouse || (keyboardNavigationActive && delegateRoot.index === selectedIndex)) ? 1 : 0
+ // Update file info when this item gets selected via keyboard or initially
+ Component.onCompleted: {
+ if (keyboardNavigationActive && delegateRoot.index === selectedIndex)
+ setSelectedFileData(delegateRoot.filePath, delegateRoot.fileName, delegateRoot.fileIsDir)
+ }
+
+ // Watch for selectedIndex changes to update file info during keyboard navigation
+ Connections {
+ function onSelectedIndexChanged() {
+ if (keyboardNavigationActive && selectedIndex === delegateRoot.index)
+ setSelectedFileData(delegateRoot.filePath, delegateRoot.fileName, delegateRoot.fileIsDir)
+ }
+
+ target: fileBrowserModal
+ }
+
+ Column {
+ anchors.centerIn: parent
+ spacing: Theme.spacingXS
+
+ Item {
+ width: 80
+ height: 60
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ CachingImage {
+ anchors.fill: parent
+ source: (!delegateRoot.fileIsDir && isImageFile(delegateRoot.fileName)) ? ("file://" + delegateRoot.filePath) : ""
+ fillMode: Image.PreserveAspectCrop
+ visible: !delegateRoot.fileIsDir && isImageFile(delegateRoot.fileName)
+ maxCacheSize: 80
+ }
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "description"
+ size: Theme.iconSizeLarge
+ color: Theme.primary
+ visible: !delegateRoot.fileIsDir && !isImageFile(delegateRoot.fileName)
+ }
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "folder"
+ size: Theme.iconSizeLarge
+ color: Theme.primary
+ visible: delegateRoot.fileIsDir
+ }
+ }
+
+ StyledText {
+ text: delegateRoot.fileName || ""
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ width: 120
+ elide: Text.ElideMiddle
+ horizontalAlignment: Text.AlignHCenter
+ anchors.horizontalCenter: parent.horizontalCenter
+ maximumLineCount: 2
+ wrapMode: Text.WordWrap
+ }
+ }
+
+ MouseArea {
+ id: mouseArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ // Update selected file info and index first
+ selectedIndex = delegateRoot.index
+ setSelectedFileData(delegateRoot.filePath, delegateRoot.fileName, delegateRoot.fileIsDir)
+ if (delegateRoot.fileIsDir) {
+ navigateTo(delegateRoot.filePath)
+ } else {
+ fileSelected(delegateRoot.filePath)
+ fileBrowserModal.close() // Close modal after file selection
+ }
+ }
+ }
+
+ // Handle keyboard selection
+ Connections {
+ function onKeyboardSelectionRequestedChanged() {
+ if (fileBrowserModal.keyboardSelectionRequested && fileBrowserModal.keyboardSelectionIndex === delegateRoot.index) {
+ fileBrowserModal.keyboardSelectionRequested = false
+ selectedIndex = delegateRoot.index
+ setSelectedFileData(delegateRoot.filePath, delegateRoot.fileName, delegateRoot.fileIsDir)
+ if (delegateRoot.fileIsDir) {
+ navigateTo(delegateRoot.filePath)
+ } else {
+ fileSelected(delegateRoot.filePath)
+ fileBrowserModal.close()
+ }
+ }
+ }
+
+ target: fileBrowserModal
+ }
+ }
+ }
+ }
+
+ Row {
+ id: saveRow
+
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.margins: Theme.spacingL
+ height: saveMode ? 40 : 0
+ visible: saveMode
+ spacing: Theme.spacingM
+
+ DankTextField {
+ id: fileNameInput
+
+ width: parent.width - saveButton.width - Theme.spacingM
+ height: 40
+ text: defaultFileName
+ placeholderText: "Enter filename..."
+ ignoreLeftRightKeys: false
+ focus: saveMode
+ topPadding: Theme.spacingS
+ bottomPadding: Theme.spacingS
+ Component.onCompleted: {
+ if (saveMode)
+ Qt.callLater(() => {
+ forceActiveFocus()
+ })
+ }
+ onAccepted: {
+ if (text.trim() !== "") {
+ // Remove file:// protocol from currentPath if present for proper construction
+ var basePath = currentPath.replace(/^file:\/\//, '')
+ var fullPath = basePath + "/" + text.trim()
+ // Ensure consistent path format - remove any double slashes and normalize
+ fullPath = fullPath.replace(/\/+/g, '/')
+ handleSaveFile(fullPath)
+ }
+ }
+ }
+
+ StyledRect {
+ id: saveButton
+
+ width: 80
+ height: 40
+ color: fileNameInput.text.trim() !== "" ? Theme.primary : Theme.surfaceVariant
+ radius: Theme.cornerRadius
+
+ StyledText {
+ anchors.centerIn: parent
+ text: "Save"
+ color: fileNameInput.text.trim() !== "" ? Theme.primaryText : Theme.surfaceVariantText
+ font.pixelSize: Theme.fontSizeMedium
+ }
+
+ StateLayer {
+ stateColor: Theme.primary
+ cornerRadius: Theme.cornerRadius
+ enabled: fileNameInput.text.trim() !== ""
+ onClicked: {
+ if (fileNameInput.text.trim() !== "") {
+ // Remove file:// protocol from currentPath if present for proper construction
+ var basePath = currentPath.replace(/^file:\/\//, '')
+ var fullPath = basePath + "/" + fileNameInput.text.trim()
+ // Ensure consistent path format - remove any double slashes and normalize
+ fullPath = fullPath.replace(/\/+/g, '/')
+ handleSaveFile(fullPath)
+ }
+ }
+ }
+ }
+ }
+
+ KeyboardHints {
+ id: keyboardHints
+
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.margins: Theme.spacingL
+ showHints: fileBrowserModal.showKeyboardHints
+ }
+
+ FileInfo {
+ id: fileInfo
+
+ anchors.top: parent.top
+ anchors.right: parent.right
+ anchors.margins: Theme.spacingL
+ width: 300
+ showFileInfo: fileBrowserModal.showFileInfo
+ selectedIndex: fileBrowserModal.selectedIndex
+ sourceFolderModel: folderModel
+ currentPath: fileBrowserModal.currentPath
+ currentFileName: fileBrowserModal.selectedFileName
+ currentFileIsDir: fileBrowserModal.selectedFileIsDir
+ currentFileExtension: {
+ if (fileBrowserModal.selectedFileIsDir || !fileBrowserModal.selectedFileName)
+ return ""
+
+ var lastDot = fileBrowserModal.selectedFileName.lastIndexOf('.')
+ return lastDot > 0 ? fileBrowserModal.selectedFileName.substring(lastDot + 1).toLowerCase() : ""
+ }
+ }
+
+ // Overwrite confirmation dialog
+ Item {
+ id: overwriteDialog
+ anchors.fill: parent
+ visible: showOverwriteConfirmation
+
+ Keys.onEscapePressed: {
+ showOverwriteConfirmation = false
+ pendingFilePath = ""
+ }
+
+ Keys.onReturnPressed: {
+ showOverwriteConfirmation = false
+ fileSelected(pendingFilePath)
+ pendingFilePath = ""
+ Qt.callLater(() => fileBrowserModal.close())
+ }
+
+ focus: showOverwriteConfirmation
+
+ Rectangle {
+ anchors.fill: parent
+ color: Theme.shadowStrong
+ opacity: 0.8
+
+ MouseArea {
+ anchors.fill: parent
+ onClicked: {
+ showOverwriteConfirmation = false
+ pendingFilePath = ""
+ }
+ }
+ }
+
+ StyledRect {
+ anchors.centerIn: parent
+ width: 400
+ height: 160
+ color: Theme.surfaceContainer
+ radius: Theme.cornerRadius
+ border.color: Theme.outlineMedium
+ border.width: 1
+
+ Column {
+ anchors.centerIn: parent
+ width: parent.width - Theme.spacingL * 2
+ spacing: Theme.spacingM
+
+ StyledText {
+ text: qsTr("File Already Exists")
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+
+ StyledText {
+ text: qsTr("A file with this name already exists. Do you want to overwrite it?")
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceTextMedium
+ width: parent.width
+ wrapMode: Text.WordWrap
+ horizontalAlignment: Text.AlignHCenter
+ }
+
+ Row {
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: Theme.spacingM
+
+ StyledRect {
+ width: 80
+ height: 36
+ radius: Theme.cornerRadius
+ color: cancelArea.containsMouse ? Theme.surfaceVariantHover : Theme.surfaceVariant
+ border.color: Theme.outline
+ border.width: 1
+
+ StyledText {
+ anchors.centerIn: parent
+ text: qsTr("Cancel")
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ }
+
+ MouseArea {
+ id: cancelArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ showOverwriteConfirmation = false
+ pendingFilePath = ""
+ }
+ }
+ }
+
+ StyledRect {
+ width: 90
+ height: 36
+ radius: Theme.cornerRadius
+ color: overwriteArea.containsMouse ? Qt.darker(Theme.primary, 1.1) : Theme.primary
+
+ StyledText {
+ anchors.centerIn: parent
+ text: qsTr("Overwrite")
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.background
+ font.weight: Font.Medium
+ }
+
+ MouseArea {
+ id: overwriteArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ showOverwriteConfirmation = false
+ fileSelected(pendingFilePath)
+ pendingFilePath = ""
+ Qt.callLater(() => fileBrowserModal.close())
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modals/FileBrowser/FileInfo.qml b/quickshell/.config/quickshell/Modals/FileBrowser/FileInfo.qml
new file mode 100644
index 0000000..bcf7a6b
--- /dev/null
+++ b/quickshell/.config/quickshell/Modals/FileBrowser/FileInfo.qml
@@ -0,0 +1,237 @@
+import QtQuick
+import QtCore
+import Quickshell.Io
+import qs.Common
+import qs.Widgets
+
+Rectangle {
+ id: root
+
+ property bool showFileInfo: false
+ property int selectedIndex: -1
+ property var sourceFolderModel: null
+ property string currentPath: ""
+
+ height: 200
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.95)
+ border.color: Theme.secondary
+ border.width: 2
+ opacity: showFileInfo ? 1 : 0
+ z: 100
+
+ onShowFileInfoChanged: {
+ if (showFileInfo && currentFileName && currentPath) {
+ const fullPath = currentPath + "/" + currentFileName
+ fileStatProcess.selectedFilePath = fullPath
+ fileStatProcess.running = true
+ }
+ }
+
+ Process {
+ id: fileStatProcess
+ command: ["stat", "-c", "%y|%A|%s|%n", selectedFilePath]
+ property string selectedFilePath: ""
+ property var fileStats: null
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ if (text && text.trim()) {
+ const parts = text.trim().split('|')
+ if (parts.length >= 4) {
+ fileStatProcess.fileStats = {
+ "modifiedTime": parts[0],
+ "permissions": parts[1],
+ "size": parseInt(parts[2]) || 0,
+ "fullPath": parts[3]
+ }
+ }
+ }
+ }
+ }
+
+ onExited: function (exitCode) {}
+ }
+
+ property string currentFileName: ""
+ property bool currentFileIsDir: false
+ property string currentFileExtension: ""
+
+ onCurrentFileNameChanged: {
+ if (showFileInfo && currentFileName && currentPath) {
+ const fullPath = currentPath + "/" + currentFileName
+ if (fullPath !== fileStatProcess.selectedFilePath) {
+ fileStatProcess.selectedFilePath = fullPath
+ fileStatProcess.running = true
+ }
+ }
+ }
+
+ function updateFileInfo(filePath, fileName, isDirectory) {
+ if (filePath && filePath !== fileStatProcess.selectedFilePath) {
+ fileStatProcess.selectedFilePath = filePath
+ currentFileName = fileName || ""
+ currentFileIsDir = isDirectory || false
+
+ let ext = ""
+ if (!isDirectory && fileName) {
+ const lastDot = fileName.lastIndexOf('.')
+ if (lastDot > 0) {
+ ext = fileName.substring(lastDot + 1).toLowerCase()
+ }
+ }
+ currentFileExtension = ext
+
+ if (showFileInfo) {
+ fileStatProcess.running = true
+ }
+ }
+ }
+
+ readonly property var currentFileDisplayData: {
+ if (selectedIndex < 0 || !sourceFolderModel) {
+ return {
+ "exists": false,
+ "name": "No selection",
+ "type": "",
+ "size": "",
+ "modified": "",
+ "permissions": "",
+ "extension": "",
+ "position": "N/A"
+ }
+ }
+
+ const hasValidFile = currentFileName !== ""
+ return {
+ "exists": hasValidFile,
+ "name": hasValidFile ? currentFileName : "Loading...",
+ "type": currentFileIsDir ? "Directory" : "File",
+ "size": fileStatProcess.fileStats ? formatFileSize(fileStatProcess.fileStats.size) : "Calculating...",
+ "modified": fileStatProcess.fileStats ? formatDateTime(fileStatProcess.fileStats.modifiedTime) : "Loading...",
+ "permissions": fileStatProcess.fileStats ? fileStatProcess.fileStats.permissions : "Loading...",
+ "extension": currentFileExtension,
+ "position": sourceFolderModel ? ((selectedIndex + 1) + " of " + sourceFolderModel.count) : "N/A"
+ }
+ }
+
+ Column {
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.margins: Theme.spacingM
+ spacing: Theme.spacingXS
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingS
+
+ DankIcon {
+ name: "info"
+ size: Theme.iconSize
+ color: Theme.secondary
+ }
+
+ StyledText {
+ text: "File Information"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ Column {
+ width: parent.width
+ spacing: Theme.spacingXS
+
+ StyledText {
+ text: currentFileDisplayData.name
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ width: parent.width
+ elide: Text.ElideMiddle
+ wrapMode: Text.NoWrap
+ font.weight: Font.Medium
+ }
+
+ StyledText {
+ text: currentFileDisplayData.type + (currentFileDisplayData.extension ? " (." + currentFileDisplayData.extension + ")" : "")
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceTextMedium
+ width: parent.width
+ }
+
+ StyledText {
+ text: currentFileDisplayData.size
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceTextMedium
+ width: parent.width
+ visible: currentFileDisplayData.exists && !currentFileIsDir
+ }
+
+ StyledText {
+ text: currentFileDisplayData.modified
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceTextMedium
+ width: parent.width
+ elide: Text.ElideRight
+ visible: currentFileDisplayData.exists
+ }
+
+ StyledText {
+ text: currentFileDisplayData.permissions
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceTextMedium
+ visible: currentFileDisplayData.exists
+ }
+
+ StyledText {
+ text: currentFileDisplayData.position
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceTextMedium
+ width: parent.width
+ }
+ }
+ }
+
+ StyledText {
+ text: "F1/I: Toggle • F10: Help"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceTextMedium
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.margins: Theme.spacingM
+ horizontalAlignment: Text.AlignHCenter
+ }
+
+ function formatFileSize(bytes) {
+ if (bytes === 0 || !bytes) {
+ return "0 B"
+ }
+ const k = 1024
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
+ }
+
+ function formatDateTime(dateTimeString) {
+ if (!dateTimeString) {
+ return "Unknown"
+ }
+ const parts = dateTimeString.split(' ')
+ if (parts.length >= 2) {
+ return parts[0] + " " + parts[1].split('.')[0]
+ }
+ return dateTimeString
+ }
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modals/FileBrowser/KeyboardHints.qml b/quickshell/.config/quickshell/Modals/FileBrowser/KeyboardHints.qml
new file mode 100644
index 0000000..2a0a102
--- /dev/null
+++ b/quickshell/.config/quickshell/Modals/FileBrowser/KeyboardHints.qml
@@ -0,0 +1,50 @@
+import QtQuick
+import qs.Common
+import qs.Widgets
+
+Rectangle {
+ id: root
+
+ property bool showHints: false
+
+ height: 80
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.95)
+ border.color: Theme.primary
+ border.width: 2
+ opacity: showHints ? 1 : 0
+ z: 100
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.margins: Theme.spacingS
+ spacing: 2
+
+ StyledText {
+ text: "Tab/Shift+Tab: Nav • ←→↑↓: Grid Nav • Enter/Space: Select"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ width: parent.width
+ wrapMode: Text.WordWrap
+ horizontalAlignment: Text.AlignHCenter
+ }
+
+ StyledText {
+ text: "Alt+←/Backspace: Back • F1/I: File Info • F10: Help • Esc: Close"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ width: parent.width
+ wrapMode: Text.WordWrap
+ horizontalAlignment: Text.AlignHCenter
+ }
+ }
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modals/NetworkInfoModal.qml b/quickshell/.config/quickshell/Modals/NetworkInfoModal.qml
new file mode 100644
index 0000000..d780bb8
--- /dev/null
+++ b/quickshell/.config/quickshell/Modals/NetworkInfoModal.qml
@@ -0,0 +1,162 @@
+import QtQuick
+import QtQuick.Controls
+import qs.Common
+import qs.Modals.Common
+import qs.Services
+import qs.Widgets
+
+DankModal {
+ id: root
+
+ property bool networkInfoModalVisible: false
+ property string networkSSID: ""
+ property var networkData: null
+
+ function showNetworkInfo(ssid, data) {
+ networkSSID = ssid
+ networkData = data
+ networkInfoModalVisible = true
+ open()
+ NetworkService.fetchNetworkInfo(ssid)
+ }
+
+ function hideDialog() {
+ networkInfoModalVisible = false
+ close()
+ networkSSID = ""
+ networkData = null
+ }
+
+ visible: networkInfoModalVisible
+ width: 600
+ height: 500
+ enableShadow: true
+ onBackgroundClicked: hideDialog()
+ onVisibleChanged: {
+ if (!visible) {
+ networkSSID = ""
+ networkData = null
+ }
+ }
+
+ content: Component {
+ Item {
+ anchors.fill: parent
+
+ Column {
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingL
+
+ Row {
+ width: parent.width
+
+ Column {
+ width: parent.width - 40
+ spacing: Theme.spacingXS
+
+ StyledText {
+ text: "Network Information"
+ font.pixelSize: Theme.fontSizeLarge
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ }
+
+ StyledText {
+ text: `Details for "${networkSSID}"`
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceTextMedium
+ width: parent.width
+ elide: Text.ElideRight
+ }
+
+ }
+
+ DankActionButton {
+ iconName: "close"
+ iconSize: Theme.iconSize - 4
+ iconColor: Theme.surfaceText
+ onClicked: root.hideDialog()
+ }
+
+ }
+
+ Rectangle {
+ id: detailsRect
+
+ width: parent.width
+ height: parent.height - 140
+ radius: Theme.cornerRadius
+ color: Theme.surfaceHover
+ border.color: Theme.outlineStrong
+ border.width: 1
+ clip: true
+
+ DankFlickable {
+ anchors.fill: parent
+ anchors.margins: Theme.spacingM
+ contentHeight: detailsText.contentHeight
+
+ StyledText {
+ id: detailsText
+
+ width: parent.width
+ text: NetworkService.networkInfoDetails && NetworkService.networkInfoDetails.replace(/\\n/g, '\n') || "No information available"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ wrapMode: Text.WordWrap
+ }
+ }
+
+ }
+
+ Item {
+ width: parent.width
+ height: 40
+
+ Rectangle {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ width: Math.max(70, closeText.contentWidth + Theme.spacingM * 2)
+ height: 36
+ radius: Theme.cornerRadius
+ color: closeArea.containsMouse ? Qt.darker(Theme.primary, 1.1) : Theme.primary
+
+ StyledText {
+ id: closeText
+
+ anchors.centerIn: parent
+ text: "Close"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.background
+ font.weight: Font.Medium
+ }
+
+ MouseArea {
+ id: closeArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: root.hideDialog()
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modals/NotificationModal.qml b/quickshell/.config/quickshell/Modals/NotificationModal.qml
new file mode 100644
index 0000000..3867992
--- /dev/null
+++ b/quickshell/.config/quickshell/Modals/NotificationModal.qml
@@ -0,0 +1,146 @@
+import QtQuick
+import Quickshell.Io
+import qs.Common
+import qs.Modals.Common
+import qs.Modules.Notifications.Center
+import qs.Services
+import qs.Widgets
+
+DankModal {
+ id: notificationModal
+
+ property bool notificationModalOpen: false
+ property var notificationListRef: null
+
+ function show() {
+ notificationModalOpen = true
+ NotificationService.onOverlayOpen()
+ open()
+ modalKeyboardController.reset()
+ if (modalKeyboardController && notificationListRef) {
+ modalKeyboardController.listView = notificationListRef
+ modalKeyboardController.rebuildFlatNavigation()
+
+ Qt.callLater(() => {
+ modalKeyboardController.keyboardNavigationActive = true
+ modalKeyboardController.selectedFlatIndex = 0
+ modalKeyboardController.updateSelectedIdFromIndex()
+ if (notificationListRef) {
+ notificationListRef.keyboardActive = true
+ }
+ modalKeyboardController.selectionVersion++
+ modalKeyboardController.ensureVisible()
+ })
+ }
+ }
+
+ function hide() {
+ notificationModalOpen = false
+ NotificationService.onOverlayClose()
+ close()
+ modalKeyboardController.reset()
+ }
+
+ function toggle() {
+ if (shouldBeVisible) {
+ hide()
+ } else {
+ show()
+ }
+ }
+
+ width: 500
+ height: 700
+ visible: false
+ onBackgroundClicked: hide()
+ onShouldBeVisibleChanged: (shouldBeVisible) => {
+ if (!shouldBeVisible) {
+ notificationModalOpen = false
+ modalKeyboardController.reset()
+ NotificationService.onOverlayClose()
+ }
+ }
+ modalFocusScope.Keys.onPressed: (event) => modalKeyboardController.handleKey(event)
+
+ NotificationKeyboardController {
+ id: modalKeyboardController
+
+ listView: null
+ isOpen: notificationModal.notificationModalOpen
+ onClose: () => notificationModal.hide()
+ }
+
+ IpcHandler {
+ function open(): string {
+ notificationModal.show();
+ return "NOTIFICATION_MODAL_OPEN_SUCCESS";
+ }
+
+ function close(): string {
+ notificationModal.hide();
+ return "NOTIFICATION_MODAL_CLOSE_SUCCESS";
+ }
+
+ function toggle(): string {
+ notificationModal.toggle();
+ return "NOTIFICATION_MODAL_TOGGLE_SUCCESS";
+ }
+
+ target: "notifications"
+ }
+
+ content: Component {
+ Item {
+ id: notificationKeyHandler
+
+ anchors.fill: parent
+
+ Column {
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingM
+
+ NotificationHeader {
+ id: notificationHeader
+
+ keyboardController: modalKeyboardController
+ }
+
+ NotificationSettings {
+ id: notificationSettings
+
+ expanded: notificationHeader.showSettings
+ }
+
+ KeyboardNavigatedNotificationList {
+ id: notificationList
+
+ width: parent.width
+ height: parent.height - y
+ keyboardController: modalKeyboardController
+ Component.onCompleted: {
+ notificationModal.notificationListRef = notificationList
+ if (modalKeyboardController) {
+ modalKeyboardController.listView = notificationList
+ modalKeyboardController.rebuildFlatNavigation()
+ }
+ }
+ }
+
+ }
+
+ NotificationKeyboardHints {
+ id: keyboardHints
+
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.margins: Theme.spacingL
+ showHints: modalKeyboardController.showKeyboardHints
+ }
+
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modals/PowerMenuModal.qml b/quickshell/.config/quickshell/Modals/PowerMenuModal.qml
new file mode 100644
index 0000000..53f3069
--- /dev/null
+++ b/quickshell/.config/quickshell/Modals/PowerMenuModal.qml
@@ -0,0 +1,345 @@
+import QtQuick
+import qs.Common
+import qs.Modals.Common
+import qs.Widgets
+
+DankModal {
+ id: root
+
+ property int selectedIndex: 0
+ property int optionCount: 4
+
+ signal powerActionRequested(string action, string title, string message)
+
+ function selectOption() {
+ close();
+ const actions = [{
+ "action": "logout",
+ "title": "Log Out",
+ "message": "Are you sure you want to log out?"
+ }, {
+ "action": "suspend",
+ "title": "Suspend",
+ "message": "Are you sure you want to suspend the system?"
+ }, {
+ "action": "reboot",
+ "title": "Reboot",
+ "message": "Are you sure you want to reboot the system?"
+ }, {
+ "action": "poweroff",
+ "title": "Power Off",
+ "message": "Are you sure you want to power off the system?"
+ }];
+ const selected = actions[selectedIndex];
+ if (selected) {
+ root.powerActionRequested(selected.action, selected.title, selected.message);
+ }
+
+ }
+
+ shouldBeVisible: false
+ width: 320
+ height: 300
+ enableShadow: true
+ onBackgroundClicked: () => {
+ return close();
+ }
+ onOpened: () => {
+ selectedIndex = 0;
+ modalFocusScope.forceActiveFocus();
+ }
+ modalFocusScope.Keys.onPressed: (event) => {
+ switch (event.key) {
+ case Qt.Key_Up:
+ selectedIndex = (selectedIndex - 1 + optionCount) % optionCount;
+ event.accepted = true;
+ break;
+ case Qt.Key_Down:
+ selectedIndex = (selectedIndex + 1) % optionCount;
+ event.accepted = true;
+ break;
+ case Qt.Key_Tab:
+ selectedIndex = (selectedIndex + 1) % optionCount;
+ event.accepted = true;
+ break;
+ case Qt.Key_Return:
+ case Qt.Key_Enter:
+ selectOption();
+ event.accepted = true;
+ break;
+ }
+ }
+
+ content: Component {
+ Item {
+ anchors.fill: parent
+
+ Column {
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingM
+
+ Row {
+ width: parent.width
+
+ StyledText {
+ text: "Power Options"
+ font.pixelSize: Theme.fontSizeLarge
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Item {
+ width: parent.width - 150
+ height: 1
+ }
+
+ DankActionButton {
+ iconName: "close"
+ iconSize: Theme.iconSize - 4
+ iconColor: Theme.surfaceText
+ onClicked: () => {
+ return close();
+ }
+ }
+
+ }
+
+ Column {
+ width: parent.width
+ spacing: Theme.spacingS
+
+ Rectangle {
+ width: parent.width
+ height: 50
+ radius: Theme.cornerRadius
+ color: {
+ if (selectedIndex === 0) {
+ return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12);
+ } else if (logoutArea.containsMouse) {
+ return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08);
+ } else {
+ return Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08);
+ }
+ }
+ border.color: selectedIndex === 0 ? Theme.primary : "transparent"
+ border.width: selectedIndex === 0 ? 1 : 0
+
+ Row {
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingM
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "logout"
+ size: Theme.iconSize
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Log Out"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ }
+
+ MouseArea {
+ id: logoutArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: () => {
+ selectedIndex = 0;
+ selectOption();
+ }
+ }
+
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 50
+ radius: Theme.cornerRadius
+ color: {
+ if (selectedIndex === 1) {
+ return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12);
+ } else if (suspendArea.containsMouse) {
+ return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08);
+ } else {
+ return Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08);
+ }
+ }
+ border.color: selectedIndex === 1 ? Theme.primary : "transparent"
+ border.width: selectedIndex === 1 ? 1 : 0
+
+ Row {
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingM
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "bedtime"
+ size: Theme.iconSize
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Suspend"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ }
+
+ MouseArea {
+ id: suspendArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: () => {
+ selectedIndex = 1;
+ selectOption();
+ }
+ }
+
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 50
+ radius: Theme.cornerRadius
+ color: {
+ if (selectedIndex === 2) {
+ return Qt.rgba(Theme.warning.r, Theme.warning.g, Theme.warning.b, 0.12);
+ } else if (rebootArea.containsMouse) {
+ return Qt.rgba(Theme.warning.r, Theme.warning.g, Theme.warning.b, 0.08);
+ } else {
+ return Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08);
+ }
+ }
+ border.color: selectedIndex === 2 ? Theme.warning : "transparent"
+ border.width: selectedIndex === 2 ? 1 : 0
+
+ Row {
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingM
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "restart_alt"
+ size: Theme.iconSize
+ color: rebootArea.containsMouse ? Theme.warning : Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Reboot"
+ font.pixelSize: Theme.fontSizeMedium
+ color: rebootArea.containsMouse ? Theme.warning : Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ }
+
+ MouseArea {
+ id: rebootArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: () => {
+ selectedIndex = 2;
+ selectOption();
+ }
+ }
+
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 50
+ radius: Theme.cornerRadius
+ color: {
+ if (selectedIndex === 3) {
+ return Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12);
+ } else if (powerOffArea.containsMouse) {
+ return Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.08);
+ } else {
+ return Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08);
+ }
+ }
+ border.color: selectedIndex === 3 ? Theme.error : "transparent"
+ border.width: selectedIndex === 3 ? 1 : 0
+
+ Row {
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingM
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "power_settings_new"
+ size: Theme.iconSize
+ color: powerOffArea.containsMouse ? Theme.error : Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Power Off"
+ font.pixelSize: Theme.fontSizeMedium
+ color: powerOffArea.containsMouse ? Theme.error : Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ }
+
+ MouseArea {
+ id: powerOffArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: () => {
+ selectedIndex = 3;
+ selectOption();
+ }
+ }
+
+ }
+
+ }
+
+ Item {
+ height: Theme.spacingS
+ }
+
+ StyledText {
+ text: "↑↓ Navigate • Tab Cycle • Enter Select • Esc Close"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ anchors.horizontalCenter: parent.horizontalCenter
+ opacity: 0.7
+ }
+
+ }
+
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modals/ProcessListModal.qml b/quickshell/.config/quickshell/Modals/ProcessListModal.qml
new file mode 100644
index 0000000..c2918ab
--- /dev/null
+++ b/quickshell/.config/quickshell/Modals/ProcessListModal.qml
@@ -0,0 +1,356 @@
+import QtQuick
+import QtQuick.Layouts
+import qs.Common
+import qs.Modals.Common
+import qs.Modules.ProcessList
+import qs.Services
+import qs.Widgets
+
+DankModal {
+ id: processListModal
+
+ property int currentTab: 0
+ property var tabNames: ["Processes", "Performance", "System"]
+
+ function show() {
+ if (!DgopService.dgopAvailable) {
+ console.warn("ProcessListModal: dgop is not available");
+ return ;
+ }
+ open();
+ UserInfoService.getUptime();
+ }
+
+ function hide() {
+ close();
+ if (processContextMenu.visible) {
+ processContextMenu.close();
+ }
+
+ }
+
+ function toggle() {
+ if (!DgopService.dgopAvailable) {
+ console.warn("ProcessListModal: dgop is not available");
+ return ;
+ }
+ if (shouldBeVisible) {
+ hide();
+ } else {
+ show();
+ }
+ }
+
+ width: 900
+ height: 680
+ visible: false
+ backgroundColor: Theme.popupBackground()
+ cornerRadius: Theme.cornerRadius
+ enableShadow: true
+ onBackgroundClicked: () => {
+ return hide();
+ }
+
+ Component {
+ id: processesTabComponent
+
+ ProcessesTab {
+ contextMenu: processContextMenu
+ }
+
+ }
+
+ Component {
+ id: performanceTabComponent
+
+ PerformanceTab {
+ }
+
+ }
+
+ Component {
+ id: systemTabComponent
+
+ SystemTab {
+ }
+
+ }
+
+ ProcessContextMenu {
+ id: processContextMenu
+ }
+
+ content: Component {
+ Item {
+ anchors.fill: parent
+ focus: true
+ Keys.onPressed: (event) => {
+ if (event.key === Qt.Key_Escape) {
+ processListModal.hide();
+ event.accepted = true;
+ } else if (event.key === Qt.Key_1) {
+ currentTab = 0;
+ event.accepted = true;
+ } else if (event.key === Qt.Key_2) {
+ currentTab = 1;
+ event.accepted = true;
+ } else if (event.key === Qt.Key_3) {
+ currentTab = 2;
+ event.accepted = true;
+ }
+ }
+
+ // Show error message when dgop is not available
+ Rectangle {
+ anchors.centerIn: parent
+ width: 400
+ height: 200
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.1)
+ border.color: Theme.error
+ border.width: 2
+ visible: !DgopService.dgopAvailable
+
+ Column {
+ anchors.centerIn: parent
+ spacing: Theme.spacingL
+
+ DankIcon {
+ name: "error"
+ size: 48
+ color: Theme.error
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+
+ StyledText {
+ text: "System Monitor Unavailable"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Bold
+ color: Theme.error
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+
+ StyledText {
+ text: "The 'dgop' tool is required for system monitoring.\nPlease install dgop to use this feature."
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ anchors.horizontalCenter: parent.horizontalCenter
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.WordWrap
+ }
+
+ }
+
+ }
+
+ ColumnLayout {
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingL
+ visible: DgopService.dgopAvailable
+
+ RowLayout {
+ Layout.fillWidth: true
+ height: 40
+
+ StyledText {
+ text: "System Monitor"
+ font.pixelSize: Theme.fontSizeLarge + 4
+ font.weight: Font.Bold
+ color: Theme.surfaceText
+ Layout.alignment: Qt.AlignVCenter
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ DankActionButton {
+ circular: false
+ iconName: "close"
+ iconSize: Theme.iconSize - 4
+ iconColor: Theme.surfaceText
+ onClicked: () => {
+ return processListModal.hide();
+ }
+ Layout.alignment: Qt.AlignVCenter
+ }
+
+ }
+
+ Rectangle {
+ Layout.fillWidth: true
+ height: 52
+ color: Theme.surfaceSelected
+ radius: Theme.cornerRadius
+ border.color: Theme.outlineLight
+ border.width: 1
+
+ Row {
+ anchors.fill: parent
+ anchors.margins: 4
+ spacing: 2
+
+ Repeater {
+ model: tabNames
+
+ Rectangle {
+ width: (parent.width - (tabNames.length - 1) * 2) / tabNames.length
+ height: 44
+ radius: Theme.cornerRadius
+ color: currentTab === index ? Theme.primaryPressed : (tabMouseArea.containsMouse ? Theme.primaryHoverLight : "transparent")
+ border.color: currentTab === index ? Theme.primary : "transparent"
+ border.width: currentTab === index ? 1 : 0
+
+ Row {
+ anchors.centerIn: parent
+ spacing: Theme.spacingXS
+
+ DankIcon {
+ name: {
+ const tabIcons = ["list_alt", "analytics", "settings"];
+ return tabIcons[index] || "tab";
+ }
+ size: Theme.iconSize - 2
+ color: currentTab === index ? Theme.primary : Theme.surfaceText
+ opacity: currentTab === index ? 1 : 0.7
+ anchors.verticalCenter: parent.verticalCenter
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ }
+
+ }
+
+ }
+
+ StyledText {
+ text: modelData
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: currentTab === index ? Theme.primary : Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.verticalCenterOffset: -1
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ }
+
+ }
+
+ }
+
+ }
+
+ MouseArea {
+ id: tabMouseArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: () => {
+ currentTab = index;
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ }
+
+ }
+
+ Behavior on border.color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ radius: Theme.cornerRadius
+ color: Theme.surfaceLight
+ border.color: Theme.outlineLight
+ border.width: 1
+
+ Loader {
+ id: processesTab
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingS
+ active: processListModal.visible && currentTab === 0
+ visible: currentTab === 0
+ opacity: currentTab === 0 ? 1 : 0
+ sourceComponent: processesTabComponent
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Theme.mediumDuration
+ easing.type: Theme.emphasizedEasing
+ }
+
+ }
+
+ }
+
+ Loader {
+ id: performanceTab
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingS
+ active: processListModal.visible && currentTab === 1
+ visible: currentTab === 1
+ opacity: currentTab === 1 ? 1 : 0
+ sourceComponent: performanceTabComponent
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Theme.mediumDuration
+ easing.type: Theme.emphasizedEasing
+ }
+
+ }
+
+ }
+
+ Loader {
+ id: systemTab
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingS
+ active: processListModal.visible && currentTab === 2
+ visible: currentTab === 2
+ opacity: currentTab === 2 ? 1 : 0
+ sourceComponent: systemTabComponent
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Theme.mediumDuration
+ easing.type: Theme.emphasizedEasing
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modals/Settings/ProfileSection.qml b/quickshell/.config/quickshell/Modals/Settings/ProfileSection.qml
new file mode 100644
index 0000000..c0389d1
--- /dev/null
+++ b/quickshell/.config/quickshell/Modals/Settings/ProfileSection.qml
@@ -0,0 +1,211 @@
+import QtQuick
+import QtQuick.Effects
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Rectangle {
+ id: root
+
+ property var parentModal: null
+
+ width: parent.width - Theme.spacingS * 2
+ height: 110
+ radius: Theme.cornerRadius
+ color: "transparent"
+ border.width: 0
+
+ Row {
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.leftMargin: Theme.spacingM
+ anchors.rightMargin: Theme.spacingM
+ spacing: Theme.spacingM
+
+ Item {
+ id: profileImageContainer
+
+ property bool hasImage: profileImageSource.status === Image.Ready
+
+ width: 80
+ height: 80
+ anchors.verticalCenter: parent.verticalCenter
+
+ Rectangle {
+ anchors.fill: parent
+ radius: width / 2
+ color: "transparent"
+ border.color: Theme.primary
+ border.width: 1
+ visible: parent.hasImage
+ }
+
+ Image {
+ id: profileImageSource
+
+ source: {
+ if (PortalService.profileImage === "") {
+ return "";
+ }
+ if (PortalService.profileImage.startsWith("/")) {
+ return "file://" + PortalService.profileImage;
+ }
+ return PortalService.profileImage;
+ }
+ smooth: true
+ asynchronous: true
+ mipmap: true
+ cache: true
+ visible: false
+ }
+
+ MultiEffect {
+ anchors.fill: parent
+ anchors.margins: 5
+ source: profileImageSource
+ maskEnabled: true
+ maskSource: profileCircularMask
+ visible: profileImageContainer.hasImage
+ maskThresholdMin: 0.5
+ maskSpreadAtMin: 1
+ }
+
+ Item {
+ id: profileCircularMask
+
+ width: 70
+ height: 70
+ layer.enabled: true
+ layer.smooth: true
+ visible: false
+
+ Rectangle {
+ anchors.fill: parent
+ radius: width / 2
+ color: "black"
+ antialiasing: true
+ }
+
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ radius: width / 2
+ color: Theme.primary
+ visible: !parent.hasImage
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "person"
+ size: Theme.iconSizeLarge
+ color: Theme.primaryText
+ }
+
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ radius: width / 2
+ color: Qt.rgba(0, 0, 0, 0.7)
+ visible: profileMouseArea.containsMouse
+
+ Row {
+ anchors.centerIn: parent
+ spacing: 4
+
+ Rectangle {
+ width: 28
+ height: 28
+ radius: 14
+ color: Qt.rgba(255, 255, 255, 0.9)
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "edit"
+ size: 16
+ color: "black"
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: () => {
+ if (root.parentModal) {
+ root.parentModal.allowFocusOverride = true;
+ root.parentModal.shouldHaveFocus = false;
+ if (root.parentModal.profileBrowser) {
+ root.parentModal.profileBrowser.open();
+ }
+ }
+ }
+ }
+
+ }
+
+ Rectangle {
+ width: 28
+ height: 28
+ radius: 14
+ color: Qt.rgba(255, 255, 255, 0.9)
+ visible: profileImageContainer.hasImage
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "close"
+ size: 16
+ color: "black"
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: () => {
+ return PortalService.setProfileImage("");
+ }
+ }
+
+ }
+
+ }
+
+ }
+
+ MouseArea {
+ id: profileMouseArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ propagateComposedEvents: true
+ acceptedButtons: Qt.NoButton
+ }
+
+ }
+
+ Column {
+ width: 120
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingXS
+
+ StyledText {
+ text: UserInfoService.fullName || "User"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ elide: Text.ElideRight
+ width: parent.width
+ }
+
+ StyledText {
+ text: DgopService.distribution || "Linux"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceVariantText
+ elide: Text.ElideRight
+ width: parent.width
+ }
+
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modals/Settings/SettingsContent.qml b/quickshell/.config/quickshell/Modals/Settings/SettingsContent.qml
new file mode 100644
index 0000000..6ceaec2
--- /dev/null
+++ b/quickshell/.config/quickshell/Modals/Settings/SettingsContent.qml
@@ -0,0 +1,142 @@
+import QtQuick
+import qs.Common
+import qs.Modules.Settings
+
+Item {
+ id: root
+
+ property int currentIndex: 0
+ property var parentModal: null
+
+ Rectangle {
+ anchors.fill: parent
+ anchors.leftMargin: 0
+ anchors.rightMargin: Theme.spacingS
+ anchors.bottomMargin: Theme.spacingM
+ anchors.topMargin: 0
+ color: "transparent"
+
+ Loader {
+ id: personalizationLoader
+
+ anchors.fill: parent
+ active: root.currentIndex === 0
+ visible: active
+ asynchronous: true
+
+ sourceComponent: Component {
+ PersonalizationTab {
+ parentModal: root.parentModal
+ }
+
+ }
+
+ }
+
+ Loader {
+ id: timeLoader
+
+ anchors.fill: parent
+ active: root.currentIndex === 1
+ visible: active
+ asynchronous: true
+
+ sourceComponent: TimeTab {
+ }
+
+ }
+
+ Loader {
+ id: weatherLoader
+
+ anchors.fill: parent
+ active: root.currentIndex === 2
+ visible: active
+ asynchronous: true
+
+ sourceComponent: WeatherTab {
+ }
+
+ }
+
+ Loader {
+ id: topBarLoader
+
+ anchors.fill: parent
+ active: root.currentIndex === 3
+ visible: active
+ asynchronous: true
+
+ sourceComponent: TopBarTab {
+ }
+
+ }
+
+ Loader {
+ id: widgetsLoader
+
+ anchors.fill: parent
+ active: root.currentIndex === 4
+ visible: active
+ asynchronous: true
+
+ sourceComponent: WidgetTweaksTab {
+ }
+
+ }
+
+ Loader {
+ id: displaysLoader
+
+ anchors.fill: parent
+ active: root.currentIndex === 6
+ visible: active
+ asynchronous: true
+
+ sourceComponent: DisplaysTab {
+ }
+
+ }
+
+ Loader {
+ id: recentAppsLoader
+
+ anchors.fill: parent
+ active: root.currentIndex === 7
+ visible: active
+ asynchronous: true
+
+ sourceComponent: RecentAppsTab {
+ }
+
+ }
+
+ Loader {
+ id: themeColorsLoader
+
+ anchors.fill: parent
+ active: root.currentIndex === 8
+ visible: active
+ asynchronous: true
+
+ sourceComponent: ThemeColorsTab {
+ }
+
+ }
+
+ Loader {
+ id: aboutLoader
+
+ anchors.fill: parent
+ active: root.currentIndex === 9
+ visible: active
+ asynchronous: true
+
+ sourceComponent: AboutTab {
+ }
+
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modals/Settings/SettingsModal.qml b/quickshell/.config/quickshell/Modals/Settings/SettingsModal.qml
new file mode 100644
index 0000000..8030082
--- /dev/null
+++ b/quickshell/.config/quickshell/Modals/Settings/SettingsModal.qml
@@ -0,0 +1,198 @@
+import QtQuick
+import QtQuick.Effects
+import Quickshell.Io
+import qs.Common
+import qs.Modals.Common
+import qs.Modals.FileBrowser
+import qs.Modules.Settings
+import qs.Services
+import qs.Widgets
+
+DankModal {
+ id: settingsModal
+
+ property Component settingsContent
+
+ signal closingModal()
+
+ function show() {
+ open();
+ }
+
+ function hide() {
+ close();
+ }
+
+ function toggle() {
+ if (shouldBeVisible) {
+ hide();
+ } else {
+ show();
+ }
+ }
+
+ objectName: "settingsModal"
+ width: 800
+ height: 750
+ visible: false
+ onBackgroundClicked: () => {
+ return hide();
+ }
+ content: settingsContent
+
+ IpcHandler {
+ function open(): string {
+ settingsModal.show();
+ return "SETTINGS_OPEN_SUCCESS";
+ }
+
+ function close(): string {
+ settingsModal.hide();
+ return "SETTINGS_CLOSE_SUCCESS";
+ }
+
+ function toggle(): string {
+ settingsModal.toggle();
+ return "SETTINGS_TOGGLE_SUCCESS";
+ }
+
+ target: "settings"
+ }
+
+ IpcHandler {
+ function browse(type: string) {
+ if (type === "wallpaper") {
+ wallpaperBrowser.allowStacking = false;
+ wallpaperBrowser.open();
+ } else if (type === "profile") {
+ profileBrowser.allowStacking = false;
+ profileBrowser.open();
+ }
+ }
+
+ target: "file"
+ }
+
+ FileBrowserModal {
+ id: profileBrowser
+
+ allowStacking: true
+ browserTitle: "Select Profile Image"
+ browserIcon: "person"
+ browserType: "profile"
+ fileExtensions: ["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.gif", "*.webp"]
+ onFileSelected: (path) => {
+ PortalService.setProfileImage(path);
+ close();
+ }
+ onDialogClosed: () => {
+ if (settingsModal) {
+ settingsModal.allowFocusOverride = false;
+ settingsModal.shouldHaveFocus = Qt.binding(() => {
+ return settingsModal.shouldBeVisible;
+ });
+ }
+ allowStacking = true;
+ }
+ }
+
+ FileBrowserModal {
+ id: wallpaperBrowser
+
+ allowStacking: true
+ browserTitle: "Select Wallpaper"
+ browserIcon: "wallpaper"
+ browserType: "wallpaper"
+ fileExtensions: ["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.gif", "*.webp"]
+ onFileSelected: (path) => {
+ SessionData.setWallpaper(path);
+ close();
+ }
+ onDialogClosed: () => {
+ allowStacking = true;
+ }
+ }
+
+ settingsContent: Component {
+ Item {
+ anchors.fill: parent
+ focus: true
+
+ Column {
+ anchors.fill: parent
+ anchors.leftMargin: Theme.spacingL
+ anchors.rightMargin: Theme.spacingL
+ anchors.topMargin: Theme.spacingM
+ anchors.bottomMargin: Theme.spacingL
+ spacing: 0
+
+ Item {
+ width: parent.width
+ height: 35
+
+ Row {
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "settings"
+ size: Theme.iconSize
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Settings"
+ font.pixelSize: Theme.fontSizeXLarge
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ }
+
+ DankActionButton {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ circular: false
+ iconName: "close"
+ iconSize: Theme.iconSize - 4
+ iconColor: Theme.surfaceText
+ onClicked: () => {
+ return settingsModal.hide();
+ }
+ }
+
+ }
+
+ Row {
+ width: parent.width
+ height: parent.height - 35
+ spacing: 0
+
+ SettingsSidebar {
+ id: sidebar
+
+ parentModal: settingsModal
+ onCurrentIndexChanged: content.currentIndex = currentIndex
+ }
+
+ SettingsContent {
+ id: content
+
+ width: parent.width - sidebar.width
+ height: parent.height
+ parentModal: settingsModal
+ currentIndex: sidebar.currentIndex
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modals/Settings/SettingsSidebar.qml b/quickshell/.config/quickshell/Modals/Settings/SettingsSidebar.qml
new file mode 100644
index 0000000..42cc362
--- /dev/null
+++ b/quickshell/.config/quickshell/Modals/Settings/SettingsSidebar.qml
@@ -0,0 +1,130 @@
+import QtQuick
+import qs.Common
+import qs.Modals.Settings
+import qs.Widgets
+
+Rectangle {
+ id: sidebarContainer
+
+ property int currentIndex: 0
+ property var parentModal: null
+ readonly property var sidebarItems: [{
+ "text": "Personalization",
+ "icon": "person"
+ }, {
+ "text": "Time & Date",
+ "icon": "schedule"
+ }, {
+ "text": "Weather",
+ "icon": "cloud"
+ }, {
+ "text": "Top Bar",
+ "icon": "toolbar"
+ }, {
+ "text": "Widgets",
+ "icon": "widgets"
+ }, {
+ "text": "Displays",
+ "icon": "monitor"
+ }, {
+ "text": "Recent Apps",
+ "icon": "history"
+ }, {
+ "text": "Theme & Colors",
+ "icon": "palette"
+ }, {
+ "text": "About",
+ "icon": "info"
+ }]
+
+ width: 270
+ height: parent.height
+ color: Theme.surfaceContainer
+ radius: Theme.cornerRadius
+
+ Column {
+ anchors.fill: parent
+ anchors.leftMargin: Theme.spacingS
+ anchors.rightMargin: Theme.spacingS
+ anchors.bottomMargin: Theme.spacingS
+ anchors.topMargin: Theme.spacingM + 2
+ spacing: Theme.spacingXS
+
+ ProfileSection {
+ parentModal: sidebarContainer.parentModal
+ }
+
+ Rectangle {
+ width: parent.width - Theme.spacingS * 2
+ height: 1
+ color: Theme.outline
+ opacity: 0.2
+ }
+
+ Item {
+ width: parent.width
+ height: Theme.spacingL
+ }
+
+ Repeater {
+ id: sidebarRepeater
+
+ model: sidebarContainer.sidebarItems
+
+ Rectangle {
+ property bool isActive: sidebarContainer.currentIndex === index
+
+ width: parent.width - Theme.spacingS * 2
+ height: 44
+ radius: Theme.cornerRadius
+ color: isActive ? Theme.surfaceContainerHigh : tabMouseArea.containsMouse ? Theme.surfaceHover : "transparent"
+
+ Row {
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingM
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: modelData.icon || ""
+ size: Theme.iconSize - 2
+ color: parent.parent.isActive ? Theme.primary : Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: modelData.text || ""
+ font.pixelSize: Theme.fontSizeMedium
+ color: parent.parent.isActive ? Theme.primary : Theme.surfaceText
+ font.weight: parent.parent.isActive ? Font.Medium : Font.Normal
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ }
+
+ MouseArea {
+ id: tabMouseArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: () => {
+ sidebarContainer.currentIndex = index;
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modals/Spotlight/SpotlightContent.qml b/quickshell/.config/quickshell/Modals/Spotlight/SpotlightContent.qml
new file mode 100644
index 0000000..f0535f2
--- /dev/null
+++ b/quickshell/.config/quickshell/Modals/Spotlight/SpotlightContent.qml
@@ -0,0 +1,226 @@
+import QtQuick
+import QtQuick.Controls
+import qs.Common
+import qs.Modals.Spotlight
+import qs.Modules.AppDrawer
+import qs.Services
+import qs.Widgets
+
+Item {
+ id: spotlightKeyHandler
+
+ property alias appLauncher: appLauncher
+ property alias searchField: searchField
+ property var parentModal: null
+
+ anchors.fill: parent
+ focus: true
+ Keys.onPressed: event => {
+ if (event.key === Qt.Key_Escape) {
+ if (parentModal)
+ parentModal.hide()
+
+ event.accepted = true
+ } else if (event.key === Qt.Key_Down) {
+ appLauncher.selectNext()
+ event.accepted = true
+ } else if (event.key === Qt.Key_Up) {
+ appLauncher.selectPrevious()
+ event.accepted = true
+ } else if (event.key === Qt.Key_Right && appLauncher.viewMode === "grid") {
+ appLauncher.selectNextInRow()
+ event.accepted = true
+ } else if (event.key === Qt.Key_Left && appLauncher.viewMode === "grid") {
+ appLauncher.selectPreviousInRow()
+ event.accepted = true
+ } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
+ appLauncher.launchSelected()
+ event.accepted = true
+ } else if (!searchField.activeFocus && event.text && event.text.length > 0 && event.text.match(/[a-zA-Z0-9\\s]/)) {
+ searchField.forceActiveFocus()
+ searchField.insertText(event.text)
+ event.accepted = true
+ }
+ }
+
+ AppLauncher {
+ id: appLauncher
+
+ viewMode: SettingsData.spotlightModalViewMode
+ gridColumns: 4
+ onAppLaunched: () => {
+ if (parentModal)
+ parentModal.hide()
+ }
+ onViewModeSelected: mode => {
+ SettingsData.setSpotlightModalViewMode(mode)
+ }
+ }
+
+ Column {
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingL
+
+ Rectangle {
+ width: parent.width
+ height: categorySelector.height + Theme.spacingM * 2
+ radius: Theme.cornerRadius
+ color: Theme.surfaceVariantAlpha
+ border.color: Theme.outlineMedium
+ border.width: 1
+ visible: appLauncher.categories.length > 1 || appLauncher.model.count > 0
+
+ CategorySelector {
+ id: categorySelector
+
+ anchors.centerIn: parent
+ width: parent.width - Theme.spacingM * 2
+ categories: appLauncher.categories
+ selectedCategory: appLauncher.selectedCategory
+ compact: false
+ onCategorySelected: category => {
+ appLauncher.setCategory(category)
+ }
+ }
+ }
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ DankTextField {
+ id: searchField
+
+ width: parent.width - 80 - Theme.spacingM
+ height: 56
+ cornerRadius: Theme.cornerRadius
+ backgroundColor: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, Theme.getContentBackgroundAlpha() * 0.7)
+ normalBorderColor: Theme.outlineMedium
+ focusedBorderColor: Theme.primary
+ leftIconName: "search"
+ leftIconSize: Theme.iconSize
+ leftIconColor: Theme.surfaceVariantText
+ leftIconFocusedColor: Theme.primary
+ showClearButton: true
+ textColor: Theme.surfaceText
+ font.pixelSize: Theme.fontSizeLarge
+ enabled: parentModal ? parentModal.spotlightOpen : true
+ placeholderText: ""
+ ignoreLeftRightKeys: true
+ keyForwardTargets: [spotlightKeyHandler]
+ text: appLauncher.searchQuery
+ onTextEdited: () => {
+ appLauncher.searchQuery = text
+ }
+ Keys.onPressed: event => {
+ if (event.key === Qt.Key_Escape) {
+ if (parentModal)
+ parentModal.hide()
+
+ event.accepted = true
+ } else if ((event.key === Qt.Key_Return || event.key === Qt.Key_Enter) && text.length > 0) {
+ if (appLauncher.keyboardNavigationActive && appLauncher.model.count > 0)
+ appLauncher.launchSelected()
+ else if (appLauncher.model.count > 0)
+ appLauncher.launchApp(appLauncher.model.get(0))
+ event.accepted = true
+ } else if (event.key === Qt.Key_Down || event.key === Qt.Key_Up || event.key === Qt.Key_Left || event.key === Qt.Key_Right || ((event.key === Qt.Key_Return || event.key === Qt.Key_Enter) && text.length === 0)) {
+ event.accepted = false
+ }
+ }
+ }
+
+ Row {
+ spacing: Theme.spacingXS
+ visible: appLauncher.model.count > 0
+ anchors.verticalCenter: parent.verticalCenter
+
+ Rectangle {
+ width: 36
+ height: 36
+ radius: Theme.cornerRadius
+ color: appLauncher.viewMode === "list" ? Theme.primaryHover : listViewArea.containsMouse ? Theme.surfaceHover : "transparent"
+ border.color: appLauncher.viewMode === "list" ? Theme.primarySelected : "transparent"
+ border.width: 1
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "view_list"
+ size: 18
+ color: appLauncher.viewMode === "list" ? Theme.primary : Theme.surfaceText
+ }
+
+ MouseArea {
+ id: listViewArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: () => {
+ appLauncher.setViewMode("list")
+ }
+ }
+ }
+
+ Rectangle {
+ width: 36
+ height: 36
+ radius: Theme.cornerRadius
+ color: appLauncher.viewMode === "grid" ? Theme.primaryHover : gridViewArea.containsMouse ? Theme.surfaceHover : "transparent"
+ border.color: appLauncher.viewMode === "grid" ? Theme.primarySelected : "transparent"
+ border.width: 1
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "grid_view"
+ size: 18
+ color: appLauncher.viewMode === "grid" ? Theme.primary : Theme.surfaceText
+ }
+
+ MouseArea {
+ id: gridViewArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: () => {
+ appLauncher.setViewMode("grid")
+ }
+ }
+ }
+ }
+ }
+
+ SpotlightResults {
+ appLauncher: spotlightKeyHandler.appLauncher
+ contextMenu: contextMenu
+ }
+ }
+
+ SpotlightContextMenu {
+ id: contextMenu
+
+ appLauncher: spotlightKeyHandler.appLauncher
+ parentHandler: spotlightKeyHandler
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ visible: contextMenu.visible
+ z: 999
+ onClicked: () => {
+ contextMenu.close()
+ }
+
+ MouseArea {
+
+ // Prevent closing when clicking on the menu itself
+ x: contextMenu.x
+ y: contextMenu.y
+ width: contextMenu.width
+ height: contextMenu.height
+ onClicked: () => {}
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modals/Spotlight/SpotlightContextMenu.qml b/quickshell/.config/quickshell/Modals/Spotlight/SpotlightContextMenu.qml
new file mode 100644
index 0000000..e6b6e89
--- /dev/null
+++ b/quickshell/.config/quickshell/Modals/Spotlight/SpotlightContextMenu.qml
@@ -0,0 +1,205 @@
+import QtQuick
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Rectangle {
+ id: contextMenu
+
+ property var currentApp: null
+ property bool menuVisible: false
+ property var appLauncher: null
+ property var parentHandler: null
+
+ function show(x, y, app) {
+ currentApp = app
+ const menuWidth = 180
+ const menuHeight = menuColumn.implicitHeight + Theme.spacingS * 2
+ let finalX = x + 8
+ let finalY = y + 8
+ if (parentHandler) {
+ if (finalX + menuWidth > parentHandler.width)
+ finalX = x - menuWidth - 8
+
+ if (finalY + menuHeight > parentHandler.height)
+ finalY = y - menuHeight - 8
+
+ finalX = Math.max(8, Math.min(finalX, parentHandler.width - menuWidth - 8))
+ finalY = Math.max(8, Math.min(finalY, parentHandler.height - menuHeight - 8))
+ }
+ contextMenu.x = finalX
+ contextMenu.y = finalY
+ contextMenu.visible = true
+ contextMenu.menuVisible = true
+ }
+
+ function close() {
+ contextMenu.menuVisible = false
+ Qt.callLater(() => {
+ contextMenu.visible = false
+ })
+ }
+
+ visible: false
+ width: 180
+ height: menuColumn.implicitHeight + Theme.spacingS * 2
+ radius: Theme.cornerRadius
+ color: Theme.popupBackground()
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
+ border.width: 1
+ z: 1000
+ opacity: menuVisible ? 1 : 0
+ scale: menuVisible ? 1 : 0.85
+
+ 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
+ }
+
+ Column {
+ id: menuColumn
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingS
+ spacing: 1
+
+ Rectangle {
+ width: parent.width
+ height: 32
+ radius: Theme.cornerRadius
+ color: pinMouseArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
+
+ Row {
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingS
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingS
+
+ DankIcon {
+ name: {
+ if (!contextMenu.currentApp || !contextMenu.currentApp.desktopEntry)
+ return "push_pin"
+
+ const appId = contextMenu.currentApp.desktopEntry.id || contextMenu.currentApp.desktopEntry.execString || ""
+ return SessionData.isPinnedApp(appId) ? "keep_off" : "push_pin"
+ }
+ size: Theme.iconSize - 2
+ color: Theme.surfaceText
+ opacity: 0.7
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: {
+ if (!contextMenu.currentApp || !contextMenu.currentApp.desktopEntry)
+ return "Pin to Dock"
+
+ const appId = contextMenu.currentApp.desktopEntry.id || contextMenu.currentApp.desktopEntry.execString || ""
+ return SessionData.isPinnedApp(appId) ? "Unpin from Dock" : "Pin to Dock"
+ }
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ font.weight: Font.Normal
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ MouseArea {
+ id: pinMouseArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: () => {
+ if (!contextMenu.currentApp || !contextMenu.currentApp.desktopEntry)
+ return
+
+ const appId = contextMenu.currentApp.desktopEntry.id || contextMenu.currentApp.desktopEntry.execString || ""
+ if (SessionData.isPinnedApp(appId))
+ SessionData.removePinnedApp(appId)
+ else
+ SessionData.addPinnedApp(appId)
+ contextMenu.close()
+ }
+ }
+ }
+
+ Rectangle {
+ width: parent.width - Theme.spacingS * 2
+ height: 5
+ anchors.horizontalCenter: parent.horizontalCenter
+ color: "transparent"
+
+ Rectangle {
+ anchors.centerIn: parent
+ width: parent.width
+ height: 1
+ color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
+ }
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 32
+ radius: Theme.cornerRadius
+ color: launchMouseArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
+
+ Row {
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingS
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingS
+
+ DankIcon {
+ name: "launch"
+ size: Theme.iconSize - 2
+ color: Theme.surfaceText
+ opacity: 0.7
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Launch"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ font.weight: Font.Normal
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ MouseArea {
+ id: launchMouseArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: () => {
+ if (contextMenu.currentApp && appLauncher)
+ appLauncher.launchApp(contextMenu.currentApp)
+
+ contextMenu.close()
+ }
+ }
+ }
+ }
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Theme.mediumDuration
+ easing.type: Theme.emphasizedEasing
+ }
+ }
+
+ Behavior on scale {
+ NumberAnimation {
+ duration: Theme.mediumDuration
+ easing.type: Theme.emphasizedEasing
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modals/Spotlight/SpotlightModal.qml b/quickshell/.config/quickshell/Modals/Spotlight/SpotlightModal.qml
new file mode 100644
index 0000000..764a771
--- /dev/null
+++ b/quickshell/.config/quickshell/Modals/Spotlight/SpotlightModal.qml
@@ -0,0 +1,109 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell
+import Quickshell.Io
+import Quickshell.Widgets
+import qs.Common
+import qs.Modals.Common
+import qs.Modules.AppDrawer
+import qs.Services
+import qs.Widgets
+
+DankModal {
+ id: spotlightModal
+
+ property bool spotlightOpen: false
+ property Component spotlightContent
+
+ function show() {
+ spotlightOpen = true
+ open()
+ if (contentLoader.item && contentLoader.item.appLauncher) {
+ contentLoader.item.appLauncher.searchQuery = ""
+ }
+
+ Qt.callLater(() => {
+ if (contentLoader.item && contentLoader.item.searchField) {
+ contentLoader.item.searchField.forceActiveFocus()
+ }
+ })
+ }
+
+ function hide() {
+ spotlightOpen = false
+ close()
+ if (contentLoader.item && contentLoader.item.appLauncher) {
+ contentLoader.item.appLauncher.searchQuery = ""
+ contentLoader.item.appLauncher.selectedIndex = 0
+ contentLoader.item.appLauncher.setCategory("All")
+ }
+ }
+
+ function toggle() {
+ if (spotlightOpen) {
+ hide()
+ } else {
+ show()
+ }
+ }
+
+ shouldBeVisible: spotlightOpen
+ width: 550
+ height: 600
+ backgroundColor: Theme.popupBackground()
+ cornerRadius: Theme.cornerRadius
+ borderColor: Theme.outlineMedium
+ borderWidth: 1
+ enableShadow: true
+ onVisibleChanged: () => {
+ if (visible && !spotlightOpen) {
+ show()
+ }
+ if (visible && contentLoader.item) {
+ Qt.callLater(() => {
+ if (contentLoader.item.searchField) {
+ contentLoader.item.searchField.forceActiveFocus()
+ }
+ })
+ }
+ }
+ onBackgroundClicked: () => {
+ return hide()
+ }
+ content: spotlightContent
+
+ Connections {
+ function onCloseAllModalsExcept(excludedModal) {
+ if (excludedModal !== spotlightModal && !allowStacking && spotlightOpen) {
+ spotlightOpen = false
+ }
+ }
+
+ target: ModalManager
+ }
+
+ IpcHandler {
+ function open(): string {
+ spotlightModal.show()
+ return "SPOTLIGHT_OPEN_SUCCESS"
+ }
+
+ function close(): string {
+ spotlightModal.hide()
+ return "SPOTLIGHT_CLOSE_SUCCESS"
+ }
+
+ function toggle(): string {
+ spotlightModal.toggle()
+ return "SPOTLIGHT_TOGGLE_SUCCESS"
+ }
+
+ target: "spotlight"
+ }
+
+ spotlightContent: Component {
+ SpotlightContent {
+ parentModal: spotlightModal
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modals/Spotlight/SpotlightResults.qml b/quickshell/.config/quickshell/Modals/Spotlight/SpotlightResults.qml
new file mode 100644
index 0000000..340321e
--- /dev/null
+++ b/quickshell/.config/quickshell/Modals/Spotlight/SpotlightResults.qml
@@ -0,0 +1,324 @@
+import QtQuick
+import Quickshell
+import Quickshell.Widgets
+import qs.Common
+import qs.Widgets
+import qs.Services
+
+Rectangle {
+ id: resultsContainer
+
+ property var appLauncher: null
+ property var contextMenu: null
+
+ width: parent.width
+ height: parent.height - y
+ radius: Theme.cornerRadius
+ color: Theme.surfaceLight
+ border.color: Theme.outlineLight
+ border.width: 1
+
+ DankListView {
+ id: resultsList
+
+ property int itemHeight: 60
+ property int iconSize: 40
+ property bool showDescription: true
+ property int itemSpacing: Theme.spacingS
+ property bool hoverUpdatesSelection: false
+ property bool keyboardNavigationActive: appLauncher ? appLauncher.keyboardNavigationActive : false
+
+ signal keyboardNavigationReset
+ signal itemClicked(int index, var modelData)
+ signal itemRightClicked(int index, var modelData, real mouseX, real mouseY)
+
+ function ensureVisible(index) {
+ if (index < 0 || index >= count)
+ return
+
+ const itemY = index * (itemHeight + itemSpacing)
+ const itemBottom = itemY + itemHeight
+ if (itemY < contentY)
+ contentY = itemY
+ else if (itemBottom > contentY + height)
+ contentY = itemBottom - height
+ }
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingS
+ visible: appLauncher && appLauncher.viewMode === "list"
+ model: appLauncher ? appLauncher.model : null
+ currentIndex: appLauncher ? appLauncher.selectedIndex : -1
+ clip: true
+ spacing: itemSpacing
+ focus: true
+ interactive: true
+ cacheBuffer: Math.max(0, Math.min(height * 2, 1000))
+ reuseItems: true
+ onCurrentIndexChanged: {
+ if (keyboardNavigationActive)
+ ensureVisible(currentIndex)
+ }
+ onItemClicked: (index, modelData) => {
+ if (appLauncher)
+ appLauncher.launchApp(modelData)
+ }
+ onItemRightClicked: (index, modelData, mouseX, mouseY) => {
+ if (contextMenu)
+ contextMenu.show(mouseX, mouseY, modelData)
+ }
+ onKeyboardNavigationReset: () => {
+ if (appLauncher)
+ appLauncher.keyboardNavigationActive = false
+ }
+
+ delegate: Rectangle {
+ width: ListView.view.width
+ height: resultsList.itemHeight
+ radius: Theme.cornerRadius
+ color: ListView.isCurrentItem ? Theme.primaryPressed : listMouseArea.containsMouse ? Theme.primaryHoverLight : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.03)
+ border.color: ListView.isCurrentItem ? Theme.primarySelected : Theme.outlineMedium
+ border.width: ListView.isCurrentItem ? 2 : 1
+
+ Row {
+ anchors.fill: parent
+ anchors.margins: Theme.spacingM
+ spacing: Theme.spacingL
+
+ Item {
+ width: resultsList.iconSize
+ height: resultsList.iconSize
+ anchors.verticalCenter: parent.verticalCenter
+
+ IconImage {
+ id: listIconImg
+
+ anchors.fill: parent
+ source: Quickshell.iconPath(model.icon, true) || DesktopService.resolveIconPath(model.icon) || DesktopService.resolveIconPath(model.startupClass)
+ asynchronous: true
+ visible: status === Image.Ready
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ visible: !listIconImg.visible
+ color: Theme.surfaceLight
+ radius: Theme.cornerRadius
+ border.width: 1
+ border.color: Theme.primarySelected
+
+ StyledText {
+ anchors.centerIn: parent
+ text: (model.name && model.name.length > 0) ? model.name.charAt(0).toUpperCase() : "A"
+ font.pixelSize: resultsList.iconSize * 0.4
+ color: Theme.primary
+ font.weight: Font.Bold
+ }
+ }
+ }
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ width: parent.width - resultsList.iconSize - Theme.spacingL
+ spacing: Theme.spacingXS
+
+ StyledText {
+ width: parent.width
+ text: model.name || ""
+ font.pixelSize: Theme.fontSizeLarge
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ elide: Text.ElideRight
+ }
+
+ StyledText {
+ width: parent.width
+ text: model.comment || "Application"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceVariantText
+ elide: Text.ElideRight
+ visible: resultsList.showDescription && model.comment && model.comment.length > 0
+ }
+ }
+ }
+
+ MouseArea {
+ id: listMouseArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ acceptedButtons: Qt.LeftButton | Qt.RightButton
+ z: 10
+ onEntered: () => {
+ if (resultsList.hoverUpdatesSelection && !resultsList.keyboardNavigationActive)
+ resultsList.currentIndex = index
+ }
+ onPositionChanged: () => {
+ resultsList.keyboardNavigationReset()
+ }
+ onClicked: mouse => {
+ if (mouse.button === Qt.LeftButton) {
+ resultsList.itemClicked(index, model)
+ } else if (mouse.button === Qt.RightButton) {
+ const modalPos = mapToItem(resultsContainer.parent, mouse.x, mouse.y)
+ resultsList.itemRightClicked(index, model, modalPos.x, modalPos.y)
+ }
+ }
+ }
+ }
+ }
+
+ DankGridView {
+ id: resultsGrid
+
+ property int currentIndex: appLauncher ? appLauncher.selectedIndex : -1
+ property int columns: 4
+ property bool adaptiveColumns: false
+ property int minCellWidth: 120
+ property int maxCellWidth: 160
+ property int cellPadding: 8
+ property real iconSizeRatio: 0.55
+ property int maxIconSize: 48
+ property int minIconSize: 32
+ property bool hoverUpdatesSelection: false
+ property bool keyboardNavigationActive: appLauncher ? appLauncher.keyboardNavigationActive : false
+ property int baseCellWidth: adaptiveColumns ? Math.max(minCellWidth, Math.min(maxCellWidth, width / columns)) : (width - Theme.spacingS * 2) / columns
+ property int baseCellHeight: baseCellWidth + 20
+ property int actualColumns: adaptiveColumns ? Math.floor(width / cellWidth) : columns
+ property int remainingSpace: width - (actualColumns * cellWidth)
+
+ signal keyboardNavigationReset
+ signal itemClicked(int index, var modelData)
+ signal itemRightClicked(int index, var modelData, real mouseX, real mouseY)
+
+ function ensureVisible(index) {
+ if (index < 0 || index >= count)
+ return
+
+ const itemY = Math.floor(index / actualColumns) * cellHeight
+ const itemBottom = itemY + cellHeight
+ if (itemY < contentY)
+ contentY = itemY
+ else if (itemBottom > contentY + height)
+ contentY = itemBottom - height
+ }
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingS
+ visible: appLauncher && appLauncher.viewMode === "grid"
+ model: appLauncher ? appLauncher.model : null
+ clip: true
+ cellWidth: baseCellWidth
+ cellHeight: baseCellHeight
+ leftMargin: Math.max(Theme.spacingS, remainingSpace / 2)
+ rightMargin: leftMargin
+ focus: true
+ interactive: true
+ cacheBuffer: Math.max(0, Math.min(height * 2, 1000))
+ reuseItems: true
+ onCurrentIndexChanged: {
+ if (keyboardNavigationActive)
+ ensureVisible(currentIndex)
+ }
+ onItemClicked: (index, modelData) => {
+ if (appLauncher)
+ appLauncher.launchApp(modelData)
+ }
+ onItemRightClicked: (index, modelData, mouseX, mouseY) => {
+ if (contextMenu)
+ contextMenu.show(mouseX, mouseY, modelData)
+ }
+ onKeyboardNavigationReset: () => {
+ if (appLauncher)
+ appLauncher.keyboardNavigationActive = false
+ }
+
+ delegate: Rectangle {
+ width: resultsGrid.cellWidth - resultsGrid.cellPadding
+ height: resultsGrid.cellHeight - resultsGrid.cellPadding
+ radius: Theme.cornerRadius
+ color: resultsGrid.currentIndex === index ? Theme.primaryPressed : gridMouseArea.containsMouse ? Theme.primaryHoverLight : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.03)
+ border.color: resultsGrid.currentIndex === index ? Theme.primarySelected : Theme.outlineMedium
+ border.width: resultsGrid.currentIndex === index ? 2 : 1
+
+ Column {
+ anchors.centerIn: parent
+ spacing: Theme.spacingS
+
+ Item {
+ property int iconSize: Math.min(resultsGrid.maxIconSize, Math.max(resultsGrid.minIconSize, resultsGrid.cellWidth * resultsGrid.iconSizeRatio))
+
+ width: iconSize
+ height: iconSize
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ IconImage {
+ id: gridIconImg
+
+ anchors.fill: parent
+ source: Quickshell.iconPath(model.icon, true) || DesktopService.resolveIconPath(model.icon) || DesktopService.resolveIconPath(model.startupClass)
+ smooth: true
+ asynchronous: true
+ visible: status === Image.Ready
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ visible: !gridIconImg.visible
+ color: Theme.surfaceLight
+ radius: Theme.cornerRadius
+ border.width: 1
+ border.color: Theme.primarySelected
+
+ StyledText {
+ anchors.centerIn: parent
+ text: (model.name && model.name.length > 0) ? model.name.charAt(0).toUpperCase() : "A"
+ font.pixelSize: Math.min(28, parent.width * 0.5)
+ color: Theme.primary
+ font.weight: Font.Bold
+ }
+ }
+ }
+
+ StyledText {
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: resultsGrid.cellWidth - 12
+ text: model.name || ""
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ elide: Text.ElideRight
+ horizontalAlignment: Text.AlignHCenter
+ maximumLineCount: 2
+ wrapMode: Text.WordWrap
+ }
+ }
+
+ MouseArea {
+ id: gridMouseArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ acceptedButtons: Qt.LeftButton | Qt.RightButton
+ z: 10
+ onEntered: () => {
+ if (resultsGrid.hoverUpdatesSelection && !resultsGrid.keyboardNavigationActive)
+ resultsGrid.currentIndex = index
+ }
+ onPositionChanged: () => {
+ resultsGrid.keyboardNavigationReset()
+ }
+ onClicked: mouse => {
+ if (mouse.button === Qt.LeftButton) {
+ resultsGrid.itemClicked(index, model)
+ } else if (mouse.button === Qt.RightButton) {
+ const modalPos = mapToItem(resultsContainer.parent, mouse.x, mouse.y)
+ resultsGrid.itemRightClicked(index, model, modalPos.x, modalPos.y)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modals/WifiPasswordModal.qml b/quickshell/.config/quickshell/Modals/WifiPasswordModal.qml
new file mode 100644
index 0000000..36e425d
--- /dev/null
+++ b/quickshell/.config/quickshell/Modals/WifiPasswordModal.qml
@@ -0,0 +1,296 @@
+import QtQuick
+import qs.Common
+import qs.Modals.Common
+import qs.Services
+import qs.Widgets
+
+DankModal {
+ id: root
+
+ property string wifiPasswordSSID: ""
+ property string wifiPasswordInput: ""
+
+ function show(ssid) {
+ wifiPasswordSSID = ssid
+ wifiPasswordInput = ""
+ open()
+ Qt.callLater(() => {
+ if (contentLoader.item && contentLoader.item.passwordInput)
+ contentLoader.item.passwordInput.forceActiveFocus()
+ })
+ }
+
+ shouldBeVisible: false
+ width: 420
+ height: 230
+ onShouldBeVisibleChanged: () => {
+ if (!shouldBeVisible)
+ wifiPasswordInput = ""
+ }
+ onOpened: {
+ Qt.callLater(() => {
+ if (contentLoader.item && contentLoader.item.passwordInput)
+ contentLoader.item.passwordInput.forceActiveFocus()
+ })
+ }
+ onBackgroundClicked: () => {
+ close()
+ wifiPasswordInput = ""
+ }
+
+ Connections {
+ target: NetworkService
+
+ function onPasswordDialogShouldReopenChanged() {
+ if (NetworkService.passwordDialogShouldReopen && NetworkService.connectingSSID !== "") {
+ wifiPasswordSSID = NetworkService.connectingSSID
+ wifiPasswordInput = ""
+ open()
+ NetworkService.passwordDialogShouldReopen = false
+ }
+ }
+ }
+
+ content: Component {
+ FocusScope {
+ id: wifiContent
+
+ property alias passwordInput: passwordInput
+
+ anchors.fill: parent
+ focus: true
+ Keys.onEscapePressed: event => {
+ close()
+ wifiPasswordInput = ""
+ event.accepted = true
+ }
+
+ Column {
+ anchors.centerIn: parent
+ width: parent.width - Theme.spacingM * 2
+ spacing: Theme.spacingM
+
+ Row {
+ width: parent.width
+
+ Column {
+ width: parent.width - 40
+ spacing: Theme.spacingXS
+
+ StyledText {
+ text: "Connect to Wi-Fi"
+ font.pixelSize: Theme.fontSizeLarge
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ }
+
+ StyledText {
+ text: `Enter password for "${wifiPasswordSSID}"`
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceTextMedium
+ width: parent.width
+ elide: Text.ElideRight
+ }
+ }
+
+ DankActionButton {
+ iconName: "close"
+ iconSize: Theme.iconSize - 4
+ iconColor: Theme.surfaceText
+ onClicked: () => {
+ close()
+ wifiPasswordInput = ""
+ }
+ }
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 50
+ radius: Theme.cornerRadius
+ color: Theme.surfaceHover
+ border.color: passwordInput.activeFocus ? Theme.primary : Theme.outlineStrong
+ border.width: passwordInput.activeFocus ? 2 : 1
+
+ MouseArea {
+ anchors.fill: parent
+ onClicked: () => {
+ passwordInput.forceActiveFocus()
+ }
+ }
+
+ DankTextField {
+ id: passwordInput
+
+ anchors.fill: parent
+ font.pixelSize: Theme.fontSizeMedium
+ textColor: Theme.surfaceText
+ text: wifiPasswordInput
+ echoMode: showPasswordCheckbox.checked ? TextInput.Normal : TextInput.Password
+ placeholderText: ""
+ backgroundColor: "transparent"
+ focus: true
+ enabled: root.shouldBeVisible
+ onTextEdited: () => {
+ wifiPasswordInput = text
+ }
+ onAccepted: () => {
+ NetworkService.connectToWifi(wifiPasswordSSID, passwordInput.text)
+ close()
+ wifiPasswordInput = ""
+ passwordInput.text = ""
+ }
+ Component.onCompleted: () => {
+ if (root.shouldBeVisible)
+ focusDelayTimer.start()
+ }
+
+ Timer {
+ id: focusDelayTimer
+
+ interval: 100
+ repeat: false
+ onTriggered: () => {
+ if (root.shouldBeVisible)
+ passwordInput.forceActiveFocus()
+ }
+ }
+
+ Connections {
+ target: root
+
+ function onShouldBeVisibleChanged() {
+ if (root.shouldBeVisible)
+ focusDelayTimer.start()
+ }
+ }
+ }
+ }
+
+ 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 : Theme.outlineButton
+ border.width: 2
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "check"
+ size: 12
+ color: Theme.background
+ visible: parent.checked
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: () => {
+ showPasswordCheckbox.checked = !showPasswordCheckbox.checked
+ }
+ }
+ }
+
+ StyledText {
+ text: "Show password"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ 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 ? Theme.surfaceTextHover : "transparent"
+ border.color: Theme.surfaceVariantAlpha
+ border.width: 1
+
+ StyledText {
+ 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: () => {
+ close()
+ 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: passwordInput.text.length > 0
+ opacity: enabled ? 1 : 0.5
+
+ StyledText {
+ 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: () => {
+ NetworkService.connectToWifi(wifiPasswordSSID, passwordInput.text)
+ close()
+ wifiPasswordInput = ""
+ passwordInput.text = ""
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/AppDrawer/AppDrawerPopout.qml b/quickshell/.config/quickshell/Modules/AppDrawer/AppDrawerPopout.qml
new file mode 100644
index 0000000..bec4410
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/AppDrawer/AppDrawerPopout.qml
@@ -0,0 +1,821 @@
+import QtQuick
+import QtQuick.Effects
+import Quickshell
+import Quickshell.Io
+import Quickshell.Wayland
+import Quickshell.Widgets
+import qs.Common
+import qs.Modules.AppDrawer
+import qs.Services
+import qs.Widgets
+
+DankPopout {
+ id: appDrawerPopout
+
+ property string triggerSection: "left"
+ property var triggerScreen: null
+
+ // Setting to Exclusive, so virtual keyboards can send input to app drawer
+ WlrLayershell.keyboardFocus: shouldBeVisible ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
+
+ function show() {
+ open()
+ }
+
+ function setTriggerPosition(x, y, width, section, screen) {
+ triggerX = x
+ triggerY = y
+ triggerWidth = width
+ triggerSection = section
+ triggerScreen = screen
+ }
+
+ popupWidth: 520
+ popupHeight: 600
+ triggerX: Theme.spacingL
+ triggerY: Theme.barHeight - 4 + SettingsData.topBarSpacing + Theme.spacingXS
+ triggerWidth: 40
+ positioning: "center"
+ screen: triggerScreen
+
+ onShouldBeVisibleChanged: {
+ if (shouldBeVisible) {
+ appLauncher.searchQuery = ""
+ appLauncher.selectedIndex = 0
+ appLauncher.setCategory("All")
+ Qt.callLater(() => {
+ if (contentLoader.item && contentLoader.item.searchField) {
+ contentLoader.item.searchField.text = ""
+ contentLoader.item.searchField.forceActiveFocus()
+ }
+ })
+ }
+ }
+
+ AppLauncher {
+ id: appLauncher
+
+ viewMode: SettingsData.appLauncherViewMode
+ gridColumns: 4
+ onAppLaunched: appDrawerPopout.close()
+ onViewModeSelected: function (mode) {
+ SettingsData.setAppLauncherViewMode(mode)
+ }
+ }
+
+ content: Component {
+ Rectangle {
+ id: launcherPanel
+
+ property alias searchField: searchField
+
+ color: Theme.popupBackground()
+ radius: Theme.cornerRadius
+ antialiasing: true
+ smooth: true
+
+ // Multi-layer border effect
+ Repeater {
+ model: [{
+ "margin": -3,
+ "color": Qt.rgba(0, 0, 0, 0.05),
+ "z": -3
+ }, {
+ "margin": -2,
+ "color": Qt.rgba(0, 0, 0, 0.08),
+ "z": -2
+ }, {
+ "margin": 0,
+ "color": Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12),
+ "z": -1
+ }]
+ Rectangle {
+ anchors.fill: parent
+ anchors.margins: modelData.margin
+ color: "transparent"
+ radius: parent.radius + Math.abs(modelData.margin)
+ border.color: modelData.color
+ border.width: 1
+ z: modelData.z
+ }
+ }
+
+ Item {
+ id: keyHandler
+
+ anchors.fill: parent
+ focus: true
+ readonly property var keyMappings: {
+ const mappings = {}
+ mappings[Qt.Key_Escape] = () => appDrawerPopout.close()
+ mappings[Qt.Key_Down] = () => appLauncher.selectNext()
+ mappings[Qt.Key_Up] = () => appLauncher.selectPrevious()
+ mappings[Qt.Key_Return] = () => appLauncher.launchSelected()
+ mappings[Qt.Key_Enter] = () => appLauncher.launchSelected()
+
+ if (appLauncher.viewMode === "grid") {
+ mappings[Qt.Key_Right] = () => appLauncher.selectNextInRow()
+ mappings[Qt.Key_Left] = () => appLauncher.selectPreviousInRow()
+ }
+
+ return mappings
+ }
+
+ Keys.onPressed: function (event) {
+ if (keyMappings[event.key]) {
+ keyMappings[event.key]()
+ event.accepted = true
+ return
+ }
+
+ if (!searchField.activeFocus && event.text && /[a-zA-Z0-9\s]/.test(event.text)) {
+ searchField.forceActiveFocus()
+ searchField.insertText(event.text)
+ event.accepted = true
+ }
+ }
+
+ Column {
+ width: parent.width - Theme.spacingL * 2
+ height: parent.height - Theme.spacingL * 2
+ x: Theme.spacingL
+ y: Theme.spacingL
+ spacing: Theme.spacingL
+
+ Row {
+ width: parent.width
+ height: 40
+
+ StyledText {
+ anchors.verticalCenter: parent.verticalCenter
+ text: "Applications"
+ font.pixelSize: Theme.fontSizeLarge + 4
+ font.weight: Font.Bold
+ color: Theme.surfaceText
+ }
+
+ Item {
+ width: parent.width - 200
+ height: 1
+ }
+
+ StyledText {
+ anchors.verticalCenter: parent.verticalCenter
+ text: appLauncher.model.count + " apps"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceVariantText
+ }
+ }
+
+ DankTextField {
+ id: searchField
+
+ width: parent.width
+ height: 52
+ cornerRadius: Theme.cornerRadius
+ backgroundColor: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, Theme.getContentBackgroundAlpha() * 0.7)
+ normalBorderColor: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.3)
+ focusedBorderColor: Theme.primary
+ leftIconName: "search"
+ leftIconSize: Theme.iconSize
+ leftIconColor: Theme.surfaceVariantText
+ leftIconFocusedColor: Theme.primary
+ showClearButton: true
+ font.pixelSize: Theme.fontSizeLarge
+ enabled: appDrawerPopout.shouldBeVisible
+ ignoreLeftRightKeys: true
+ keyForwardTargets: [keyHandler]
+ onTextEdited: {
+ appLauncher.searchQuery = text
+ }
+ Keys.onPressed: function (event) {
+ if (event.key === Qt.Key_Escape) {
+ appDrawerPopout.close()
+ event.accepted = true
+ return
+ }
+
+ const isEnterKey = [Qt.Key_Return, Qt.Key_Enter].includes(event.key)
+ const hasText = text.length > 0
+
+ if (isEnterKey && hasText) {
+ if (appLauncher.keyboardNavigationActive && appLauncher.model.count > 0) {
+ appLauncher.launchSelected()
+ } else if (appLauncher.model.count > 0) {
+ appLauncher.launchApp(appLauncher.model.get(0))
+ }
+ event.accepted = true
+ return
+ }
+
+ const navigationKeys = [Qt.Key_Down, Qt.Key_Up, Qt.Key_Left, Qt.Key_Right]
+ const isNavigationKey = navigationKeys.includes(event.key)
+ const isEmptyEnter = isEnterKey && !hasText
+
+ event.accepted = !(isNavigationKey || isEmptyEnter)
+ }
+
+ Connections {
+ function onShouldBeVisibleChanged() {
+ if (!appDrawerPopout.shouldBeVisible) {
+ searchField.focus = false
+ }
+ }
+
+ target: appDrawerPopout
+ }
+ }
+
+ Row {
+ width: parent.width
+ height: 40
+ spacing: Theme.spacingM
+ visible: searchField.text.length === 0
+
+ Item {
+ width: 200
+ height: 36
+
+ DankDropdown {
+ anchors.fill: parent
+ text: ""
+ currentValue: appLauncher.selectedCategory
+ options: appLauncher.categories
+ optionIcons: appLauncher.categoryIcons
+ onValueChanged: function (value) {
+ appLauncher.setCategory(value)
+ }
+ }
+ }
+
+ Item {
+ width: parent.width - 300
+ height: 1
+ }
+
+ Row {
+ spacing: 4
+ anchors.verticalCenter: parent.verticalCenter
+
+ DankActionButton {
+ buttonSize: 36
+ circular: false
+ iconName: "view_list"
+ iconSize: 20
+ iconColor: appLauncher.viewMode === "list" ? Theme.primary : Theme.surfaceText
+ backgroundColor: appLauncher.viewMode === "list" ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
+ onClicked: {
+ appLauncher.setViewMode("list")
+ }
+ }
+
+ DankActionButton {
+ buttonSize: 36
+ circular: false
+ iconName: "grid_view"
+ iconSize: 20
+ iconColor: appLauncher.viewMode === "grid" ? Theme.primary : Theme.surfaceText
+ backgroundColor: appLauncher.viewMode === "grid" ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
+ onClicked: {
+ appLauncher.setViewMode("grid")
+ }
+ }
+ }
+ }
+
+ Rectangle {
+ width: parent.width
+ height: {
+ let usedHeight = 40 + Theme.spacingL
+ usedHeight += 52 + Theme.spacingL
+ usedHeight += (searchField.text.length === 0 ? 40 + Theme.spacingL : 0)
+ return parent.height - usedHeight
+ }
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.1)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.05)
+ border.width: 1
+
+ DankListView {
+ id: appList
+
+ property int itemHeight: 72
+ property int iconSize: 56
+ property bool showDescription: true
+ property int itemSpacing: Theme.spacingS
+ property bool hoverUpdatesSelection: false
+ property bool keyboardNavigationActive: appLauncher.keyboardNavigationActive
+
+ signal keyboardNavigationReset
+ signal itemClicked(int index, var modelData)
+ signal itemRightClicked(int index, var modelData, real mouseX, real mouseY)
+
+ function ensureVisible(index) {
+ if (index < 0 || index >= count)
+ return
+
+ var itemY = index * (itemHeight + itemSpacing)
+ var itemBottom = itemY + itemHeight
+ if (itemY < contentY)
+ contentY = itemY
+ else if (itemBottom > contentY + height)
+ contentY = itemBottom - height
+ }
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingS
+ visible: appLauncher.viewMode === "list"
+ model: appLauncher.model
+ currentIndex: appLauncher.selectedIndex
+ clip: true
+ spacing: itemSpacing
+ focus: true
+ interactive: true
+ cacheBuffer: Math.max(0, Math.min(height * 2, 1000))
+ reuseItems: true
+
+ onCurrentIndexChanged: {
+ if (keyboardNavigationActive)
+ ensureVisible(currentIndex)
+ }
+
+ onItemClicked: function (index, modelData) {
+ appLauncher.launchApp(modelData)
+ }
+ onItemRightClicked: function (index, modelData, mouseX, mouseY) {
+ contextMenu.show(mouseX, mouseY, modelData)
+ }
+ onKeyboardNavigationReset: {
+ appLauncher.keyboardNavigationActive = false
+ }
+
+ delegate: Rectangle {
+ width: ListView.view.width
+ height: appList.itemHeight
+ radius: Theme.cornerRadius
+ color: ListView.isCurrentItem ? Theme.primaryPressed : listMouseArea.containsMouse ? Theme.primaryHoverLight : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.03)
+ border.color: ListView.isCurrentItem ? Theme.primarySelected : Theme.outlineMedium
+ border.width: ListView.isCurrentItem ? 2 : 1
+
+ Row {
+ anchors.fill: parent
+ anchors.margins: Theme.spacingM
+ spacing: Theme.spacingL
+
+ Item {
+ width: appList.iconSize
+ height: appList.iconSize
+ anchors.verticalCenter: parent.verticalCenter
+
+ IconImage {
+ id: listIconImg
+
+ anchors.fill: parent
+ source: Quickshell.iconPath(model.icon, true) || DesktopService.resolveIconPath(model.icon) || DesktopService.resolveIconPath(model.startupClass)
+ smooth: true
+ asynchronous: true
+ visible: status === Image.Ready
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ visible: !listIconImg.visible
+ color: Theme.surfaceLight
+ radius: Theme.cornerRadius
+ border.width: 1
+ border.color: Theme.primarySelected
+
+ StyledText {
+ anchors.centerIn: parent
+ text: (model.name && model.name.length > 0) ? model.name.charAt(0).toUpperCase() : "A"
+ font.pixelSize: appList.iconSize * 0.4
+ color: Theme.primary
+ font.weight: Font.Bold
+ }
+ }
+ }
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ width: parent.width - appList.iconSize - Theme.spacingL
+ spacing: Theme.spacingXS
+
+ StyledText {
+ width: parent.width
+ text: model.name || ""
+ font.pixelSize: Theme.fontSizeLarge
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ elide: Text.ElideRight
+ }
+
+ StyledText {
+ width: parent.width
+ text: model.comment || "Application"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceVariantText
+ elide: Text.ElideRight
+ visible: appList.showDescription && model.comment && model.comment.length > 0
+ }
+ }
+ }
+
+ MouseArea {
+ id: listMouseArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ acceptedButtons: Qt.LeftButton | Qt.RightButton
+ z: 10
+ onEntered: {
+ if (appList.hoverUpdatesSelection && !appList.keyboardNavigationActive)
+ appList.currentIndex = index
+ }
+ onPositionChanged: {
+ appList.keyboardNavigationReset()
+ }
+ onClicked: mouse => {
+ if (mouse.button === Qt.LeftButton) {
+ appList.itemClicked(index, model)
+ } else if (mouse.button === Qt.RightButton) {
+ var panelPos = mapToItem(contextMenu.parent, mouse.x, mouse.y)
+ appList.itemRightClicked(index, model, panelPos.x, panelPos.y)
+ }
+ }
+ }
+ }
+ }
+
+ DankGridView {
+ id: appGrid
+
+ property int currentIndex: appLauncher.selectedIndex
+ property int columns: 4
+ property bool adaptiveColumns: false
+ property int minCellWidth: 120
+ property int maxCellWidth: 160
+ property int cellPadding: 8
+ property real iconSizeRatio: 0.6
+ property int maxIconSize: 56
+ property int minIconSize: 32
+ property bool hoverUpdatesSelection: false
+ property bool keyboardNavigationActive: appLauncher.keyboardNavigationActive
+ property int baseCellWidth: adaptiveColumns ? Math.max(minCellWidth, Math.min(maxCellWidth, width / columns)) : (width - Theme.spacingS * 2) / columns
+ property int baseCellHeight: baseCellWidth + 20
+ property int actualColumns: adaptiveColumns ? Math.floor(width / cellWidth) : columns
+ property int remainingSpace: width - (actualColumns * cellWidth)
+
+ signal keyboardNavigationReset
+ signal itemClicked(int index, var modelData)
+ signal itemRightClicked(int index, var modelData, real mouseX, real mouseY)
+
+ function ensureVisible(index) {
+ if (index < 0 || index >= count)
+ return
+
+ var itemY = Math.floor(index / actualColumns) * cellHeight
+ var itemBottom = itemY + cellHeight
+ if (itemY < contentY)
+ contentY = itemY
+ else if (itemBottom > contentY + height)
+ contentY = itemBottom - height
+ }
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingS
+ visible: appLauncher.viewMode === "grid"
+ model: appLauncher.model
+ clip: true
+ cellWidth: baseCellWidth
+ cellHeight: baseCellHeight
+ leftMargin: Math.max(Theme.spacingS, remainingSpace / 2)
+ rightMargin: leftMargin
+ focus: true
+ interactive: true
+ cacheBuffer: Math.max(0, Math.min(height * 2, 1000))
+ reuseItems: true
+
+ onCurrentIndexChanged: {
+ if (keyboardNavigationActive)
+ ensureVisible(currentIndex)
+ }
+
+ onItemClicked: function (index, modelData) {
+ appLauncher.launchApp(modelData)
+ }
+ onItemRightClicked: function (index, modelData, mouseX, mouseY) {
+ contextMenu.show(mouseX, mouseY, modelData)
+ }
+ onKeyboardNavigationReset: {
+ appLauncher.keyboardNavigationActive = false
+ }
+
+ delegate: Rectangle {
+ width: appGrid.cellWidth - appGrid.cellPadding
+ height: appGrid.cellHeight - appGrid.cellPadding
+ radius: Theme.cornerRadius
+ color: appGrid.currentIndex === index ? Theme.primaryPressed : gridMouseArea.containsMouse ? Theme.primaryHoverLight : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.03)
+ border.color: appGrid.currentIndex === index ? Theme.primarySelected : Theme.outlineMedium
+ border.width: appGrid.currentIndex === index ? 2 : 1
+
+ Column {
+ anchors.centerIn: parent
+ spacing: Theme.spacingS
+
+ Item {
+ property int iconSize: Math.min(appGrid.maxIconSize, Math.max(appGrid.minIconSize, appGrid.cellWidth * appGrid.iconSizeRatio))
+
+ width: iconSize
+ height: iconSize
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ IconImage {
+ id: gridIconImg
+
+ anchors.fill: parent
+ source: Quickshell.iconPath(model.icon, true) || DesktopService.resolveIconPath(model.icon)
+ smooth: true
+ asynchronous: true
+ visible: status === Image.Ready
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ visible: !gridIconImg.visible
+ color: Theme.surfaceLight
+ radius: Theme.cornerRadius
+ border.width: 1
+ border.color: Theme.primarySelected
+
+ StyledText {
+ anchors.centerIn: parent
+ text: (model.name && model.name.length > 0) ? model.name.charAt(0).toUpperCase() : "A"
+ font.pixelSize: Math.min(28, parent.width * 0.5)
+ color: Theme.primary
+ font.weight: Font.Bold
+ }
+ }
+ }
+
+ StyledText {
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: appGrid.cellWidth - 12
+ text: model.name || ""
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ elide: Text.ElideRight
+ horizontalAlignment: Text.AlignHCenter
+ maximumLineCount: 2
+ wrapMode: Text.WordWrap
+ }
+ }
+
+ MouseArea {
+ id: gridMouseArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ acceptedButtons: Qt.LeftButton | Qt.RightButton
+ z: 10
+ onEntered: {
+ if (appGrid.hoverUpdatesSelection && !appGrid.keyboardNavigationActive)
+ appGrid.currentIndex = index
+ }
+ onPositionChanged: {
+ appGrid.keyboardNavigationReset()
+ }
+ onClicked: mouse => {
+ if (mouse.button === Qt.LeftButton) {
+ appGrid.itemClicked(index, model)
+ } else if (mouse.button === Qt.RightButton) {
+ var panelPos = mapToItem(contextMenu.parent, mouse.x, mouse.y)
+ appGrid.itemRightClicked(index, model, panelPos.x, panelPos.y)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Rectangle {
+ id: contextMenu
+
+ property var currentApp: null
+ property bool menuVisible: false
+
+ readonly property string appId: (currentApp && currentApp.desktopEntry) ? (currentApp.desktopEntry.id || currentApp.desktopEntry.execString || "") : ""
+ readonly property bool isPinned: appId && SessionData.isPinnedApp(appId)
+
+ function show(x, y, app) {
+ currentApp = app
+
+ const menuWidth = 180
+ const menuHeight = menuColumn.implicitHeight + Theme.spacingS * 2
+
+ let finalX = x + 8
+ let finalY = y + 8
+
+ if (finalX + menuWidth > appDrawerPopout.popupWidth) {
+ finalX = x - menuWidth - 8
+ }
+
+ if (finalY + menuHeight > appDrawerPopout.popupHeight) {
+ finalY = y - menuHeight - 8
+ }
+
+ finalX = Math.max(8, Math.min(finalX, appDrawerPopout.popupWidth - menuWidth - 8))
+ finalY = Math.max(8, Math.min(finalY, appDrawerPopout.popupHeight - menuHeight - 8))
+
+ contextMenu.x = finalX
+ contextMenu.y = finalY
+ contextMenu.visible = true
+ contextMenu.menuVisible = true
+ }
+
+ function close() {
+ contextMenu.menuVisible = false
+ Qt.callLater(() => {
+ contextMenu.visible = false
+ })
+ }
+
+ visible: false
+ width: 180
+ height: menuColumn.implicitHeight + Theme.spacingS * 2
+ radius: Theme.cornerRadius
+ color: Theme.popupBackground()
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
+ border.width: 1
+ z: 1000
+ opacity: menuVisible ? 1 : 0
+ scale: menuVisible ? 1 : 0.85
+
+ 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
+ }
+
+ Column {
+ id: menuColumn
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingS
+ spacing: 1
+
+ Rectangle {
+ width: parent.width
+ height: 32
+ radius: Theme.cornerRadius
+ color: pinMouseArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
+
+ Row {
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingS
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingS
+
+ DankIcon {
+ name: contextMenu.isPinned ? "keep_off" : "push_pin"
+ size: Theme.iconSize - 2
+ color: Theme.surfaceText
+ opacity: 0.7
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: contextMenu.isPinned ? "Unpin from Dock" : "Pin to Dock"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ font.weight: Font.Normal
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ MouseArea {
+ id: pinMouseArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ if (!contextMenu.currentApp || !contextMenu.currentApp.desktopEntry) {
+ return
+ }
+
+ if (contextMenu.isPinned) {
+ SessionData.removePinnedApp(contextMenu.appId)
+ } else {
+ SessionData.addPinnedApp(contextMenu.appId)
+ }
+ contextMenu.close()
+ }
+ }
+ }
+
+ Rectangle {
+ width: parent.width - Theme.spacingS * 2
+ height: 5
+ anchors.horizontalCenter: parent.horizontalCenter
+ color: "transparent"
+
+ Rectangle {
+ anchors.centerIn: parent
+ width: parent.width
+ height: 1
+ color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
+ }
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 32
+ radius: Theme.cornerRadius
+ color: launchMouseArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
+
+ Row {
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingS
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingS
+
+ DankIcon {
+ name: "launch"
+ size: Theme.iconSize - 2
+ color: Theme.surfaceText
+ opacity: 0.7
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Launch"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ font.weight: Font.Normal
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ MouseArea {
+ id: launchMouseArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ if (contextMenu.currentApp)
+ appLauncher.launchApp(contextMenu.currentApp)
+
+ contextMenu.close()
+ }
+ }
+ }
+ }
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Theme.mediumDuration
+ easing.type: Theme.emphasizedEasing
+ }
+ }
+
+ Behavior on scale {
+ NumberAnimation {
+ duration: Theme.mediumDuration
+ easing.type: Theme.emphasizedEasing
+ }
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ visible: contextMenu.visible
+ z: 999
+ onClicked: {
+ contextMenu.close()
+ }
+
+ MouseArea {
+ x: contextMenu.x
+ y: contextMenu.y
+ width: contextMenu.width
+ height: contextMenu.height
+ onClicked: {
+
+ // Prevent closing when clicking on the menu itself
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/AppDrawer/AppLauncher.qml b/quickshell/.config/quickshell/Modules/AppDrawer/AppLauncher.qml
new file mode 100644
index 0000000..16a2b5a
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/AppDrawer/AppLauncher.qml
@@ -0,0 +1,168 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Item {
+ id: root
+
+ property string searchQuery: ""
+ property string selectedCategory: "All"
+ property string viewMode: "list" // "list" or "grid"
+ property int selectedIndex: 0
+ property int maxResults: 50
+ property int gridColumns: 4
+ property bool debounceSearch: true
+ property int debounceInterval: 50
+ property bool keyboardNavigationActive: false
+ readonly property var categories: {
+ const allCategories = AppSearchService.getAllCategories().filter(cat => cat !== "Education" && cat !== "Science")
+ const result = ["All"]
+ return result.concat(allCategories.filter(cat => cat !== "All"))
+ }
+ readonly property var categoryIcons: categories.map(category => AppSearchService.getCategoryIcon(category))
+ property var appUsageRanking: AppUsageHistoryData.appUsageRanking || {}
+ property alias model: filteredModel
+ property var _watchApplications: AppSearchService.applications
+
+ signal appLaunched(var app)
+ signal categorySelected(string category)
+ signal viewModeSelected(string mode)
+
+ function updateFilteredModel() {
+ filteredModel.clear()
+ selectedIndex = 0
+ keyboardNavigationActive = false
+
+ let apps = []
+ if (searchQuery.length === 0) {
+ apps = selectedCategory === "All" ? AppSearchService.getAppsInCategory("All") : AppSearchService.getAppsInCategory(selectedCategory).slice(0, maxResults)
+ } else {
+ if (selectedCategory === "All") {
+ apps = AppSearchService.searchApplications(searchQuery)
+ } else {
+ const categoryApps = AppSearchService.getAppsInCategory(selectedCategory)
+ if (categoryApps.length > 0) {
+ const allSearchResults = AppSearchService.searchApplications(searchQuery)
+ const categoryNames = new Set(categoryApps.map(app => app.name))
+ apps = allSearchResults.filter(searchApp => categoryNames.has(searchApp.name)).slice(0, maxResults)
+ } else {
+ apps = []
+ }
+ }
+ }
+
+ if (searchQuery.length === 0) {
+ apps = apps.sort((a, b) => {
+ const aId = a.id || a.execString || a.exec || ""
+ const bId = b.id || b.execString || b.exec || ""
+ const aUsage = appUsageRanking[aId] ? appUsageRanking[aId].usageCount : 0
+ const bUsage = appUsageRanking[bId] ? appUsageRanking[bId].usageCount : 0
+ if (aUsage !== bUsage) {
+ return bUsage - aUsage
+ }
+ return (a.name || "").localeCompare(b.name || "")
+ })
+ }
+
+ apps.forEach(app => {
+ if (app) {
+ filteredModel.append({
+ "name": app.name || "",
+ "exec": app.execString || "",
+ "icon": app.icon || "application-x-executable",
+ "comment": app.comment || "",
+ "categories": app.categories || [],
+ "desktopEntry": app
+ })
+ }
+ })
+ }
+
+ function selectNext() {
+ if (filteredModel.count === 0) {
+ return
+ }
+ keyboardNavigationActive = true
+ selectedIndex = viewMode === "grid" ? Math.min(selectedIndex + gridColumns, filteredModel.count - 1) : Math.min(selectedIndex + 1, filteredModel.count - 1)
+ }
+
+ function selectPrevious() {
+ if (filteredModel.count === 0) {
+ return
+ }
+ keyboardNavigationActive = true
+ selectedIndex = viewMode === "grid" ? Math.max(selectedIndex - gridColumns, 0) : Math.max(selectedIndex - 1, 0)
+ }
+
+ function selectNextInRow() {
+ if (filteredModel.count === 0 || viewMode !== "grid") {
+ return
+ }
+ keyboardNavigationActive = true
+ selectedIndex = Math.min(selectedIndex + 1, filteredModel.count - 1)
+ }
+
+ function selectPreviousInRow() {
+ if (filteredModel.count === 0 || viewMode !== "grid") {
+ return
+ }
+ keyboardNavigationActive = true
+ selectedIndex = Math.max(selectedIndex - 1, 0)
+ }
+
+ function launchSelected() {
+ if (filteredModel.count === 0 || selectedIndex < 0 || selectedIndex >= filteredModel.count) {
+ return
+ }
+ const selectedApp = filteredModel.get(selectedIndex)
+ launchApp(selectedApp)
+ }
+
+ function launchApp(appData) {
+ if (!appData) {
+ return
+ }
+ appData.desktopEntry.execute()
+ appLaunched(appData)
+ AppUsageHistoryData.addAppUsage(appData.desktopEntry)
+ }
+
+ function setCategory(category) {
+ selectedCategory = category
+ categorySelected(category)
+ }
+
+ function setViewMode(mode) {
+ viewMode = mode
+ viewModeSelected(mode)
+ }
+
+ onSearchQueryChanged: {
+ if (debounceSearch) {
+ searchDebounceTimer.restart()
+ } else {
+ updateFilteredModel()
+ }
+ }
+ onSelectedCategoryChanged: updateFilteredModel()
+ onAppUsageRankingChanged: updateFilteredModel()
+ on_WatchApplicationsChanged: updateFilteredModel()
+ Component.onCompleted: {
+ updateFilteredModel()
+ }
+
+ ListModel {
+ id: filteredModel
+ }
+
+ Timer {
+ id: searchDebounceTimer
+
+ interval: root.debounceInterval
+ repeat: false
+ onTriggered: updateFilteredModel()
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/AppDrawer/CategorySelector.qml b/quickshell/.config/quickshell/Modules/AppDrawer/CategorySelector.qml
new file mode 100644
index 0000000..910c7f7
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/AppDrawer/CategorySelector.qml
@@ -0,0 +1,143 @@
+import QtQuick
+import QtQuick.Controls
+import qs.Common
+import qs.Widgets
+
+Item {
+ id: root
+
+ property var categories: []
+ property string selectedCategory: "All"
+ property bool compact: false
+
+ signal categorySelected(string category)
+
+ readonly property int maxCompactItems: 8
+ readonly property int itemHeight: 36
+ readonly property color selectedBorderColor: "transparent"
+ readonly property color unselectedBorderColor: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.3)
+
+ function handleCategoryClick(category) {
+ selectedCategory = category
+ categorySelected(category)
+ }
+
+ function getButtonWidth(itemCount, containerWidth) {
+ return itemCount > 0 ? (containerWidth - (itemCount - 1) * Theme.spacingS) / itemCount : 0
+ }
+
+ height: compact ? itemHeight : (itemHeight * 2 + Theme.spacingS)
+
+ Row {
+ visible: compact
+ width: parent.width
+ spacing: Theme.spacingS
+
+ Repeater {
+ model: categories ? categories.slice(0, Math.min(categories.length || 0, maxCompactItems)) : []
+
+ Rectangle {
+ property int itemCount: Math.min(categories ? categories.length || 0 : 0, maxCompactItems)
+
+ height: root.itemHeight
+ width: root.getButtonWidth(itemCount, parent.width)
+ radius: Theme.cornerRadius
+ color: selectedCategory === modelData ? Theme.primary : "transparent"
+ border.color: selectedCategory === modelData ? selectedBorderColor : unselectedBorderColor
+
+ StyledText {
+ anchors.centerIn: parent
+ text: modelData
+ color: selectedCategory === modelData ? Theme.surface : Theme.surfaceText
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: selectedCategory === modelData ? Font.Medium : Font.Normal
+ elide: Text.ElideRight
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: root.handleCategoryClick(modelData)
+ }
+ }
+ }
+ }
+
+ Column {
+ visible: !compact
+ width: parent.width
+ spacing: Theme.spacingS
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingS
+
+ Repeater {
+ model: categories ? categories.slice(0, Math.min(4, categories.length || 0)) : []
+
+ Rectangle {
+ property int itemCount: Math.min(4, categories ? categories.length || 0 : 0)
+
+ height: root.itemHeight
+ width: root.getButtonWidth(itemCount, parent.width)
+ radius: Theme.cornerRadius
+ color: selectedCategory === modelData ? Theme.primary : "transparent"
+ border.color: selectedCategory === modelData ? selectedBorderColor : unselectedBorderColor
+
+ StyledText {
+ anchors.centerIn: parent
+ text: modelData
+ color: selectedCategory === modelData ? Theme.surface : Theme.surfaceText
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: selectedCategory === modelData ? Font.Medium : Font.Normal
+ elide: Text.ElideRight
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: root.handleCategoryClick(modelData)
+ }
+ }
+ }
+ }
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingS
+ visible: categories && categories.length > 4
+
+ Repeater {
+ model: categories && categories.length > 4 ? categories.slice(4) : []
+
+ Rectangle {
+ property int itemCount: categories && categories.length > 4 ? categories.length - 4 : 0
+
+ height: root.itemHeight
+ width: root.getButtonWidth(itemCount, parent.width)
+ radius: Theme.cornerRadius
+ color: selectedCategory === modelData ? Theme.primary : "transparent"
+ border.color: selectedCategory === modelData ? selectedBorderColor : unselectedBorderColor
+
+ StyledText {
+ anchors.centerIn: parent
+ text: modelData
+ color: selectedCategory === modelData ? Theme.surface : Theme.surfaceText
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: selectedCategory === modelData ? Font.Medium : Font.Normal
+ elide: Text.ElideRight
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: root.handleCategoryClick(modelData)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/ControlCenter/ControlCenterPopout.qml b/quickshell/.config/quickshell/Modules/ControlCenter/ControlCenterPopout.qml
new file mode 100644
index 0000000..890d548
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/ControlCenter/ControlCenterPopout.qml
@@ -0,0 +1,750 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Effects
+import QtQuick.Layouts
+import Quickshell
+import Quickshell.Io
+import Quickshell.Wayland
+import Quickshell.Widgets
+import qs.Common
+import qs.Modules.ControlCenter
+import qs.Modules.ControlCenter.Widgets
+import qs.Modules.ControlCenter.Details
+import qs.Modules.ControlCenter.Details 1.0 as Details
+import qs.Services
+import qs.Widgets
+
+DankPopout {
+ id: root
+
+ property string expandedSection: ""
+ property bool powerOptionsExpanded: false
+ property string triggerSection: "right"
+ property var triggerScreen: null
+
+ function setTriggerPosition(x, y, width, section, screen) {
+ triggerX = x
+ triggerY = y
+ triggerWidth = width
+ triggerSection = section
+ triggerScreen = screen
+ }
+
+ function openWithSection(section) {
+ if (shouldBeVisible) {
+ close()
+ } else {
+ expandedSection = section
+ open()
+ }
+ }
+
+ function toggleSection(section) {
+ if (expandedSection === section) {
+ expandedSection = ""
+ } else {
+ expandedSection = section
+ }
+ }
+
+ signal powerActionRequested(string action, string title, string message)
+ signal lockRequested
+
+ popupWidth: 550
+ popupHeight: Math.min((triggerScreen?.height ?? 1080) - 100, contentLoader.item && contentLoader.item.implicitHeight > 0 ? contentLoader.item.implicitHeight + 20 : 400)
+ triggerX: (triggerScreen?.width ?? 1920) - 600 - Theme.spacingL
+ triggerY: Theme.barHeight - 4 + SettingsData.topBarSpacing + Theme.spacingXS
+ triggerWidth: 80
+ positioning: "center"
+ screen: triggerScreen
+ shouldBeVisible: false
+ visible: shouldBeVisible
+
+ onShouldBeVisibleChanged: {
+ if (shouldBeVisible) {
+ Qt.callLater(() => {
+ NetworkService.autoRefreshEnabled = NetworkService.wifiEnabled
+ if (UserInfoService)
+ UserInfoService.getUptime()
+ })
+ } else {
+ Qt.callLater(() => {
+ NetworkService.autoRefreshEnabled = false
+ if (BluetoothService.adapter
+ && BluetoothService.adapter.discovering)
+ BluetoothService.adapter.discovering = false
+ })
+ }
+ }
+
+ content: Component {
+ Rectangle {
+ id: controlContent
+
+ implicitHeight: mainColumn.implicitHeight + Theme.spacingM
+ property alias bluetoothCodecSelector: bluetoothCodecSelector
+
+ color: {
+ const transparency = Theme.popupTransparency || 0.92
+ const surface = Theme.surfaceContainer || Qt.rgba(0.1, 0.1, 0.1, 1)
+ return Qt.rgba(surface.r, surface.g, surface.b, transparency)
+ }
+ radius: Theme.cornerRadius
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g,
+ Theme.outline.b, 0.08)
+ border.width: 1
+ antialiasing: true
+ smooth: true
+
+ Column {
+ id: mainColumn
+ width: parent.width - Theme.spacingL * 2
+ x: Theme.spacingL
+ y: Theme.spacingL
+ spacing: Theme.spacingS
+
+ Rectangle {
+ width: parent.width
+ height: 90
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r,
+ Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b,
+ Theme.getContentBackgroundAlpha() * 0.4)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g,
+ Theme.outline.b, 0.08)
+ border.width: 1
+
+ Row {
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.leftMargin: Theme.spacingL
+ anchors.rightMargin: Theme.spacingL
+ spacing: Theme.spacingL
+
+ Item {
+ id: avatarContainer
+
+ property bool hasImage: profileImageLoader.status === Image.Ready
+
+ width: 64
+ height: 64
+
+ Rectangle {
+ anchors.fill: parent
+ radius: width / 2
+ color: "transparent"
+ border.color: Theme.primary
+ border.width: 1
+ visible: parent.hasImage
+ }
+
+ Image {
+ id: profileImageLoader
+
+ source: {
+ if (PortalService.profileImage === "")
+ return ""
+
+ if (PortalService.profileImage.startsWith(
+ "/"))
+ return "file://" + PortalService.profileImage
+
+ return PortalService.profileImage
+ }
+ smooth: true
+ asynchronous: true
+ mipmap: true
+ cache: true
+ visible: false
+ }
+
+ MultiEffect {
+ anchors.fill: parent
+ anchors.margins: 5
+ source: profileImageLoader
+ maskEnabled: true
+ maskSource: circularMask
+ visible: avatarContainer.hasImage
+ maskThresholdMin: 0.5
+ maskSpreadAtMin: 1
+ }
+
+ Item {
+ id: circularMask
+
+ width: 64 - 10
+ height: 64 - 10
+ layer.enabled: true
+ layer.smooth: true
+ visible: false
+
+ Rectangle {
+ anchors.fill: parent
+ radius: width / 2
+ color: "black"
+ antialiasing: true
+ }
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ radius: width / 2
+ color: Theme.primary
+ visible: !parent.hasImage
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "person"
+ size: Theme.iconSize + 8
+ color: Theme.primaryText
+ }
+ }
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "warning"
+ size: Theme.iconSize + 8
+ color: Theme.primaryText
+ visible: PortalService.profileImage !== ""
+ && profileImageLoader.status === Image.Error
+ }
+ }
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingXS
+
+ StyledText {
+ text: UserInfoService.fullName
+ || UserInfoService.username || "User"
+ font.pixelSize: Theme.fontSizeLarge
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ }
+
+ StyledText {
+ text: (UserInfoService.uptime
+ || "Unknown")
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ font.weight: Font.Normal
+ }
+ }
+ }
+
+ Row {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.rightMargin: Theme.spacingL
+ spacing: Theme.spacingS
+
+ DankActionButton {
+ buttonSize: 40
+ iconName: "lock"
+ iconSize: Theme.iconSize - 2
+ iconColor: Theme.surfaceText
+ backgroundColor: Qt.rgba(
+ Theme.surfaceVariant.r,
+ Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b,
+ 0.5)
+ onClicked: {
+ root.close()
+ root.lockRequested()
+ }
+ }
+
+ DankActionButton {
+ buttonSize: 40
+ iconName: root.powerOptionsExpanded ? "expand_less" : "power_settings_new"
+ iconSize: Theme.iconSize - 2
+ iconColor: Theme.surfaceText
+ backgroundColor: Qt.rgba(
+ Theme.surfaceVariant.r,
+ Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b,
+ 0.5)
+ onClicked: {
+ root.powerOptionsExpanded = !root.powerOptionsExpanded
+ }
+
+ }
+
+ DankActionButton {
+ buttonSize: 40
+ iconName: "settings"
+ iconSize: Theme.iconSize - 2
+ iconColor: Theme.surfaceText
+ backgroundColor: Qt.rgba(
+ Theme.surfaceVariant.r,
+ Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b,
+ 0.5)
+ onClicked: {
+ root.close()
+ settingsModal.show()
+ }
+ }
+ }
+ }
+
+ Item {
+ width: parent.width
+ implicitHeight: root.powerOptionsExpanded ? 60 : 0
+ height: implicitHeight
+ clip: true
+
+ Rectangle {
+ width: parent.width
+ height: 60
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r,
+ Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b,
+ Theme.getContentBackgroundAlpha() * 0.4)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g,
+ Theme.outline.b, 0.08)
+ border.width: root.powerOptionsExpanded ? 1 : 0
+ opacity: root.powerOptionsExpanded ? 1 : 0
+ clip: true
+
+ Row {
+ anchors.centerIn: parent
+ spacing: Theme.spacingL
+ visible: root.powerOptionsExpanded
+
+ Rectangle {
+ width: 100
+ height: 34
+ radius: Theme.cornerRadius
+ color: logoutButton.containsMouse ? Qt.rgba(
+ Theme.primary.r,
+ Theme.primary.g,
+ Theme.primary.b,
+ 0.12) : Qt.rgba(
+ Theme.surfaceVariant.r,
+ Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b,
+ 0.5)
+
+ Row {
+ anchors.centerIn: parent
+ spacing: Theme.spacingXS
+
+ DankIcon {
+ name: "logout"
+ size: Theme.fontSizeSmall
+ color: logoutButton.containsMouse ? Theme.primary : Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Logout"
+ font.pixelSize: Theme.fontSizeSmall
+ color: logoutButton.containsMouse ? Theme.primary : Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ MouseArea {
+ id: logoutButton
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onPressed: {
+ root.powerOptionsExpanded = false
+ root.close()
+ root.powerActionRequested(
+ "logout", "Logout",
+ "Are you sure you want to logout?")
+ }
+ }
+ }
+
+ Rectangle {
+ width: 100
+ height: 34
+ radius: Theme.cornerRadius
+ color: rebootButton.containsMouse ? Qt.rgba(
+ Theme.primary.r,
+ Theme.primary.g,
+ Theme.primary.b,
+ 0.12) : Qt.rgba(
+ Theme.surfaceVariant.r,
+ Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b,
+ 0.5)
+
+ Row {
+ anchors.centerIn: parent
+ spacing: Theme.spacingXS
+
+ DankIcon {
+ name: "restart_alt"
+ size: Theme.fontSizeSmall
+ color: rebootButton.containsMouse ? Theme.primary : Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Restart"
+ font.pixelSize: Theme.fontSizeSmall
+ color: rebootButton.containsMouse ? Theme.primary : Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ MouseArea {
+ id: rebootButton
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onPressed: {
+ root.powerOptionsExpanded = false
+ root.close()
+ root.powerActionRequested(
+ "reboot", "Restart",
+ "Are you sure you want to restart?")
+ }
+ }
+ }
+
+ Rectangle {
+ width: 100
+ height: 34
+ radius: Theme.cornerRadius
+ color: suspendButton.containsMouse ? Qt.rgba(
+ Theme.primary.r,
+ Theme.primary.g,
+ Theme.primary.b,
+ 0.12) : Qt.rgba(
+ Theme.surfaceVariant.r,
+ Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b,
+ 0.5)
+
+ Row {
+ anchors.centerIn: parent
+ spacing: Theme.spacingXS
+
+ DankIcon {
+ name: "bedtime"
+ size: Theme.fontSizeSmall
+ color: suspendButton.containsMouse ? Theme.primary : Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Suspend"
+ font.pixelSize: Theme.fontSizeSmall
+ color: suspendButton.containsMouse ? Theme.primary : Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ MouseArea {
+ id: suspendButton
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onPressed: {
+ root.powerOptionsExpanded = false
+ root.close()
+ root.powerActionRequested(
+ "suspend", "Suspend",
+ "Are you sure you want to suspend?")
+ }
+ }
+ }
+
+ Rectangle {
+ width: 100
+ height: 34
+ radius: Theme.cornerRadius
+ color: shutdownButton.containsMouse ? Qt.rgba(
+ Theme.primary.r,
+ Theme.primary.g,
+ Theme.primary.b,
+ 0.12) : Qt.rgba(
+ Theme.surfaceVariant.r,
+ Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b,
+ 0.5)
+
+ Row {
+ anchors.centerIn: parent
+ spacing: Theme.spacingXS
+
+ DankIcon {
+ name: "power_settings_new"
+ size: Theme.fontSizeSmall
+ color: shutdownButton.containsMouse ? Theme.primary : Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Shutdown"
+ font.pixelSize: Theme.fontSizeSmall
+ color: shutdownButton.containsMouse ? Theme.primary : Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ MouseArea {
+ id: shutdownButton
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onPressed: {
+ root.powerOptionsExpanded = false
+ root.close()
+ root.powerActionRequested(
+ "poweroff", "Shutdown",
+ "Are you sure you want to shutdown?")
+ }
+ }
+ }
+ }
+ }
+
+ }
+
+ Item {
+ width: parent.width
+ height: audioSliderRow.implicitHeight
+
+ Row {
+ id: audioSliderRow
+ x: -Theme.spacingS
+ width: parent.width + Theme.spacingS * 2
+ spacing: Theme.spacingM
+
+ AudioSliderRow {
+ width: SettingsData.hideBrightnessSlider ? parent.width - Theme.spacingM : (parent.width - Theme.spacingM) / 2
+ }
+
+ Item {
+ width: (parent.width - Theme.spacingM) / 2
+ height: parent.height
+ visible: !SettingsData.hideBrightnessSlider
+
+ BrightnessSliderRow {
+ width: parent.width
+ height: parent.height
+ x: -Theme.spacingS
+ }
+ }
+ }
+ }
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ NetworkPill {
+ width: (parent.width - Theme.spacingM) / 2
+ expanded: root.expandedSection === "network"
+ onClicked: {
+ if (NetworkService.wifiToggling) {
+ return
+ }
+ if (NetworkService.networkStatus === "ethernet") {
+ if (NetworkService.ethernetConnected && !NetworkService.wifiEnabled) {
+ NetworkService.toggleWifiRadio()
+ return
+ }
+ root.toggleSection("network")
+ return
+ }
+ if (NetworkService.networkStatus === "wifi") {
+ if (NetworkService.ethernetConnected) {
+ NetworkService.toggleWifiRadio()
+ return
+ }
+ NetworkService.disconnectWifi()
+ return
+ }
+ if (!NetworkService.wifiEnabled) {
+ NetworkService.toggleWifiRadio()
+ return
+ }
+ if (NetworkService.wifiEnabled && NetworkService.networkStatus === "disconnected") {
+ root.toggleSection("network")
+ }
+ }
+ onExpandClicked: root.toggleSection("network")
+ }
+
+ BluetoothPill {
+ width: (parent.width - Theme.spacingM) / 2
+ expanded: root.expandedSection === "bluetooth"
+ onClicked: {
+ if (BluetoothService.adapter)
+ BluetoothService.adapter.enabled = !BluetoothService.adapter.enabled
+ }
+ onExpandClicked: root.toggleSection("bluetooth")
+ visible: BluetoothService.available
+ }
+ }
+
+ Loader {
+ width: parent.width
+ active: root.expandedSection === "network" || root.expandedSection === "bluetooth"
+ visible: active
+ sourceComponent: DetailView {
+ width: parent.width
+ isVisible: true
+ title: {
+ switch (root.expandedSection) {
+ case "network": return "Network Settings"
+ case "bluetooth": return "Bluetooth Settings"
+ default: return ""
+ }
+ }
+ content: {
+ switch (root.expandedSection) {
+ case "network": return networkDetailComponent
+ case "bluetooth": return bluetoothDetailComponent
+ default: return null
+ }
+ }
+ contentHeight: 250
+ }
+ }
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ AudioOutputPill {
+ width: (parent.width - Theme.spacingM) / 2
+ expanded: root.expandedSection === "audio_output"
+ onClicked: {
+ if (AudioService.sink) {
+ AudioService.sink.audio.muted = !AudioService.sink.audio.muted
+ }
+ }
+ onExpandClicked: root.toggleSection("audio_output")
+ }
+
+ AudioInputPill {
+ width: (parent.width - Theme.spacingM) / 2
+ expanded: root.expandedSection === "audio_input"
+ onClicked: {
+ if (AudioService.source) {
+ AudioService.source.audio.muted = !AudioService.source.audio.muted
+ }
+ }
+ onExpandClicked: root.toggleSection("audio_input")
+ }
+ }
+
+ Loader {
+ width: parent.width
+ active: root.expandedSection === "audio_output" || root.expandedSection === "audio_input"
+ visible: active
+ sourceComponent: DetailView {
+ width: parent.width
+ isVisible: true
+ title: {
+ switch (root.expandedSection) {
+ case "audio_output": return "Audio Output"
+ case "audio_input": return "Audio Input"
+ default: return ""
+ }
+ }
+ content: {
+ switch (root.expandedSection) {
+ case "audio_output": return audioOutputDetailComponent
+ case "audio_input": return audioInputDetailComponent
+ default: return null
+ }
+ }
+ contentHeight: 250
+ }
+ }
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ ToggleButton {
+ width: (parent.width - Theme.spacingM) / 2
+ iconName: DisplayService.nightModeEnabled ? "nightlight" : "dark_mode"
+ text: "Night Mode"
+ secondaryText: SessionData.nightModeAutoEnabled ? "Auto" : (DisplayService.nightModeEnabled ? "On" : "Off")
+ isActive: DisplayService.nightModeEnabled
+ enabled: DisplayService.automationAvailable
+ onClicked: DisplayService.toggleNightMode()
+
+ DankIcon {
+ anchors.top: parent.top
+ anchors.right: parent.right
+ anchors.topMargin: Theme.spacingS
+ anchors.rightMargin: Theme.spacingS
+ name: "schedule"
+ size: 12
+ color: Theme.primary
+ visible: SessionData.nightModeAutoEnabled
+ opacity: 0.8
+ }
+ }
+
+ ToggleButton {
+ width: (parent.width - Theme.spacingM) / 2
+ iconName: SessionData.isLightMode ? "light_mode" : "palette"
+ text: "Theme"
+ secondaryText: SessionData.isLightMode ? "Light" : "Dark"
+ isActive: true
+ onClicked: Theme.toggleLightMode()
+ }
+ }
+ }
+
+ Details.BluetoothCodecSelector {
+ id: bluetoothCodecSelector
+ anchors.fill: parent
+ z: 10000
+ }
+ }
+ }
+
+ Component {
+ id: networkDetailComponent
+ NetworkDetail {}
+ }
+
+ Component {
+ id: bluetoothDetailComponent
+ BluetoothDetail {
+ id: bluetoothDetail
+ onShowCodecSelector: function(device) {
+ if (contentLoader.item && contentLoader.item.bluetoothCodecSelector) {
+ contentLoader.item.bluetoothCodecSelector.show(device)
+ contentLoader.item.bluetoothCodecSelector.codecSelected.connect(function(deviceAddress, codecName) {
+ bluetoothDetail.updateDeviceCodecDisplay(deviceAddress, codecName)
+ })
+ }
+ }
+ }
+ }
+
+ Component {
+ id: audioOutputDetailComponent
+ AudioOutputDetail {}
+ }
+
+ Component {
+ id: audioInputDetailComponent
+ AudioInputDetail {}
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/ControlCenter/Details/AudioInputDetail.qml b/quickshell/.config/quickshell/Modules/ControlCenter/Details/AudioInputDetail.qml
new file mode 100644
index 0000000..d8e7dbd
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/ControlCenter/Details/AudioInputDetail.qml
@@ -0,0 +1,178 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell
+import Quickshell.Services.Pipewire
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Rectangle {
+ implicitHeight: headerRow.height + volumeSlider.height + audioContent.height + Theme.spacingM
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, Theme.getContentBackgroundAlpha() * 0.6)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
+ border.width: 1
+
+ Row {
+ id: headerRow
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.leftMargin: Theme.spacingM
+ anchors.rightMargin: Theme.spacingM
+ anchors.topMargin: Theme.spacingS
+ height: 40
+
+ StyledText {
+ id: headerText
+ text: "Input Devices"
+ font.pixelSize: Theme.fontSizeLarge
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ DankSlider {
+ id: volumeSlider
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: headerRow.bottom
+ anchors.leftMargin: Theme.spacingM
+ anchors.rightMargin: Theme.spacingM
+ anchors.topMargin: Theme.spacingXS
+ height: 35
+ value: AudioService.source && AudioService.source.audio ? Math.round(AudioService.source.audio.volume * 100) : 0
+ minimum: 0
+ maximum: 100
+ leftIcon: {
+ if (!AudioService.source || !AudioService.source.audio) return "mic_off"
+ let muted = AudioService.source.audio.muted
+ return muted ? "mic_off" : "mic"
+ }
+ rightIcon: "volume_up"
+ enabled: AudioService.source && AudioService.source.audio
+ unit: "%"
+ showValue: true
+ visible: AudioService.source && AudioService.source.audio
+
+ onSliderValueChanged: function(newValue) {
+ if (AudioService.source && AudioService.source.audio) {
+ AudioService.source.audio.volume = newValue / 100
+ }
+ }
+
+ MouseArea {
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ width: Theme.iconSize + Theme.spacingS * 2
+ height: parent.height
+ onClicked: {
+ if (AudioService.source && AudioService.source.audio) {
+ AudioService.source.audio.muted = !AudioService.source.audio.muted
+ }
+ }
+ }
+ }
+
+ DankFlickable {
+ id: audioContent
+ anchors.top: volumeSlider.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ anchors.margins: Theme.spacingM
+ anchors.topMargin: Theme.spacingS
+ contentHeight: audioColumn.height
+ clip: true
+
+ Column {
+ id: audioColumn
+ width: parent.width
+ spacing: Theme.spacingS
+
+ Repeater {
+ model: Pipewire.nodes.values.filter(node => {
+ return node.audio && !node.isSink && !node.isStream
+ })
+
+ delegate: Rectangle {
+ required property var modelData
+ required property int index
+
+ width: parent.width
+ height: 50
+ radius: Theme.cornerRadius
+ color: deviceMouseArea.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, index % 2 === 0 ? 0.3 : 0.2)
+ border.color: modelData === AudioService.source ? Theme.primary : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
+ border.width: modelData === AudioService.source ? 2 : 1
+
+ Row {
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.leftMargin: Theme.spacingM
+ spacing: Theme.spacingS
+
+ DankIcon {
+ name: {
+ if (modelData.name.includes("bluez"))
+ return "headset"
+ else if (modelData.name.includes("usb"))
+ return "headset"
+ else
+ return "mic"
+ }
+ size: Theme.iconSize - 4
+ color: modelData === AudioService.source ? Theme.primary : Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ width: parent.parent.width - parent.parent.anchors.leftMargin - parent.spacing - Theme.iconSize - Theme.spacingM
+
+ StyledText {
+ text: AudioService.displayName(modelData)
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ font.weight: modelData === AudioService.source ? Font.Medium : Font.Normal
+ elide: Text.ElideRight
+ width: parent.width
+ wrapMode: Text.NoWrap
+ }
+
+ StyledText {
+ text: modelData === AudioService.source ? "Active" : "Available"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ elide: Text.ElideRight
+ width: parent.width
+ wrapMode: Text.NoWrap
+ }
+ }
+ }
+
+ MouseArea {
+ id: deviceMouseArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ if (modelData) {
+ Pipewire.preferredDefaultAudioSource = modelData
+ }
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation { duration: Theme.shortDuration }
+ }
+
+ Behavior on border.color {
+ ColorAnimation { duration: Theme.shortDuration }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/quickshell/.config/quickshell/Modules/ControlCenter/Details/AudioOutputDetail.qml b/quickshell/.config/quickshell/Modules/ControlCenter/Details/AudioOutputDetail.qml
new file mode 100644
index 0000000..7755620
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/ControlCenter/Details/AudioOutputDetail.qml
@@ -0,0 +1,138 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell
+import Quickshell.Services.Pipewire
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Rectangle {
+ implicitHeight: headerRow.height + audioContent.height + Theme.spacingM
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, Theme.getContentBackgroundAlpha() * 0.6)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
+ border.width: 1
+
+ Row {
+ id: headerRow
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.leftMargin: Theme.spacingM
+ anchors.rightMargin: Theme.spacingM
+ anchors.topMargin: Theme.spacingS
+ height: 40
+
+ StyledText {
+ id: headerText
+ text: "Audio Devices"
+ font.pixelSize: Theme.fontSizeLarge
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ DankFlickable {
+ id: audioContent
+ anchors.top: headerRow.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ anchors.margins: Theme.spacingM
+ anchors.topMargin: Theme.spacingM
+ contentHeight: audioColumn.height
+ clip: true
+
+ Column {
+ id: audioColumn
+ width: parent.width
+ spacing: Theme.spacingS
+
+ Repeater {
+ model: Pipewire.nodes.values.filter(node => {
+ return node.audio && node.isSink && !node.isStream
+ })
+
+ delegate: Rectangle {
+ required property var modelData
+ required property int index
+
+ width: parent.width
+ height: 50
+ radius: Theme.cornerRadius
+ color: deviceMouseArea.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, index % 2 === 0 ? 0.3 : 0.2)
+ border.color: modelData === AudioService.sink ? Theme.primary : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
+ border.width: modelData === AudioService.sink ? 2 : 1
+
+ Row {
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.leftMargin: Theme.spacingM
+ spacing: Theme.spacingS
+
+ DankIcon {
+ name: {
+ if (modelData.name.includes("bluez"))
+ return "headset"
+ else if (modelData.name.includes("hdmi"))
+ return "tv"
+ else if (modelData.name.includes("usb"))
+ return "headset"
+ else
+ return "speaker"
+ }
+ size: Theme.iconSize - 4
+ color: modelData === AudioService.sink ? Theme.primary : Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ width: parent.parent.width - parent.parent.anchors.leftMargin - parent.spacing - Theme.iconSize - Theme.spacingM
+
+ StyledText {
+ text: AudioService.displayName(modelData)
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ font.weight: modelData === AudioService.sink ? Font.Medium : Font.Normal
+ elide: Text.ElideRight
+ width: parent.width
+ wrapMode: Text.NoWrap
+ }
+
+ StyledText {
+ text: modelData === AudioService.sink ? "Active" : "Available"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ elide: Text.ElideRight
+ width: parent.width
+ wrapMode: Text.NoWrap
+ }
+ }
+ }
+
+ MouseArea {
+ id: deviceMouseArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ if (modelData) {
+ Pipewire.preferredDefaultAudioSink = modelData
+ }
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation { duration: Theme.shortDuration }
+ }
+
+ Behavior on border.color {
+ ColorAnimation { duration: Theme.shortDuration }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/quickshell/.config/quickshell/Modules/ControlCenter/Details/BluetoothCodecSelector.qml b/quickshell/.config/quickshell/Modules/ControlCenter/Details/BluetoothCodecSelector.qml
new file mode 100644
index 0000000..d79e4e8
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/ControlCenter/Details/BluetoothCodecSelector.qml
@@ -0,0 +1,307 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell
+import Quickshell.Io
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Item {
+ id: root
+
+ property var device: null
+ property bool modalVisible: false
+ property var parentItem
+ property var availableCodecs: []
+ property string currentCodec: ""
+ property bool isLoading: false
+
+ signal codecSelected(string deviceAddress, string codecName)
+
+ function show(bluetoothDevice) {
+ device = bluetoothDevice;
+ isLoading = true;
+ availableCodecs = [];
+ currentCodec = "";
+ visible = true;
+ modalVisible = true;
+ queryCodecs();
+ Qt.callLater(() => {
+ focusScope.forceActiveFocus();
+ });
+ }
+
+ function hide() {
+ modalVisible = false;
+ Qt.callLater(() => {
+ visible = false;
+ });
+ }
+
+ function queryCodecs() {
+ if (!device)
+ return;
+
+ BluetoothService.getAvailableCodecs(device, function(codecs, current) {
+ availableCodecs = codecs;
+ currentCodec = current;
+ isLoading = false;
+ });
+ }
+
+ function selectCodec(profileName) {
+ if (!device || isLoading)
+ return;
+
+ let selectedCodec = availableCodecs.find(c => c.profile === profileName);
+ if (selectedCodec && device) {
+ BluetoothService.updateDeviceCodec(device.address, selectedCodec.name);
+ codecSelected(device.address, selectedCodec.name);
+ }
+
+ isLoading = true;
+ BluetoothService.switchCodec(device, profileName, function(success, message) {
+ isLoading = false;
+ if (success) {
+ ToastService.showToast(message, ToastService.levelInfo);
+ Qt.callLater(root.hide);
+ } else {
+ ToastService.showToast(message, ToastService.levelError);
+ }
+ });
+ }
+
+ visible: false
+ anchors.fill: parent
+ z: 2000
+
+ MouseArea {
+ id: modalBlocker
+ anchors.fill: parent
+ visible: modalVisible
+ enabled: modalVisible
+ hoverEnabled: true
+ preventStealing: true
+ propagateComposedEvents: false
+
+ onClicked: root.hide()
+ onWheel: (wheel) => { wheel.accepted = true }
+ onPositionChanged: (mouse) => { mouse.accepted = true }
+ }
+
+ Rectangle {
+ id: modalBackground
+ anchors.fill: parent
+ color: Qt.rgba(0, 0, 0, 0.5)
+ opacity: modalVisible ? 1 : 0
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Theme.mediumDuration
+ easing.type: Theme.emphasizedEasing
+ }
+ }
+ }
+
+ FocusScope {
+ id: focusScope
+
+ anchors.fill: parent
+ focus: root.visible
+ enabled: root.visible
+
+ Keys.onEscapePressed: {
+ root.hide()
+ event.accepted = true
+ }
+ }
+
+ Rectangle {
+ id: modalContent
+ anchors.centerIn: parent
+ width: 320
+ height: contentColumn.implicitHeight + Theme.spacingL * 2
+ radius: Theme.cornerRadius
+ color: Theme.surfaceContainer
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
+ border.width: 1
+ opacity: modalVisible ? 1 : 0
+ scale: modalVisible ? 1 : 0.9
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ preventStealing: true
+ propagateComposedEvents: false
+ onClicked: (mouse) => { mouse.accepted = true }
+ onWheel: (wheel) => { wheel.accepted = true }
+ onPositionChanged: (mouse) => { mouse.accepted = true }
+ }
+
+ Column {
+ id: contentColumn
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingM
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: device ? BluetoothService.getDeviceIcon(device) : "headset"
+ size: Theme.iconSize + 4
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 2
+
+ StyledText {
+ text: device ? (device.name || device.deviceName) : ""
+ font.pixelSize: Theme.fontSizeLarge
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ }
+
+ StyledText {
+ text: "Audio Codec Selection"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceTextMedium
+ }
+
+ }
+
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 1
+ color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.05)
+ }
+
+ StyledText {
+ text: isLoading ? "Loading codecs..." : `Current: ${currentCodec}`
+ font.pixelSize: Theme.fontSizeSmall
+ color: isLoading ? Theme.primary : Theme.surfaceTextMedium
+ font.weight: Font.Medium
+ }
+
+ Column {
+ width: parent.width
+ spacing: Theme.spacingXS
+ visible: !isLoading
+
+ Repeater {
+ model: availableCodecs
+
+ Rectangle {
+ width: parent.width
+ height: 48
+ radius: Theme.cornerRadius
+ color: {
+ if (modelData.name === currentCodec)
+ return Theme.surfaceContainerHigh;
+ else if (codecMouseArea.containsMouse)
+ return Theme.surfaceHover;
+ else
+ return "transparent";
+ }
+ border.color: "transparent"
+ border.width: 1
+
+ Row {
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingM
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingS
+
+ Rectangle {
+ width: 6
+ height: 6
+ radius: 3
+ color: modelData.qualityColor
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 2
+
+ StyledText {
+ text: modelData.name
+ font.pixelSize: Theme.fontSizeMedium
+ color: modelData.name === currentCodec ? Theme.primary : Theme.surfaceText
+ font.weight: modelData.name === currentCodec ? Font.Medium : Font.Normal
+ }
+
+ StyledText {
+ text: modelData.description
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceTextMedium
+ }
+
+ }
+
+ }
+
+ DankIcon {
+ name: "check"
+ size: Theme.iconSize - 4
+ color: Theme.primary
+ anchors.right: parent.right
+ anchors.rightMargin: Theme.spacingM
+ anchors.verticalCenter: parent.verticalCenter
+ visible: modelData.name === currentCodec
+ }
+
+ MouseArea {
+ id: codecMouseArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ enabled: modelData.name !== currentCodec && !isLoading
+ onClicked: {
+ selectCodec(modelData.profile);
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Theme.mediumDuration
+ easing.type: Theme.emphasizedEasing
+ }
+
+ }
+
+ Behavior on scale {
+ NumberAnimation {
+ duration: Theme.mediumDuration
+ easing.type: Theme.emphasizedEasing
+ }
+
+ }
+
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/ControlCenter/Details/BluetoothDetail.qml b/quickshell/.config/quickshell/Modules/ControlCenter/Details/BluetoothDetail.qml
new file mode 100644
index 0000000..7de1f6c
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/ControlCenter/Details/BluetoothDetail.qml
@@ -0,0 +1,543 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell
+import Quickshell.Bluetooth
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Rectangle {
+ implicitHeight: BluetoothService.adapter && BluetoothService.adapter.enabled ? headerRow.height + bluetoothContent.height + Theme.spacingM : headerRow.height
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, Theme.getContentBackgroundAlpha() * 0.6)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
+ border.width: 1
+
+ property var bluetoothCodecModalRef: null
+
+ signal showCodecSelector(var device)
+
+ function updateDeviceCodecDisplay(deviceAddress, codecName) {
+ for (let i = 0; i < pairedRepeater.count; i++) {
+ let item = pairedRepeater.itemAt(i)
+ if (item && item.modelData && item.modelData.address === deviceAddress) {
+ item.currentCodec = codecName
+ break
+ }
+ }
+ }
+
+ Row {
+ id: headerRow
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.leftMargin: Theme.spacingM
+ anchors.rightMargin: Theme.spacingM
+ anchors.topMargin: Theme.spacingS
+ height: 40
+
+ StyledText {
+ id: headerText
+ text: "Bluetooth Settings"
+ font.pixelSize: Theme.fontSizeLarge
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Item {
+ width: Math.max(0, parent.width - headerText.implicitWidth - scanButton.width - Theme.spacingM)
+ height: parent.height
+ }
+
+ Rectangle {
+ id: scanButton
+ width: 100
+ height: 36
+ radius: 18
+ color: {
+ if (!BluetoothService.adapter || !BluetoothService.adapter.enabled)
+ return Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3)
+ return scanMouseArea.containsMouse ? Theme.surfaceContainerHigh : "transparent"
+ }
+ border.color: BluetoothService.adapter && BluetoothService.adapter.enabled ? Theme.primary : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
+ border.width: 1
+ visible: BluetoothService.adapter && BluetoothService.adapter.enabled
+
+ Row {
+ anchors.centerIn: parent
+ spacing: Theme.spacingXS
+
+ DankIcon {
+ name: BluetoothService.adapter && BluetoothService.adapter.discovering ? "stop" : "bluetooth_searching"
+ size: 18
+ color: BluetoothService.adapter && BluetoothService.adapter.enabled ? Theme.primary : Theme.surfaceVariantText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: BluetoothService.adapter && BluetoothService.adapter.discovering ? "Scanning" : "Scan"
+ color: BluetoothService.adapter && BluetoothService.adapter.enabled ? Theme.primary : Theme.surfaceVariantText
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: Font.Medium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ MouseArea {
+ id: scanMouseArea
+ anchors.fill: parent
+ hoverEnabled: true
+ enabled: BluetoothService.adapter && BluetoothService.adapter.enabled
+ cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
+ onClicked: {
+ if (BluetoothService.adapter)
+ BluetoothService.adapter.discovering = !BluetoothService.adapter.discovering
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+ }
+ }
+
+ DankFlickable {
+ id: bluetoothContent
+ anchors.top: headerRow.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ anchors.margins: Theme.spacingM
+ anchors.topMargin: Theme.spacingM
+ visible: BluetoothService.adapter && BluetoothService.adapter.enabled
+ contentHeight: bluetoothColumn.height
+ clip: true
+
+ Column {
+ id: bluetoothColumn
+ width: parent.width
+ spacing: Theme.spacingS
+
+
+ Repeater {
+ id: pairedRepeater
+ model: {
+ if (!BluetoothService.adapter || !BluetoothService.adapter.devices)
+ return []
+
+ let devices = [...BluetoothService.adapter.devices.values.filter(dev => dev && (dev.paired || dev.trusted))]
+ devices.sort((a, b) => {
+ if (a.connected && !b.connected) return -1
+ if (!a.connected && b.connected) return 1
+ return (b.signalStrength || 0) - (a.signalStrength || 0)
+ })
+ return devices
+ }
+
+ delegate: Rectangle {
+ required property var modelData
+ required property int index
+
+ property string currentCodec: BluetoothService.deviceCodecs[modelData.address] || ""
+
+ width: parent.width
+ height: 50
+ radius: Theme.cornerRadius
+
+ Component.onCompleted: {
+ if (modelData.connected && BluetoothService.isAudioDevice(modelData)) {
+ BluetoothService.refreshDeviceCodec(modelData)
+ }
+ }
+ color: {
+ if (modelData.state === BluetoothDeviceState.Connecting)
+ return Qt.rgba(Theme.warning.r, Theme.warning.g, Theme.warning.b, 0.12)
+ if (deviceMouseArea.containsMouse)
+ return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08)
+ return Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, index % 2 === 0 ? 0.3 : 0.2)
+ }
+ border.color: {
+ if (modelData.state === BluetoothDeviceState.Connecting)
+ return Theme.warning
+ if (modelData.connected)
+ return Theme.primary
+ return Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
+ }
+ border.width: (modelData.connected || modelData.state === BluetoothDeviceState.Connecting) ? 2 : 1
+
+ Row {
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.leftMargin: Theme.spacingM
+ spacing: Theme.spacingS
+
+ DankIcon {
+ name: BluetoothService.getDeviceIcon(modelData)
+ size: Theme.iconSize - 4
+ color: {
+ if (modelData.state === BluetoothDeviceState.Connecting)
+ return Theme.warning
+ if (modelData.connected)
+ return Theme.primary
+ return Theme.surfaceText
+ }
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ width: 200
+
+ StyledText {
+ text: modelData.name || modelData.deviceName || "Unknown Device"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ font.weight: modelData.connected ? Font.Medium : Font.Normal
+ elide: Text.ElideRight
+ width: parent.width
+ }
+
+ Row {
+ spacing: Theme.spacingXS
+
+ StyledText {
+ text: {
+ if (modelData.state === BluetoothDeviceState.Connecting)
+ return "Connecting..."
+ if (modelData.connected) {
+ let status = "Connected"
+ if (currentCodec) {
+ status += " • " + currentCodec
+ }
+ return status
+ }
+ return "Paired"
+ }
+ font.pixelSize: Theme.fontSizeSmall
+ color: {
+ if (modelData.state === BluetoothDeviceState.Connecting)
+ return Theme.warning
+ return Theme.surfaceVariantText
+ }
+ }
+
+ StyledText {
+ text: {
+ if (modelData.batteryAvailable && modelData.battery > 0)
+ return "• " + Math.round(modelData.battery * 100) + "%"
+
+ var btBattery = BatteryService.bluetoothDevices.find(dev => {
+ return dev.name === (modelData.name || modelData.deviceName) ||
+ dev.name.toLowerCase().includes((modelData.name || modelData.deviceName).toLowerCase()) ||
+ (modelData.name || modelData.deviceName).toLowerCase().includes(dev.name.toLowerCase())
+ })
+ return btBattery ? "• " + btBattery.percentage + "%" : ""
+ }
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ visible: text.length > 0
+ }
+
+ StyledText {
+ text: modelData.signalStrength !== undefined && modelData.signalStrength > 0 ? "• " + modelData.signalStrength + "%" : ""
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ visible: text.length > 0
+ }
+ }
+ }
+ }
+
+ DankActionButton {
+ id: pairedOptionsButton
+ anchors.right: parent.right
+ anchors.rightMargin: Theme.spacingS
+ anchors.verticalCenter: parent.verticalCenter
+ iconName: "more_horiz"
+ buttonSize: 28
+ onClicked: {
+ if (bluetoothContextMenu.visible) {
+ bluetoothContextMenu.close()
+ } else {
+ bluetoothContextMenu.currentDevice = modelData
+ bluetoothContextMenu.popup(pairedOptionsButton, -bluetoothContextMenu.width + pairedOptionsButton.width, pairedOptionsButton.height + Theme.spacingXS)
+ }
+ }
+ }
+
+ MouseArea {
+ id: deviceMouseArea
+ anchors.fill: parent
+ anchors.rightMargin: pairedOptionsButton.width + Theme.spacingS
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ if (modelData.connected) {
+ modelData.disconnect()
+ } else {
+ BluetoothService.connectDeviceWithTrust(modelData)
+ }
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation { duration: Theme.shortDuration }
+ }
+
+ Behavior on border.color {
+ ColorAnimation { duration: Theme.shortDuration }
+ }
+ }
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 1
+ color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
+ visible: pairedRepeater.count > 0 && availableRepeater.count > 0
+ }
+
+
+ Item {
+ width: parent.width
+ height: 80
+ visible: BluetoothService.adapter && BluetoothService.adapter.discovering && availableRepeater.count === 0
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "sync"
+ size: 24
+ color: Qt.rgba(Theme.surfaceText.r || 0.8, Theme.surfaceText.g || 0.8, Theme.surfaceText.b || 0.8, 0.4)
+
+ RotationAnimation on rotation {
+ running: parent.visible && BluetoothService.adapter && BluetoothService.adapter.discovering && availableRepeater.count === 0
+ loops: Animation.Infinite
+ from: 0
+ to: 360
+ duration: 1500
+ }
+ }
+ }
+
+ Repeater {
+ id: availableRepeater
+ model: {
+ if (!BluetoothService.adapter || !BluetoothService.adapter.discovering || !Bluetooth.devices)
+ return []
+
+ var filtered = Bluetooth.devices.values.filter(dev => {
+ return dev && !dev.paired && !dev.pairing && !dev.blocked &&
+ (dev.signalStrength === undefined || dev.signalStrength > 0)
+ })
+ return BluetoothService.sortDevices(filtered)
+ }
+
+ delegate: Rectangle {
+ required property var modelData
+ required property int index
+
+ property bool canConnect: BluetoothService.canConnect(modelData)
+ property bool isBusy: BluetoothService.isDeviceBusy(modelData)
+
+ width: parent.width
+ height: 50
+ radius: Theme.cornerRadius
+ color: availableMouseArea.containsMouse && !isBusy ? 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.15)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
+ border.width: 1
+ opacity: canConnect ? 1 : 0.6
+
+ Row {
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.leftMargin: Theme.spacingM
+ spacing: Theme.spacingS
+
+ DankIcon {
+ name: BluetoothService.getDeviceIcon(modelData)
+ size: Theme.iconSize - 4
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ width: 200
+
+ StyledText {
+ text: modelData.name || modelData.deviceName || "Unknown Device"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ elide: Text.ElideRight
+ width: parent.width
+ }
+
+ Row {
+ spacing: Theme.spacingXS
+
+ StyledText {
+ text: {
+ if (modelData.pairing) return "Pairing..."
+ if (modelData.blocked) return "Blocked"
+ return BluetoothService.getSignalStrength(modelData)
+ }
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ }
+
+ StyledText {
+ text: modelData.signalStrength !== undefined && modelData.signalStrength > 0 ? "• " + modelData.signalStrength + "%" : ""
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ visible: text.length > 0 && !modelData.pairing && !modelData.blocked
+ }
+ }
+ }
+ }
+
+ StyledText {
+ anchors.right: parent.right
+ anchors.rightMargin: Theme.spacingM
+ anchors.verticalCenter: parent.verticalCenter
+ text: {
+ if (modelData.pairing) return "Pairing..."
+ if (!canConnect) return "Cannot pair"
+ return "Pair"
+ }
+ font.pixelSize: Theme.fontSizeSmall
+ color: canConnect ? Theme.primary : Theme.surfaceVariantText
+ font.weight: Font.Medium
+ }
+
+ MouseArea {
+ id: availableMouseArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: canConnect && !isBusy ? Qt.PointingHandCursor : Qt.ArrowCursor
+ enabled: canConnect && !isBusy
+ onClicked: {
+ if (modelData) {
+ BluetoothService.connectDeviceWithTrust(modelData)
+ }
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation { duration: Theme.shortDuration }
+ }
+ }
+ }
+
+ Item {
+ width: parent.width
+ height: 60
+ visible: !BluetoothService.adapter
+
+ StyledText {
+ anchors.centerIn: parent
+ text: "No Bluetooth adapter found"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceVariantText
+ }
+ }
+ }
+ }
+
+ Menu {
+ id: bluetoothContextMenu
+ width: 150
+ closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutsideParent
+
+ property var currentDevice: null
+
+ background: Rectangle {
+ color: Theme.popupBackground()
+ radius: Theme.cornerRadius
+ border.width: 1
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
+ }
+
+ MenuItem {
+ text: bluetoothContextMenu.currentDevice && bluetoothContextMenu.currentDevice.connected ? "Disconnect" : "Connect"
+ height: 32
+
+ contentItem: StyledText {
+ text: parent.text
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ leftPadding: Theme.spacingS
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ background: Rectangle {
+ color: parent.hovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : "transparent"
+ radius: Theme.cornerRadius / 2
+ }
+
+ onTriggered: {
+ if (bluetoothContextMenu.currentDevice) {
+ if (bluetoothContextMenu.currentDevice.connected) {
+ bluetoothContextMenu.currentDevice.disconnect()
+ } else {
+ BluetoothService.connectDeviceWithTrust(bluetoothContextMenu.currentDevice)
+ }
+ }
+ }
+ }
+
+ MenuItem {
+ text: "Audio Codec"
+ height: bluetoothContextMenu.currentDevice && BluetoothService.isAudioDevice(bluetoothContextMenu.currentDevice) && bluetoothContextMenu.currentDevice.connected ? 32 : 0
+ visible: bluetoothContextMenu.currentDevice && BluetoothService.isAudioDevice(bluetoothContextMenu.currentDevice) && bluetoothContextMenu.currentDevice.connected
+
+ contentItem: StyledText {
+ text: parent.text
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ leftPadding: Theme.spacingS
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ background: Rectangle {
+ color: parent.hovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : "transparent"
+ radius: Theme.cornerRadius / 2
+ }
+
+ onTriggered: {
+ if (bluetoothContextMenu.currentDevice) {
+ showCodecSelector(bluetoothContextMenu.currentDevice)
+ }
+ }
+ }
+
+ MenuItem {
+ text: "Forget Device"
+ height: 32
+
+ contentItem: StyledText {
+ text: parent.text
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.error
+ leftPadding: Theme.spacingS
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ background: Rectangle {
+ color: parent.hovered ? Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.08) : "transparent"
+ radius: Theme.cornerRadius / 2
+ }
+
+ onTriggered: {
+ if (bluetoothContextMenu.currentDevice) {
+ bluetoothContextMenu.currentDevice.forget()
+ }
+ }
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/quickshell/.config/quickshell/Modules/ControlCenter/Details/NetworkDetail.qml b/quickshell/.config/quickshell/Modules/ControlCenter/Details/NetworkDetail.qml
new file mode 100644
index 0000000..3207020
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/ControlCenter/Details/NetworkDetail.qml
@@ -0,0 +1,533 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell
+import qs.Common
+import qs.Services
+import qs.Widgets
+import qs.Modals
+
+Rectangle {
+ implicitHeight: {
+ if (NetworkService.wifiToggling) {
+ return headerRow.height + wifiToggleContent.height + Theme.spacingM
+ }
+ if (NetworkService.wifiEnabled) {
+ return headerRow.height + wifiContent.height + Theme.spacingM
+ }
+ return headerRow.height + wifiOffContent.height + Theme.spacingM
+ }
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, Theme.getContentBackgroundAlpha() * 0.6)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
+ border.width: 1
+
+ Component.onCompleted: {
+ NetworkService.addRef()
+ if (NetworkService.wifiEnabled) {
+ NetworkService.scanWifi()
+ }
+ }
+
+ Component.onDestruction: {
+ NetworkService.removeRef()
+ }
+
+ property var wifiPasswordModalRef: {
+ wifiPasswordModalLoader.active = true
+ return wifiPasswordModalLoader.item
+ }
+ property var networkInfoModalRef: {
+ networkInfoModalLoader.active = true
+ return networkInfoModalLoader.item
+ }
+
+ Row {
+ id: headerRow
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.leftMargin: Theme.spacingM
+ anchors.rightMargin: Theme.spacingM
+ anchors.topMargin: Theme.spacingS
+ height: 40
+
+ StyledText {
+ id: headerText
+ text: "Network Settings"
+ font.pixelSize: Theme.fontSizeLarge
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Item {
+ width: Math.max(0, parent.width - headerText.implicitWidth - preferenceControls.width - Theme.spacingM)
+ height: parent.height
+ }
+
+ Row {
+ id: preferenceControls
+ spacing: Theme.spacingS
+ anchors.verticalCenter: parent.verticalCenter
+ visible: NetworkService.ethernetConnected && NetworkService.wifiConnected
+
+ Rectangle {
+ property bool isActive: NetworkService.userPreference === "ethernet"
+
+ width: 90
+ height: 36
+ radius: 18
+ color: isActive ? Theme.surfaceContainerHigh : ethernetMouseArea.containsMouse ? Theme.surfaceHover : "transparent"
+
+ StyledText {
+ anchors.centerIn: parent
+ text: "Ethernet"
+ color: parent.isActive ? Theme.primary : Theme.surfaceText
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: parent.isActive ? Font.Medium : Font.Normal
+ }
+
+ MouseArea {
+ id: ethernetMouseArea
+ anchors.fill: parent
+ hoverEnabled: true
+ onClicked: NetworkService.setNetworkPreference("ethernet")
+ cursorShape: Qt.PointingHandCursor
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+ }
+
+ Rectangle {
+ property bool isActive: NetworkService.userPreference === "wifi"
+
+ width: 70
+ height: 36
+ radius: 18
+ color: isActive ? Theme.surfaceContainerHigh : wifiMouseArea.containsMouse ? Theme.surfaceHover : "transparent"
+
+ StyledText {
+ anchors.centerIn: parent
+ text: "WiFi"
+ color: parent.isActive ? Theme.primary : Theme.surfaceText
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: parent.isActive ? Font.Medium : Font.Normal
+ }
+
+ MouseArea {
+ id: wifiMouseArea
+ anchors.fill: parent
+ hoverEnabled: true
+ onClicked: NetworkService.setNetworkPreference("wifi")
+ cursorShape: Qt.PointingHandCursor
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+ }
+ }
+ }
+
+ Item {
+ id: wifiToggleContent
+ anchors.top: headerRow.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.margins: Theme.spacingM
+ anchors.topMargin: Theme.spacingM
+ visible: NetworkService.wifiToggling
+ height: visible ? 80 : 0
+
+ Column {
+ anchors.centerIn: parent
+ spacing: Theme.spacingM
+
+ DankIcon {
+ anchors.horizontalCenter: parent.horizontalCenter
+ name: "sync"
+ size: 32
+ color: Theme.primary
+
+ RotationAnimation on rotation {
+ running: NetworkService.wifiToggling
+ loops: Animation.Infinite
+ from: 0
+ to: 360
+ duration: 1000
+ }
+ }
+
+ StyledText {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: NetworkService.wifiEnabled ? "Disabling WiFi..." : "Enabling WiFi..."
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ horizontalAlignment: Text.AlignHCenter
+ }
+ }
+ }
+
+ Item {
+ id: wifiOffContent
+ anchors.top: headerRow.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.margins: Theme.spacingM
+ anchors.topMargin: Theme.spacingM
+ visible: !NetworkService.wifiEnabled && !NetworkService.wifiToggling
+ height: visible ? 120 : 0
+
+ Column {
+ anchors.centerIn: parent
+ spacing: Theme.spacingL
+ width: parent.width
+
+ DankIcon {
+ anchors.horizontalCenter: parent.horizontalCenter
+ name: "wifi_off"
+ size: 48
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.5)
+ }
+
+ StyledText {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: "WiFi is off"
+ font.pixelSize: Theme.fontSizeLarge
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ horizontalAlignment: Text.AlignHCenter
+ }
+
+ Rectangle {
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: 120
+ height: 36
+ radius: 18
+ color: enableWifiButton.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08)
+ border.width: 1
+ border.color: Theme.primary
+
+ StyledText {
+ anchors.centerIn: parent
+ text: "Enable WiFi"
+ color: Theme.primary
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: Font.Medium
+ }
+
+ MouseArea {
+ id: enableWifiButton
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: NetworkService.toggleWifiRadio()
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+ }
+ }
+ }
+
+ DankFlickable {
+ id: wifiContent
+ anchors.top: headerRow.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ anchors.margins: Theme.spacingM
+ anchors.topMargin: Theme.spacingM
+ visible: NetworkService.wifiInterface && NetworkService.wifiEnabled && !NetworkService.wifiToggling
+ contentHeight: wifiColumn.height
+ clip: true
+
+ Column {
+ id: wifiColumn
+ width: parent.width
+ spacing: Theme.spacingS
+
+ Item {
+ width: parent.width
+ height: 200
+ visible: NetworkService.wifiInterface && NetworkService.wifiNetworks?.length < 1 && !NetworkService.wifiToggling
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "refresh"
+ size: 48
+ color: Qt.rgba(Theme.surfaceText.r || 0.8, Theme.surfaceText.g || 0.8, Theme.surfaceText.b || 0.8, 0.3)
+
+ RotationAnimation on rotation {
+ running: true
+ loops: Animation.Infinite
+ from: 0
+ to: 360
+ duration: 1000
+ }
+ }
+ }
+
+ Repeater {
+ model: {
+ let networks = [...NetworkService.wifiNetworks]
+ networks.sort((a, b) => {
+ if (a.ssid === NetworkService.currentWifiSSID) return -1
+ if (b.ssid === NetworkService.currentWifiSSID) return 1
+ return b.signal - a.signal
+ })
+ return networks
+ }
+ delegate: Rectangle {
+ required property var modelData
+ required property int index
+
+ width: parent.width
+ height: 50
+ radius: Theme.cornerRadius
+ color: networkMouseArea.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, index % 2 === 0 ? 0.3 : 0.2)
+ border.color: modelData.ssid === NetworkService.currentWifiSSID ? Theme.primary : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
+ border.width: modelData.ssid === NetworkService.currentWifiSSID ? 2 : 1
+
+ Row {
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.leftMargin: Theme.spacingM
+ spacing: Theme.spacingS
+
+ DankIcon {
+ name: {
+ let strength = modelData.signal || 0
+ if (strength >= 70) return "signal_wifi_4_bar"
+ if (strength >= 50) return "network_wifi_3_bar"
+ if (strength >= 25) return "network_wifi_2_bar"
+ if (strength >= 10) return "network_wifi_1_bar"
+ return "signal_wifi_bad"
+ }
+ size: Theme.iconSize - 4
+ color: modelData.ssid === NetworkService.currentWifiSSID ? Theme.primary : Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ width: 200
+
+ StyledText {
+ text: modelData.ssid || "Unknown Network"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ font.weight: modelData.ssid === NetworkService.currentWifiSSID ? Font.Medium : Font.Normal
+ elide: Text.ElideRight
+ width: parent.width
+ }
+
+ Row {
+ spacing: Theme.spacingXS
+
+ StyledText {
+ text: modelData.ssid === NetworkService.currentWifiSSID ? "Connected" : (modelData.secured ? "Secured" : "Open")
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ }
+
+ StyledText {
+ text: modelData.saved ? "• Saved" : ""
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.primary
+ visible: text.length > 0
+ }
+
+ StyledText {
+ text: "• " + modelData.signal + "%"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ }
+ }
+ }
+ }
+
+ DankActionButton {
+ id: optionsButton
+ anchors.right: parent.right
+ anchors.rightMargin: Theme.spacingS
+ anchors.verticalCenter: parent.verticalCenter
+ iconName: "more_horiz"
+ buttonSize: 28
+ onClicked: {
+ if (networkContextMenu.visible) {
+ networkContextMenu.close()
+ } else {
+ networkContextMenu.currentSSID = modelData.ssid
+ networkContextMenu.currentSecured = modelData.secured
+ networkContextMenu.currentConnected = modelData.ssid === NetworkService.currentWifiSSID
+ networkContextMenu.currentSaved = modelData.saved
+ networkContextMenu.currentSignal = modelData.signal
+ networkContextMenu.popup(optionsButton, -networkContextMenu.width + optionsButton.width, optionsButton.height + Theme.spacingXS)
+ }
+ }
+ }
+
+ MouseArea {
+ id: networkMouseArea
+ anchors.fill: parent
+ anchors.rightMargin: optionsButton.width + Theme.spacingS
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: function(event) {
+ if (modelData.ssid !== NetworkService.currentWifiSSID) {
+ if (modelData.secured && !modelData.saved) {
+ if (wifiPasswordModalRef) {
+ wifiPasswordModalRef.show(modelData.ssid)
+ }
+ } else {
+ NetworkService.connectToWifi(modelData.ssid)
+ }
+ }
+ event.accepted = true
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation { duration: Theme.shortDuration }
+ }
+
+ Behavior on border.color {
+ ColorAnimation { duration: Theme.shortDuration }
+ }
+ }
+ }
+ }
+ }
+
+ Menu {
+ id: networkContextMenu
+ width: 150
+ closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutsideParent
+
+ property string currentSSID: ""
+ property bool currentSecured: false
+ property bool currentConnected: false
+ property bool currentSaved: false
+ property int currentSignal: 0
+
+ background: Rectangle {
+ color: Theme.popupBackground()
+ radius: Theme.cornerRadius
+ border.width: 1
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
+ }
+
+ MenuItem {
+ text: networkContextMenu.currentConnected ? "Disconnect" : "Connect"
+ height: 32
+
+ contentItem: StyledText {
+ text: parent.text
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ leftPadding: Theme.spacingS
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ background: Rectangle {
+ color: parent.hovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : "transparent"
+ radius: Theme.cornerRadius / 2
+ }
+
+ onTriggered: {
+ if (networkContextMenu.currentConnected) {
+ NetworkService.disconnectWifi()
+ } else {
+ if (networkContextMenu.currentSecured && !networkContextMenu.currentSaved) {
+ if (wifiPasswordModalRef) {
+ wifiPasswordModalRef.show(networkContextMenu.currentSSID)
+ }
+ } else {
+ NetworkService.connectToWifi(networkContextMenu.currentSSID)
+ }
+ }
+ }
+ }
+
+ MenuItem {
+ text: "Network Info"
+ height: 32
+
+ contentItem: StyledText {
+ text: parent.text
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ leftPadding: Theme.spacingS
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ background: Rectangle {
+ color: parent.hovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : "transparent"
+ radius: Theme.cornerRadius / 2
+ }
+
+ onTriggered: {
+ if (networkInfoModalRef) {
+ let networkData = NetworkService.getNetworkInfo(networkContextMenu.currentSSID)
+ networkInfoModalRef.showNetworkInfo(networkContextMenu.currentSSID, networkData)
+ }
+ }
+ }
+
+ MenuItem {
+ text: "Forget Network"
+ height: networkContextMenu.currentSaved || networkContextMenu.currentConnected ? 32 : 0
+ visible: networkContextMenu.currentSaved || networkContextMenu.currentConnected
+
+ contentItem: StyledText {
+ text: parent.text
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.error
+ leftPadding: Theme.spacingS
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ background: Rectangle {
+ color: parent.hovered ? Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.08) : "transparent"
+ radius: Theme.cornerRadius / 2
+ }
+
+ onTriggered: {
+ NetworkService.forgetWifiNetwork(networkContextMenu.currentSSID)
+ }
+ }
+ }
+
+ LazyLoader {
+ id: wifiPasswordModalLoader
+ active: false
+
+ WifiPasswordModal {
+ id: wifiPasswordModal
+ }
+ }
+
+ LazyLoader {
+ id: networkInfoModalLoader
+ active: false
+
+ NetworkInfoModal {
+ id: networkInfoModal
+ }
+ }
+
+
+}
\ No newline at end of file
diff --git a/quickshell/.config/quickshell/Modules/ControlCenter/PowerMenu.qml b/quickshell/.config/quickshell/Modules/ControlCenter/PowerMenu.qml
new file mode 100644
index 0000000..a4acfeb
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/ControlCenter/PowerMenu.qml
@@ -0,0 +1,310 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell
+import Quickshell.Io
+import Quickshell.Wayland
+import Quickshell.Widgets
+import qs.Common
+import qs.Widgets
+
+PanelWindow {
+ id: root
+
+ property bool powerMenuVisible: false
+ signal powerActionRequested(string action, string title, string message)
+
+ visible: powerMenuVisible
+ implicitWidth: 400
+ implicitHeight: 320
+ WlrLayershell.layer: WlrLayershell.Overlay
+ WlrLayershell.exclusiveZone: -1
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
+ color: "transparent"
+
+ anchors {
+ top: true
+ left: true
+ right: true
+ bottom: true
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ onClicked: {
+ powerMenuVisible = false
+ }
+ }
+
+ Rectangle {
+ width: Math.min(320, parent.width - Theme.spacingL * 2)
+ height: 320 // Fixed height to prevent cropping
+ x: Math.max(Theme.spacingL, parent.width - width - Theme.spacingL)
+ y: Theme.barHeight + Theme.spacingXS
+ color: Theme.popupBackground()
+ radius: Theme.cornerRadius
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g,
+ Theme.outline.b, 0.08)
+ border.width: 1
+ opacity: powerMenuVisible ? 1 : 0
+ scale: powerMenuVisible ? 1 : 0.85
+
+ MouseArea {
+
+ anchors.fill: parent
+ onClicked: {
+
+ }
+ }
+
+ Column {
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingM
+
+ Row {
+ width: parent.width
+
+ StyledText {
+ text: "Power Options"
+ font.pixelSize: Theme.fontSizeLarge
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Item {
+ width: parent.width - 150
+ height: 1
+ }
+
+ DankActionButton {
+ iconName: "close"
+ iconSize: Theme.iconSize - 4
+ iconColor: Theme.surfaceText
+ onClicked: {
+ powerMenuVisible = false
+ }
+ }
+ }
+
+ Column {
+ width: parent.width
+ spacing: Theme.spacingS
+
+ Rectangle {
+ width: parent.width
+ height: 50
+ radius: Theme.cornerRadius
+ color: logoutArea.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.left: parent.left
+ anchors.leftMargin: Theme.spacingM
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "logout"
+ size: Theme.iconSize
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Log Out"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ MouseArea {
+ id: logoutArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ powerMenuVisible = false
+ root.powerActionRequested(
+ "logout", "Log Out",
+ "Are you sure you want to log out?")
+ }
+ }
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 50
+ radius: Theme.cornerRadius
+ color: suspendArea.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.left: parent.left
+ anchors.leftMargin: Theme.spacingM
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "bedtime"
+ size: Theme.iconSize
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Suspend"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ MouseArea {
+ id: suspendArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ powerMenuVisible = false
+ root.powerActionRequested(
+ "suspend", "Suspend",
+ "Are you sure you want to suspend the system?")
+ }
+ }
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 50
+ radius: Theme.cornerRadius
+ color: rebootArea.containsMouse ? Qt.rgba(Theme.warning.r,
+ Theme.warning.g,
+ Theme.warning.b,
+ 0.08) : Qt.rgba(
+ Theme.surfaceVariant.r,
+ Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b,
+ 0.08)
+
+ Row {
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingM
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "restart_alt"
+ size: Theme.iconSize
+ color: rebootArea.containsMouse ? Theme.warning : Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Reboot"
+ font.pixelSize: Theme.fontSizeMedium
+ color: rebootArea.containsMouse ? Theme.warning : Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ MouseArea {
+ id: rebootArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ powerMenuVisible = false
+ root.powerActionRequested(
+ "reboot", "Reboot",
+ "Are you sure you want to reboot the system?")
+ }
+ }
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 50
+ radius: Theme.cornerRadius
+ color: powerOffArea.containsMouse ? Qt.rgba(Theme.error.r,
+ Theme.error.g,
+ Theme.error.b,
+ 0.08) : Qt.rgba(
+ Theme.surfaceVariant.r,
+ Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b,
+ 0.08)
+
+ Row {
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingM
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "power_settings_new"
+ size: Theme.iconSize
+ color: powerOffArea.containsMouse ? Theme.error : Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Power Off"
+ font.pixelSize: Theme.fontSizeMedium
+ color: powerOffArea.containsMouse ? Theme.error : Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ MouseArea {
+ id: powerOffArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ powerMenuVisible = false
+ root.powerActionRequested(
+ "poweroff", "Power Off",
+ "Are you sure you want to power off the system?")
+ }
+ }
+ }
+ }
+ }
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Theme.mediumDuration
+ easing.type: Theme.emphasizedEasing
+ }
+ }
+
+ Behavior on scale {
+ NumberAnimation {
+ duration: Theme.mediumDuration
+ easing.type: Theme.emphasizedEasing
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/AudioInputPill.qml b/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/AudioInputPill.qml
new file mode 100644
index 0000000..298f3a3
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/AudioInputPill.qml
@@ -0,0 +1,57 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell
+import Quickshell.Services.Pipewire
+import qs.Common
+import qs.Services
+import qs.Widgets
+import qs.Modules.ControlCenter.Widgets
+
+BasePill {
+ id: root
+
+ property var defaultSource: AudioService.source
+
+ iconName: {
+ if (!defaultSource) return "mic_off"
+
+ let volume = defaultSource.audio.volume
+ let muted = defaultSource.audio.muted
+
+ if (muted || volume === 0.0) return "mic_off"
+ return "mic"
+ }
+
+ isActive: defaultSource && !defaultSource.audio.muted
+
+ primaryText: {
+ if (!defaultSource) {
+ return "No input device"
+ }
+ return defaultSource.description || "Audio Input"
+ }
+
+ secondaryText: {
+ if (!defaultSource) {
+ return "Select device"
+ }
+ if (defaultSource.audio.muted) {
+ return "Muted"
+ }
+ return Math.round(defaultSource.audio.volume * 100) + "%"
+ }
+
+ onWheelEvent: function (wheelEvent) {
+ if (!defaultSource || !defaultSource.audio) return
+ let delta = wheelEvent.angleDelta.y
+ let currentVolume = defaultSource.audio.volume * 100
+ let newVolume
+ if (delta > 0)
+ newVolume = Math.min(100, currentVolume + 5)
+ else
+ newVolume = Math.max(0, currentVolume - 5)
+ defaultSource.audio.muted = false
+ defaultSource.audio.volume = newVolume / 100
+ wheelEvent.accepted = true
+ }
+}
\ No newline at end of file
diff --git a/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/AudioOutputPill.qml b/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/AudioOutputPill.qml
new file mode 100644
index 0000000..3d1c679
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/AudioOutputPill.qml
@@ -0,0 +1,60 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell
+import Quickshell.Services.Pipewire
+import qs.Common
+import qs.Services
+import qs.Widgets
+import qs.Modules.ControlCenter.Widgets
+
+BasePill {
+ id: root
+
+ property var defaultSink: AudioService.sink
+
+ iconName: {
+ if (!defaultSink) return "volume_off"
+
+ let volume = defaultSink.audio.volume
+ let muted = defaultSink.audio.muted
+
+ if (muted || volume === 0.0) return "volume_off"
+ if (volume <= 0.33) return "volume_down"
+ if (volume <= 0.66) return "volume_up"
+ return "volume_up"
+ }
+
+ isActive: defaultSink && !defaultSink.audio.muted
+
+ primaryText: {
+ if (!defaultSink) {
+ return "No output device"
+ }
+ return defaultSink.description || "Audio Output"
+ }
+
+ secondaryText: {
+ if (!defaultSink) {
+ return "Select device"
+ }
+ if (defaultSink.audio.muted) {
+ return "Muted"
+ }
+ return Math.round(defaultSink.audio.volume * 100) + "%"
+ }
+
+ onWheelEvent: function (wheelEvent) {
+ if (!defaultSink || !defaultSink.audio) return
+ let delta = wheelEvent.angleDelta.y
+ let currentVolume = defaultSink.audio.volume * 100
+ let newVolume
+ if (delta > 0)
+ newVolume = Math.min(100, currentVolume + 5)
+ else
+ newVolume = Math.max(0, currentVolume - 5)
+ defaultSink.audio.muted = false
+ defaultSink.audio.volume = newVolume / 100
+ AudioService.volumeChanged()
+ wheelEvent.accepted = true
+ }
+}
\ No newline at end of file
diff --git a/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/AudioSlider.qml b/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/AudioSlider.qml
new file mode 100644
index 0000000..cc194ab
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/AudioSlider.qml
@@ -0,0 +1,50 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell
+import Quickshell.Services.Pipewire
+import qs.Common
+import qs.Services
+import qs.Widgets
+import qs.Modules.ControlCenter.Widgets
+
+SimpleSlider {
+ id: root
+
+ property var defaultSink: AudioService.sink
+
+ iconName: {
+ if (!defaultSink) return "volume_off"
+
+ let volume = defaultSink.audio.volume
+ let muted = defaultSink.audio.muted
+
+ if (muted || volume === 0.0) return "volume_off"
+ if (volume <= 0.33) return "volume_down"
+ if (volume <= 0.66) return "volume_up"
+ return "volume_up"
+ }
+
+ iconColor: defaultSink && !defaultSink.audio.muted && defaultSink.audio.volume > 0 ? Theme.primary : Theme.surfaceText
+
+ enabled: defaultSink !== null
+ allowIconClick: defaultSink !== null
+
+ value: defaultSink ? defaultSink.audio.volume : 0.0
+ maximumValue: 1.0
+ minimumValue: 0.0
+
+ onSliderValueChanged: function(newValue) {
+ if (defaultSink) {
+ defaultSink.audio.volume = newValue
+ if (newValue > 0 && defaultSink.audio.muted) {
+ defaultSink.audio.muted = false
+ }
+ }
+ }
+
+ onIconClicked: function() {
+ if (defaultSink) {
+ defaultSink.audio.muted = !defaultSink.audio.muted
+ }
+ }
+}
\ No newline at end of file
diff --git a/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/AudioSliderRow.qml b/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/AudioSliderRow.qml
new file mode 100644
index 0000000..20e2236
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/AudioSliderRow.qml
@@ -0,0 +1,75 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell
+import Quickshell.Services.Pipewire
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Row {
+ id: root
+
+ property var defaultSink: AudioService.sink
+
+ height: 40
+ spacing: Theme.spacingS
+
+ Rectangle {
+ width: Theme.iconSize + Theme.spacingS * 2
+ height: Theme.iconSize + Theme.spacingS * 2
+ anchors.verticalCenter: parent.verticalCenter
+ radius: (Theme.iconSize + Theme.spacingS * 2) / 2 // Make it circular
+ color: iconArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
+
+ Behavior on color {
+ ColorAnimation { duration: Theme.shortDuration }
+ }
+
+ MouseArea {
+ id: iconArea
+ anchors.fill: parent
+ visible: defaultSink !== null
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ if (defaultSink) {
+ defaultSink.audio.muted = !defaultSink.audio.muted
+ }
+ }
+ }
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: {
+ if (!defaultSink) return "volume_off"
+
+ let volume = defaultSink.audio.volume
+ let muted = defaultSink.audio.muted
+
+ if (muted || volume === 0.0) return "volume_off"
+ if (volume <= 0.33) return "volume_down"
+ if (volume <= 0.66) return "volume_up"
+ return "volume_up"
+ }
+ size: Theme.iconSize
+ color: defaultSink && !defaultSink.audio.muted && defaultSink.audio.volume > 0 ? Theme.primary : Theme.surfaceText
+ }
+ }
+
+ DankSlider {
+ anchors.verticalCenter: parent.verticalCenter
+ width: parent.width - (Theme.iconSize + Theme.spacingS * 2) - Theme.spacingM
+ enabled: defaultSink !== null
+ minimum: 0
+ maximum: 100
+ value: defaultSink ? Math.round(defaultSink.audio.volume * 100) : 0
+ onSliderValueChanged: function(newValue) {
+ if (defaultSink) {
+ defaultSink.audio.volume = newValue / 100.0
+ if (newValue > 0 && defaultSink.audio.muted) {
+ defaultSink.audio.muted = false
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/BasePill.qml b/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/BasePill.qml
new file mode 100644
index 0000000..0e37299
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/BasePill.qml
@@ -0,0 +1,149 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell
+import qs.Common
+import qs.Widgets
+
+Rectangle {
+ id: root
+
+ property string iconName: ""
+ property color iconColor: Theme.surfaceText
+ property string primaryText: ""
+ property string secondaryText: ""
+ property bool expanded: false
+ property bool isActive: false
+
+ signal clicked()
+ signal expandClicked()
+ signal wheelEvent(var wheelEvent)
+
+ width: parent ? parent.width : 200
+ height: 60
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, Theme.getContentBackgroundAlpha() * 0.6)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
+ border.width: 1
+
+ Rectangle {
+ id: mainArea
+ anchors.left: parent.left
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ width: parent.width - expandArea.width
+ topLeftRadius: Theme.cornerRadius
+ bottomLeftRadius: Theme.cornerRadius
+ topRightRadius: 0
+ bottomRightRadius: 0
+ color: mainAreaMouse.containsMouse ?
+ Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) :
+ "transparent"
+
+ Behavior on color {
+ ColorAnimation { duration: Theme.shortDuration }
+ }
+
+ Row {
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.right: parent.right
+ anchors.leftMargin: Theme.spacingM
+ anchors.rightMargin: Theme.spacingS
+ spacing: Theme.spacingS
+
+ DankIcon {
+ name: root.iconName
+ size: Theme.iconSize
+ color: root.isActive ? Theme.primary : root.iconColor
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ width: parent.width - Theme.iconSize - Theme.spacingS
+ spacing: 2
+
+ StyledText {
+ width: parent.width
+ text: root.primaryText
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ elide: Text.ElideRight
+ wrapMode: Text.NoWrap
+ }
+
+ StyledText {
+ width: parent.width
+ text: root.secondaryText
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ visible: text.length > 0
+ elide: Text.ElideRight
+ wrapMode: Text.NoWrap
+ }
+ }
+ }
+
+ MouseArea {
+ id: mainAreaMouse
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: root.clicked()
+ onWheel: function (wheelEvent) {
+ root.wheelEvent(wheelEvent)
+ }
+ }
+ }
+
+ Rectangle {
+ id: expandArea
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ width: Theme.iconSize + Theme.spacingM * 2
+ topLeftRadius: 0
+ bottomLeftRadius: 0
+ topRightRadius: Theme.cornerRadius
+ bottomRightRadius: Theme.cornerRadius
+ color: expandAreaMouse.containsMouse ?
+ Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) :
+ "transparent"
+
+ Behavior on color {
+ ColorAnimation { duration: Theme.shortDuration }
+ }
+
+ Rectangle {
+ anchors.left: parent.left
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ width: 1
+ color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
+ }
+
+ DankIcon {
+ id: expandIcon
+ anchors.centerIn: parent
+ name: expanded ? "expand_less" : "expand_more"
+ size: Theme.iconSize - 2
+ color: Theme.surfaceVariantText
+ }
+
+ MouseArea {
+ id: expandAreaMouse
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: root.expandClicked()
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+}
\ No newline at end of file
diff --git a/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/BluetoothPill.qml b/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/BluetoothPill.qml
new file mode 100644
index 0000000..dccd295
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/BluetoothPill.qml
@@ -0,0 +1,66 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell
+import qs.Common
+import qs.Services
+import qs.Widgets
+import qs.Modules.ControlCenter.Widgets
+
+BasePill {
+ id: root
+
+ property var primaryDevice: {
+ if (!BluetoothService.adapter || !BluetoothService.adapter.devices) {
+ return null
+ }
+
+ let devices = [...BluetoothService.adapter.devices.values.filter(dev => dev && (dev.paired || dev.trusted))]
+ for (let device of devices) {
+ if (device && device.connected) {
+ return device
+ }
+ }
+ return null
+ }
+
+ iconName: {
+ if (!BluetoothService.available) {
+ return "bluetooth_disabled"
+ }
+ if (!BluetoothService.adapter || !BluetoothService.adapter.enabled) {
+ return "bluetooth_disabled"
+ }
+ if (primaryDevice) {
+ return BluetoothService.getDeviceIcon(primaryDevice)
+ }
+ return "bluetooth"
+ }
+
+ isActive: !!(BluetoothService.available && BluetoothService.adapter && BluetoothService.adapter.enabled)
+
+ primaryText: {
+ if (!BluetoothService.available) {
+ return "Bluetooth unavailable"
+ }
+ if (!BluetoothService.adapter) {
+ return "No adapter"
+ }
+ if (!BluetoothService.adapter.enabled) {
+ return "Disabled"
+ }
+ return "Enabled"
+ }
+
+ secondaryText: {
+ if (!BluetoothService.available) {
+ return "Hardware not found"
+ }
+ if (!BluetoothService.adapter || !BluetoothService.adapter.enabled) {
+ return "Off"
+ }
+ if (primaryDevice) {
+ return primaryDevice.name || primaryDevice.alias || primaryDevice.deviceName || "Connected Device"
+ }
+ return "No devices"
+ }
+}
\ No newline at end of file
diff --git a/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/BrightnessSlider.qml b/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/BrightnessSlider.qml
new file mode 100644
index 0000000..69cb6f5
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/BrightnessSlider.qml
@@ -0,0 +1,34 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell
+import qs.Common
+import qs.Services
+import qs.Widgets
+import qs.Modules.ControlCenter.Widgets
+
+SimpleSlider {
+ id: root
+
+ iconName: {
+ if (!DisplayService.brightnessAvailable) return "brightness_low"
+
+ let brightness = DisplayService.brightnessLevel
+ if (brightness <= 33) return "brightness_low"
+ if (brightness <= 66) return "brightness_medium"
+ return "brightness_high"
+ }
+
+ iconColor: DisplayService.brightnessAvailable && DisplayService.brightnessLevel > 0 ? Theme.primary : Theme.surfaceText
+
+ enabled: DisplayService.brightnessAvailable
+
+ value: DisplayService.brightnessLevel
+ maximumValue: 100.0
+ minimumValue: 0.0
+
+ onSliderValueChanged: function(newValue) {
+ if (DisplayService.brightnessAvailable) {
+ DisplayService.brightnessLevel = newValue
+ }
+ }
+}
\ No newline at end of file
diff --git a/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/BrightnessSliderRow.qml b/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/BrightnessSliderRow.qml
new file mode 100644
index 0000000..b19ee73
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/BrightnessSliderRow.qml
@@ -0,0 +1,142 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Row {
+ id: root
+
+ height: 40
+ spacing: Theme.spacingS
+
+ Rectangle {
+ width: Theme.iconSize + Theme.spacingS * 2
+ height: Theme.iconSize + Theme.spacingS * 2
+ anchors.verticalCenter: parent.verticalCenter
+ radius: (Theme.iconSize + Theme.spacingS * 2) / 2
+ color: iconArea.containsMouse
+ ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12)
+ : "transparent"
+
+ Behavior on color {
+ ColorAnimation { duration: Theme.shortDuration }
+ }
+
+ MouseArea {
+ id: iconArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+
+ onClicked: function(event) {
+ if (DisplayService.devices.length > 1) {
+ if (deviceMenu.visible) {
+ deviceMenu.close()
+ } else {
+ deviceMenu.popup(iconArea, 0, iconArea.height + Theme.spacingXS)
+ }
+ event.accepted = true
+ }
+ }
+ }
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: {
+ if (!DisplayService.brightnessAvailable) return "brightness_low"
+
+ let brightness = DisplayService.brightnessLevel
+ if (brightness <= 33) return "brightness_low"
+ if (brightness <= 66) return "brightness_medium"
+ return "brightness_high"
+ }
+ size: Theme.iconSize
+ color: DisplayService.brightnessAvailable && DisplayService.brightnessLevel > 0 ? Theme.primary : Theme.surfaceText
+ }
+ }
+
+ DankSlider {
+ anchors.verticalCenter: parent.verticalCenter
+ width: parent.width - (Theme.iconSize + Theme.spacingS * 2) - Theme.spacingM
+ enabled: DisplayService.brightnessAvailable
+ minimum: 1
+ maximum: 100
+ value: {
+ let level = DisplayService.brightnessLevel
+ if (level > 100) {
+ let deviceInfo = DisplayService.getCurrentDeviceInfo()
+ if (deviceInfo && deviceInfo.max > 0) {
+ return Math.round((level / deviceInfo.max) * 100)
+ }
+ return 50
+ }
+ return level
+ }
+ onSliderValueChanged: function(newValue) {
+ if (DisplayService.brightnessAvailable) {
+ DisplayService.setBrightness(newValue)
+ }
+ }
+ }
+
+ Menu {
+ id: deviceMenu
+ width: 200
+ closePolicy: Popup.CloseOnEscape
+
+ background: Rectangle {
+ color: Theme.popupBackground()
+ radius: Theme.cornerRadius
+ border.width: 1
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
+ }
+
+ Instantiator {
+ model: DisplayService.devices
+ delegate: MenuItem {
+ required property var modelData
+ required property int index
+
+ property string deviceName: modelData.name || ""
+ property string deviceClass: modelData.class || ""
+
+ text: deviceName
+ font.pixelSize: Theme.fontSizeMedium
+ height: 40
+
+ indicator: Rectangle {
+ visible: DisplayService.currentDevice === parent.deviceName
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.leftMargin: Theme.spacingS
+ width: 4
+ height: parent.height - Theme.spacingS * 2
+ radius: 2
+ color: Theme.primary
+ }
+
+ contentItem: StyledText {
+ text: parent.text
+ font: parent.font
+ color: DisplayService.currentDevice === parent.deviceName ? Theme.primary : Theme.surfaceText
+ leftPadding: Theme.spacingL
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ background: Rectangle {
+ color: parent.hovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : "transparent"
+ radius: Theme.cornerRadius / 2
+ }
+
+ onTriggered: {
+ DisplayService.setCurrentDevice(deviceName, true)
+ deviceMenu.close()
+ }
+ }
+ onObjectAdded: (index, object) => deviceMenu.insertItem(index, object)
+ onObjectRemoved: (index, object) => deviceMenu.removeItem(object)
+ }
+ }
+}
\ No newline at end of file
diff --git a/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/CompactSlider.qml b/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/CompactSlider.qml
new file mode 100644
index 0000000..4f187f1
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/CompactSlider.qml
@@ -0,0 +1,70 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell
+import qs.Common
+import qs.Widgets
+
+Rectangle {
+ id: root
+
+ property string iconName: ""
+ property color iconColor: Theme.surfaceText
+ property string labelText: ""
+ property real value: 0.0
+ property real maximumValue: 1.0
+ property real minimumValue: 0.0
+ property bool enabled: true
+
+ signal sliderValueChanged(real value)
+
+ width: parent ? parent.width : 200
+ height: 60
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, Theme.getContentBackgroundAlpha() * 0.6)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
+ border.width: 1
+ opacity: enabled ? 1.0 : 0.6
+
+ Row {
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.leftMargin: Theme.spacingM
+ anchors.right: sliderContainer.left
+ anchors.rightMargin: Theme.spacingS
+ spacing: Theme.spacingS
+
+ DankIcon {
+ name: root.iconName
+ size: Theme.iconSize
+ color: root.iconColor
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: root.labelText
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ Rectangle {
+ id: sliderContainer
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.rightMargin: Theme.spacingM
+ width: 120
+ height: parent.height - Theme.spacingS * 2
+
+ DankSlider {
+ anchors.centerIn: parent
+ width: parent.width
+ enabled: root.enabled
+ minimum: Math.round(root.minimumValue * 100)
+ maximum: Math.round(root.maximumValue * 100)
+ value: Math.round(root.value * 100)
+ onSliderValueChanged: root.sliderValueChanged(newValue / 100.0)
+ }
+ }
+}
\ No newline at end of file
diff --git a/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/DetailView.qml b/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/DetailView.qml
new file mode 100644
index 0000000..bffa0ff
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/DetailView.qml
@@ -0,0 +1,29 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell
+import qs.Common
+import qs.Widgets
+
+Rectangle {
+ id: root
+
+ property string title: ""
+ property Component content: null
+ property bool isVisible: true
+ property int contentHeight: 300
+
+ width: parent ? parent.width : 400
+ implicitHeight: isVisible ? contentHeight : 0
+ height: implicitHeight
+ color: "transparent"
+ clip: true
+
+ Loader {
+ id: contentLoader
+ anchors.fill: parent
+ sourceComponent: root.content
+ asynchronous: true
+ }
+
+
+}
\ No newline at end of file
diff --git a/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/NetworkPill.qml b/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/NetworkPill.qml
new file mode 100644
index 0000000..51a673e
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/NetworkPill.qml
@@ -0,0 +1,72 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell
+import qs.Common
+import qs.Services
+import qs.Widgets
+import qs.Modules.ControlCenter.Widgets
+
+BasePill {
+ id: root
+
+ isActive: {
+ if (NetworkService.wifiToggling) {
+ return false
+ }
+ if (NetworkService.networkStatus === "ethernet") {
+ return true
+ }
+ if (NetworkService.networkStatus === "wifi") {
+ return true
+ }
+ return NetworkService.wifiEnabled
+ }
+
+ iconName: {
+ if (NetworkService.wifiToggling) {
+ return "sync"
+ }
+ if (NetworkService.networkStatus === "ethernet") {
+ return "settings_ethernet"
+ }
+ if (NetworkService.networkStatus === "wifi") {
+ return NetworkService.wifiSignalIcon
+ }
+ if (NetworkService.wifiEnabled) {
+ return "signal_wifi_off"
+ }
+ return "wifi_off"
+ }
+
+ primaryText: {
+ if (NetworkService.wifiToggling) {
+ return NetworkService.wifiEnabled ? "Disabling WiFi..." : "Enabling WiFi..."
+ }
+ if (NetworkService.networkStatus === "ethernet") {
+ return "Ethernet"
+ }
+ if (NetworkService.networkStatus === "wifi" && NetworkService.currentWifiSSID) {
+ return NetworkService.currentWifiSSID
+ }
+ if (NetworkService.wifiEnabled) {
+ return "Not connected"
+ }
+ return "WiFi off"
+ }
+
+ secondaryText: {
+ if (NetworkService.wifiToggling) {
+ return "Please wait..."
+ }
+ if (NetworkService.networkStatus === "ethernet") {
+ return "Connected"
+ }
+ if (NetworkService.networkStatus === "wifi") {
+ return NetworkService.wifiSignalStrength > 0 ? NetworkService.wifiSignalStrength + "%" : "Connected"
+ }
+ if (NetworkService.wifiEnabled) {
+ return "Select network"
+ }
+ return "Tap to enable"
+ }
+}
\ No newline at end of file
diff --git a/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/SimpleSlider.qml b/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/SimpleSlider.qml
new file mode 100644
index 0000000..cc77999
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/SimpleSlider.qml
@@ -0,0 +1,50 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell
+import qs.Common
+import qs.Widgets
+
+Row {
+ id: root
+
+ property string iconName: ""
+ property color iconColor: Theme.surfaceText
+ property real value: 0.0
+ property real maximumValue: 1.0
+ property real minimumValue: 0.0
+ property bool enabled: true
+ property bool allowIconClick: false
+
+ signal sliderValueChanged(real value)
+ signal iconClicked()
+
+ height: 60
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: root.iconName
+ size: Theme.iconSize
+ color: root.iconColor
+ anchors.verticalCenter: parent.verticalCenter
+
+ MouseArea {
+ anchors.fill: parent
+ visible: root.allowIconClick
+ cursorShape: Qt.PointingHandCursor
+ onClicked: root.iconClicked()
+ }
+ }
+
+ DankSlider {
+ anchors.verticalCenter: parent.verticalCenter
+ width: {
+ if (parent.width <= 0) return 80
+ return Math.max(80, Math.min(400, parent.width - Theme.iconSize - Theme.spacingM))
+ }
+ enabled: root.enabled
+ minimum: Math.round(root.minimumValue * 100)
+ maximum: Math.round(root.maximumValue * 100)
+ value: Math.round(root.value * 100)
+ onSliderValueChanged: function(newValue) { root.sliderValueChanged(newValue / 100.0) }
+ }
+}
\ No newline at end of file
diff --git a/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/ToggleButton.qml b/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/ToggleButton.qml
new file mode 100644
index 0000000..8a6b4b7
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/ControlCenter/Widgets/ToggleButton.qml
@@ -0,0 +1,95 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell
+import qs.Common
+import qs.Widgets
+
+Rectangle {
+ id: root
+
+ property string iconName: ""
+ property string text: ""
+ property bool isActive: false
+ property bool enabled: true
+ property string secondaryText: ""
+
+ signal clicked()
+
+ width: parent ? parent.width : 200
+ height: 60
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, Theme.getContentBackgroundAlpha() * 0.6)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
+ border.width: 1
+ opacity: enabled ? 1.0 : 0.6
+
+ Rectangle {
+ anchors.fill: parent
+ radius: Theme.cornerRadius
+ color: mouseArea.containsMouse ?
+ Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) :
+ "transparent"
+
+ Behavior on color {
+ ColorAnimation { duration: Theme.shortDuration }
+ }
+ }
+
+ Row {
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.right: parent.right
+ anchors.leftMargin: Theme.spacingM
+ anchors.rightMargin: Theme.spacingM
+ spacing: Theme.spacingS
+
+ DankIcon {
+ name: root.iconName
+ size: Theme.iconSize
+ color: root.isActive ? Theme.primary : Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ width: parent.width - Theme.iconSize - Theme.spacingS
+ spacing: 2
+
+ StyledText {
+ width: parent.width
+ text: root.text
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ elide: Text.ElideRight
+ wrapMode: Text.NoWrap
+ }
+
+ StyledText {
+ width: parent.width
+ text: root.secondaryText
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ visible: text.length > 0
+ elide: Text.ElideRight
+ wrapMode: Text.NoWrap
+ }
+ }
+ }
+
+ MouseArea {
+ id: mouseArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ enabled: root.enabled
+ onClicked: root.clicked()
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+}
\ No newline at end of file
diff --git a/quickshell/.config/quickshell/Modules/DankDash/DankDashPopout.qml b/quickshell/.config/quickshell/Modules/DankDash/DankDashPopout.qml
new file mode 100644
index 0000000..64f5d2a
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/DankDash/DankDashPopout.qml
@@ -0,0 +1,315 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Effects
+import QtQuick.Layouts
+import Quickshell
+import Quickshell.Services.Mpris
+import Quickshell.Wayland
+import qs.Common
+import qs.Widgets
+import qs.Modules.DankDash
+
+DankPopout {
+ id: root
+
+ property bool dashVisible: false
+ property var triggerScreen: null
+ property int currentTabIndex: 0
+
+ function setTriggerPosition(x, y, width, section, screen) {
+ triggerScreen = screen
+ triggerY = y
+
+ const screenWidth = screen ? screen.width : Screen.width
+ triggerX = (screenWidth - popupWidth) / 2
+ triggerWidth = popupWidth
+ }
+
+ popupWidth: 700
+ popupHeight: contentLoader.item ? contentLoader.item.implicitHeight : 500
+ triggerX: Screen.width - 620 - Theme.spacingL
+ triggerY: Math.max(26 + SettingsData.dankBarInnerPadding + 4, Theme.barHeight - 4 - (8 - SettingsData.dankBarInnerPadding)) + SettingsData.dankBarSpacing + SettingsData.dankBarBottomGap - 2
+ triggerWidth: 80
+ shouldBeVisible: dashVisible
+ visible: shouldBeVisible
+
+ property bool __focusArmed: false
+ property bool __contentReady: false
+
+ function __tryFocusOnce() {
+ if (!__focusArmed)
+ return
+ const win = root.window
+ if (!win || !win.visible)
+ return
+ if (!contentLoader.item)
+ return
+
+ if (win.requestActivate)
+ win.requestActivate()
+ contentLoader.item.forceActiveFocus(Qt.TabFocusReason)
+
+ if (contentLoader.item.activeFocus)
+ __focusArmed = false
+ }
+
+ onDashVisibleChanged: {
+ if (dashVisible) {
+ __focusArmed = true
+ __contentReady = !!contentLoader.item
+ open()
+ __tryFocusOnce()
+ } else {
+ __focusArmed = false
+ __contentReady = false
+ close()
+ }
+ }
+
+ Connections {
+ target: contentLoader
+ function onLoaded() {
+ __contentReady = true
+ if (__focusArmed)
+ __tryFocusOnce()
+ }
+ }
+
+ Connections {
+ target: root.window ? root.window : null
+ enabled: !!root.window
+ function onVisibleChanged() {
+ if (__focusArmed)
+ __tryFocusOnce()
+ }
+ }
+
+ onBackgroundClicked: {
+ dashVisible = false
+ }
+
+ content: Component {
+ Rectangle {
+ id: mainContainer
+
+ implicitHeight: contentColumn.height + Theme.spacingM * 2
+ color: Theme.surfaceContainer
+ radius: Theme.cornerRadius
+ focus: true
+
+ Component.onCompleted: {
+ if (root.shouldBeVisible) {
+ mainContainer.forceActiveFocus()
+ }
+ }
+
+ Connections {
+ target: root
+ function onShouldBeVisibleChanged() {
+ if (root.shouldBeVisible) {
+ Qt.callLater(function () {
+ mainContainer.forceActiveFocus()
+ })
+ }
+ }
+ }
+
+ Keys.onPressed: function (event) {
+ if (event.key === Qt.Key_Escape) {
+ root.dashVisible = false
+ event.accepted = true
+ return
+ }
+
+ if (event.key === Qt.Key_Tab && !(event.modifiers & Qt.ShiftModifier)) {
+ let nextIndex = root.currentTabIndex + 1
+ while (nextIndex < tabBar.model.length && tabBar.model[nextIndex] && tabBar.model[nextIndex].isAction) {
+ nextIndex++
+ }
+ if (nextIndex >= tabBar.model.length) {
+ nextIndex = 0
+ }
+ root.currentTabIndex = nextIndex
+ event.accepted = true
+ return
+ }
+
+ if (event.key === Qt.Key_Backtab || (event.key === Qt.Key_Tab && (event.modifiers & Qt.ShiftModifier))) {
+ let prevIndex = root.currentTabIndex - 1
+ while (prevIndex >= 0 && tabBar.model[prevIndex] && tabBar.model[prevIndex].isAction) {
+ prevIndex--
+ }
+ if (prevIndex < 0) {
+ prevIndex = tabBar.model.length - 1
+ while (prevIndex >= 0 && tabBar.model[prevIndex] && tabBar.model[prevIndex].isAction) {
+ prevIndex--
+ }
+ }
+ if (prevIndex >= 0) {
+ root.currentTabIndex = prevIndex
+ }
+ event.accepted = true
+ return
+ }
+
+ if (root.currentTabIndex === 2 && wallpaperTab.handleKeyEvent) {
+ if (wallpaperTab.handleKeyEvent(event)) {
+ event.accepted = true
+ return
+ }
+ }
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ color: Qt.rgba(Theme.surfaceTint.r, Theme.surfaceTint.g, Theme.surfaceTint.b, 0.04)
+ radius: parent.radius
+
+ SequentialAnimation on opacity {
+ running: root.shouldBeVisible
+ loops: Animation.Infinite
+
+ NumberAnimation {
+ to: 0.08
+ duration: Theme.extraLongDuration
+ easing.type: Theme.standardEasing
+ }
+
+ NumberAnimation {
+ to: 0.02
+ duration: Theme.extraLongDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+ }
+
+ Column {
+ id: contentColumn
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.margins: Theme.spacingM
+ spacing: Theme.spacingS
+
+ DankTabBar {
+ id: tabBar
+
+ width: parent.width
+ height: 48
+ currentIndex: root.currentTabIndex
+ spacing: Theme.spacingS
+ equalWidthTabs: true
+ enableArrowNavigation: false
+ focus: false
+ activeFocusOnTab: false
+ nextFocusTarget: {
+ const item = pages.currentItem
+ if (!item)
+ return null
+ if (item.focusTarget)
+ return item.focusTarget
+ return item
+ }
+
+ model: {
+ let tabs = [{
+ "icon": "dashboard",
+ "text": "Overview"
+ }, {
+ "icon": "music_note",
+ "text": "Media"
+ }, {
+ "icon": "wallpaper",
+ "text": "Wallpapers"
+ }]
+
+ if (SettingsData.weatherEnabled) {
+ tabs.push({
+ "icon": "wb_sunny",
+ "text": "Weather"
+ })
+ }
+
+ tabs.push({
+ "icon": "settings",
+ "text": "Settings",
+ "isAction": true
+ })
+ return tabs
+ }
+
+ onTabClicked: function (index) {
+ root.currentTabIndex = index
+ }
+
+ onActionTriggered: function (index) {
+ let settingsIndex = SettingsData.weatherEnabled ? 4 : 3
+ if (index === settingsIndex) {
+ dashVisible = false
+ settingsModal.show()
+ }
+ }
+ }
+
+ Item {
+ width: parent.width
+ height: Theme.spacingXS
+ }
+
+ StackLayout {
+ id: pages
+ width: parent.width
+ implicitHeight: {
+ if (currentIndex === 0)
+ return overviewTab.implicitHeight
+ if (currentIndex === 1)
+ return mediaTab.implicitHeight
+ if (currentIndex === 2)
+ return wallpaperTab.implicitHeight
+ if (SettingsData.weatherEnabled && currentIndex === 3)
+ return weatherTab.implicitHeight
+ return overviewTab.implicitHeight
+ }
+ currentIndex: root.currentTabIndex
+
+ OverviewTab {
+ id: overviewTab
+
+ onCloseDash: {
+ root.dashVisible = false
+ }
+
+ onSwitchToWeatherTab: {
+ if (SettingsData.weatherEnabled) {
+ tabBar.currentIndex = 3
+ tabBar.tabClicked(3)
+ }
+ }
+
+ onSwitchToMediaTab: {
+ tabBar.currentIndex = 1
+ tabBar.tabClicked(1)
+ }
+ }
+
+ MediaPlayerTab {
+ id: mediaTab
+ }
+
+ WallpaperTab {
+ id: wallpaperTab
+ active: root.currentTabIndex === 2
+ tabBarItem: tabBar
+ keyForwardTarget: mainContainer
+ targetScreen: root.triggerScreen
+ }
+
+ WeatherTab {
+ id: weatherTab
+ visible: SettingsData.weatherEnabled && root.currentTabIndex === 3
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/quickshell/.config/quickshell/Modules/DankDash/MediaPlayerTab.qml b/quickshell/.config/quickshell/Modules/DankDash/MediaPlayerTab.qml
new file mode 100644
index 0000000..872cf25
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/DankDash/MediaPlayerTab.qml
@@ -0,0 +1,759 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Effects
+import QtQuick.Shapes
+import Quickshell.Services.Mpris
+import Quickshell.Services.Pipewire
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Item {
+ id: root
+
+ property MprisPlayer activePlayer: MprisController.activePlayer
+ property string lastValidTitle: ""
+ property string lastValidArtist: ""
+ property string lastValidAlbum: ""
+ property string lastValidArtUrl: ""
+ property real currentPosition: activePlayer && activePlayer.positionSupported ? activePlayer.position : 0
+ property real displayPosition: currentPosition
+ property var defaultSink: AudioService.sink
+
+ readonly property real ratio: {
+ if (!activePlayer || activePlayer.length <= 0) {
+ return 0
+ }
+ const calculatedRatio = displayPosition / activePlayer.length
+ return Math.max(0, Math.min(1, calculatedRatio))
+ }
+
+ implicitWidth: 700
+ implicitHeight: 410
+
+ onActivePlayerChanged: {
+ if (activePlayer && activePlayer.positionSupported) {
+ currentPosition = Qt.binding(() => activePlayer?.position || 0)
+ } else {
+ currentPosition = 0
+ }
+ }
+
+ Timer {
+ id: positionTimer
+ interval: 300
+ running: activePlayer && activePlayer.playbackState === MprisPlaybackState.Playing && !progressSliderArea.isSeeking
+ repeat: true
+ onTriggered: activePlayer && activePlayer.positionSupported && activePlayer.positionChanged()
+ }
+
+ Timer {
+ id: cleanupTimer
+ interval: 2000
+ running: !activePlayer
+ onTriggered: {
+ lastValidTitle = ""
+ lastValidArtist = ""
+ lastValidAlbum = ""
+ lastValidArtUrl = ""
+ currentPosition = 0
+ stop()
+ }
+ }
+
+ Column {
+ anchors.centerIn: parent
+ spacing: Theme.spacingM
+ visible: (!activePlayer && !lastValidTitle) || (activePlayer && activePlayer.trackTitle === "" && lastValidTitle === "")
+
+ DankIcon {
+ name: "music_note"
+ size: Theme.iconSize * 3
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.5)
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+
+ StyledText {
+ text: "No Active Players"
+ font.pixelSize: Theme.fontSizeLarge
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+ }
+
+ Item {
+ anchors.fill: parent
+ visible: (activePlayer && activePlayer.trackTitle !== "") || lastValidTitle !== ""
+
+ // Left Column: Album Art and Controls (60%)
+ Column {
+ x: 0
+ y: 0
+ width: parent.width * 0.6 - Theme.spacingM
+ height: parent.height
+ spacing: Theme.spacingL
+
+ // Album Art Section
+ Item {
+ width: parent.width
+ height: parent.height * 0.55
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ Item {
+ width: Math.min(parent.width * 0.8, parent.height * 0.9)
+ height: width
+ anchors.centerIn: parent
+
+ Loader {
+ active: activePlayer?.playbackState === MprisPlaybackState.Playing
+ sourceComponent: Component {
+ Ref {
+ service: CavaService
+ }
+ }
+ }
+
+ Shape {
+ id: morphingBlob
+ width: parent.width * 1.1
+ height: parent.height * 1.1
+ anchors.centerIn: parent
+ visible: activePlayer?.playbackState === MprisPlaybackState.Playing
+ asynchronous: false
+ antialiasing: true
+ preferredRendererType: Shape.CurveRenderer
+ z: 0
+
+ layer.enabled: true
+ layer.smooth: true
+ layer.samples: 4
+
+ readonly property real centerX: width / 2
+ readonly property real centerY: height / 2
+ readonly property real baseRadius: Math.min(width, height) * 0.35
+ readonly property int segments: 24
+
+ property var audioLevels: {
+ if (!CavaService.cavaAvailable || CavaService.values.length === 0) {
+ return [0.5, 0.3, 0.7, 0.4, 0.6, 0.5]
+ }
+ return CavaService.values
+ }
+
+ property var smoothedLevels: [0.5, 0.3, 0.7, 0.4, 0.6, 0.5]
+ property var cubics: []
+
+ onAudioLevelsChanged: updatePath()
+
+ Timer {
+ running: morphingBlob.visible
+ interval: 16
+ repeat: true
+ onTriggered: morphingBlob.updatePath()
+ }
+
+ Component {
+ id: cubicSegment
+ PathCubic {}
+ }
+
+ Component.onCompleted: {
+ shapePath.pathElements.push(Qt.createQmlObject(
+ 'import QtQuick; import QtQuick.Shapes; PathMove {}', shapePath
+ ))
+
+ for (let i = 0; i < segments; i++) {
+ const seg = cubicSegment.createObject(shapePath)
+ shapePath.pathElements.push(seg)
+ cubics.push(seg)
+ }
+
+ updatePath()
+ }
+
+ function expSmooth(prev, next, alpha) {
+ return prev + alpha * (next - prev)
+ }
+
+ function updatePath() {
+ if (cubics.length === 0) return
+
+ for (let i = 0; i < Math.min(smoothedLevels.length, audioLevels.length); i++) {
+ smoothedLevels[i] = expSmooth(smoothedLevels[i], audioLevels[i], 0.2)
+ }
+
+ const points = []
+ for (let i = 0; i < segments; i++) {
+ const angle = (i / segments) * 2 * Math.PI
+ const audioIndex = i % Math.min(smoothedLevels.length, 6)
+ const audioLevel = Math.max(0.1, Math.min(1.5, (smoothedLevels[audioIndex] || 0) / 50))
+
+ const radius = baseRadius * (1.0 + audioLevel * 0.3)
+ const x = centerX + Math.cos(angle) * radius
+ const y = centerY + Math.sin(angle) * radius
+ points.push({x: x, y: y})
+ }
+
+ const startMove = shapePath.pathElements[0]
+ startMove.x = points[0].x
+ startMove.y = points[0].y
+
+ const tension = 0.5
+ for (let i = 0; i < segments; i++) {
+ const p0 = points[(i - 1 + segments) % segments]
+ const p1 = points[i]
+ const p2 = points[(i + 1) % segments]
+ const p3 = points[(i + 2) % segments]
+
+ const c1x = p1.x + (p2.x - p0.x) * tension / 3
+ const c1y = p1.y + (p2.y - p0.y) * tension / 3
+ const c2x = p2.x - (p3.x - p1.x) * tension / 3
+ const c2y = p2.y - (p3.y - p1.y) * tension / 3
+
+ const seg = cubics[i]
+ seg.control1X = c1x
+ seg.control1Y = c1y
+ seg.control2X = c2x
+ seg.control2Y = c2y
+ seg.x = p2.x
+ seg.y = p2.y
+ }
+ }
+
+ ShapePath {
+ id: shapePath
+ fillColor: Theme.primary
+ strokeColor: "transparent"
+ strokeWidth: 0
+ joinStyle: ShapePath.RoundJoin
+ fillRule: ShapePath.WindingFill
+ }
+ }
+
+ Rectangle {
+ width: parent.width * 0.75
+ height: width
+ radius: width / 2
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3)
+ border.color: Theme.surfaceContainer
+ border.width: 1
+ anchors.centerIn: parent
+ z: 1
+
+ Image {
+ id: albumArt
+ source: (activePlayer && activePlayer.trackArtUrl) || lastValidArtUrl || ""
+ onSourceChanged: {
+ if (activePlayer && activePlayer.trackArtUrl && albumArt.status !== Image.Error) {
+ lastValidArtUrl = activePlayer.trackArtUrl
+ }
+ }
+ anchors.fill: parent
+ anchors.margins: 2
+ fillMode: Image.PreserveAspectCrop
+ smooth: true
+ mipmap: true
+ cache: true
+ asynchronous: true
+ visible: false
+ onStatusChanged: {
+ if (status === Image.Error) {
+ console.warn("Failed to load album art:", source)
+ source = ""
+ if (activePlayer && activePlayer.trackArtUrl === source) {
+ lastValidArtUrl = ""
+ }
+ }
+ }
+ }
+
+ MultiEffect {
+ anchors.fill: parent
+ anchors.margins: 2
+ source: albumArt
+ maskEnabled: true
+ maskSource: circularMask
+ visible: albumArt.status === Image.Ready
+ maskThresholdMin: 0.5
+ maskSpreadAtMin: 1
+ }
+
+ Item {
+ id: circularMask
+ width: parent.width - 4
+ height: parent.height - 4
+ layer.enabled: true
+ layer.smooth: true
+ visible: false
+
+ Rectangle {
+ anchors.fill: parent
+ radius: width / 2
+ color: "black"
+ antialiasing: true
+ }
+ }
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "album"
+ size: parent.width * 0.3
+ color: Theme.surfaceVariantText
+ visible: albumArt.status !== Image.Ready
+ }
+ }
+ }
+ }
+
+ // Song Info and Controls Section
+ Column {
+ width: parent.width
+ height: parent.height * 0.45
+ spacing: Theme.spacingS
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ // Song Info
+ Column {
+ width: parent.width
+ spacing: Theme.spacingS
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ StyledText {
+ text: (activePlayer && activePlayer.trackTitle) || lastValidTitle || "Unknown Track"
+ onTextChanged: {
+ if (activePlayer && activePlayer.trackTitle) {
+ lastValidTitle = activePlayer.trackTitle
+ }
+ }
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Bold
+ color: Theme.surfaceText
+ width: parent.width
+ horizontalAlignment: Text.AlignHCenter
+ elide: Text.ElideRight
+ wrapMode: Text.NoWrap
+ maximumLineCount: 1
+ }
+
+ StyledText {
+ text: (activePlayer && activePlayer.trackArtist) || lastValidArtist || "Unknown Artist"
+ onTextChanged: {
+ if (activePlayer && activePlayer.trackArtist) {
+ lastValidArtist = activePlayer.trackArtist
+ }
+ }
+ font.pixelSize: Theme.fontSizeMedium
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.8)
+ width: parent.width
+ horizontalAlignment: Text.AlignHCenter
+ elide: Text.ElideRight
+ wrapMode: Text.NoWrap
+ maximumLineCount: 1
+ }
+
+ StyledText {
+ text: (activePlayer && activePlayer.trackAlbum) || lastValidAlbum || ""
+ onTextChanged: {
+ if (activePlayer && activePlayer.trackAlbum) {
+ lastValidAlbum = activePlayer.trackAlbum
+ }
+ }
+ font.pixelSize: Theme.fontSizeSmall
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.6)
+ width: parent.width
+ horizontalAlignment: Text.AlignHCenter
+ elide: Text.ElideRight
+ wrapMode: Text.NoWrap
+ maximumLineCount: 1
+ visible: text.length > 0
+ }
+ }
+
+ // Progress Bar
+ Item {
+ id: progressSlider
+ width: parent.width
+ height: 20
+ visible: activePlayer?.length > 0
+
+ property real value: ratio
+ property real lineWidth: 2.5
+ property color trackColor: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.40)
+ property color fillColor: Theme.primary
+ property color playheadColor: Theme.primary
+ readonly property real midY: height / 2
+
+ // Background track
+ Rectangle {
+ width: parent.width
+ height: progressSlider.lineWidth
+ anchors.verticalCenter: parent.verticalCenter
+ color: progressSlider.trackColor
+ radius: height / 2
+ }
+
+ // Filled portion
+ Rectangle {
+ width: Math.max(0, Math.min(parent.width, parent.width * progressSlider.value))
+ height: progressSlider.lineWidth
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ color: progressSlider.fillColor
+ radius: height / 2
+ Behavior on width { NumberAnimation { duration: 80 } }
+ }
+
+ // Playhead
+ Rectangle {
+ id: playhead
+ width: 2.5
+ height: Math.max(progressSlider.lineWidth + 8, 12)
+ radius: width / 2
+ color: progressSlider.playheadColor
+ x: Math.max(0, Math.min(progressSlider.width, progressSlider.width * progressSlider.value)) - width / 2
+ y: progressSlider.midY - height / 2
+ z: 3
+ Behavior on x { NumberAnimation { duration: 80 } }
+ }
+
+ MouseArea {
+ id: progressSliderArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ enabled: activePlayer ? (activePlayer.canSeek && activePlayer.length > 0) : false
+
+ property bool isSeeking: false
+ property real pendingSeekPosition: -1
+
+ Timer {
+ id: seekDebounceTimer
+ interval: 150
+ onTriggered: {
+ if (progressSliderArea.pendingSeekPosition >= 0 && activePlayer?.canSeek && activePlayer?.length > 0) {
+ const clamped = Math.min(progressSliderArea.pendingSeekPosition, activePlayer.length * 0.99)
+ activePlayer.position = clamped
+ progressSliderArea.pendingSeekPosition = -1
+ }
+ }
+ }
+
+ onPressed: (mouse) => {
+ isSeeking = true
+ if (activePlayer?.length > 0 && activePlayer?.canSeek) {
+ const r = Math.max(0, Math.min(1, mouse.x / progressSlider.width))
+ pendingSeekPosition = r * activePlayer.length
+ displayPosition = pendingSeekPosition
+ seekDebounceTimer.restart()
+ }
+ }
+ onReleased: {
+ isSeeking = false
+ seekDebounceTimer.stop()
+ if (pendingSeekPosition >= 0 && activePlayer?.canSeek && activePlayer?.length > 0) {
+ const clamped = Math.min(pendingSeekPosition, activePlayer.length * 0.99)
+ activePlayer.position = clamped
+ pendingSeekPosition = -1
+ }
+ displayPosition = Qt.binding(() => currentPosition)
+ }
+ onPositionChanged: (mouse) => {
+ if (pressed && isSeeking && activePlayer?.length > 0 && activePlayer?.canSeek) {
+ const r = Math.max(0, Math.min(1, mouse.x / progressSlider.width))
+ pendingSeekPosition = r * activePlayer.length
+ displayPosition = pendingSeekPosition
+ seekDebounceTimer.restart()
+ }
+ }
+ onClicked: (mouse) => {
+ if (activePlayer?.length > 0 && activePlayer?.canSeek) {
+ const r = Math.max(0, Math.min(1, mouse.x / progressSlider.width))
+ activePlayer.position = r * activePlayer.length
+ }
+ }
+ }
+ }
+
+ // Media Controls
+ Row {
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: Theme.spacingXL
+ height: 64
+
+ Rectangle {
+ width: 32
+ height: 32
+ radius: 16
+ color: prevBtnArea.containsMouse ? Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.12) : "transparent"
+ anchors.verticalCenter: parent.verticalCenter
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "skip_previous"
+ size: 18
+ color: Theme.surfaceText
+ }
+
+ MouseArea {
+ id: prevBtnArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ if (!activePlayer) {
+ return
+ }
+
+ if (activePlayer.position > 8 && activePlayer.canSeek) {
+ activePlayer.position = 0
+ } else {
+ activePlayer.previous()
+ }
+ }
+ }
+ }
+
+ Rectangle {
+ width: 44
+ height: 44
+ radius: 22
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: activePlayer && activePlayer.playbackState === MprisPlaybackState.Playing ? "pause" : "play_arrow"
+ size: 24
+ color: Theme.background
+ weight: 500
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: activePlayer && activePlayer.togglePlaying()
+ }
+
+ layer.enabled: true
+ layer.effect: MultiEffect {
+ shadowEnabled: true
+ shadowHorizontalOffset: 0
+ shadowVerticalOffset: 6
+ shadowBlur: 1.0
+ shadowColor: Qt.rgba(0, 0, 0, 0.3)
+ shadowOpacity: 0.3
+ }
+ }
+
+ Rectangle {
+ width: 32
+ height: 32
+ radius: 16
+ color: nextBtnArea.containsMouse ? Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.12) : "transparent"
+ anchors.verticalCenter: parent.verticalCenter
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "skip_next"
+ size: 18
+ color: Theme.surfaceText
+ }
+
+ MouseArea {
+ id: nextBtnArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: activePlayer && activePlayer.next()
+ }
+ }
+ }
+ }
+ }
+
+ // Right Column: Audio Controls (40%)
+ Column {
+ x: parent.width * 0.6 + Theme.spacingM
+ y: 0
+ width: parent.width * 0.4 - Theme.spacingM
+ height: parent.height
+ spacing: Theme.spacingS
+ anchors.verticalCenter: parent.verticalCenter
+
+ // Volume Control
+ Row {
+ x: -Theme.spacingS
+ width: parent.width + Theme.spacingS
+ height: 40
+ spacing: Theme.spacingXS
+
+ Rectangle {
+ width: Theme.iconSize + Theme.spacingS * 2
+ height: Theme.iconSize + Theme.spacingS * 2
+ anchors.verticalCenter: parent.verticalCenter
+ radius: (Theme.iconSize + Theme.spacingS * 2) / 2
+ color: iconArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
+
+ Behavior on color {
+ ColorAnimation { duration: Theme.shortDuration }
+ }
+
+ MouseArea {
+ id: iconArea
+ anchors.fill: parent
+ visible: defaultSink !== null
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ if (defaultSink) {
+ defaultSink.audio.muted = !defaultSink.audio.muted
+ }
+ }
+ }
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: {
+ if (!defaultSink) return "volume_off"
+
+ let volume = defaultSink.audio.volume
+ let muted = defaultSink.audio.muted
+
+ if (muted || volume === 0.0) return "volume_off"
+ if (volume <= 0.33) return "volume_down"
+ if (volume <= 0.66) return "volume_up"
+ return "volume_up"
+ }
+ size: Theme.iconSize
+ color: defaultSink && !defaultSink.audio.muted && defaultSink.audio.volume > 0 ? Theme.primary : Theme.surfaceText
+ }
+ }
+
+ DankSlider {
+ anchors.verticalCenter: parent.verticalCenter
+ width: parent.width - (Theme.iconSize + Theme.spacingS * 2) - Theme.spacingXS
+ enabled: defaultSink !== null
+ minimum: 0
+ maximum: 100
+ value: defaultSink ? Math.round(defaultSink.audio.volume * 100) : 0
+ onSliderValueChanged: function(newValue) {
+ if (defaultSink) {
+ defaultSink.audio.volume = newValue / 100.0
+ if (newValue > 0 && defaultSink.audio.muted) {
+ defaultSink.audio.muted = false
+ }
+ }
+ }
+ }
+ }
+
+ // Audio Devices
+ DankFlickable {
+ width: parent.width
+ height: parent.height - y
+ contentHeight: deviceColumn.height
+ clip: true
+
+ Column {
+ id: deviceColumn
+ width: parent.width
+ spacing: Theme.spacingXS
+
+ Repeater {
+ model: Pipewire.nodes.values.filter(node => {
+ return node.audio && node.isSink && !node.isStream
+ })
+
+ delegate: Rectangle {
+ required property var modelData
+ required property int index
+
+ width: parent.width
+ height: 42
+ radius: Theme.cornerRadius
+ color: deviceMouseArea.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, index % 2 === 0 ? 0.3 : 0.2)
+ border.color: modelData === AudioService.sink ? Theme.primary : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
+ border.width: modelData === AudioService.sink ? 2 : 1
+
+ Row {
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.leftMargin: Theme.spacingS
+ spacing: Theme.spacingS
+
+ DankIcon {
+ name: {
+ if (modelData.name.includes("bluez"))
+ return "headset"
+ else if (modelData.name.includes("hdmi"))
+ return "tv"
+ else if (modelData.name.includes("usb"))
+ return "headset"
+ else
+ return "speaker"
+ }
+ size: Theme.iconSize - 4
+ color: modelData === AudioService.sink ? Theme.primary : Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ width: parent.parent.width - parent.parent.anchors.leftMargin - parent.spacing - Theme.iconSize - Theme.spacingS * 2
+
+ StyledText {
+ text: AudioService.displayName(modelData)
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ font.weight: modelData === AudioService.sink ? Font.Medium : Font.Normal
+ elide: Text.ElideRight
+ width: parent.width
+ wrapMode: Text.NoWrap
+ }
+
+ StyledText {
+ text: modelData === AudioService.sink ? "Active" : "Available"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ elide: Text.ElideRight
+ width: parent.width
+ wrapMode: Text.NoWrap
+ }
+ }
+ }
+
+ MouseArea {
+ id: deviceMouseArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ if (modelData) {
+ Pipewire.preferredDefaultAudioSink = modelData
+ }
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation { duration: Theme.shortDuration }
+ }
+
+ Behavior on border.color {
+ ColorAnimation { duration: Theme.shortDuration }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ MouseArea {
+ id: progressMouseArea
+ anchors.fill: parent
+ enabled: false
+ visible: false
+ property bool isSeeking: false
+ }
+}
\ No newline at end of file
diff --git a/quickshell/.config/quickshell/Modules/DankDash/Overview/CalendarOverviewCard.qml b/quickshell/.config/quickshell/Modules/DankDash/Overview/CalendarOverviewCard.qml
new file mode 100644
index 0000000..978ef6d
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/DankDash/Overview/CalendarOverviewCard.qml
@@ -0,0 +1,447 @@
+import QtQuick
+import QtQuick.Effects
+import Quickshell
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Rectangle {
+ id: root
+
+ property bool showEventDetails: false
+ property date selectedDate: systemClock.date
+ property var selectedDateEvents: []
+ property bool hasEvents: selectedDateEvents && selectedDateEvents.length > 0
+
+ signal closeDash()
+
+ function weekStartJs() {
+ return Qt.locale().firstDayOfWeek % 7
+ }
+
+ function startOfWeek(dateObj) {
+ const d = new Date(dateObj)
+ const jsDow = d.getDay()
+ const diff = (jsDow - weekStartJs() + 7) % 7
+ d.setDate(d.getDate() - diff)
+ return d
+ }
+
+ function endOfWeek(dateObj) {
+ const d = new Date(dateObj)
+ const jsDow = d.getDay()
+ const add = (weekStartJs() + 6 - jsDow + 7) % 7
+ d.setDate(d.getDate() + add)
+ return d
+ }
+
+ function updateSelectedDateEvents() {
+ if (CalendarService && CalendarService.khalAvailable) {
+ const events = CalendarService.getEventsForDate(selectedDate)
+ selectedDateEvents = events
+ } else {
+ selectedDateEvents = []
+ }
+ }
+
+ function loadEventsForMonth() {
+ if (!CalendarService || !CalendarService.khalAvailable) {
+ return
+ }
+
+ const firstOfMonth = new Date(calendarGrid.displayDate.getFullYear(),
+ calendarGrid.displayDate.getMonth(), 1)
+ const lastOfMonth = new Date(calendarGrid.displayDate.getFullYear(),
+ calendarGrid.displayDate.getMonth() + 1, 0)
+
+ const startDate = startOfWeek(firstOfMonth)
+ startDate.setDate(startDate.getDate() - 7)
+
+ const endDate = endOfWeek(lastOfMonth)
+ endDate.setDate(endDate.getDate() + 7)
+
+ CalendarService.loadEvents(startDate, endDate)
+ }
+
+ onSelectedDateChanged: updateSelectedDateEvents()
+ Component.onCompleted: {
+ loadEventsForMonth()
+ updateSelectedDateEvents()
+ }
+
+ Connections {
+ function onEventsByDateChanged() {
+ updateSelectedDateEvents()
+ }
+
+ function onKhalAvailableChanged() {
+ if (CalendarService && CalendarService.khalAvailable) {
+ loadEventsForMonth()
+ }
+ updateSelectedDateEvents()
+ }
+
+ target: CalendarService
+ enabled: CalendarService !== null
+ }
+
+ radius: Theme.cornerRadius
+ color: Theme.surfaceContainerHigh
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.05)
+ border.width: 1
+
+ Column {
+ anchors.fill: parent
+ anchors.margins: Theme.spacingM
+ spacing: Theme.spacingS
+
+ Item {
+ width: parent.width
+ height: 40
+ visible: showEventDetails
+
+ Rectangle {
+ width: 32
+ height: 32
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingS
+ radius: Theme.cornerRadius
+ color: backButtonArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "arrow_back"
+ size: 14
+ color: Theme.primary
+ }
+
+ MouseArea {
+ id: backButtonArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: root.showEventDetails = false
+ }
+ }
+
+ StyledText {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.leftMargin: 32 + Theme.spacingS * 2
+ anchors.rightMargin: Theme.spacingS
+ height: 40
+ anchors.verticalCenter: parent.verticalCenter
+ text: {
+ const dateStr = Qt.formatDate(selectedDate, "MMM d")
+ if (selectedDateEvents && selectedDateEvents.length > 0) {
+ const eventCount = selectedDateEvents.length === 1 ? I18n.tr("1 event") : selectedDateEvents.length + " " + I18n.tr("events")
+ return dateStr + " • " + eventCount
+ }
+ return dateStr
+ }
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ verticalAlignment: Text.AlignVCenter
+ elide: Text.ElideRight
+ }
+ }
+ Row {
+ width: parent.width
+ height: 28
+ visible: !showEventDetails
+
+ Rectangle {
+ width: 28
+ height: 28
+ radius: Theme.cornerRadius
+ color: prevMonthArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "chevron_left"
+ size: 14
+ color: Theme.primary
+ }
+
+ MouseArea {
+ id: prevMonthArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ let newDate = new Date(calendarGrid.displayDate)
+ newDate.setMonth(newDate.getMonth() - 1)
+ calendarGrid.displayDate = newDate
+ loadEventsForMonth()
+ }
+ }
+ }
+
+ StyledText {
+ width: parent.width - 56
+ height: 28
+ text: calendarGrid.displayDate.toLocaleDateString(Qt.locale(), "MMMM yyyy")
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ Rectangle {
+ width: 28
+ height: 28
+ radius: Theme.cornerRadius
+ color: nextMonthArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "chevron_right"
+ size: 14
+ color: Theme.primary
+ }
+
+ MouseArea {
+ id: nextMonthArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ let newDate = new Date(calendarGrid.displayDate)
+ newDate.setMonth(newDate.getMonth() + 1)
+ calendarGrid.displayDate = newDate
+ loadEventsForMonth()
+ }
+ }
+ }
+ }
+
+ Row {
+ width: parent.width
+ height: 18
+ visible: !showEventDetails
+
+ Repeater {
+ model: {
+ const days = []
+ const loc = Qt.locale()
+ const qtFirst = loc.firstDayOfWeek
+ for (let i = 0; i < 7; ++i) {
+ const qtDay = ((qtFirst - 1 + i) % 7) + 1
+ days.push(loc.dayName(qtDay, Locale.ShortFormat))
+ }
+ return days
+ }
+
+ Rectangle {
+ width: parent.width / 7
+ height: 18
+ color: "transparent"
+
+ StyledText {
+ 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 {
+ id: calendarGrid
+ visible: !showEventDetails
+
+ property date displayDate: systemClock.date
+ property date selectedDate: systemClock.date
+
+ readonly property date firstDay: {
+ const firstOfMonth = new Date(displayDate.getFullYear(), displayDate.getMonth(), 1)
+ return startOfWeek(firstOfMonth)
+ }
+
+ width: parent.width
+ height: parent.height - 28 - 18 - Theme.spacingS * 2
+ columns: 7
+ rows: 6
+
+ Repeater {
+ model: 42
+
+ Rectangle {
+ readonly property date dayDate: {
+ const date = new Date(parent.firstDay)
+ date.setDate(date.getDate() + index)
+ return date
+ }
+ readonly property bool isCurrentMonth: dayDate.getMonth() === calendarGrid.displayDate.getMonth()
+ readonly property bool isToday: dayDate.toDateString() === new Date().toDateString()
+ readonly property bool isSelected: dayDate.toDateString() === calendarGrid.selectedDate.toDateString()
+
+ width: parent.width / 7
+ height: parent.height / 6
+ color: "transparent"
+
+ Rectangle {
+ anchors.centerIn: parent
+ width: Math.min(parent.width - 4, parent.height - 4, 32)
+ height: width
+ color: 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: width / 2
+
+ StyledText {
+ anchors.centerIn: parent
+ text: dayDate.getDate()
+ font.pixelSize: Theme.fontSizeSmall
+ color: isToday ? Theme.primary : isCurrentMonth ? Theme.surfaceText : Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.4)
+ font.weight: isToday ? Font.Medium : Font.Normal
+ }
+
+ Rectangle {
+ anchors.bottom: parent.bottom
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.bottomMargin: 4
+ width: 12
+ height: 2
+ radius: 1
+ visible: CalendarService && CalendarService.khalAvailable && CalendarService.hasEventsForDate(dayDate)
+ color: isToday ? Qt.lighter(Theme.primary, 1.3) : Theme.primary
+ opacity: isToday ? 0.9 : 0.7
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+ }
+ }
+
+ MouseArea {
+ id: dayArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ if (CalendarService && CalendarService.khalAvailable && CalendarService.hasEventsForDate(dayDate)) {
+ root.selectedDate = dayDate
+ root.showEventDetails = true
+ }
+ }
+ }
+ }
+ }
+ }
+ DankListView {
+ width: parent.width - Theme.spacingS * 2
+ height: parent.height - (showEventDetails ? 40 : 28 + 18) - Theme.spacingS
+ anchors.horizontalCenter: parent.horizontalCenter
+ model: selectedDateEvents
+ visible: showEventDetails
+ clip: true
+ spacing: Theme.spacingXS
+
+ delegate: Rectangle {
+ width: parent ? parent.width : 0
+ height: eventContent.implicitHeight + Theme.spacingS
+ radius: Theme.cornerRadius
+ color: {
+ if (modelData.url && eventMouseArea.containsMouse) {
+ return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12)
+ } else if (eventMouseArea.containsMouse) {
+ return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.06)
+ }
+ return Theme.surfaceContainerHigh
+ }
+ border.color: {
+ if (modelData.url && eventMouseArea.containsMouse) {
+ return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.3)
+ } else if (eventMouseArea.containsMouse) {
+ return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.15)
+ }
+ return "transparent"
+ }
+ border.width: 1
+
+ Rectangle {
+ width: 3
+ height: parent.height - 6
+ anchors.left: parent.left
+ anchors.leftMargin: 3
+ anchors.verticalCenter: parent.verticalCenter
+ radius: 2
+ color: Theme.primary
+ opacity: 0.8
+ }
+
+ Column {
+ id: eventContent
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.leftMargin: Theme.spacingS + 6
+ anchors.rightMargin: Theme.spacingXS
+ spacing: 2
+
+ StyledText {
+ width: parent.width
+ text: modelData.title
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ elide: Text.ElideRight
+ maximumLineCount: 1
+ }
+
+ StyledText {
+ width: parent.width
+ text: {
+ if (!modelData || modelData.allDay) {
+ return I18n.tr("All day")
+ } else if (modelData.start && modelData.end) {
+ const timeFormat = SettingsData.use24HourClock ? "HH:mm" : "h:mm AP"
+ const startTime = Qt.formatTime(modelData.start, timeFormat)
+ if (modelData.start.toDateString() !== modelData.end.toDateString() || modelData.start.getTime() !== modelData.end.getTime()) {
+ return startTime + " – " + Qt.formatTime(modelData.end, timeFormat)
+ }
+ return startTime
+ }
+ return ""
+ }
+ font.pixelSize: Theme.fontSizeSmall
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
+ font.weight: Font.Normal
+ visible: text !== ""
+ }
+ }
+
+ MouseArea {
+ id: eventMouseArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: modelData.url ? Qt.PointingHandCursor : Qt.ArrowCursor
+ enabled: modelData.url !== ""
+ onClicked: {
+ if (modelData.url && modelData.url !== "") {
+ if (Qt.openUrlExternally(modelData.url) === false) {
+ console.warn("Failed to open URL: " + modelData.url)
+ } else {
+ root.closeDash()
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ SystemClock {
+ id: systemClock
+ precision: SystemClock.Hours
+ }
+}
\ No newline at end of file
diff --git a/quickshell/.config/quickshell/Modules/DankDash/Overview/Card.qml b/quickshell/.config/quickshell/Modules/DankDash/Overview/Card.qml
new file mode 100644
index 0000000..cd1f578
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/DankDash/Overview/Card.qml
@@ -0,0 +1,22 @@
+import QtQuick
+import QtQuick.Controls
+import qs.Common
+
+Rectangle {
+ id: card
+
+ property int pad: Theme.spacingM
+
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.2)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
+ border.width: 1
+
+ default property alias content: contentItem.data
+
+ Item {
+ id: contentItem
+ anchors.fill: parent
+ anchors.margins: card.pad
+ }
+}
\ No newline at end of file
diff --git a/quickshell/.config/quickshell/Modules/DankDash/Overview/ClockCard.qml b/quickshell/.config/quickshell/Modules/DankDash/Overview/ClockCard.qml
new file mode 100644
index 0000000..88bb346
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/DankDash/Overview/ClockCard.qml
@@ -0,0 +1,98 @@
+import QtQuick
+import QtQuick.Effects
+import Quickshell
+import qs.Common
+import qs.Widgets
+
+Card {
+ id: root
+
+ Column {
+ anchors.centerIn: parent
+ spacing: Theme.spacingS
+
+ Column {
+ spacing: -8
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ Row {
+ spacing: 0
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ StyledText {
+ text: {
+ if (SettingsData.use24HourClock) {
+ return String(systemClock?.date?.getHours()).padStart(2, '0').charAt(0)
+ } else {
+ const hours = systemClock?.date?.getHours()
+ const display = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours
+ return String(display).padStart(2, '0').charAt(0)
+ }
+ }
+ font.pixelSize: 48
+ color: Theme.primary
+ font.weight: Font.Medium
+ width: 28
+ horizontalAlignment: Text.AlignHCenter
+ }
+
+ StyledText {
+ text: {
+ if (SettingsData.use24HourClock) {
+ return String(systemClock?.date?.getHours()).padStart(2, '0').charAt(1)
+ } else {
+ const hours = systemClock?.date?.getHours()
+ const display = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours
+ return String(display).padStart(2, '0').charAt(1)
+ }
+ }
+ font.pixelSize: 48
+ color: Theme.primary
+ font.weight: Font.Medium
+ width: 28
+ horizontalAlignment: Text.AlignHCenter
+ }
+ }
+
+ Row {
+ spacing: 0
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ StyledText {
+ text: String(systemClock?.date?.getMinutes()).padStart(2, '0').charAt(0)
+ font.pixelSize: 48
+ color: Theme.primary
+ font.weight: Font.Medium
+ width: 28
+ horizontalAlignment: Text.AlignHCenter
+ }
+
+ StyledText {
+ text: String(systemClock?.date?.getMinutes()).padStart(2, '0').charAt(1)
+ font.pixelSize: 48
+ color: Theme.primary
+ font.weight: Font.Medium
+ width: 28
+ horizontalAlignment: Text.AlignHCenter
+ }
+ }
+ }
+
+ StyledText {
+ text: {
+ if (SettingsData.clockDateFormat && SettingsData.clockDateFormat.length > 0) {
+ return systemClock?.date?.toLocaleDateString(Qt.locale(), SettingsData.clockDateFormat)
+ }
+ return systemClock?.date?.toLocaleDateString(Qt.locale(), "MMM d")
+ }
+ font.pixelSize: Theme.fontSizeSmall
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+ }
+
+ SystemClock {
+ id: systemClock
+ precision: SystemClock.Seconds
+ }
+}
\ No newline at end of file
diff --git a/quickshell/.config/quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml b/quickshell/.config/quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml
new file mode 100644
index 0000000..5ecef55
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml
@@ -0,0 +1,481 @@
+import QtQuick
+import QtQuick.Effects
+import QtQuick.Shapes
+import Quickshell.Services.Mpris
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Card {
+ id: root
+ signal clicked()
+
+ property MprisPlayer activePlayer: MprisController.activePlayer
+ property real currentPosition: activePlayer?.positionSupported ? activePlayer.position : 0
+ property real displayPosition: currentPosition
+
+ readonly property real ratio: {
+ if (!activePlayer || activePlayer.length <= 0) return 0
+ const calculatedRatio = displayPosition / activePlayer.length
+ return Math.max(0, Math.min(1, calculatedRatio))
+ }
+
+ onActivePlayerChanged: {
+ if (activePlayer?.positionSupported) {
+ currentPosition = Qt.binding(() => activePlayer?.position || 0)
+ } else {
+ currentPosition = 0
+ }
+ }
+
+ Timer {
+ interval: 300
+ running: activePlayer?.playbackState === MprisPlaybackState.Playing && !progressMouseArea.isSeeking
+ repeat: true
+ onTriggered: activePlayer?.positionSupported && activePlayer.positionChanged()
+ }
+
+ Column {
+ anchors.centerIn: parent
+ spacing: Theme.spacingS
+ visible: !activePlayer
+
+ DankIcon {
+ name: "music_note"
+ size: Theme.iconSize
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.5)
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+
+ StyledText {
+ text: "No Media"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+ }
+
+ Column {
+ anchors.centerIn: parent
+ width: parent.width - Theme.spacingXS * 2
+ spacing: Theme.spacingL
+ visible: activePlayer
+
+ Item {
+ width: 110
+ height: 80
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ Loader {
+ active: activePlayer?.playbackState === MprisPlaybackState.Playing
+ sourceComponent: Component {
+ Ref {
+ service: CavaService
+ }
+ }
+ }
+
+ Shape {
+ id: morphingBlob
+ width: 120
+ height: 120
+ anchors.centerIn: parent
+ visible: activePlayer?.playbackState === MprisPlaybackState.Playing
+ asynchronous: false
+ antialiasing: true
+ preferredRendererType: Shape.CurveRenderer
+
+ layer.enabled: true
+ layer.smooth: true
+ layer.samples: 4
+
+
+ readonly property real centerX: width / 2
+ readonly property real centerY: height / 2
+ readonly property real baseRadius: 40
+ readonly property int segments: 24
+
+ property var audioLevels: {
+ if (!CavaService.cavaAvailable || CavaService.values.length === 0) {
+ return [0.5, 0.3, 0.7, 0.4, 0.6, 0.5]
+ }
+ return CavaService.values
+ }
+
+ property var smoothedLevels: [0.5, 0.3, 0.7, 0.4, 0.6, 0.5]
+ property var cubics: []
+
+
+ onAudioLevelsChanged: updatePath()
+
+ Timer {
+ running: morphingBlob.visible
+ interval: 16
+ repeat: true
+ onTriggered: morphingBlob.updatePath()
+ }
+
+ Component {
+ id: cubicSegment
+ PathCubic {}
+ }
+
+ Component.onCompleted: {
+ shapePath.pathElements.push(Qt.createQmlObject(
+ 'import QtQuick; import QtQuick.Shapes; PathMove {}', shapePath
+ ))
+
+ for (let i = 0; i < segments; i++) {
+ const seg = cubicSegment.createObject(shapePath)
+ shapePath.pathElements.push(seg)
+ cubics.push(seg)
+ }
+
+ updatePath()
+ }
+
+ function expSmooth(prev, next, alpha) {
+ return prev + alpha * (next - prev)
+ }
+
+ function updatePath() {
+ if (cubics.length === 0) return
+
+ for (let i = 0; i < Math.min(smoothedLevels.length, audioLevels.length); i++) {
+ smoothedLevels[i] = expSmooth(smoothedLevels[i], audioLevels[i], 0.2)
+ }
+
+ const points = []
+ for (let i = 0; i < segments; i++) {
+ const angle = (i / segments) * 2 * Math.PI
+ const audioIndex = i % Math.min(smoothedLevels.length, 6)
+ const audioLevel = Math.max(0.1, Math.min(1.5, (smoothedLevels[audioIndex] || 0) / 50))
+
+ const radius = baseRadius * (1.0 + audioLevel * 0.3)
+ const x = centerX + Math.cos(angle) * radius
+ const y = centerY + Math.sin(angle) * radius
+ points.push({x: x, y: y})
+ }
+
+ const startMove = shapePath.pathElements[0]
+ startMove.x = points[0].x
+ startMove.y = points[0].y
+
+ const tension = 0.5
+ for (let i = 0; i < segments; i++) {
+ const p0 = points[(i - 1 + segments) % segments]
+ const p1 = points[i]
+ const p2 = points[(i + 1) % segments]
+ const p3 = points[(i + 2) % segments]
+
+ const c1x = p1.x + (p2.x - p0.x) * tension / 3
+ const c1y = p1.y + (p2.y - p0.y) * tension / 3
+ const c2x = p2.x - (p3.x - p1.x) * tension / 3
+ const c2y = p2.y - (p3.y - p1.y) * tension / 3
+
+ const seg = cubics[i]
+ seg.control1X = c1x
+ seg.control1Y = c1y
+ seg.control2X = c2x
+ seg.control2Y = c2y
+ seg.x = p2.x
+ seg.y = p2.y
+ }
+ }
+
+ ShapePath {
+ id: shapePath
+ fillColor: Theme.primary
+ strokeColor: "transparent"
+ strokeWidth: 0
+ joinStyle: ShapePath.RoundJoin
+ fillRule: ShapePath.WindingFill
+ }
+ }
+
+ Rectangle {
+ width: 72
+ height: 72
+ radius: 36
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3)
+ border.color: Theme.surfaceContainer
+ border.width: 1
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.verticalCenter: parent.verticalCenter
+ z: 1
+
+ Image {
+ id: albumArt
+ source: activePlayer?.trackArtUrl || ""
+ anchors.fill: parent
+ anchors.margins: 2
+ fillMode: Image.PreserveAspectCrop
+ smooth: true
+ mipmap: true
+ cache: true
+ asynchronous: true
+ visible: false
+ }
+
+ MultiEffect {
+ anchors.fill: parent
+ anchors.margins: 2
+ source: albumArt
+ maskEnabled: true
+ maskSource: circularMask
+ visible: albumArt.status === Image.Ready
+ maskThresholdMin: 0.5
+ maskSpreadAtMin: 1
+ }
+
+ Item {
+ id: circularMask
+ width: 68
+ height: 68
+ layer.enabled: true
+ layer.smooth: true
+ visible: false
+
+ Rectangle {
+ anchors.fill: parent
+ radius: width / 2
+ color: "black"
+ antialiasing: true
+ }
+ }
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "album"
+ size: 20
+ color: Theme.surfaceVariantText
+ visible: albumArt.status !== Image.Ready
+ }
+ }
+ }
+
+ Column {
+ width: parent.width
+ spacing: Theme.spacingXS
+ topPadding: Theme.spacingL
+
+ StyledText {
+ text: activePlayer?.trackTitle || "Unknown"
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ width: parent.width
+ elide: Text.ElideRight
+ maximumLineCount: 1
+ horizontalAlignment: Text.AlignHCenter
+ }
+
+ StyledText {
+ text: activePlayer?.trackArtist || "Unknown Artist"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
+ width: parent.width
+ elide: Text.ElideRight
+ maximumLineCount: 1
+ horizontalAlignment: Text.AlignHCenter
+ }
+ }
+
+ Item {
+ id: progressSlider
+ width: parent.width
+ height: 20
+ visible: activePlayer?.length > 0
+
+ property real value: ratio
+ property real lineWidth: 2.5
+ property color trackColor: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.40)
+ property color fillColor: Theme.primary
+ property color playheadColor: Theme.primary
+
+ Rectangle {
+ width: parent.width
+ height: progressSlider.lineWidth
+ anchors.verticalCenter: parent.verticalCenter
+ color: progressSlider.trackColor
+ radius: height / 2
+ }
+
+ Rectangle {
+ width: Math.max(0, Math.min(parent.width, parent.width * progressSlider.value))
+ height: progressSlider.lineWidth
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ color: progressSlider.fillColor
+ radius: height / 2
+ Behavior on width { NumberAnimation { duration: 80 } }
+ }
+
+ Rectangle {
+ id: playhead
+ width: 2.5
+ height: Math.max(progressSlider.lineWidth + 8, 12)
+ radius: width / 2
+ color: progressSlider.playheadColor
+ x: Math.max(0, Math.min(progressSlider.width, progressSlider.width * progressSlider.value)) - width / 2
+ anchors.verticalCenter: parent.verticalCenter
+ z: 3
+ Behavior on x { NumberAnimation { duration: 80 } }
+ }
+
+ MouseArea {
+ id: progressMouseArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ enabled: activePlayer ? (activePlayer.canSeek && activePlayer.length > 0) : false
+
+ property bool isSeeking: false
+ property real pendingSeekPosition: -1
+
+ Timer {
+ id: seekDebounceTimer
+ interval: 150
+ onTriggered: {
+ if (progressMouseArea.pendingSeekPosition >= 0 && activePlayer?.canSeek && activePlayer?.length > 0) {
+ const clamped = Math.min(progressMouseArea.pendingSeekPosition, activePlayer.length * 0.99)
+ activePlayer.position = clamped
+ progressMouseArea.pendingSeekPosition = -1
+ }
+ }
+ }
+
+ onPressed: (mouse) => {
+ isSeeking = true
+ if (activePlayer?.length > 0 && activePlayer?.canSeek) {
+ const r = Math.max(0, Math.min(1, mouse.x / progressSlider.width))
+ pendingSeekPosition = r * activePlayer.length
+ displayPosition = pendingSeekPosition
+ seekDebounceTimer.restart()
+ }
+ }
+ onReleased: {
+ isSeeking = false
+ seekDebounceTimer.stop()
+ if (pendingSeekPosition >= 0 && activePlayer?.canSeek && activePlayer?.length > 0) {
+ const clamped = Math.min(pendingSeekPosition, activePlayer.length * 0.99)
+ activePlayer.position = clamped
+ pendingSeekPosition = -1
+ }
+ displayPosition = Qt.binding(() => currentPosition)
+ }
+ onPositionChanged: (mouse) => {
+ if (pressed && isSeeking && activePlayer?.length > 0 && activePlayer?.canSeek) {
+ const r = Math.max(0, Math.min(1, mouse.x / progressSlider.width))
+ pendingSeekPosition = r * activePlayer.length
+ displayPosition = pendingSeekPosition
+ seekDebounceTimer.restart()
+ }
+ }
+ onClicked: (mouse) => {
+ if (activePlayer?.length > 0 && activePlayer?.canSeek) {
+ const r = Math.max(0, Math.min(1, mouse.x / progressSlider.width))
+ activePlayer.position = r * activePlayer.length
+ }
+ }
+ }
+ }
+
+ Item {
+ width: parent.width
+ height: 32
+
+ Row {
+ spacing: Theme.spacingS
+ anchors.centerIn: parent
+
+ Rectangle {
+ width: 28
+ height: 28
+ radius: 14
+ anchors.verticalCenter: playPauseButton.verticalCenter
+ color: prevArea.containsMouse ? Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.12) : "transparent"
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "skip_previous"
+ size: 14
+ color: Theme.surfaceText
+ }
+
+ MouseArea {
+ id: prevArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ if (!activePlayer) return
+ if (activePlayer.position > 8 && activePlayer.canSeek) {
+ activePlayer.position = 0
+ } else {
+ activePlayer.previous()
+ }
+ }
+ }
+ }
+
+ Rectangle {
+ id: playPauseButton
+ width: 32
+ height: 32
+ radius: 16
+ color: Theme.primary
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: activePlayer?.playbackState === MprisPlaybackState.Playing ? "pause" : "play_arrow"
+ size: 16
+ color: Theme.background
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: activePlayer?.togglePlaying()
+ }
+ }
+
+ Rectangle {
+ width: 28
+ height: 28
+ radius: 14
+ anchors.verticalCenter: playPauseButton.verticalCenter
+ color: nextArea.containsMouse ? Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.12) : "transparent"
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "skip_next"
+ size: 14
+ color: Theme.surfaceText
+ }
+
+ MouseArea {
+ id: nextArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: activePlayer?.next()
+ }
+ }
+ }
+ }
+ }
+
+ MouseArea {
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: 123
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: root.clicked()
+ visible: activePlayer
+ }
+}
\ No newline at end of file
diff --git a/quickshell/.config/quickshell/Modules/DankDash/Overview/SystemMonitorCard.qml b/quickshell/.config/quickshell/Modules/DankDash/Overview/SystemMonitorCard.qml
new file mode 100644
index 0000000..9251a36
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/DankDash/Overview/SystemMonitorCard.qml
@@ -0,0 +1,178 @@
+import QtQuick
+import QtQuick.Effects
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Card {
+ id: root
+
+ Component.onCompleted: {
+ DgopService.addRef(["cpu", "memory", "system"])
+ }
+ Component.onDestruction: {
+ DgopService.removeRef(["cpu", "memory", "system"])
+ }
+
+ Row {
+ anchors.fill: parent
+ anchors.margins: Theme.spacingS
+ spacing: Theme.spacingM
+
+ // CPU Bar
+ Column {
+ width: (parent.width - 2 * Theme.spacingM) / 3
+ height: parent.height
+ spacing: Theme.spacingS
+
+ Rectangle {
+ width: 8
+ height: parent.height - Theme.iconSizeSmall - Theme.spacingS
+ radius: 4
+ anchors.horizontalCenter: parent.horizontalCenter
+ color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
+
+ Rectangle {
+ width: parent.width
+ height: parent.height * Math.min((DgopService.cpuUsage || 6) / 100, 1)
+ radius: parent.radius
+ anchors.bottom: parent.bottom
+ anchors.horizontalCenter: parent.horizontalCenter
+ color: {
+ if (DgopService.cpuUsage > 80) return Theme.error
+ if (DgopService.cpuUsage > 60) return Theme.warning
+ return Theme.primary
+ }
+
+ Behavior on height {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+ }
+ }
+
+ Item {
+ width: parent.width
+ height: Theme.iconSizeSmall
+
+ DankIcon {
+ name: "memory"
+ size: Theme.iconSizeSmall
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.bottom: parent.bottom
+ color: {
+ if (DgopService.cpuUsage > 80) return Theme.error
+ if (DgopService.cpuUsage > 60) return Theme.warning
+ return Theme.primary
+ }
+ }
+ }
+ }
+
+ // Temperature Bar
+ Column {
+ width: (parent.width - 2 * Theme.spacingM) / 3
+ height: parent.height
+ spacing: Theme.spacingS
+
+ Rectangle {
+ width: 8
+ height: parent.height - Theme.iconSizeSmall - Theme.spacingS
+ radius: 4
+ anchors.horizontalCenter: parent.horizontalCenter
+ color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
+
+ Rectangle {
+ width: parent.width
+ height: parent.height * Math.min(Math.max((DgopService.cpuTemperature || 40) / 100, 0), 1)
+ radius: parent.radius
+ anchors.bottom: parent.bottom
+ anchors.horizontalCenter: parent.horizontalCenter
+ color: {
+ if (DgopService.cpuTemperature > 85) return Theme.error
+ if (DgopService.cpuTemperature > 69) return Theme.warning
+ return Theme.primary
+ }
+
+ Behavior on height {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+ }
+ }
+
+ Item {
+ width: parent.width
+ height: Theme.iconSizeSmall
+
+ DankIcon {
+ name: "device_thermostat"
+ size: Theme.iconSizeSmall
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.bottom: parent.bottom
+ color: {
+ if (DgopService.cpuTemperature > 85) return Theme.error
+ if (DgopService.cpuTemperature > 69) return Theme.warning
+ return Theme.primary
+ }
+ }
+ }
+ }
+
+ // RAM Bar
+ Column {
+ width: (parent.width - 2 * Theme.spacingM) / 3
+ height: parent.height
+ spacing: Theme.spacingS
+
+ Rectangle {
+ width: 8
+ height: parent.height - Theme.iconSizeSmall - Theme.spacingS
+ radius: 4
+ anchors.horizontalCenter: parent.horizontalCenter
+ color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
+
+ Rectangle {
+ width: parent.width
+ height: parent.height * Math.min((DgopService.memoryUsage || 42) / 100, 1)
+ radius: parent.radius
+ anchors.bottom: parent.bottom
+ anchors.horizontalCenter: parent.horizontalCenter
+ color: {
+ if (DgopService.memoryUsage > 90) return Theme.error
+ if (DgopService.memoryUsage > 75) return Theme.warning
+ return Theme.primary
+ }
+
+ Behavior on height {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+ }
+ }
+
+ Item {
+ width: parent.width
+ height: Theme.iconSizeSmall
+
+ DankIcon {
+ name: "developer_board"
+ size: Theme.iconSizeSmall
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.bottom: parent.bottom
+ color: {
+ if (DgopService.memoryUsage > 90) return Theme.error
+ if (DgopService.memoryUsage > 75) return Theme.warning
+ return Theme.primary
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/quickshell/.config/quickshell/Modules/DankDash/Overview/UserInfoCard.qml b/quickshell/.config/quickshell/Modules/DankDash/Overview/UserInfoCard.qml
new file mode 100644
index 0000000..2cbc593
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/DankDash/Overview/UserInfoCard.qml
@@ -0,0 +1,167 @@
+import QtQuick
+import QtQuick.Effects
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Card {
+ id: root
+
+ Row {
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingM
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingM
+
+ Item {
+ id: avatarContainer
+
+ property bool hasImage: profileImageLoader.status === Image.Ready
+
+ width: 77
+ height: 77
+ anchors.verticalCenter: parent.verticalCenter
+
+ Rectangle {
+ anchors.fill: parent
+ radius: 36
+ color: Theme.primary
+ visible: !avatarContainer.hasImage
+
+ StyledText {
+ anchors.centerIn: parent
+ text: UserInfoService.username.length > 0 ? UserInfoService.username.charAt(0).toUpperCase() : "b"
+ font.pixelSize: Theme.fontSizeXLarge + 4
+ font.weight: Font.Bold
+ color: Theme.background
+ }
+ }
+
+ Image {
+ id: profileImageLoader
+
+ source: {
+ if (PortalService.profileImage === "")
+ return ""
+
+ if (PortalService.profileImage.startsWith("/"))
+ return "file://" + PortalService.profileImage
+
+ return PortalService.profileImage
+ }
+ smooth: true
+ asynchronous: true
+ mipmap: true
+ cache: true
+ visible: false
+ }
+
+ MultiEffect {
+ anchors.fill: parent
+ anchors.margins: 2
+ source: profileImageLoader
+ maskEnabled: true
+ maskSource: circularMask
+ visible: avatarContainer.hasImage
+ maskThresholdMin: 0.5
+ maskSpreadAtMin: 1
+ }
+
+ Item {
+ id: circularMask
+ width: 77 - 4
+ height: 77 - 4
+ layer.enabled: true
+ layer.smooth: true
+ visible: false
+
+ Rectangle {
+ anchors.fill: parent
+ radius: width / 2
+ color: "black"
+ antialiasing: true
+ }
+ }
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "person"
+ size: Theme.iconSize + 8
+ color: Theme.error
+ visible: PortalService.profileImage !== "" && profileImageLoader.status === Image.Error
+ }
+ }
+
+ Column {
+ spacing: Theme.spacingS
+ anchors.verticalCenter: parent.verticalCenter
+
+ StyledText {
+ text: UserInfoService.username || "brandon"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ elide: Text.ElideRight
+ width: parent.parent.parent.width - avatarContainer.width - Theme.spacingM * 3
+ }
+
+ Row {
+ spacing: Theme.spacingS
+
+ SystemLogo {
+ width: 16
+ height: 16
+ anchors.verticalCenter: parent.verticalCenter
+ colorOverride: Theme.primary
+ }
+
+ StyledText {
+ text: {
+ if (CompositorService.isNiri) return "on niri"
+ if (CompositorService.isHyprland) return "on Hyprland"
+ return ""
+ }
+ font.pixelSize: Theme.fontSizeSmall
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.8)
+ anchors.verticalCenter: parent.verticalCenter
+ elide: Text.ElideRight
+ width: parent.parent.parent.parent.width - avatarContainer.width - Theme.spacingM * 3 - 16 - Theme.spacingS
+ }
+ }
+
+ Row {
+ spacing: Theme.spacingS
+
+ DankIcon {
+ name: "schedule"
+ size: 16
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ id: uptimeText
+
+ property real availableWidth: parent.parent.parent.parent.width - avatarContainer.width - Theme.spacingM * 3 - 16 - Theme.spacingS
+ property real longTextWidth: {
+ const testMetrics = Qt.createQmlObject('import QtQuick; TextMetrics { font.pixelSize: ' + Theme.fontSizeSmall + ' }', uptimeText)
+ testMetrics.text = UserInfoService.uptime || "up 1 hour, 23 minutes"
+ const result = testMetrics.width
+ testMetrics.destroy()
+ return result
+ }
+ // Just using truncated is always true initially idk
+ property bool shouldUseShort: longTextWidth > availableWidth
+
+ text: shouldUseShort ? UserInfoService.shortUptime : UserInfoService.uptime || "up 1h 23m"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
+ anchors.verticalCenter: parent.verticalCenter
+ elide: Text.ElideRight
+ width: availableWidth
+ wrapMode: Text.NoWrap
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/quickshell/.config/quickshell/Modules/DankDash/Overview/WeatherOverviewCard.qml b/quickshell/.config/quickshell/Modules/DankDash/Overview/WeatherOverviewCard.qml
new file mode 100644
index 0000000..4db0e97
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/DankDash/Overview/WeatherOverviewCard.qml
@@ -0,0 +1,91 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Effects
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Card {
+ id: root
+
+ signal clicked()
+
+ Component.onCompleted: WeatherService.addRef()
+ Component.onDestruction: WeatherService.removeRef()
+
+ Column {
+ anchors.centerIn: parent
+ spacing: Theme.spacingS
+ visible: !WeatherService.weather.available || WeatherService.weather.temp === 0
+
+ DankIcon {
+ name: "cloud_off"
+ size: 24
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.5)
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+
+ StyledText {
+ text: WeatherService.weather.loading ? "Loading..." : "No Weather"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+
+ Button {
+ text: "Refresh"
+ flat: true
+ visible: !WeatherService.weather.loading
+ anchors.horizontalCenter: parent.horizontalCenter
+ onClicked: WeatherService.forceRefresh()
+ }
+ }
+
+ Row {
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingL
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingL
+ visible: WeatherService.weather.available && WeatherService.weather.temp !== 0
+
+ DankIcon {
+ name: WeatherService.getWeatherIcon(WeatherService.weather.wCode)
+ size: 48
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ spacing: Theme.spacingXS
+ anchors.verticalCenter: parent.verticalCenter
+
+ StyledText {
+ text: {
+ const temp = SettingsData.useFahrenheit ? WeatherService.weather.tempF : WeatherService.weather.temp;
+ if (temp === undefined || temp === null || temp === 0) {
+ return "--°" + (SettingsData.useFahrenheit ? "F" : "C");
+ }
+ return temp + "°" + (SettingsData.useFahrenheit ? "F" : "C");
+ }
+ font.pixelSize: Theme.fontSizeXLarge + 4
+ color: Theme.surfaceText
+ font.weight: Font.Light
+ }
+
+ StyledText {
+ text: WeatherService.getWeatherCondition(WeatherService.weather.wCode)
+ font.pixelSize: Theme.fontSizeSmall
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
+ elide: Text.ElideRight
+ width: parent.parent.parent.width - 48 - Theme.spacingL * 2
+ }
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: root.clicked()
+ }
+}
\ No newline at end of file
diff --git a/quickshell/.config/quickshell/Modules/DankDash/OverviewTab.qml b/quickshell/.config/quickshell/Modules/DankDash/OverviewTab.qml
new file mode 100644
index 0000000..eb900ff
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/DankDash/OverviewTab.qml
@@ -0,0 +1,76 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import qs.Common
+import qs.Services
+import qs.Widgets
+import qs.Modules.DankDash.Overview
+
+Item {
+ id: root
+
+ implicitWidth: 700
+ implicitHeight: 410
+
+ signal switchToWeatherTab()
+ signal switchToMediaTab()
+ signal closeDash()
+
+ Item {
+ anchors.fill: parent
+ // Clock - top left (narrower and shorter)
+ ClockCard {
+ x: 0
+ y: 0
+ width: parent.width * 0.2 - Theme.spacingM * 2
+ height: 180
+ }
+
+ // Weather - top middle-left (narrower)
+ WeatherOverviewCard {
+ x: SettingsData.weatherEnabled ? parent.width * 0.2 - Theme.spacingM : 0
+ y: 0
+ width: SettingsData.weatherEnabled ? parent.width * 0.3 : 0
+ height: 100
+ visible: SettingsData.weatherEnabled
+
+ onClicked: root.switchToWeatherTab()
+ }
+
+ // UserInfo - top middle-right (extend when weather disabled)
+ UserInfoCard {
+ x: SettingsData.weatherEnabled ? parent.width * 0.5 : parent.width * 0.2 - Theme.spacingM
+ y: 0
+ width: SettingsData.weatherEnabled ? parent.width * 0.5 : parent.width * 0.8
+ height: 100
+ }
+
+ // SystemMonitor - middle left (narrow and shorter)
+ SystemMonitorCard {
+ x: 0
+ y: 180 + Theme.spacingM
+ width: parent.width * 0.2 - Theme.spacingM * 2
+ height: 220
+ }
+
+ // Calendar - bottom middle (wider and taller)
+ CalendarOverviewCard {
+ x: parent.width * 0.2 - Theme.spacingM
+ y: 100 + Theme.spacingM
+ width: parent.width * 0.6
+ height: 300
+
+ onCloseDash: root.closeDash()
+ }
+
+ // Media - bottom right (narrow and taller)
+ MediaOverviewCard {
+ x: parent.width * 0.8
+ y: 100 + Theme.spacingM
+ width: parent.width * 0.2
+ height: 300
+
+ onClicked: root.switchToMediaTab()
+ }
+ }
+}
\ No newline at end of file
diff --git a/quickshell/.config/quickshell/Modules/DankDash/WallpaperTab.qml b/quickshell/.config/quickshell/Modules/DankDash/WallpaperTab.qml
new file mode 100644
index 0000000..df5f763
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/DankDash/WallpaperTab.qml
@@ -0,0 +1,525 @@
+import Qt.labs.folderlistmodel
+import QtCore
+import QtQuick
+import QtQuick.Effects
+import qs.Common
+import qs.Modals.FileBrowser
+import qs.Widgets
+
+Item {
+ id: root
+
+ implicitWidth: 700
+ implicitHeight: 410
+
+ property string wallpaperDir: ""
+ property int currentPage: 0
+ property int itemsPerPage: 16
+ property int totalPages: Math.max(1, Math.ceil(wallpaperFolderModel.count / itemsPerPage))
+ property bool active: false
+ property Item focusTarget: wallpaperGrid
+ property Item tabBarItem: null
+ property int gridIndex: 0
+ property Item keyForwardTarget: null
+ property var parentPopout: null
+ property int lastPage: 0
+ property bool enableAnimation: false
+ property string homeDir: StandardPaths.writableLocation(StandardPaths.HomeLocation)
+ property string selectedFileName: ""
+ property var targetScreen: null
+ property string targetScreenName: targetScreen ? targetScreen.name : ""
+
+ signal requestTabChange(int newIndex)
+
+ function getCurrentWallpaper() {
+ if (SessionData.perMonitorWallpaper && targetScreenName) {
+ return SessionData.getMonitorWallpaper(targetScreenName);
+ }
+ return SessionData.wallpaperPath;
+ }
+
+ function setCurrentWallpaper(path) {
+ if (SessionData.perMonitorWallpaper && targetScreenName) {
+ SessionData.setMonitorWallpaper(targetScreenName, path);
+ } else {
+ SessionData.setWallpaper(path);
+ }
+ }
+
+ onCurrentPageChanged: {
+ if (currentPage !== lastPage) {
+ enableAnimation = false;
+ lastPage = currentPage;
+ }
+ updateSelectedFileName();
+ }
+
+ onGridIndexChanged: {
+ updateSelectedFileName();
+ }
+
+ onVisibleChanged: {
+ if (visible && active) {
+ setInitialSelection();
+ }
+ }
+
+ Component.onCompleted: {
+ loadWallpaperDirectory();
+ }
+
+ onActiveChanged: {
+ if (active && visible) {
+ setInitialSelection();
+ }
+ }
+
+ function handleKeyEvent(event) {
+ const columns = 4;
+ const currentCol = gridIndex % columns;
+ const visibleCount = Math.min(itemsPerPage, wallpaperFolderModel.count - currentPage * itemsPerPage);
+
+ if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
+ if (gridIndex >= 0 && gridIndex < visibleCount) {
+ const absoluteIndex = currentPage * itemsPerPage + gridIndex;
+ if (absoluteIndex < wallpaperFolderModel.count) {
+ const filePath = wallpaperFolderModel.get(absoluteIndex, "filePath");
+ if (filePath) {
+ setCurrentWallpaper(filePath.toString().replace(/^file:\/\//, ''));
+ }
+ }
+ }
+ return true;
+ }
+
+ if (event.key === Qt.Key_Right || event.key === Qt.Key_L) {
+ if (gridIndex + 1 < visibleCount) {
+ gridIndex++;
+ } else if (currentPage < totalPages - 1) {
+ gridIndex = 0;
+ currentPage++;
+ }
+ return true;
+ }
+
+ if (event.key === Qt.Key_Left || event.key === Qt.Key_H) {
+ if (gridIndex > 0) {
+ gridIndex--;
+ } else if (currentPage > 0) {
+ currentPage--;
+ const prevPageCount = Math.min(itemsPerPage, wallpaperFolderModel.count - currentPage * itemsPerPage);
+ gridIndex = prevPageCount - 1;
+ }
+ return true;
+ }
+
+ if (event.key === Qt.Key_Down || event.key === Qt.Key_J) {
+ if (gridIndex + columns < visibleCount) {
+ gridIndex += columns;
+ } else if (currentPage < totalPages - 1) {
+ gridIndex = currentCol;
+ currentPage++;
+ }
+ return true;
+ }
+
+ if (event.key === Qt.Key_Up || event.key === Qt.Key_K) {
+ if (gridIndex >= columns) {
+ gridIndex -= columns;
+ } else if (currentPage > 0) {
+ currentPage--;
+ const prevPageCount = Math.min(itemsPerPage, wallpaperFolderModel.count - currentPage * itemsPerPage);
+ const prevPageRows = Math.ceil(prevPageCount / columns);
+ gridIndex = (prevPageRows - 1) * columns + currentCol;
+ gridIndex = Math.min(gridIndex, prevPageCount - 1);
+ }
+ return true;
+ }
+
+ if (event.key === Qt.Key_PageUp && currentPage > 0) {
+ gridIndex = 0;
+ currentPage--;
+ return true;
+ }
+
+ if (event.key === Qt.Key_PageDown && currentPage < totalPages - 1) {
+ gridIndex = 0;
+ currentPage++;
+ return true;
+ }
+
+ if (event.key === Qt.Key_Home && event.modifiers & Qt.ControlModifier) {
+ gridIndex = 0;
+ currentPage = 0;
+ return true;
+ }
+
+ if (event.key === Qt.Key_End && event.modifiers & Qt.ControlModifier) {
+ currentPage = totalPages - 1;
+ const lastPageCount = Math.min(itemsPerPage, wallpaperFolderModel.count - currentPage * itemsPerPage);
+ gridIndex = Math.max(0, lastPageCount - 1);
+ return true;
+ }
+
+ return false;
+ }
+
+ function setInitialSelection() {
+ const currentWallpaper = getCurrentWallpaper();
+ if (!currentWallpaper || wallpaperFolderModel.count === 0) {
+ gridIndex = 0;
+ updateSelectedFileName();
+ Qt.callLater(() => {
+ enableAnimation = true;
+ });
+ return;
+ }
+
+ for (var i = 0; i < wallpaperFolderModel.count; i++) {
+ const filePath = wallpaperFolderModel.get(i, "filePath");
+ if (filePath && filePath.toString().replace(/^file:\/\//, '') === currentWallpaper) {
+ const targetPage = Math.floor(i / itemsPerPage);
+ const targetIndex = i % itemsPerPage;
+ currentPage = targetPage;
+ gridIndex = targetIndex;
+ updateSelectedFileName();
+ Qt.callLater(() => {
+ enableAnimation = true;
+ });
+ return;
+ }
+ }
+ gridIndex = 0;
+ updateSelectedFileName();
+ Qt.callLater(() => {
+ enableAnimation = true;
+ });
+ }
+
+ function loadWallpaperDirectory() {
+ const currentWallpaper = getCurrentWallpaper();
+
+ if (!currentWallpaper || currentWallpaper.startsWith("#")) {
+ if (CacheData.wallpaperLastPath && CacheData.wallpaperLastPath !== "") {
+ wallpaperDir = CacheData.wallpaperLastPath;
+ } else {
+ wallpaperDir = "";
+ }
+ return;
+ }
+
+ wallpaperDir = currentWallpaper.substring(0, currentWallpaper.lastIndexOf('/'));
+ }
+
+ function updateSelectedFileName() {
+ if (wallpaperFolderModel.count === 0) {
+ selectedFileName = "";
+ return;
+ }
+
+ const absoluteIndex = currentPage * itemsPerPage + gridIndex;
+ if (absoluteIndex < wallpaperFolderModel.count) {
+ const filePath = wallpaperFolderModel.get(absoluteIndex, "filePath");
+ if (filePath) {
+ const pathStr = filePath.toString().replace(/^file:\/\//, '');
+ selectedFileName = pathStr.substring(pathStr.lastIndexOf('/') + 1);
+ return;
+ }
+ }
+ selectedFileName = "";
+ }
+
+ Connections {
+ target: SessionData
+ function onWallpaperPathChanged() {
+ loadWallpaperDirectory();
+ if (visible && active) {
+ setInitialSelection();
+ }
+ }
+ function onMonitorWallpapersChanged() {
+ loadWallpaperDirectory();
+ if (visible && active) {
+ setInitialSelection();
+ }
+ }
+ function onPerMonitorWallpaperChanged() {
+ loadWallpaperDirectory();
+ if (visible && active) {
+ setInitialSelection();
+ }
+ }
+ }
+
+ onTargetScreenNameChanged: {
+ loadWallpaperDirectory();
+ if (visible && active) {
+ setInitialSelection();
+ }
+ }
+
+ Connections {
+ target: wallpaperFolderModel
+ function onCountChanged() {
+ if (wallpaperFolderModel.status === FolderListModel.Ready) {
+ if (visible && active) {
+ setInitialSelection();
+ }
+ updateSelectedFileName();
+ }
+ }
+ function onStatusChanged() {
+ if (wallpaperFolderModel.status === FolderListModel.Ready && wallpaperFolderModel.count > 0) {
+ if (visible && active) {
+ setInitialSelection();
+ }
+ updateSelectedFileName();
+ }
+ }
+ }
+
+ FolderListModel {
+ id: wallpaperFolderModel
+
+ showDirsFirst: false
+ showDotAndDotDot: false
+ showHidden: false
+ nameFilters: ["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.gif", "*.webp"]
+ showFiles: true
+ showDirs: false
+ sortField: FolderListModel.Name
+ folder: wallpaperDir ? "file://" + wallpaperDir.split('/').map(s => encodeURIComponent(s)).join('/') : ""
+ }
+
+ Column {
+ anchors.fill: parent
+ spacing: 0
+
+ Item {
+ width: parent.width
+ height: parent.height - 50
+
+ GridView {
+ id: wallpaperGrid
+ anchors.centerIn: parent
+ width: parent.width - Theme.spacingS
+ height: parent.height - Theme.spacingS
+ cellWidth: width / 4
+ cellHeight: height / 4
+ clip: true
+ enabled: root.active
+ interactive: root.active
+ boundsBehavior: Flickable.StopAtBounds
+ keyNavigationEnabled: false
+ activeFocusOnTab: false
+ highlightFollowsCurrentItem: true
+ highlightMoveDuration: enableAnimation ? Theme.shortDuration : 0
+ focus: false
+
+ highlight: Item {
+ z: 1000
+ Rectangle {
+ anchors.fill: parent
+ anchors.margins: Theme.spacingXS
+ color: "transparent"
+ border.width: 3
+ border.color: Theme.primary
+ radius: Theme.cornerRadius
+ }
+ }
+
+ model: {
+ const startIndex = currentPage * itemsPerPage;
+ const endIndex = Math.min(startIndex + itemsPerPage, wallpaperFolderModel.count);
+ const items = [];
+ for (var i = startIndex; i < endIndex; i++) {
+ const filePath = wallpaperFolderModel.get(i, "filePath");
+ if (filePath) {
+ items.push(filePath.toString().replace(/^file:\/\//, ''));
+ }
+ }
+ return items;
+ }
+
+ onModelChanged: {
+ const clampedIndex = model.length > 0 ? Math.min(Math.max(0, gridIndex), model.length - 1) : 0;
+ if (gridIndex !== clampedIndex) {
+ gridIndex = clampedIndex;
+ }
+ }
+
+ onCountChanged: {
+ if (count > 0) {
+ const clampedIndex = Math.min(gridIndex, count - 1);
+ currentIndex = clampedIndex;
+ positionViewAtIndex(clampedIndex, GridView.Contain);
+ }
+ Qt.callLater(() => {
+ enableAnimation = true;
+ });
+ }
+
+ Connections {
+ target: root
+ function onGridIndexChanged() {
+ if (wallpaperGrid.count > 0) {
+ wallpaperGrid.currentIndex = gridIndex;
+ if (!enableAnimation) {
+ wallpaperGrid.positionViewAtIndex(gridIndex, GridView.Contain);
+ }
+ }
+ }
+ }
+
+ delegate: Item {
+ width: wallpaperGrid.cellWidth
+ height: wallpaperGrid.cellHeight
+
+ property string wallpaperPath: modelData || ""
+ property bool isSelected: getCurrentWallpaper() === modelData
+
+ Rectangle {
+ id: wallpaperCard
+ anchors.fill: parent
+ anchors.margins: Theme.spacingXS
+ radius: Theme.cornerRadius
+ clip: true
+
+ Rectangle {
+ anchors.fill: parent
+ color: isSelected ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.15) : "transparent"
+ radius: parent.radius
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+ }
+
+ CachingImage {
+ id: thumbnailImage
+ anchors.fill: parent
+ imagePath: modelData || ""
+ maxCacheSize: 256
+
+ layer.enabled: true
+ layer.effect: MultiEffect {
+ maskEnabled: true
+ maskThresholdMin: 0.5
+ maskSpreadAtMin: 1.0
+ maskSource: ShaderEffectSource {
+ sourceItem: Rectangle {
+ width: thumbnailImage.width
+ height: thumbnailImage.height
+ radius: Theme.cornerRadius
+ }
+ }
+ }
+ }
+
+ StateLayer {
+ anchors.fill: parent
+ cornerRadius: parent.radius
+ stateColor: Theme.primary
+ }
+
+ MouseArea {
+ id: wallpaperMouseArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+
+ onClicked: {
+ gridIndex = index;
+ if (modelData) {
+ setCurrentWallpaper(modelData);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ StyledText {
+ anchors.centerIn: parent
+ visible: wallpaperFolderModel.count === 0
+ text: "No wallpapers found\n\nClick the folder icon below to browse"
+ font.pixelSize: 14
+ color: Theme.outline
+ horizontalAlignment: Text.AlignHCenter
+ }
+ }
+
+ Column {
+ width: parent.width
+ height: 50
+
+ Row {
+ width: parent.width
+ height: 32
+ spacing: Theme.spacingS
+
+ Item {
+ width: (parent.width - controlsRow.width - Theme.spacingS) / 2
+ height: parent.height
+ }
+
+ Row {
+ id: controlsRow
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingS
+
+ DankActionButton {
+ anchors.verticalCenter: parent.verticalCenter
+ iconName: "skip_previous"
+ iconSize: 20
+ buttonSize: 32
+ enabled: currentPage > 0
+ opacity: enabled ? 1.0 : 0.3
+ onClicked: {
+ if (currentPage > 0) {
+ currentPage--;
+ }
+ }
+ }
+
+ StyledText {
+ anchors.verticalCenter: parent.verticalCenter
+ text: wallpaperFolderModel.count > 0 ? `${wallpaperFolderModel.count} wallpapers • ${currentPage + 1} / ${totalPages}` : "No wallpapers"
+ font.pixelSize: 14
+ color: Theme.surfaceText
+ opacity: 0.7
+ }
+
+ DankActionButton {
+ anchors.verticalCenter: parent.verticalCenter
+ iconName: "skip_next"
+ iconSize: 20
+ buttonSize: 32
+ enabled: currentPage < totalPages - 1
+ opacity: enabled ? 1.0 : 0.3
+ onClicked: {
+ if (currentPage < totalPages - 1) {
+ currentPage++;
+ }
+ }
+ }
+ }
+ }
+
+ StyledText {
+ width: parent.width
+ height: 18
+ text: selectedFileName
+ font.pixelSize: 12
+ color: Theme.surfaceText
+ opacity: 0.5
+ visible: selectedFileName !== ""
+ elide: Text.ElideMiddle
+ horizontalAlignment: Text.AlignHCenter
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/quickshell/.config/quickshell/Modules/DankDash/WeatherTab.qml b/quickshell/.config/quickshell/Modules/DankDash/WeatherTab.qml
new file mode 100644
index 0000000..bf5be6d
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/DankDash/WeatherTab.qml
@@ -0,0 +1,642 @@
+import QtQuick
+import QtQuick.Effects
+import QtQuick.Layouts
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Item {
+ id: root
+
+ implicitWidth: 700
+ implicitHeight: 410
+
+ Column {
+ anchors.centerIn: parent
+ spacing: Theme.spacingL
+ visible: !WeatherService.weather.available || WeatherService.weather.temp === 0
+
+ DankIcon {
+ name: "cloud_off"
+ size: Theme.iconSize * 2
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.5)
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+
+ StyledText {
+ text: "No Weather Data Available"
+ font.pixelSize: Theme.fontSizeLarge
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+ }
+
+ Column {
+ anchors.fill: parent
+ spacing: Theme.spacingM
+ visible: WeatherService.weather.available && WeatherService.weather.temp !== 0
+
+ Item {
+ width: parent.width
+ height: 70
+
+ DankIcon {
+ id: refreshButton
+ name: "refresh"
+ size: Theme.iconSize - 4
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.4)
+ anchors.right: parent.right
+ anchors.top: parent.top
+
+ property bool isRefreshing: false
+ enabled: !isRefreshing
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: parent.enabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor
+ onClicked: {
+ refreshButton.isRefreshing = true
+ WeatherService.forceRefresh()
+ refreshTimer.restart()
+ }
+ enabled: parent.enabled
+ }
+
+ Timer {
+ id: refreshTimer
+ interval: 2000
+ onTriggered: refreshButton.isRefreshing = false
+ }
+
+ NumberAnimation on rotation {
+ running: refreshButton.isRefreshing
+ from: 0
+ to: 360
+ duration: 1000
+ loops: Animation.Infinite
+ }
+ }
+
+ Item {
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.verticalCenter: parent.verticalCenter
+ width: weatherIcon.width + tempColumn.width + sunriseColumn.width + Theme.spacingM * 2
+ height: 70
+
+ DankIcon {
+ id: weatherIcon
+ name: WeatherService.getWeatherIcon(WeatherService.weather.wCode)
+ size: Theme.iconSize * 1.5
+ color: Theme.primary
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+
+ layer.enabled: true
+ layer.effect: MultiEffect {
+ shadowEnabled: true
+ shadowHorizontalOffset: 0
+ shadowVerticalOffset: 4
+ shadowBlur: 0.8
+ shadowColor: Qt.rgba(0, 0, 0, 0.2)
+ shadowOpacity: 0.2
+ }
+ }
+
+ Column {
+ id: tempColumn
+ spacing: Theme.spacingXS
+ anchors.left: weatherIcon.right
+ anchors.leftMargin: Theme.spacingM
+ anchors.verticalCenter: parent.verticalCenter
+
+ Item {
+ width: tempText.width + unitText.width + Theme.spacingXS
+ height: tempText.height
+
+ StyledText {
+ id: tempText
+ text: (SettingsData.useFahrenheit ? WeatherService.weather.tempF : WeatherService.weather.temp) + "°"
+ font.pixelSize: Theme.fontSizeLarge + 4
+ color: Theme.surfaceText
+ font.weight: Font.Light
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ id: unitText
+ text: SettingsData.useFahrenheit ? "F" : "C"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
+ anchors.left: tempText.right
+ anchors.leftMargin: Theme.spacingXS
+ anchors.verticalCenter: parent.verticalCenter
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ if (WeatherService.weather.available) {
+ SettingsData.setTemperatureUnit(!SettingsData.useFahrenheit)
+ }
+ }
+ enabled: WeatherService.weather.available
+ }
+ }
+ }
+
+ StyledText {
+ text: WeatherService.weather.city || ""
+ font.pixelSize: Theme.fontSizeMedium
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
+ visible: text.length > 0
+ }
+ }
+
+ Column {
+ id: sunriseColumn
+ spacing: Theme.spacingXS
+ anchors.left: tempColumn.right
+ anchors.leftMargin: Theme.spacingM
+ anchors.verticalCenter: parent.verticalCenter
+ visible: WeatherService.weather.sunrise && WeatherService.weather.sunset
+
+ Item {
+ width: sunriseIcon.width + sunriseText.width + Theme.spacingXS
+ height: sunriseIcon.height
+
+ DankIcon {
+ id: sunriseIcon
+ name: "wb_twilight"
+ size: Theme.iconSize - 6
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.6)
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ id: sunriseText
+ text: WeatherService.weather.sunrise || ""
+ font.pixelSize: Theme.fontSizeSmall
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.6)
+ anchors.left: sunriseIcon.right
+ anchors.leftMargin: Theme.spacingXS
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ Item {
+ width: sunsetIcon.width + sunsetText.width + Theme.spacingXS
+ height: sunsetIcon.height
+
+ DankIcon {
+ id: sunsetIcon
+ name: "bedtime"
+ size: Theme.iconSize - 6
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.6)
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ id: sunsetText
+ text: WeatherService.weather.sunset || ""
+ font.pixelSize: Theme.fontSizeSmall
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.6)
+ anchors.left: sunsetIcon.right
+ anchors.leftMargin: Theme.spacingXS
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+ }
+ }
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 1
+ color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.1)
+ }
+
+ GridLayout {
+ width: parent.width
+ height: 95
+ columns: 6
+ columnSpacing: Theme.spacingS
+ rowSpacing: 0
+
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3)
+
+ Column {
+ anchors.centerIn: parent
+ spacing: Theme.spacingXS
+
+ Rectangle {
+ width: 32
+ height: 32
+ radius: 16
+ color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1)
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "device_thermostat"
+ size: Theme.iconSize - 4
+ color: Theme.primary
+ }
+ }
+
+ Column {
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: 2
+
+ StyledText {
+ text: "Feels Like"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+
+ StyledText {
+ text: (SettingsData.useFahrenheit ? (WeatherService.weather.feelsLikeF || WeatherService.weather.tempF) : (WeatherService.weather.feelsLike || WeatherService.weather.temp)) + "°"
+ font.pixelSize: Theme.fontSizeSmall + 1
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+ }
+ }
+ }
+
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3)
+
+ Column {
+ anchors.centerIn: parent
+ spacing: Theme.spacingXS
+
+ Rectangle {
+ width: 32
+ height: 32
+ radius: 16
+ color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1)
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "humidity_low"
+ size: Theme.iconSize - 4
+ color: Theme.primary
+ }
+ }
+
+ Column {
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: 2
+
+ StyledText {
+ text: "Humidity"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+
+ StyledText {
+ text: WeatherService.weather.humidity ? WeatherService.weather.humidity + "%" : "--"
+ font.pixelSize: Theme.fontSizeSmall + 1
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+ }
+ }
+ }
+
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3)
+
+ Column {
+ anchors.centerIn: parent
+ spacing: Theme.spacingXS
+
+ Rectangle {
+ width: 32
+ height: 32
+ radius: 16
+ color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1)
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "air"
+ size: Theme.iconSize - 4
+ color: Theme.primary
+ }
+ }
+
+ Column {
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: 2
+
+ StyledText {
+ text: "Wind"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+
+ StyledText {
+ text: WeatherService.weather.wind || "--"
+ font.pixelSize: Theme.fontSizeSmall + 1
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+ }
+ }
+ }
+
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3)
+
+ Column {
+ anchors.centerIn: parent
+ spacing: Theme.spacingXS
+
+ Rectangle {
+ width: 32
+ height: 32
+ radius: 16
+ color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1)
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "speed"
+ size: Theme.iconSize - 4
+ color: Theme.primary
+ }
+ }
+
+ Column {
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: 2
+
+ StyledText {
+ text: "Pressure"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+
+ StyledText {
+ text: WeatherService.weather.pressure ? WeatherService.weather.pressure + " hPa" : "--"
+ font.pixelSize: Theme.fontSizeSmall + 1
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+ }
+ }
+ }
+
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3)
+
+ Column {
+ anchors.centerIn: parent
+ spacing: Theme.spacingXS
+
+ Rectangle {
+ width: 32
+ height: 32
+ radius: 16
+ color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1)
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "rainy"
+ size: Theme.iconSize - 4
+ color: Theme.primary
+ }
+ }
+
+ Column {
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: 2
+
+ StyledText {
+ text: "Rain Chance"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+
+ StyledText {
+ text: WeatherService.weather.precipitationProbability ? WeatherService.weather.precipitationProbability + "%" : "0%"
+ font.pixelSize: Theme.fontSizeSmall + 1
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+ }
+ }
+ }
+
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3)
+
+ Column {
+ anchors.centerIn: parent
+ spacing: Theme.spacingXS
+
+ Rectangle {
+ width: 32
+ height: 32
+ radius: 16
+ color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1)
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "wb_sunny"
+ size: Theme.iconSize - 4
+ color: Theme.primary
+ }
+ }
+
+ Column {
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: 2
+
+ StyledText {
+ text: "Visibility"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+
+ StyledText {
+ text: "Good"
+ font.pixelSize: Theme.fontSizeSmall + 1
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+ }
+ }
+ }
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 1
+ color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.1)
+ }
+
+ Column {
+ width: parent.width
+ height: parent.height - 70 - 95 - Theme.spacingM * 3 - 2
+ spacing: Theme.spacingS
+
+ StyledText {
+ text: "7-Day Forecast"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ }
+
+ Row {
+ width: parent.width
+ height: parent.height - Theme.fontSizeMedium - Theme.spacingS - Theme.spacingL
+ spacing: Theme.spacingXS
+
+ Repeater {
+ model: 7
+
+ Rectangle {
+ width: (parent.width - Theme.spacingXS * 6) / 7
+ height: parent.height
+ radius: Theme.cornerRadius
+
+ property var dayDate: {
+ const date = new Date()
+ date.setDate(date.getDate() + index)
+ return date
+ }
+ property bool isToday: index === 0
+ property var forecastData: {
+ if (WeatherService.weather.forecast && WeatherService.weather.forecast.length > index) {
+ return WeatherService.weather.forecast[index]
+ }
+ return null
+ }
+
+ color: isToday ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1) : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.1)
+ border.color: isToday ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.3) : "transparent"
+ border.width: isToday ? 1 : 0
+
+ Column {
+ anchors.centerIn: parent
+ spacing: Theme.spacingXS
+
+ StyledText {
+ text: Qt.locale().dayName(dayDate.getDay(), Locale.ShortFormat)
+ font.pixelSize: Theme.fontSizeSmall
+ color: isToday ? Theme.primary : Theme.surfaceText
+ font.weight: isToday ? Font.Medium : Font.Normal
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+
+ DankIcon {
+ name: forecastData ? WeatherService.getWeatherIcon(forecastData.wCode || 0) : "cloud"
+ size: Theme.iconSize
+ color: isToday ? Theme.primary : Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.8)
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+
+ Column {
+ spacing: 2
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ StyledText {
+ text: forecastData ? (SettingsData.useFahrenheit ? (forecastData.tempMaxF || forecastData.tempMax) : (forecastData.tempMax || 0)) + "°/" + (SettingsData.useFahrenheit ? (forecastData.tempMinF || forecastData.tempMin) : (forecastData.tempMin || 0)) + "°" : "--/--"
+ font.pixelSize: Theme.fontSizeSmall
+ color: isToday ? Theme.primary : Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+
+ Column {
+ spacing: 1
+ anchors.horizontalCenter: parent.horizontalCenter
+ visible: forecastData && forecastData.sunrise && forecastData.sunset
+
+ Row {
+ spacing: 2
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ DankIcon {
+ name: "wb_twilight"
+ size: 8
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.6)
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: forecastData ? forecastData.sunrise : ""
+ font.pixelSize: Theme.fontSizeSmall - 2
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.6)
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ Row {
+ spacing: 2
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ DankIcon {
+ name: "bedtime"
+ size: 8
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.6)
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: forecastData ? forecastData.sunset : ""
+ font.pixelSize: Theme.fontSizeSmall - 2
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.6)
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/quickshell/.config/quickshell/Modules/Lock/CustomButtonKeyboard.qml b/quickshell/.config/quickshell/Modules/Lock/CustomButtonKeyboard.qml
new file mode 100644
index 0000000..8166905
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/Lock/CustomButtonKeyboard.qml
@@ -0,0 +1,24 @@
+import QtQuick
+import QtQuick.Controls
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+DankActionButton {
+ id: customButtonKeyboard
+ circular: false
+ property string text: ""
+ width: 40
+ height: 40
+ property bool isShift: false
+ color: Theme.surface
+
+ StyledText {
+ id: contentItem
+ anchors.centerIn: parent
+ text: parent.text
+ color: Theme.surfaceText
+ font.pixelSize: Theme.fontSizeXLarge
+ font.weight: Font.Normal
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/Lock/Keyboard.qml b/quickshell/.config/quickshell/Modules/Lock/Keyboard.qml
new file mode 100644
index 0000000..a80afd2
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/Lock/Keyboard.qml
@@ -0,0 +1,361 @@
+import QtQuick
+import qs.Common
+
+Rectangle {
+ id: root
+ property Item target
+ height: 60 * 5
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ color: Theme.widgetBackground()
+
+ property double rowSpacing: 0.01 * width // horizontal spacing between keyboard
+ property double columnSpacing: 0.02 * height // vertical spacing between keyboard
+ property bool shift: false //Boolean for the shift state
+ property bool symbols: false //Boolean for the symbol state
+ property double columns: 10 // Number of column
+ property double rows: 4 // Number of row
+
+ property string strShift: '\u2191' // UPWARDS ARROW unicode
+ property string strBackspace: "Backspace"
+
+ property var modelKeyboard: {
+ "row_1": [
+ {
+ text: 'q',
+ symbol: '1',
+ width: 1
+ },
+ {
+ text: 'w',
+ symbol: '2',
+ width: 1
+ },
+ {
+ text: 'e',
+ symbol: '3',
+ width: 1
+ },
+ {
+ text: 'r',
+ symbol: '4',
+ width: 1
+ },
+ {
+ text: 't',
+ symbol: '5',
+ width: 1
+ },
+ {
+ text: 'y',
+ symbol: '6',
+ width: 1
+ },
+ {
+ text: 'u',
+ symbol: '7',
+ width: 1
+ },
+ {
+ text: 'i',
+ symbol: '8',
+ width: 1
+ },
+ {
+ text: 'o',
+ symbol: '9',
+ width: 1
+ },
+ {
+ text: 'p',
+ symbol: '0',
+ width: 1
+ },
+ ],
+ "row_2": [
+ {
+ text: 'a',
+ symbol: '-',
+ width: 1
+ },
+ {
+ text: 's',
+ symbol: '/',
+ width: 1
+ },
+ {
+ text: 'd',
+ symbol: ':',
+ width: 1
+ },
+ {
+ text: 'f',
+ symbol: ';',
+ width: 1
+ },
+ {
+ text: 'g',
+ symbol: '(',
+ width: 1
+ },
+ {
+ text: 'h',
+ symbol: ')',
+ width: 1
+ },
+ {
+ text: 'j',
+ symbol: '€',
+ width: 1
+ },
+ {
+ text: 'k',
+ symbol: '&',
+ width: 1
+ },
+ {
+ text: 'l',
+ symbol: '@',
+ width: 1
+ }
+ ],
+ "row_3": [
+ {
+ text: strShift,
+ symbol: strShift,
+ width: 1.5
+ },
+ {
+ text: 'z',
+ symbol: '.',
+ width: 1
+ },
+ {
+ text: 'x',
+ symbol: ',',
+ width: 1
+ },
+ {
+ text: 'c',
+ symbol: '?',
+ width: 1
+ },
+ {
+ text: 'v',
+ symbol: '!',
+ width: 1
+ },
+ {
+ text: 'b',
+ symbol: "'",
+ width: 1
+ },
+ {
+ text: 'n',
+ symbol: "%",
+ width: 1
+ },
+ {
+ text: 'm',
+ symbol: '"',
+ width: 1
+ },
+ {
+ text: "'",
+ symbol: "*",
+ width: 1.5
+ }
+ ],
+ "row_4": [
+ {
+ text: '123',
+ symbol: 'ABC',
+ width: 1.5
+ },
+ {
+ text: ' ',
+ symbol: ' ',
+ width: 6
+ },
+ {
+ text: '.',
+ symbol: '.',
+ width: 1
+ },
+ {
+ text: strBackspace,
+ symbol: strBackspace,
+ width: 1.5
+ }
+ ]
+ }
+
+ //Here is the corresponding table between the ascii and the key event
+ property var tableKeyEvent: {
+ "_0": Qt.Key_0,
+ "_1": Qt.Key_1,
+ "_2": Qt.Key_2,
+ "_3": Qt.Key_3,
+ "_4": Qt.Key_4,
+ "_5": Qt.Key_5,
+ "_6": Qt.Key_6,
+ "_7": Qt.Key_7,
+ "_8": Qt.Key_8,
+ "_9": Qt.Key_9,
+ "_a": Qt.Key_A,
+ "_b": Qt.Key_B,
+ "_c": Qt.Key_C,
+ "_d": Qt.Key_D,
+ "_e": Qt.Key_E,
+ "_f": Qt.Key_F,
+ "_g": Qt.Key_G,
+ "_h": Qt.Key_H,
+ "_i": Qt.Key_I,
+ "_j": Qt.Key_J,
+ "_k": Qt.Key_K,
+ "_l": Qt.Key_L,
+ "_m": Qt.Key_M,
+ "_n": Qt.Key_N,
+ "_o": Qt.Key_O,
+ "_p": Qt.Key_P,
+ "_q": Qt.Key_Q,
+ "_r": Qt.Key_R,
+ "_s": Qt.Key_S,
+ "_t": Qt.Key_T,
+ "_u": Qt.Key_U,
+ "_v": Qt.Key_V,
+ "_w": Qt.Key_W,
+ "_x": Qt.Key_X,
+ "_y": Qt.Key_Y,
+ "_z": Qt.Key_Z,
+ "_\u2190": Qt.Key_Backspace,
+ "_return": Qt.Key_Return,
+ "_ ": Qt.Key_Space,
+ "_-": Qt.Key_Minus,
+ "_/": Qt.Key_Slash,
+ "_:": Qt.Key_Colon,
+ "_;": Qt.Key_Semicolon,
+ "_(": Qt.Key_BracketLeft,
+ "_)": Qt.Key_BracketRight,
+ "_€": parseInt("20ac", 16) // I didn't find the appropriate Qt event so I used the hex format
+ ,
+ "_&": Qt.Key_Ampersand,
+ "_@": Qt.Key_At,
+ '_"': Qt.Key_QuoteDbl,
+ "_.": Qt.Key_Period,
+ "_,": Qt.Key_Comma,
+ "_?": Qt.Key_Question,
+ "_!": Qt.Key_Exclam,
+ "_'": Qt.Key_Apostrophe,
+ "_%": Qt.Key_Percent,
+ "_*": Qt.Key_Asterisk
+ }
+
+ Item {
+ id: keyboard_container
+ anchors.left: parent.left
+ anchors.leftMargin: 5
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.topMargin: 5
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: 5
+
+ //One column which contains 5 rows
+ Column {
+ spacing: columnSpacing
+
+ Row {
+ id: row_1
+ spacing: rowSpacing
+ Repeater {
+ model: modelKeyboard["row_1"]
+ delegate: CustomButtonKeyboard {
+ text: symbols ? modelData.symbol : shift ? modelData.text.toUpperCase() : modelData.text
+ width: modelData.width * keyboard_container.width / columns - rowSpacing
+ height: keyboard_container.height / rows - columnSpacing
+
+ onClicked: root.clicked(text)
+ }
+ }
+ }
+ Row {
+ id: row_2
+ spacing: rowSpacing
+ Repeater {
+ model: modelKeyboard["row_2"]
+ delegate: CustomButtonKeyboard {
+ text: symbols ? modelData.symbol : shift ? modelData.text.toUpperCase() : modelData.text
+ width: modelData.width * keyboard_container.width / columns - rowSpacing
+ height: keyboard_container.height / rows - columnSpacing
+
+ onClicked: root.clicked(text)
+ }
+ }
+ }
+ Row {
+ id: row_3
+ spacing: rowSpacing
+ Repeater {
+ model: modelKeyboard["row_3"]
+ delegate: CustomButtonKeyboard {
+ text: symbols ? modelData.symbol : shift ? modelData.text.toUpperCase() : modelData.text
+ width: modelData.width * keyboard_container.width / columns - rowSpacing
+ height: keyboard_container.height / rows - columnSpacing
+ isShift: shift && text === strShift
+
+ onClicked: root.clicked(text)
+ }
+ }
+ }
+ Row {
+ id: row_4
+ spacing: rowSpacing
+ Repeater {
+ model: modelKeyboard["row_4"]
+ delegate: CustomButtonKeyboard {
+ text: symbols ? modelData.symbol : shift ? modelData.text.toUpperCase() : modelData.text
+ width: modelData.width * keyboard_container.width / columns - rowSpacing
+ height: keyboard_container.height / rows - columnSpacing
+
+ onClicked: root.clicked(text)
+ }
+ }
+ }
+ }
+ }
+ signal clicked(string text)
+
+ Connections {
+ target: root
+ function onClicked(text) {
+ if (!keyboard_controller.target)
+ return;
+ if (text === strShift) {
+ root.shift = !root.shift; // toggle shift
+ } else if (text === '123') {
+ root.symbols = true;
+ } else if (text === 'ABC') {
+ root.symbols = false;
+ } else {
+ // insert text into target
+ if (text === strBackspace) {
+ var current = keyboard_controller.target.text;
+ keyboard_controller.target.text = current.slice(0, current.length - 1);
+ } else {
+ // normal character
+ var charToInsert = root.symbols ? text : (root.shift ? text.toUpperCase() : text);
+ var current = keyboard_controller.target.text;
+ var cursorPos = keyboard_controller.target.cursorPosition;
+ keyboard_controller.target.text = current.slice(0, cursorPos) + charToInsert + current.slice(cursorPos);
+ keyboard_controller.target.cursorPosition = cursorPos + 1;
+ }
+
+ // shift is momentary
+ if (root.shift && text !== strShift)
+ root.shift = false;
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/Lock/KeyboardController.qml b/quickshell/.config/quickshell/Modules/Lock/KeyboardController.qml
new file mode 100644
index 0000000..4ee606e
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/Lock/KeyboardController.qml
@@ -0,0 +1,36 @@
+import QtQuick
+
+Item {
+ id: keyboard_controller
+
+ // reference on the TextInput
+ property Item target
+ //Booléan on the state of the keyboard
+ property bool isKeyboardActive: false
+
+ property var rootObject
+
+ function show() {
+ if (!isKeyboardActive && keyboard === null) {
+ keyboard = keyboardComponent.createObject(keyboard_controller.rootObject);
+ keyboard.target = keyboard_controller.target;
+ isKeyboardActive = true;
+ } else
+ console.info("The keyboard is already shown");
+ }
+
+ function hide() {
+ if (isKeyboardActive && keyboard !== null) {
+ keyboard.destroy();
+ isKeyboardActive = false;
+ } else
+ console.info("The keyboard is already hidden");
+ }
+
+ // private
+ property Item keyboard: null
+ Component {
+ id: keyboardComponent
+ Keyboard {}
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/Lock/Lock.qml b/quickshell/.config/quickshell/Modules/Lock/Lock.qml
new file mode 100644
index 0000000..0d1e004
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/Lock/Lock.qml
@@ -0,0 +1,153 @@
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import Quickshell.Wayland
+import qs.Common
+import qs.Services
+
+Item {
+ id: root
+ property string sid: Quickshell.env("XDG_SESSION_ID") || "self"
+ property string sessionPath: ""
+
+ function activate() {
+ loader.activeAsync = true
+ }
+
+ Component.onCompleted: {
+ getSessionPath.running = true
+ }
+
+ Process {
+ id: getSessionPath
+ command: ["gdbus", "call", "--system", "--dest", "org.freedesktop.login1", "--object-path", "/org/freedesktop/login1", "--method", "org.freedesktop.login1.Manager.GetSession", sid]
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const match = text.match(/objectpath '([^']+)'/)
+ if (match) {
+ root.sessionPath = match[1]
+ console.log("Found session path:", root.sessionPath)
+ checkCurrentLockState.running = true
+ lockStateMonitor.running = true
+ } else {
+ console.warn("Could not determine session path")
+ }
+ }
+ }
+
+ onExited: (exitCode, exitStatus) => {
+ if (exitCode !== 0) {
+ console.warn("Failed to get session path, exit code:", exitCode)
+ }
+ }
+ }
+
+ Process {
+ id: checkCurrentLockState
+ command: root.sessionPath ? ["gdbus", "call", "--system", "--dest", "org.freedesktop.login1", "--object-path", root.sessionPath, "--method", "org.freedesktop.DBus.Properties.Get", "org.freedesktop.login1.Session", "LockedHint"] : []
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ if (text.includes("true")) {
+ console.log("Session is locked on startup, activating lock screen")
+ LockScreenService.resetState()
+ loader.activeAsync = true
+ }
+ }
+ }
+
+ onExited: (exitCode, exitStatus) => {
+ if (exitCode !== 0) {
+ console.warn("Failed to check initial lock state, exit code:", exitCode)
+ }
+ }
+ }
+
+ Process {
+ id: lockStateMonitor
+ command: root.sessionPath ? ["gdbus", "monitor", "--system", "--dest", "org.freedesktop.login1", "--object-path", root.sessionPath] : []
+ running: false
+
+ stdout: SplitParser {
+ splitMarker: "\n"
+
+ onRead: line => {
+ if (line.includes("org.freedesktop.login1.Session.Lock")) {
+ console.log("login1: Lock signal received -> show lock")
+ LockScreenService.resetState()
+ loader.activeAsync = true
+ } else if (line.includes("org.freedesktop.login1.Session.Unlock")) {
+ console.log("login1: Unlock signal received -> hide lock")
+ loader.active = false
+ } else if (line.includes("LockedHint") && line.includes("true")) {
+ console.log("login1: LockedHint=true -> show lock")
+ LockScreenService.resetState()
+ loader.activeAsync = true
+ } else if (line.includes("LockedHint") && line.includes("false")) {
+ console.log("login1: LockedHint=false -> hide lock")
+ loader.active = false
+ }
+ }
+ }
+
+ onExited: (exitCode, exitStatus) => {
+ if (exitCode !== 0) {
+ console.warn("gdbus monitor failed, exit code:", exitCode)
+ }
+ }
+ }
+
+ LazyLoader {
+ id: loader
+
+ WlSessionLock {
+ id: sessionLock
+
+ property bool unlocked: false
+ property string sharedPasswordBuffer: ""
+
+ locked: true
+
+ onLockedChanged: {
+ if (!locked) {
+ loader.active = false
+ }
+ }
+
+ LockSurface {
+ id: lockSurface
+ lock: sessionLock
+ sharedPasswordBuffer: sessionLock.sharedPasswordBuffer
+ onPasswordChanged: newPassword => {
+ sessionLock.sharedPasswordBuffer = newPassword
+ }
+ }
+ }
+ }
+
+ LockScreenDemo {
+ id: demoWindow
+ }
+
+ IpcHandler {
+ target: "lock"
+
+ function lock() {
+ console.log("Lock screen requested via IPC")
+ LockScreenService.resetState()
+ loader.activeAsync = true
+ }
+
+ function demo() {
+ console.log("Lock screen DEMO mode requested via IPC")
+ demoWindow.showDemo()
+ }
+
+ function isLocked(): bool {
+ return loader.active
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/Lock/LockScreenContent.qml b/quickshell/.config/quickshell/Modules/Lock/LockScreenContent.qml
new file mode 100644
index 0000000..feaf561
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/Lock/LockScreenContent.qml
@@ -0,0 +1,1010 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Effects
+import QtQuick.Layouts
+import Quickshell
+import Quickshell.Io
+import Quickshell.Services.Pam
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Item {
+ id: root
+
+ property string passwordBuffer: ""
+ property bool demoMode: false
+ property string screenName: ""
+
+ signal unlockRequested
+
+ // Internal power dialog state
+ property bool powerDialogVisible: false
+ property string powerDialogTitle: ""
+ property string powerDialogMessage: ""
+ property string powerDialogConfirmText: ""
+ property color powerDialogConfirmColor: Theme.primary
+ property var powerDialogOnConfirm: function () {}
+
+ function showPowerDialog(title, message, confirmText, confirmColor, onConfirm) {
+ powerDialogTitle = title
+ powerDialogMessage = message
+ powerDialogConfirmText = confirmText
+ powerDialogConfirmColor = confirmColor
+ powerDialogOnConfirm = onConfirm
+ powerDialogVisible = true
+ }
+
+ function hidePowerDialog() {
+ powerDialogVisible = false
+ }
+
+ Component.onCompleted: {
+ if (demoMode) {
+ LockScreenService.pickRandomFact()
+ }
+
+ WeatherService.addRef()
+ UserInfoService.refreshUserInfo()
+ }
+ onDemoModeChanged: {
+ if (demoMode) {
+ LockScreenService.pickRandomFact()
+ }
+ }
+ Component.onDestruction: {
+ WeatherService.removeRef()
+ }
+
+ Loader {
+ anchors.fill: parent
+ active: {
+ var currentWallpaper = SessionData.getMonitorWallpaper(screenName)
+ return !currentWallpaper || (currentWallpaper && currentWallpaper.startsWith("#"))
+ }
+ asynchronous: true
+
+ sourceComponent: DankBackdrop {
+ screenName: root.screenName
+ }
+ }
+
+ Image {
+ id: wallpaperBackground
+
+ anchors.fill: parent
+ source: {
+ var currentWallpaper = SessionData.getMonitorWallpaper(screenName)
+ return (currentWallpaper && !currentWallpaper.startsWith("#")) ? currentWallpaper : ""
+ }
+ fillMode: Image.PreserveAspectCrop
+ smooth: true
+ asynchronous: false
+ cache: true
+ visible: source !== ""
+ layer.enabled: true
+
+ layer.effect: MultiEffect {
+ autoPaddingEnabled: false
+ blurEnabled: true
+ blur: 0.8
+ blurMax: 32
+ blurMultiplier: 1
+ }
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Theme.mediumDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ color: "black"
+ opacity: 0.4
+ }
+
+ SystemClock {
+ id: systemClock
+
+ precision: SystemClock.Minutes
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ color: "transparent"
+
+ Item {
+ anchors.centerIn: parent
+ anchors.verticalCenterOffset: -100
+ width: 400
+ height: 140
+
+ StyledText {
+ id: clockText
+
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.top: parent.top
+ text: {
+ const format = SettingsData.use24HourClock ? "HH:mm" : "h:mm AP"
+ return systemClock.date.toLocaleTimeString(Qt.locale(), format)
+ }
+ font.pixelSize: 120
+ font.weight: Font.Light
+ color: "white"
+ lineHeight: 0.8
+ }
+
+ StyledText {
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.top: clockText.bottom
+ anchors.topMargin: -20
+ text: {
+ if (SettingsData.lockDateFormat && SettingsData.lockDateFormat.length > 0) {
+ return systemClock.date.toLocaleDateString(Qt.locale(), SettingsData.lockDateFormat)
+ }
+ return systemClock.date.toLocaleDateString(Qt.locale(), Locale.LongFormat)
+ }
+ font.pixelSize: Theme.fontSizeXLarge
+ color: "white"
+ opacity: 0.9
+ }
+ }
+
+ ColumnLayout {
+ anchors.centerIn: parent
+ anchors.verticalCenterOffset: 50
+ spacing: Theme.spacingM
+ width: 380
+
+ RowLayout {
+ spacing: Theme.spacingL
+ Layout.fillWidth: true
+
+ Item {
+ id: avatarContainer
+
+ property bool hasImage: profileImageLoader.status === Image.Ready
+
+ Layout.preferredWidth: 60
+ Layout.preferredHeight: 60
+
+ Rectangle {
+ anchors.fill: parent
+ radius: width / 2
+ color: "transparent"
+ border.color: Theme.primary
+ border.width: 1
+ visible: parent.hasImage
+ }
+
+ Image {
+ id: profileImageLoader
+
+ source: {
+ if (PortalService.profileImage === "") {
+ return ""
+ }
+
+ if (PortalService.profileImage.startsWith("/")) {
+ return "file://" + PortalService.profileImage
+ }
+
+ return PortalService.profileImage
+ }
+ smooth: true
+ asynchronous: true
+ mipmap: true
+ cache: true
+ visible: false
+ }
+
+ MultiEffect {
+ anchors.fill: parent
+ anchors.margins: 5
+ source: profileImageLoader
+ maskEnabled: true
+ maskSource: circularMask
+ visible: avatarContainer.hasImage
+ maskThresholdMin: 0.5
+ maskSpreadAtMin: 1
+ }
+
+ Item {
+ id: circularMask
+
+ width: 60 - 10
+ height: 60 - 10
+ layer.enabled: true
+ layer.smooth: true
+ visible: false
+
+ Rectangle {
+ anchors.fill: parent
+ radius: width / 2
+ color: "black"
+ antialiasing: true
+ }
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ radius: width / 2
+ color: Theme.primary
+ visible: !parent.hasImage
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "person"
+ size: Theme.iconSize + 4
+ color: Theme.primaryText
+ }
+ }
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "warning"
+ size: Theme.iconSize + 4
+ color: Theme.primaryText
+ visible: PortalService.profileImage !== "" && profileImageLoader.status === Image.Error
+ }
+ }
+
+ Rectangle {
+ property bool showPassword: false
+
+ Layout.fillWidth: true
+ Layout.preferredHeight: 60
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.9)
+ border.color: passwordField.activeFocus ? Theme.primary : Qt.rgba(1, 1, 1, 0.3)
+ border.width: passwordField.activeFocus ? 2 : 1
+
+ DankIcon {
+ id: lockIcon
+
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingM
+ anchors.verticalCenter: parent.verticalCenter
+ name: "lock"
+ size: 20
+ color: passwordField.activeFocus ? Theme.primary : Theme.surfaceVariantText
+ }
+
+ TextInput {
+ id: passwordField
+
+ anchors.fill: parent
+ anchors.leftMargin: lockIcon.width + Theme.spacingM * 2
+ anchors.rightMargin: {
+ let margin = Theme.spacingM
+ if (loadingSpinner.visible) {
+ margin += loadingSpinner.width
+ }
+ if (enterButton.visible) {
+ margin += enterButton.width + 2
+ }
+ if (virtualKeyboardButton.visible) {
+ margin += virtualKeyboardButton.width
+ }
+ if (revealButton.visible) {
+ margin += revealButton.width
+ }
+ return margin
+ }
+ opacity: 0
+ focus: !demoMode
+ enabled: !demoMode
+ echoMode: parent.showPassword ? TextInput.Normal : TextInput.Password
+ onTextChanged: {
+ if (!demoMode) {
+ root.passwordBuffer = text
+ }
+ }
+ onAccepted: {
+ if (!demoMode && root.passwordBuffer.length > 0 && !pam.active) {
+ console.log("Enter pressed, starting PAM authentication")
+ pam.start()
+ }
+ }
+ Keys.onPressed: event => {
+ if (demoMode) {
+ return
+ }
+
+ if (pam.active) {
+ console.log("PAM is active, ignoring input")
+ event.accepted = true
+ return
+ }
+ }
+
+ Timer {
+ id: focusTimer
+
+ interval: 100
+ running: !demoMode
+ onTriggered: passwordField.forceActiveFocus()
+ }
+ }
+
+ KeyboardController {
+ id: keyboardController
+ target: passwordField
+ rootObject: root
+ }
+
+ StyledText {
+ id: placeholder
+
+ anchors.left: lockIcon.right
+ anchors.leftMargin: Theme.spacingM
+ anchors.right: (revealButton.visible ? revealButton.left : (virtualKeyboardButton.visible ? virtualKeyboardButton.left : (enterButton.visible ? enterButton.left : (loadingSpinner.visible ? loadingSpinner.left : parent.right))))
+ anchors.rightMargin: 2
+ anchors.verticalCenter: parent.verticalCenter
+ text: {
+ if (demoMode) {
+ return ""
+ }
+ if (LockScreenService.unlocking) {
+ return "Unlocking..."
+ }
+ if (pam.active) {
+ return "Authenticating..."
+ }
+ return "Password..."
+ }
+ color: LockScreenService.unlocking ? Theme.primary : (pam.active ? Theme.primary : Theme.outline)
+ font.pixelSize: Theme.fontSizeMedium
+ opacity: (demoMode || root.passwordBuffer.length === 0) ? 1 : 0
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Theme.mediumDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+ }
+
+ StyledText {
+ anchors.left: lockIcon.right
+ anchors.leftMargin: Theme.spacingM
+ anchors.right: (revealButton.visible ? revealButton.left : (virtualKeyboardButton.visible ? virtualKeyboardButton.left : (enterButton.visible ? enterButton.left : (loadingSpinner.visible ? loadingSpinner.left : parent.right))))
+ anchors.rightMargin: 2
+ anchors.verticalCenter: parent.verticalCenter
+ text: {
+ if (demoMode) {
+ return "••••••••"
+ }
+ if (parent.showPassword) {
+ return root.passwordBuffer
+ }
+ return "•".repeat(Math.min(root.passwordBuffer.length, 25))
+ }
+ color: Theme.surfaceText
+ font.pixelSize: parent.showPassword ? Theme.fontSizeMedium : Theme.fontSizeLarge
+ opacity: (demoMode || root.passwordBuffer.length > 0) ? 1 : 0
+ elide: Text.ElideRight
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Theme.mediumDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+ }
+
+ DankActionButton {
+ id: revealButton
+
+ anchors.right: virtualKeyboardButton.visible ? virtualKeyboardButton.left : (enterButton.visible ? enterButton.left : (loadingSpinner.visible ? loadingSpinner.left : parent.right))
+ anchors.rightMargin: 0
+ anchors.verticalCenter: parent.verticalCenter
+ iconName: parent.showPassword ? "visibility_off" : "visibility"
+ buttonSize: 32
+ visible: !demoMode && root.passwordBuffer.length > 0 && !pam.active && !LockScreenService.unlocking
+ enabled: visible
+ onClicked: parent.showPassword = !parent.showPassword
+ }
+ DankActionButton {
+ id: virtualKeyboardButton
+
+ anchors.right: enterButton.visible ? enterButton.left : (loadingSpinner.visible ? loadingSpinner.left : parent.right)
+ anchors.rightMargin: enterButton.visible ? 0 : Theme.spacingS
+ anchors.verticalCenter: parent.verticalCenter
+ iconName: "keyboard"
+ buttonSize: 32
+ visible: !demoMode && !pam.active && !LockScreenService.unlocking
+ enabled: visible
+ onClicked: {
+ if (keyboardController.isKeyboardActive) {
+ keyboardController.hide()
+ } else {
+ keyboardController.show()
+ }
+ }
+ }
+
+ Rectangle {
+ id: loadingSpinner
+
+ anchors.right: enterButton.visible ? enterButton.left : parent.right
+ anchors.rightMargin: Theme.spacingM
+ anchors.verticalCenter: parent.verticalCenter
+ width: 24
+ height: 24
+ radius: 12
+ color: "transparent"
+ visible: !demoMode && (pam.active || LockScreenService.unlocking)
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "check_circle"
+ size: 20
+ color: Theme.primary
+ visible: LockScreenService.unlocking
+
+ SequentialAnimation on scale {
+ running: LockScreenService.unlocking
+
+ NumberAnimation {
+ from: 0
+ to: 1.2
+ duration: Anims.durShort
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Anims.emphasizedDecel
+ }
+
+ NumberAnimation {
+ from: 1.2
+ to: 1
+ duration: Anims.durShort
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Anims.emphasizedAccel
+ }
+ }
+ }
+
+ Item {
+ anchors.fill: parent
+ visible: pam.active && !LockScreenService.unlocking
+
+ Rectangle {
+ width: 20
+ height: 20
+ radius: 10
+ anchors.centerIn: parent
+ color: "transparent"
+ border.color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.3)
+ border.width: 2
+ }
+
+ Rectangle {
+ width: 20
+ height: 20
+ radius: 10
+ anchors.centerIn: parent
+ color: "transparent"
+ border.color: Theme.primary
+ border.width: 2
+
+ Rectangle {
+ width: parent.width
+ height: parent.height / 2
+ anchors.top: parent.top
+ anchors.horizontalCenter: parent.horizontalCenter
+ color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.9)
+ }
+
+ RotationAnimation on rotation {
+ running: pam.active && !LockScreenService.unlocking
+ loops: Animation.Infinite
+ duration: Anims.durLong
+ from: 0
+ to: 360
+ }
+ }
+ }
+ }
+
+ DankActionButton {
+ id: enterButton
+
+ anchors.right: parent.right
+ anchors.rightMargin: 2
+ anchors.verticalCenter: parent.verticalCenter
+ iconName: "keyboard_return"
+ buttonSize: 36
+ visible: (demoMode || (root.passwordBuffer.length > 0 && !pam.active && !LockScreenService.unlocking))
+ enabled: !demoMode
+ onClicked: {
+ if (!demoMode) {
+ console.log("Enter button clicked, starting PAM authentication")
+ pam.start()
+ }
+ }
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+ }
+
+ Behavior on border.color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+ }
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ Layout.preferredHeight: LockScreenService.pamState ? 20 : 0
+ text: {
+ if (LockScreenService.pamState === "error") {
+ return "Authentication error - try again"
+ }
+ if (LockScreenService.pamState === "max") {
+ return "Too many attempts - locked out"
+ }
+ if (LockScreenService.pamState === "fail") {
+ return "Incorrect password - try again"
+ }
+ return ""
+ }
+ color: Theme.error
+ font.pixelSize: Theme.fontSizeSmall
+ horizontalAlignment: Text.AlignHCenter
+ visible: LockScreenService.pamState !== ""
+ opacity: LockScreenService.pamState !== "" ? 1 : 0
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+
+ Behavior on Layout.preferredHeight {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+ }
+ }
+
+ StyledText {
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.margins: Theme.spacingXL
+ text: "DEMO MODE - Click anywhere to exit"
+ font.pixelSize: Theme.fontSizeSmall
+ color: "white"
+ opacity: 0.7
+ visible: demoMode
+ }
+
+ Row {
+ anchors.top: parent.top
+ anchors.right: parent.right
+ anchors.margins: Theme.spacingXL
+ spacing: Theme.spacingL
+
+ Row {
+ spacing: 6
+ visible: WeatherService.weather.available
+ anchors.verticalCenter: parent.verticalCenter
+
+ DankIcon {
+ name: WeatherService.getWeatherIcon(WeatherService.weather.wCode)
+ size: Theme.iconSize
+ color: "white"
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: (SettingsData.useFahrenheit ? WeatherService.weather.tempF : WeatherService.weather.temp) + "°"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Light
+ color: "white"
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ Rectangle {
+ width: 1
+ height: 24
+ color: Qt.rgba(255, 255, 255, 0.2)
+ anchors.verticalCenter: parent.verticalCenter
+ visible: WeatherService.weather.available && (NetworkService.networkStatus !== "disconnected" || BluetoothService.enabled || (AudioService.sink && AudioService.sink.audio) || BatteryService.batteryAvailable)
+ }
+
+ Row {
+ spacing: Theme.spacingM
+ anchors.verticalCenter: parent.verticalCenter
+ visible: NetworkService.networkStatus !== "disconnected" || (BluetoothService.available && BluetoothService.enabled) || (AudioService.sink && AudioService.sink.audio)
+
+ DankIcon {
+ name: NetworkService.networkStatus === "ethernet" ? "lan" : NetworkService.wifiSignalIcon
+ size: Theme.iconSize - 2
+ color: NetworkService.networkStatus !== "disconnected" ? "white" : Qt.rgba(255, 255, 255, 0.5)
+ anchors.verticalCenter: parent.verticalCenter
+ visible: NetworkService.networkStatus !== "disconnected"
+ }
+
+ DankIcon {
+ name: "bluetooth"
+ size: Theme.iconSize - 2
+ color: "white"
+ anchors.verticalCenter: parent.verticalCenter
+ visible: BluetoothService.available && BluetoothService.enabled
+ }
+
+ DankIcon {
+ name: {
+ if (!AudioService.sink?.audio) {
+ return "volume_up"
+ }
+ if (AudioService.sink.audio.muted || AudioService.sink.audio.volume === 0) {
+ return "volume_off"
+ }
+ if (AudioService.sink.audio.volume * 100 < 33) {
+ return "volume_down"
+ }
+ return "volume_up"
+ }
+ size: Theme.iconSize - 2
+ color: (AudioService.sink && AudioService.sink.audio && (AudioService.sink.audio.muted || AudioService.sink.audio.volume === 0)) ? Qt.rgba(255, 255, 255, 0.5) : "white"
+ anchors.verticalCenter: parent.verticalCenter
+ visible: AudioService.sink && AudioService.sink.audio
+ }
+ }
+
+ Rectangle {
+ width: 1
+ height: 24
+ color: Qt.rgba(255, 255, 255, 0.2)
+ anchors.verticalCenter: parent.verticalCenter
+ visible: BatteryService.batteryAvailable && (NetworkService.networkStatus !== "disconnected" || BluetoothService.enabled || (AudioService.sink && AudioService.sink.audio))
+ }
+
+ Row {
+ spacing: 4
+ visible: BatteryService.batteryAvailable
+ anchors.verticalCenter: parent.verticalCenter
+
+ DankIcon {
+ name: {
+ if (BatteryService.isCharging) {
+ if (BatteryService.batteryLevel >= 90) {
+ return "battery_charging_full"
+ }
+
+ if (BatteryService.batteryLevel >= 80) {
+ return "battery_charging_90"
+ }
+
+ if (BatteryService.batteryLevel >= 60) {
+ return "battery_charging_80"
+ }
+
+ if (BatteryService.batteryLevel >= 50) {
+ return "battery_charging_60"
+ }
+
+ if (BatteryService.batteryLevel >= 30) {
+ return "battery_charging_50"
+ }
+
+ if (BatteryService.batteryLevel >= 20) {
+ return "battery_charging_30"
+ }
+
+ return "battery_charging_20"
+ }
+ if (BatteryService.isPluggedIn) {
+ if (BatteryService.batteryLevel >= 90) {
+ return "battery_charging_full"
+ }
+
+ if (BatteryService.batteryLevel >= 80) {
+ return "battery_charging_90"
+ }
+
+ if (BatteryService.batteryLevel >= 60) {
+ return "battery_charging_80"
+ }
+
+ if (BatteryService.batteryLevel >= 50) {
+ return "battery_charging_60"
+ }
+
+ if (BatteryService.batteryLevel >= 30) {
+ return "battery_charging_50"
+ }
+
+ if (BatteryService.batteryLevel >= 20) {
+ return "battery_charging_30"
+ }
+
+ return "battery_charging_20"
+ }
+ if (BatteryService.batteryLevel >= 95) {
+ return "battery_full"
+ }
+
+ if (BatteryService.batteryLevel >= 85) {
+ return "battery_6_bar"
+ }
+
+ if (BatteryService.batteryLevel >= 70) {
+ return "battery_5_bar"
+ }
+
+ if (BatteryService.batteryLevel >= 55) {
+ return "battery_4_bar"
+ }
+
+ if (BatteryService.batteryLevel >= 40) {
+ return "battery_3_bar"
+ }
+
+ if (BatteryService.batteryLevel >= 25) {
+ return "battery_2_bar"
+ }
+
+ return "battery_1_bar"
+ }
+ size: Theme.iconSize
+ color: {
+ if (BatteryService.isLowBattery && !BatteryService.isCharging) {
+ return Theme.error
+ }
+
+ if (BatteryService.isCharging || BatteryService.isPluggedIn) {
+ return Theme.primary
+ }
+
+ return "white"
+ }
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: BatteryService.batteryLevel + "%"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Light
+ color: "white"
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+ }
+
+ Row {
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+ anchors.margins: Theme.spacingXL
+ spacing: Theme.spacingL
+ visible: SettingsData.lockScreenShowPowerActions
+
+ DankActionButton {
+ iconName: "power_settings_new"
+ iconColor: Theme.error
+ buttonSize: 40
+ onClicked: {
+ if (demoMode) {
+ console.log("Demo: Power")
+ } else {
+ showPowerDialog("Power Off", "Power off this computer?", "Power Off", Theme.error, function () {
+ SessionService.poweroff()
+ })
+ }
+ }
+ }
+
+ DankActionButton {
+ iconName: "refresh"
+ buttonSize: 40
+ onClicked: {
+ if (demoMode) {
+ console.log("Demo: Reboot")
+ } else {
+ showPowerDialog("Restart", "Restart this computer?", "Restart", Theme.primary, function () {
+ SessionService.reboot()
+ })
+ }
+ }
+ }
+
+ DankActionButton {
+ iconName: "logout"
+ buttonSize: 40
+ onClicked: {
+ if (demoMode) {
+ console.log("Demo: Logout")
+ } else {
+ showPowerDialog("Log Out", "End this session?", "Log Out", Theme.primary, function () {
+ SessionService.logout()
+ })
+ }
+ }
+ }
+ }
+
+ StyledText {
+ anchors.bottom: parent.bottom
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.margins: Theme.spacingL
+ width: Math.min(parent.width - Theme.spacingXL * 2, implicitWidth)
+ text: LockScreenService.randomFact
+ font.pixelSize: Theme.fontSizeSmall
+ color: "white"
+ opacity: 0.8
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.NoWrap
+ visible: LockScreenService.randomFact !== ""
+ }
+ }
+
+ FileView {
+ id: pamConfigWatcher
+
+ path: "/etc/pam.d/dankshell"
+ printErrors: false
+ }
+
+ PamContext {
+ id: pam
+
+ config: pamConfigWatcher.loaded ? "dankshell" : "login"
+ onResponseRequiredChanged: {
+ if (demoMode)
+ return
+
+ console.log("PAM response required:", responseRequired)
+ if (!responseRequired)
+ return
+
+ console.log("Responding to PAM with password buffer length:", root.passwordBuffer.length)
+ respond(root.passwordBuffer)
+ }
+ onCompleted: res => {
+ if (demoMode)
+ return
+
+ console.log("PAM authentication completed with result:", res)
+ if (res === PamResult.Success) {
+ console.log("Authentication successful, unlocking")
+ LockScreenService.setUnlocking(true)
+ passwordField.text = ""
+ root.passwordBuffer = ""
+ root.unlockRequested()
+ return
+ }
+ console.log("Authentication failed:", res)
+ if (res === PamResult.Error)
+ LockScreenService.setPamState("error")
+ else if (res === PamResult.MaxTries)
+ LockScreenService.setPamState("max")
+ else if (res === PamResult.Failed)
+ LockScreenService.setPamState("fail")
+ placeholderDelay.restart()
+ }
+ }
+
+ Timer {
+ id: placeholderDelay
+
+ interval: 4000
+ onTriggered: LockScreenService.setPamState("")
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ enabled: demoMode
+ onClicked: root.unlockRequested()
+ }
+
+ // Internal power dialog
+ Rectangle {
+ anchors.fill: parent
+ color: Qt.rgba(0, 0, 0, 0.8)
+ visible: powerDialogVisible
+ z: 1000
+
+ Rectangle {
+ anchors.centerIn: parent
+ width: 320
+ height: 180
+ radius: Theme.cornerRadius
+ color: Theme.surfaceContainer
+ border.color: Theme.outline
+ border.width: 1
+
+ Column {
+ anchors.centerIn: parent
+ spacing: Theme.spacingXL
+
+ DankIcon {
+ anchors.horizontalCenter: parent.horizontalCenter
+ name: "power_settings_new"
+ size: 32
+ color: powerDialogConfirmColor
+ }
+
+ StyledText {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: powerDialogMessage
+ color: Theme.surfaceText
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ }
+
+ Row {
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: Theme.spacingM
+
+ Rectangle {
+ width: 100
+ height: 40
+ radius: Theme.cornerRadius
+ color: Theme.surfaceVariant
+
+ StyledText {
+ anchors.centerIn: parent
+ text: "Cancel"
+ color: Theme.surfaceText
+ font.pixelSize: Theme.fontSizeMedium
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: hidePowerDialog()
+ }
+ }
+
+ Rectangle {
+ width: 100
+ height: 40
+ radius: Theme.cornerRadius
+ color: powerDialogConfirmColor
+
+ StyledText {
+ anchors.centerIn: parent
+ text: powerDialogConfirmText
+ color: Theme.primaryText
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: Font.Medium
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ hidePowerDialog()
+ powerDialogOnConfirm()
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/Lock/LockScreenDemo.qml b/quickshell/.config/quickshell/Modules/Lock/LockScreenDemo.qml
new file mode 100644
index 0000000..63599fb
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/Lock/LockScreenDemo.qml
@@ -0,0 +1,45 @@
+import QtQuick
+import Quickshell
+import Quickshell.Wayland
+import qs.Common
+
+PanelWindow {
+ id: root
+
+ property bool demoActive: false
+
+ visible: demoActive
+
+ anchors {
+ top: true
+ bottom: true
+ left: true
+ right: true
+ }
+
+ WlrLayershell.layer: WlrLayershell.Overlay
+ WlrLayershell.exclusiveZone: -1
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
+
+ color: "transparent"
+
+ function showDemo(): void {
+ console.log("Showing lock screen demo")
+ demoActive = true
+ }
+
+ function hideDemo(): void {
+ console.log("Hiding lock screen demo")
+ demoActive = false
+ }
+
+ Loader {
+ anchors.fill: parent
+ active: demoActive
+ sourceComponent: LockScreenContent {
+ demoMode: true
+ screenName: root.screen?.name ?? ""
+ onUnlockRequested: root.hideDemo()
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/Lock/LockSurface.qml b/quickshell/.config/quickshell/Modules/Lock/LockSurface.qml
new file mode 100644
index 0000000..77ca4a9
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/Lock/LockSurface.qml
@@ -0,0 +1,37 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell
+import Quickshell.Wayland
+import qs.Common
+
+WlSessionLockSurface {
+ id: root
+
+ required property WlSessionLock lock
+ required property string sharedPasswordBuffer
+
+ signal passwordChanged(string newPassword)
+
+ readonly property bool locked: lock && !lock.locked
+
+ function unlock(): void {
+ lock.locked = false
+ }
+
+ color: "transparent"
+
+ Loader {
+ anchors.fill: parent
+ sourceComponent: LockScreenContent {
+ demoMode: false
+ passwordBuffer: root.sharedPasswordBuffer
+ screenName: root.screen?.name ?? ""
+ onUnlockRequested: root.unlock()
+ onPasswordBufferChanged: {
+ if (root.sharedPasswordBuffer !== passwordBuffer) {
+ root.passwordChanged(passwordBuffer)
+ }
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/Notifications/Center/KeyboardNavigatedNotificationList.qml b/quickshell/.config/quickshell/Modules/Notifications/Center/KeyboardNavigatedNotificationList.qml
new file mode 100644
index 0000000..1287a81
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/Notifications/Center/KeyboardNavigatedNotificationList.qml
@@ -0,0 +1,137 @@
+import QtQuick
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+DankListView {
+ id: listView
+
+ property var keyboardController: null
+ property bool keyboardActive: false
+ property bool autoScrollDisabled: false
+ property alias count: listView.count
+ property alias listContentHeight: listView.contentHeight
+
+ clip: true
+ model: NotificationService.groupedNotifications
+ spacing: Theme.spacingL
+
+ onIsUserScrollingChanged: {
+ if (isUserScrolling && keyboardController && keyboardController.keyboardNavigationActive) {
+ autoScrollDisabled = true
+ }
+ }
+
+ function enableAutoScroll() {
+ autoScrollDisabled = false
+ }
+
+ Timer {
+ id: positionPreservationTimer
+ interval: 200
+ running: keyboardController && keyboardController.keyboardNavigationActive && !autoScrollDisabled
+ repeat: true
+ onTriggered: {
+ if (keyboardController && keyboardController.keyboardNavigationActive && !autoScrollDisabled) {
+ keyboardController.ensureVisible()
+ }
+ }
+ }
+
+ NotificationEmptyState {
+ visible: listView.count === 0
+ anchors.centerIn: parent
+ }
+
+ onModelChanged: {
+ if (!keyboardController || !keyboardController.keyboardNavigationActive) {
+ return
+ }
+ keyboardController.rebuildFlatNavigation()
+ Qt.callLater(() => {
+ if (keyboardController && keyboardController.keyboardNavigationActive && !autoScrollDisabled) {
+ keyboardController.ensureVisible()
+ }
+ })
+ }
+
+ delegate: Item {
+ required property var modelData
+ required property int index
+
+ readonly property bool isExpanded: (NotificationService.expandedGroups[modelData && modelData.key] || false)
+
+ width: ListView.view.width
+ height: notificationCard.height
+
+ NotificationCard {
+ id: notificationCard
+ width: parent.width
+ notificationGroup: modelData
+ keyboardNavigationActive: listView.keyboardActive
+
+ isGroupSelected: {
+ if (!keyboardController || !keyboardController.keyboardNavigationActive || !listView.keyboardActive) {
+ return false
+ }
+ keyboardController.selectionVersion
+ const selection = keyboardController.getCurrentSelection()
+ return selection.type === "group" && selection.groupIndex === index
+ }
+
+ selectedNotificationIndex: {
+ if (!keyboardController || !keyboardController.keyboardNavigationActive || !listView.keyboardActive) {
+ return -1
+ }
+ keyboardController.selectionVersion
+ const selection = keyboardController.getCurrentSelection()
+ return (selection.type === "notification" && selection.groupIndex === index) ? selection.notificationIndex : -1
+ }
+ }
+ }
+
+ Connections {
+ target: NotificationService
+
+ function onGroupedNotificationsChanged() {
+ if (!keyboardController) {
+ return
+ }
+
+ if (keyboardController.isTogglingGroup) {
+ keyboardController.rebuildFlatNavigation()
+ return
+ }
+
+ keyboardController.rebuildFlatNavigation()
+
+ if (keyboardController.keyboardNavigationActive) {
+ Qt.callLater(() => {
+ if (!autoScrollDisabled) {
+ keyboardController.ensureVisible()
+ }
+ })
+ }
+ }
+
+ function onExpandedGroupsChanged() {
+ if (keyboardController && keyboardController.keyboardNavigationActive) {
+ Qt.callLater(() => {
+ if (!autoScrollDisabled) {
+ keyboardController.ensureVisible()
+ }
+ })
+ }
+ }
+
+ function onExpandedMessagesChanged() {
+ if (keyboardController && keyboardController.keyboardNavigationActive) {
+ Qt.callLater(() => {
+ if (!autoScrollDisabled) {
+ keyboardController.ensureVisible()
+ }
+ })
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/Notifications/Center/NotificationCard.qml b/quickshell/.config/quickshell/Modules/Notifications/Center/NotificationCard.qml
new file mode 100644
index 0000000..22d411c
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/Notifications/Center/NotificationCard.qml
@@ -0,0 +1,728 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell
+import Quickshell.Widgets
+import Quickshell.Services.Notifications
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Rectangle {
+ id: root
+
+ property var notificationGroup
+ property bool expanded: (NotificationService.expandedGroups[notificationGroup && notificationGroup.key] || false)
+ property bool descriptionExpanded: (NotificationService.expandedMessages[(notificationGroup && notificationGroup.latestNotification && notificationGroup.latestNotification.notification
+ && notificationGroup.latestNotification.notification.id) ? (notificationGroup.latestNotification.notification.id + "_desc") : ""] || false)
+ property bool userInitiatedExpansion: false
+
+ property bool isGroupSelected: false
+ property int selectedNotificationIndex: -1
+ property bool keyboardNavigationActive: false
+
+ width: parent ? parent.width : 400
+ height: {
+ if (expanded) {
+ return expandedContent.height + 28
+ }
+ const baseHeight = 116
+ if (descriptionExpanded) {
+ return baseHeight + descriptionText.contentHeight - (descriptionText.font.pixelSize * 1.2 * 2)
+ }
+ return baseHeight
+ }
+ radius: Theme.cornerRadius
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+
+ Behavior on border.color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+
+ color: {
+ if (isGroupSelected && keyboardNavigationActive) {
+ return Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.2)
+ }
+ if (keyboardNavigationActive && expanded && selectedNotificationIndex >= 0) {
+ return Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.12)
+ }
+ return Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.1)
+ }
+ border.color: {
+ if (isGroupSelected && keyboardNavigationActive) {
+ return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.5)
+ }
+ if (keyboardNavigationActive && expanded && selectedNotificationIndex >= 0) {
+ return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.2)
+ }
+ if (notificationGroup?.latestNotification?.urgency === NotificationUrgency.Critical) {
+ return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.3)
+ }
+ return Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.05)
+ }
+ border.width: {
+ if (isGroupSelected && keyboardNavigationActive) {
+ return 1.5
+ }
+ if (keyboardNavigationActive && expanded && selectedNotificationIndex >= 0) {
+ return 1
+ }
+ if (notificationGroup?.latestNotification?.urgency === NotificationUrgency.Critical) {
+ return 2
+ }
+ return 1
+ }
+ clip: true
+
+ Rectangle {
+ anchors.fill: parent
+ radius: parent.radius
+ visible: notificationGroup?.latestNotification?.urgency === NotificationUrgency.Critical
+ gradient: Gradient {
+ orientation: Gradient.Horizontal
+ GradientStop {
+ position: 0.0
+ color: Theme.primary
+ }
+ GradientStop {
+ position: 0.02
+ color: Theme.primary
+ }
+ GradientStop {
+ position: 0.021
+ color: "transparent"
+ }
+ }
+ opacity: 1.0
+ }
+
+ Item {
+ id: collapsedContent
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.topMargin: 12
+ anchors.leftMargin: 16
+ anchors.rightMargin: 56
+ height: 92
+ visible: !expanded
+
+ Rectangle {
+ id: iconContainer
+ readonly property bool hasNotificationImage: notificationGroup?.latestNotification?.image && notificationGroup.latestNotification.image !== ""
+
+ width: 55
+ height: 55
+ radius: 27.5
+ color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1)
+ border.color: "transparent"
+ border.width: 0
+ anchors.left: parent.left
+ anchors.top: parent.top
+ anchors.topMargin: 18
+
+ IconImage {
+ anchors.fill: parent
+ anchors.margins: 2
+ source: {
+ if (parent.hasNotificationImage)
+ return notificationGroup.latestNotification.cleanImage
+ if (notificationGroup?.latestNotification?.appIcon) {
+ const appIcon = notificationGroup.latestNotification.appIcon
+ if (appIcon.startsWith("file://") || appIcon.startsWith("http://") || appIcon.startsWith("https://"))
+ return appIcon
+ return Quickshell.iconPath(appIcon, true)
+ }
+ return ""
+ }
+ visible: status === Image.Ready
+ }
+
+ StyledText {
+ anchors.centerIn: parent
+ visible: !parent.hasNotificationImage && (!notificationGroup?.latestNotification?.appIcon || notificationGroup.latestNotification.appIcon === "")
+ text: {
+ const appName = notificationGroup?.appName || "?"
+ return appName.charAt(0).toUpperCase()
+ }
+ font.pixelSize: 20
+ font.weight: Font.Bold
+ color: Theme.primaryText
+ }
+
+ Rectangle {
+ width: 18
+ height: 18
+ radius: 9
+ color: Theme.primary
+ anchors.top: parent.top
+ anchors.right: parent.right
+ anchors.topMargin: -2
+ anchors.rightMargin: -2
+ visible: (notificationGroup?.count || 0) > 1
+
+ StyledText {
+ anchors.centerIn: parent
+ text: (notificationGroup?.count || 0) > 99 ? "99+" : (notificationGroup?.count || 0).toString()
+ color: Theme.primaryText
+ font.pixelSize: 9
+ font.weight: Font.Bold
+ }
+ }
+ }
+
+ Rectangle {
+ id: textContainer
+
+ anchors.left: iconContainer.right
+ anchors.leftMargin: 12
+ anchors.right: parent.right
+ anchors.rightMargin: 0
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: 8
+ color: "transparent"
+
+ Item {
+ width: parent.width
+ height: parent.height
+ anchors.top: parent.top
+ anchors.topMargin: -2
+
+ Column {
+ width: parent.width
+ spacing: 2
+
+ StyledText {
+ width: parent.width
+ text: {
+ const timeStr = (notificationGroup && notificationGroup.latestNotification && notificationGroup.latestNotification.timeStr) || ""
+ const appName = (notificationGroup && notificationGroup.appName) || ""
+ return timeStr.length > 0 ? `${appName} • ${timeStr}` : appName
+ }
+ color: Theme.surfaceVariantText
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ elide: Text.ElideRight
+ maximumLineCount: 1
+ }
+
+ StyledText {
+ text: (notificationGroup && notificationGroup.latestNotification && notificationGroup.latestNotification.summary) || ""
+ color: Theme.surfaceText
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: Font.Medium
+ width: parent.width
+ elide: Text.ElideRight
+ maximumLineCount: 1
+ visible: text.length > 0
+ }
+
+ StyledText {
+ id: descriptionText
+ property string fullText: (notificationGroup && notificationGroup.latestNotification && notificationGroup.latestNotification.htmlBody) || ""
+ property bool hasMoreText: truncated
+
+ text: fullText
+ color: Theme.surfaceVariantText
+ font.pixelSize: Theme.fontSizeSmall
+ width: parent.width
+ elide: Text.ElideRight
+ maximumLineCount: descriptionExpanded ? -1 : 2
+ wrapMode: Text.WordWrap
+ visible: text.length > 0
+ linkColor: Theme.primary
+ onLinkActivated: link => Qt.openUrlExternally(link)
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : (parent.hasMoreText || descriptionExpanded) ? Qt.PointingHandCursor : Qt.ArrowCursor
+
+ onClicked: mouse => {
+ if (!parent.hoveredLink && (parent.hasMoreText || descriptionExpanded)) {
+ const messageId = (notificationGroup && notificationGroup.latestNotification && notificationGroup.latestNotification.notification
+ && notificationGroup.latestNotification.notification.id) ? (notificationGroup.latestNotification.notification.id + "_desc") : ""
+ NotificationService.toggleMessageExpansion(messageId)
+ }
+ }
+
+ propagateComposedEvents: true
+ onPressed: mouse => {
+ if (parent.hoveredLink) {
+ mouse.accepted = false
+ }
+ }
+ onReleased: mouse => {
+ if (parent.hoveredLink) {
+ mouse.accepted = false
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Column {
+ id: expandedContent
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.topMargin: 14
+ anchors.bottomMargin: 14
+ anchors.leftMargin: Theme.spacingL
+ anchors.rightMargin: Theme.spacingL
+ spacing: -1
+ visible: expanded
+
+ Item {
+ width: parent.width
+ height: 40
+
+ Row {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.rightMargin: 56
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingS
+
+ StyledText {
+ text: notificationGroup?.appName || ""
+ color: Theme.surfaceText
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Bold
+ anchors.verticalCenter: parent.verticalCenter
+ elide: Text.ElideRight
+ maximumLineCount: 1
+ }
+
+ Rectangle {
+ width: 18
+ height: 18
+ radius: 9
+ color: Theme.primary
+ visible: (notificationGroup?.count || 0) > 1
+ anchors.verticalCenter: parent.verticalCenter
+
+ StyledText {
+ anchors.centerIn: parent
+ text: (notificationGroup?.count || 0) > 99 ? "99+" : (notificationGroup?.count || 0).toString()
+ color: Theme.primaryText
+ font.pixelSize: 9
+ font.weight: Font.Bold
+ }
+ }
+ }
+ }
+
+ Column {
+ width: parent.width
+ spacing: 16
+
+ Repeater {
+ model: notificationGroup?.notifications?.slice(0, 10) || []
+
+ delegate: Rectangle {
+ required property var modelData
+ required property int index
+ readonly property bool messageExpanded: NotificationService.expandedMessages[modelData?.notification?.id] || false
+ readonly property bool isSelected: root.selectedNotificationIndex === index
+
+ width: parent.width
+ height: {
+ const baseHeight = 120
+ if (messageExpanded) {
+ const twoLineHeight = bodyText.font.pixelSize * 1.2 * 2
+ if (bodyText.implicitHeight > twoLineHeight + 2) {
+ const extraHeight = bodyText.implicitHeight - twoLineHeight
+ return baseHeight + extraHeight
+ }
+ }
+ return baseHeight
+ }
+ radius: Theme.cornerRadius
+ color: isSelected ? Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.25) : "transparent"
+ border.color: isSelected ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.4) : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.05)
+ border.width: isSelected ? 1 : 1
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+
+ Behavior on border.color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+
+ Behavior on height {
+ enabled: false
+ }
+
+ Item {
+ anchors.fill: parent
+ anchors.margins: 12
+ anchors.bottomMargin: 8
+
+ Rectangle {
+ id: messageIcon
+
+ readonly property bool hasNotificationImage: modelData?.image && modelData.image !== ""
+
+ width: 32
+ height: 32
+ radius: 16
+ anchors.left: parent.left
+ anchors.top: parent.top
+ anchors.topMargin: 32
+ color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1)
+ border.color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.2)
+ border.width: 1
+
+ IconImage {
+ anchors.fill: parent
+ anchors.margins: 1
+ source: {
+ if (parent.hasNotificationImage)
+ return modelData.cleanImage
+
+ if (modelData?.appIcon) {
+ const appIcon = modelData.appIcon
+ if (appIcon.startsWith("file://") || appIcon.startsWith("http://") || appIcon.startsWith("https://"))
+ return appIcon
+
+ return Quickshell.iconPath(appIcon, true)
+ }
+ return ""
+ }
+ visible: status === Image.Ready
+ }
+
+ StyledText {
+ anchors.centerIn: parent
+ visible: !parent.hasNotificationImage && (!modelData?.appIcon || modelData.appIcon === "")
+ text: {
+ const appName = modelData?.appName || "?"
+ return appName.charAt(0).toUpperCase()
+ }
+ font.pixelSize: 12
+ font.weight: Font.Bold
+ color: Theme.primaryText
+ }
+ }
+
+ Item {
+ anchors.left: messageIcon.right
+ anchors.leftMargin: 12
+ anchors.right: parent.right
+ anchors.rightMargin: 12
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+
+ Column {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.bottom: buttonArea.top
+ anchors.bottomMargin: 4
+ spacing: 2
+
+ StyledText {
+ width: parent.width
+ text: modelData?.timeStr || ""
+ color: Theme.surfaceVariantText
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ elide: Text.ElideRight
+ maximumLineCount: 1
+ visible: text.length > 0
+ }
+
+ StyledText {
+ width: parent.width
+ text: modelData?.summary || ""
+ color: Theme.surfaceText
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: Font.Medium
+ elide: Text.ElideRight
+ maximumLineCount: 1
+ visible: text.length > 0
+ }
+
+ StyledText {
+ id: bodyText
+ property bool hasMoreText: truncated
+
+ text: modelData?.htmlBody || ""
+ color: Theme.surfaceVariantText
+ font.pixelSize: Theme.fontSizeSmall
+ width: parent.width
+ elide: messageExpanded ? Text.ElideNone : Text.ElideRight
+ maximumLineCount: messageExpanded ? -1 : 2
+ wrapMode: Text.WordWrap
+ visible: text.length > 0
+ linkColor: Theme.primary
+ onLinkActivated: link => Qt.openUrlExternally(link)
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : (bodyText.hasMoreText || messageExpanded) ? Qt.PointingHandCursor : Qt.ArrowCursor
+
+ onClicked: mouse => {
+ if (!parent.hoveredLink && (bodyText.hasMoreText || messageExpanded)) {
+ NotificationService.toggleMessageExpansion(modelData?.notification?.id || "")
+ }
+ }
+
+ propagateComposedEvents: true
+ onPressed: mouse => {
+ if (parent.hoveredLink) {
+ mouse.accepted = false
+ }
+ }
+ onReleased: mouse => {
+ if (parent.hoveredLink) {
+ mouse.accepted = false
+ }
+ }
+ }
+ }
+ }
+
+ Item {
+ id: buttonArea
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ height: 30
+
+ Row {
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ spacing: 8
+
+ Repeater {
+ model: modelData?.actions || []
+
+ Rectangle {
+ property bool isHovered: false
+
+ width: Math.max(actionText.implicitWidth + 12, 50)
+ height: 24
+ radius: 4
+ color: isHovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1) : "transparent"
+
+ StyledText {
+ id: actionText
+ text: {
+ const baseText = modelData.text || "View"
+ if (keyboardNavigationActive && (isGroupSelected || selectedNotificationIndex >= 0)) {
+ return `${baseText} (${index + 1})`
+ }
+ return baseText
+ }
+ color: parent.isHovered ? Theme.primary : Theme.surfaceVariantText
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ anchors.centerIn: parent
+ elide: Text.ElideRight
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onEntered: parent.isHovered = true
+ onExited: parent.isHovered = false
+ onClicked: {
+ if (modelData && modelData.invoke) {
+ modelData.invoke()
+ }
+ }
+ }
+ }
+ }
+
+ Rectangle {
+ property bool isHovered: false
+
+ width: Math.max(clearText.implicitWidth + 12, 50)
+ height: 24
+ radius: 4
+ color: isHovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1) : "transparent"
+
+ StyledText {
+ id: clearText
+ text: "Clear"
+ color: parent.isHovered ? Theme.primary : Theme.surfaceVariantText
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ anchors.centerIn: parent
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onEntered: parent.isHovered = true
+ onExited: parent.isHovered = false
+ onClicked: NotificationService.dismissNotification(modelData)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Row {
+ visible: !expanded
+ anchors.right: clearButton.left
+ anchors.rightMargin: 8
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: 8
+ spacing: 8
+
+ Repeater {
+ model: notificationGroup?.latestNotification?.actions || []
+
+ Rectangle {
+ property bool isHovered: false
+
+ width: Math.max(actionText.implicitWidth + 12, 50)
+ height: 24
+ radius: 4
+ color: isHovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1) : "transparent"
+
+ StyledText {
+ id: actionText
+ text: {
+ const baseText = modelData.text || "View"
+ if (keyboardNavigationActive && isGroupSelected) {
+ return `${baseText} (${index + 1})`
+ }
+ return baseText
+ }
+ color: parent.isHovered ? Theme.primary : Theme.surfaceVariantText
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ anchors.centerIn: parent
+ elide: Text.ElideRight
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onEntered: parent.isHovered = true
+ onExited: parent.isHovered = false
+ onClicked: {
+ if (modelData && modelData.invoke) {
+ modelData.invoke()
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Rectangle {
+ id: clearButton
+
+ property bool isHovered: false
+
+ visible: !expanded
+ anchors.right: parent.right
+ anchors.rightMargin: 16
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: 8
+ width: clearText.width + 16
+ height: clearText.height + 8
+ radius: 6
+ color: isHovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1) : "transparent"
+
+ StyledText {
+ id: clearText
+ text: "Clear"
+ color: clearButton.isHovered ? Theme.primary : Theme.surfaceVariantText
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ anchors.centerIn: parent
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onEntered: clearButton.isHovered = true
+ onExited: clearButton.isHovered = false
+ onClicked: NotificationService.dismissGroup(notificationGroup?.key || "")
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ visible: !expanded && (notificationGroup?.count || 0) > 1 && !descriptionExpanded
+ onClicked: {
+ root.userInitiatedExpansion = true
+ NotificationService.toggleGroupExpansion(notificationGroup?.key || "")
+ }
+ z: -1
+ }
+
+ Item {
+ id: fixedControls
+ anchors.top: parent.top
+ anchors.right: parent.right
+ anchors.topMargin: 12
+ anchors.rightMargin: 16
+ width: 60
+ height: 28
+
+ DankActionButton {
+ anchors.left: parent.left
+ anchors.top: parent.top
+ visible: (notificationGroup?.count || 0) > 1
+ iconName: expanded ? "expand_less" : "expand_more"
+ iconSize: 18
+ buttonSize: 28
+ onClicked: {
+ root.userInitiatedExpansion = true
+ NotificationService.toggleGroupExpansion(notificationGroup?.key || "")
+ }
+ }
+
+ DankActionButton {
+ anchors.right: parent.right
+ anchors.top: parent.top
+ iconName: "close"
+ iconSize: 18
+ buttonSize: 28
+ onClicked: NotificationService.dismissGroup(notificationGroup?.key || "")
+ }
+ }
+
+ Behavior on height {
+ enabled: root.userInitiatedExpansion
+ NumberAnimation {
+ duration: Theme.mediumDuration
+ easing.type: Theme.emphasizedEasing
+ onFinished: root.userInitiatedExpansion = false
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/Notifications/Center/NotificationCenterPopout.qml b/quickshell/.config/quickshell/Modules/Notifications/Center/NotificationCenterPopout.qml
new file mode 100644
index 0000000..3ea923c
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/Notifications/Center/NotificationCenterPopout.qml
@@ -0,0 +1,202 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Effects
+import Quickshell
+import Quickshell.Wayland
+import Quickshell.Widgets
+import qs.Common
+import qs.Services
+import qs.Widgets
+import qs.Modules.Notifications.Center
+
+DankPopout {
+ id: root
+
+ property bool notificationHistoryVisible: false
+ property string triggerSection: "right"
+ property var triggerScreen: null
+
+ NotificationKeyboardController {
+ id: keyboardController
+ listView: null
+ isOpen: notificationHistoryVisible
+ onClose: () => {
+ notificationHistoryVisible = false
+ }
+ }
+
+ function setTriggerPosition(x, y, width, section, screen) {
+ triggerX = x
+ triggerY = y
+ triggerWidth = width
+ triggerSection = section
+ triggerScreen = screen
+ }
+
+ popupWidth: 400
+ popupHeight: contentLoader.item ? contentLoader.item.implicitHeight : 400
+ triggerX: Screen.width - 400 - Theme.spacingL
+ triggerY: Theme.barHeight - 4 + SettingsData.topBarSpacing + Theme.spacingXS
+ triggerWidth: 40
+ positioning: "center"
+ screen: triggerScreen
+ shouldBeVisible: notificationHistoryVisible
+ visible: shouldBeVisible
+
+ onNotificationHistoryVisibleChanged: {
+ if (notificationHistoryVisible) {
+ open()
+ } else {
+ close()
+ }
+ }
+
+ onShouldBeVisibleChanged: {
+ if (shouldBeVisible) {
+ NotificationService.onOverlayOpen()
+ Qt.callLater(() => {
+ if (contentLoader.item) {
+ contentLoader.item.externalKeyboardController = keyboardController
+
+ const notificationList = findChild(contentLoader.item, "notificationList")
+ const notificationHeader = findChild(contentLoader.item, "notificationHeader")
+
+ if (notificationList) {
+ keyboardController.listView = notificationList
+ notificationList.keyboardController = keyboardController
+ }
+ if (notificationHeader) {
+ notificationHeader.keyboardController = keyboardController
+ }
+
+ keyboardController.reset()
+ keyboardController.rebuildFlatNavigation()
+ }
+ })
+ } else {
+ NotificationService.onOverlayClose()
+ keyboardController.keyboardNavigationActive = false
+ }
+ }
+
+ function findChild(parent, objectName) {
+ if (parent.objectName === objectName) {
+ return parent
+ }
+ for (let i = 0; i < parent.children.length; i++) {
+ const child = parent.children[i]
+ const result = findChild(child, objectName)
+ if (result) {
+ return result
+ }
+ }
+ return null
+ }
+
+ content: Component {
+ Rectangle {
+ id: notificationContent
+
+ property var externalKeyboardController: null
+ property real cachedHeaderHeight: 32
+
+ implicitHeight: {
+ let baseHeight = Theme.spacingL * 2
+ baseHeight += cachedHeaderHeight
+ baseHeight += (notificationSettings.expanded ? notificationSettings.contentHeight : 0)
+ baseHeight += Theme.spacingM * 2
+ let listHeight = notificationList.listContentHeight
+ if (NotificationService.groupedNotifications.length === 0) {
+ listHeight = 200
+ }
+ baseHeight += Math.min(listHeight, 600)
+ const maxHeight = root.screen ? root.screen.height * 0.8 : Screen.height * 0.8
+ return Math.max(300, Math.min(baseHeight, maxHeight))
+ }
+
+ color: Theme.popupBackground()
+ radius: Theme.cornerRadius
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
+ border.width: 1
+ focus: true
+
+ Component.onCompleted: {
+ if (root.shouldBeVisible) {
+ forceActiveFocus()
+ }
+ }
+
+ Keys.onPressed: event => {
+ if (event.key === Qt.Key_Escape) {
+ root.close()
+ event.accepted = true
+ } else if (externalKeyboardController) {
+ externalKeyboardController.handleKey(event)
+ }
+ }
+
+ Connections {
+ function onShouldBeVisibleChanged() {
+ if (root.shouldBeVisible) {
+ Qt.callLater(() => {
+ notificationContent.forceActiveFocus()
+ })
+ } else {
+ notificationContent.focus = false
+ }
+ }
+ target: root
+ }
+
+ FocusScope {
+ id: contentColumn
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ focus: true
+
+ Column {
+ id: contentColumnInner
+ anchors.fill: parent
+ spacing: Theme.spacingM
+
+ NotificationHeader {
+ id: notificationHeader
+ objectName: "notificationHeader"
+ onHeightChanged: notificationContent.cachedHeaderHeight = height
+ }
+
+ NotificationSettings {
+ id: notificationSettings
+ expanded: notificationHeader.showSettings
+ }
+
+ KeyboardNavigatedNotificationList {
+ id: notificationList
+ objectName: "notificationList"
+
+ width: parent.width
+ height: parent.height - notificationContent.cachedHeaderHeight - notificationSettings.height - contentColumnInner.spacing * 2
+ }
+ }
+ }
+
+ NotificationKeyboardHints {
+ id: keyboardHints
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.margins: Theme.spacingL
+ showHints: (externalKeyboardController && externalKeyboardController.showKeyboardHints) || false
+ z: 200
+ }
+
+ Behavior on implicitHeight {
+ NumberAnimation {
+ duration: 180
+ easing.type: Easing.OutQuart
+ }
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/Notifications/Center/NotificationEmptyState.qml b/quickshell/.config/quickshell/Modules/Notifications/Center/NotificationEmptyState.qml
new file mode 100644
index 0000000..569bfa2
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/Notifications/Center/NotificationEmptyState.qml
@@ -0,0 +1,34 @@
+import QtQuick
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Item {
+ id: root
+
+ width: parent.width
+ height: 200
+ visible: NotificationService.notifications.length === 0
+
+ Column {
+ anchors.centerIn: parent
+ spacing: Theme.spacingXS
+ width: parent.width * 0.8
+
+ DankIcon {
+ anchors.horizontalCenter: parent.horizontalCenter
+ name: "notifications_none"
+ size: Theme.iconSizeLarge + 16
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.3)
+ }
+
+ StyledText {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: "Nothing to see here"
+ font.pixelSize: Theme.fontSizeLarge
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.3)
+ font.weight: Font.Medium
+ horizontalAlignment: Text.AlignHCenter
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/Notifications/Center/NotificationHeader.qml b/quickshell/.config/quickshell/Modules/Notifications/Center/NotificationHeader.qml
new file mode 100644
index 0000000..b93051a
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/Notifications/Center/NotificationHeader.qml
@@ -0,0 +1,157 @@
+import QtQuick
+import QtQuick.Controls
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Item {
+ id: root
+
+ property var keyboardController: null
+ property bool showSettings: false
+
+ width: parent.width
+ height: 32
+
+ Row {
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingXS
+
+ StyledText {
+ text: "Notifications"
+ font.pixelSize: Theme.fontSizeLarge
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ DankActionButton {
+ id: doNotDisturbButton
+
+ iconName: SessionData.doNotDisturb ? "notifications_off" : "notifications"
+ iconColor: SessionData.doNotDisturb ? Theme.error : Theme.surfaceText
+ buttonSize: 28
+ anchors.verticalCenter: parent.verticalCenter
+ onClicked: SessionData.setDoNotDisturb(!SessionData.doNotDisturb)
+
+ Rectangle {
+ id: doNotDisturbTooltip
+
+ width: tooltipText.contentWidth + Theme.spacingS * 2
+ height: tooltipText.contentHeight + Theme.spacingXS * 2
+ radius: Theme.cornerRadius
+ color: Theme.surfaceContainer
+ border.color: Theme.outline
+ border.width: 1
+ anchors.bottom: parent.top
+ anchors.bottomMargin: Theme.spacingS
+ anchors.horizontalCenter: parent.horizontalCenter
+ visible: doNotDisturbButton.children[1].containsMouse
+ opacity: visible ? 1 : 0
+
+ StyledText {
+ id: tooltipText
+
+ text: "Do Not Disturb"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.centerIn: parent
+ font.hintingPreference: Font.PreferFullHinting
+ }
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+ }
+ }
+ }
+
+ Row {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingXS
+
+ DankActionButton {
+ id: helpButton
+ iconName: "info"
+ iconColor: (keyboardController && keyboardController.showKeyboardHints) ? Theme.primary : Theme.surfaceText
+ buttonSize: 28
+ visible: keyboardController !== null
+ anchors.verticalCenter: parent.verticalCenter
+ onClicked: {
+ if (keyboardController) {
+ keyboardController.showKeyboardHints = !keyboardController.showKeyboardHints
+ }
+ }
+ }
+
+ DankActionButton {
+ id: settingsButton
+ iconName: "settings"
+ iconColor: root.showSettings ? Theme.primary : Theme.surfaceText
+ buttonSize: 28
+ anchors.verticalCenter: parent.verticalCenter
+ onClicked: root.showSettings = !root.showSettings
+ }
+
+ Rectangle {
+ id: clearAllButton
+
+ width: 120
+ height: 28
+ radius: Theme.cornerRadius
+ visible: NotificationService.notifications.length > 0
+ color: clearArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3)
+ border.color: clearArea.containsMouse ? Theme.primary : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
+ border.width: 1
+
+ Row {
+ anchors.centerIn: parent
+ spacing: Theme.spacingXS
+
+ DankIcon {
+ name: "delete_sweep"
+ size: Theme.iconSizeSmall
+ color: clearArea.containsMouse ? Theme.primary : Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Clear All"
+ font.pixelSize: Theme.fontSizeSmall
+ color: clearArea.containsMouse ? Theme.primary : Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ MouseArea {
+ id: clearArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: NotificationService.clearAllNotifications()
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+
+ Behavior on border.color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/Notifications/Center/NotificationKeyboardController.qml b/quickshell/.config/quickshell/Modules/Notifications/Center/NotificationKeyboardController.qml
new file mode 100644
index 0000000..928ffd7
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/Notifications/Center/NotificationKeyboardController.qml
@@ -0,0 +1,459 @@
+import QtQuick
+import qs.Common
+import qs.Services
+
+QtObject {
+ id: controller
+
+ property var listView: null
+ property bool isOpen: false
+ property var onClose: null
+
+ property int selectionVersion: 0
+
+ property bool keyboardNavigationActive: false
+ property int selectedFlatIndex: 0
+ property var flatNavigation: []
+ property bool showKeyboardHints: false
+
+ property string selectedNotificationId: ""
+ property string selectedGroupKey: ""
+ property string selectedItemType: ""
+ property bool isTogglingGroup: false
+ property bool isRebuilding: false
+
+ function rebuildFlatNavigation() {
+ isRebuilding = true
+
+ const nav = []
+ const groups = NotificationService.groupedNotifications
+
+ for (let i = 0; i < groups.length; i++) {
+ const group = groups[i]
+ const isExpanded = NotificationService.expandedGroups[group.key] || false
+
+ nav.push({
+ "type": "group",
+ "groupIndex": i,
+ "notificationIndex": -1,
+ "groupKey": group.key,
+ "notificationId": ""
+ })
+
+ if (isExpanded) {
+ const notifications = group.notifications || []
+ for (let j = 0; j < notifications.length; j++) {
+ const notifId = String(notifications[j] && notifications[j].notification && notifications[j].notification.id ? notifications[j].notification.id : "")
+ nav.push({
+ "type": "notification",
+ "groupIndex": i,
+ "notificationIndex": j,
+ "groupKey": group.key,
+ "notificationId": notifId
+ })
+ }
+ }
+ }
+
+ flatNavigation = nav
+ updateSelectedIndexFromId()
+ isRebuilding = false
+ }
+
+ function updateSelectedIndexFromId() {
+ if (!keyboardNavigationActive) {
+ return
+ }
+
+ for (let i = 0; i < flatNavigation.length; i++) {
+ const item = flatNavigation[i]
+
+ if (selectedItemType === "group" && item.type === "group" && item.groupKey === selectedGroupKey) {
+ selectedFlatIndex = i
+ selectionVersion++ // Trigger UI update
+ return
+ } else if (selectedItemType === "notification" && item.type === "notification" && String(item.notificationId) === String(selectedNotificationId)) {
+ selectedFlatIndex = i
+ selectionVersion++ // Trigger UI update
+ return
+ }
+ }
+
+ // If not found, try to find the same group but select the group header instead
+ if (selectedItemType === "notification") {
+ for (let j = 0; j < flatNavigation.length; j++) {
+ const groupItem = flatNavigation[j]
+ if (groupItem.type === "group" && groupItem.groupKey === selectedGroupKey) {
+ selectedFlatIndex = j
+ selectedItemType = "group"
+ selectedNotificationId = ""
+ selectionVersion++ // Trigger UI update
+ return
+ }
+ }
+ }
+
+ // If still not found, clamp to valid range and update
+ if (flatNavigation.length > 0) {
+ selectedFlatIndex = Math.min(selectedFlatIndex, flatNavigation.length - 1)
+ selectedFlatIndex = Math.max(selectedFlatIndex, 0)
+ updateSelectedIdFromIndex()
+ selectionVersion++ // Trigger UI update
+ }
+ }
+
+ function updateSelectedIdFromIndex() {
+ if (selectedFlatIndex >= 0 && selectedFlatIndex < flatNavigation.length) {
+ const item = flatNavigation[selectedFlatIndex]
+ selectedItemType = item.type
+ selectedGroupKey = item.groupKey
+ selectedNotificationId = item.notificationId
+ }
+ }
+
+ function reset() {
+ selectedFlatIndex = 0
+ keyboardNavigationActive = false
+ showKeyboardHints = false
+ // Reset keyboardActive when modal is reset
+ if (listView) {
+ listView.keyboardActive = false
+ }
+ rebuildFlatNavigation()
+ }
+
+ function selectNext() {
+ keyboardNavigationActive = true
+ if (flatNavigation.length === 0)
+ return
+
+ // Re-enable auto-scrolling when arrow keys are used
+ if (listView && listView.enableAutoScroll) {
+ listView.enableAutoScroll()
+ }
+
+ selectedFlatIndex = Math.min(selectedFlatIndex + 1, flatNavigation.length - 1)
+ updateSelectedIdFromIndex()
+ selectionVersion++
+ ensureVisible()
+ }
+
+ function selectNextWrapping() {
+ keyboardNavigationActive = true
+ if (flatNavigation.length === 0)
+ return
+
+ // Re-enable auto-scrolling when arrow keys are used
+ if (listView && listView.enableAutoScroll) {
+ listView.enableAutoScroll()
+ }
+
+ selectedFlatIndex = (selectedFlatIndex + 1) % flatNavigation.length
+ updateSelectedIdFromIndex()
+ selectionVersion++
+ ensureVisible()
+ }
+
+ function selectPrevious() {
+ keyboardNavigationActive = true
+ if (flatNavigation.length === 0)
+ return
+
+ // Re-enable auto-scrolling when arrow keys are used
+ if (listView && listView.enableAutoScroll) {
+ listView.enableAutoScroll()
+ }
+
+ selectedFlatIndex = Math.max(selectedFlatIndex - 1, 0)
+ updateSelectedIdFromIndex()
+ selectionVersion++
+ ensureVisible()
+ }
+
+ function toggleGroupExpanded() {
+ if (flatNavigation.length === 0 || selectedFlatIndex >= flatNavigation.length)
+ return
+
+ const currentItem = flatNavigation[selectedFlatIndex]
+ const groups = NotificationService.groupedNotifications
+ const group = groups[currentItem.groupIndex]
+ if (!group)
+ return
+
+ // Prevent expanding groups with < 2 notifications
+ const notificationCount = group.notifications ? group.notifications.length : 0
+ if (notificationCount < 2)
+ return
+
+ const wasExpanded = NotificationService.expandedGroups[group.key] || false
+ const groupIndex = currentItem.groupIndex
+
+ isTogglingGroup = true
+ NotificationService.toggleGroupExpansion(group.key)
+ rebuildFlatNavigation()
+
+ // Smart selection after toggle
+ if (!wasExpanded) {
+ // Just expanded - move to first notification in the group
+ for (let i = 0; i < flatNavigation.length; i++) {
+ if (flatNavigation[i].type === "notification" && flatNavigation[i].groupIndex === groupIndex) {
+ selectedFlatIndex = i
+ break
+ }
+ }
+ } else {
+ // Just collapsed - stay on the group header
+ for (let i = 0; i < flatNavigation.length; i++) {
+ if (flatNavigation[i].type === "group" && flatNavigation[i].groupIndex === groupIndex) {
+ selectedFlatIndex = i
+ break
+ }
+ }
+ }
+
+ isTogglingGroup = false
+ ensureVisible()
+ }
+
+ function handleEnterKey() {
+ if (flatNavigation.length === 0 || selectedFlatIndex >= flatNavigation.length)
+ return
+
+ const currentItem = flatNavigation[selectedFlatIndex]
+ const groups = NotificationService.groupedNotifications
+ const group = groups[currentItem.groupIndex]
+ if (!group)
+ return
+
+ if (currentItem.type === "group") {
+ const notificationCount = group.notifications ? group.notifications.length : 0
+ if (notificationCount >= 2) {
+ toggleGroupExpanded()
+ } else {
+ executeAction(0)
+ }
+ } else if (currentItem.type === "notification") {
+ executeAction(0)
+ }
+ }
+
+ function toggleTextExpanded() {
+ if (flatNavigation.length === 0 || selectedFlatIndex >= flatNavigation.length)
+ return
+
+ const currentItem = flatNavigation[selectedFlatIndex]
+ const groups = NotificationService.groupedNotifications
+ const group = groups[currentItem.groupIndex]
+ if (!group)
+ return
+
+ let messageId = ""
+
+ if (currentItem.type === "group") {
+ messageId = group.latestNotification?.notification?.id + "_desc"
+ } else if (currentItem.type === "notification" && currentItem.notificationIndex >= 0 && currentItem.notificationIndex < group.notifications.length) {
+ messageId = group.notifications[currentItem.notificationIndex]?.notification?.id + "_desc"
+ }
+
+ if (messageId) {
+ NotificationService.toggleMessageExpansion(messageId)
+ }
+ }
+
+ function executeAction(actionIndex) {
+ if (flatNavigation.length === 0 || selectedFlatIndex >= flatNavigation.length)
+ return
+
+ const currentItem = flatNavigation[selectedFlatIndex]
+ const groups = NotificationService.groupedNotifications
+ const group = groups[currentItem.groupIndex]
+ if (!group)
+ return
+
+ let actions = []
+
+ if (currentItem.type === "group") {
+ actions = group.latestNotification?.actions || []
+ } else if (currentItem.type === "notification" && currentItem.notificationIndex >= 0 && currentItem.notificationIndex < group.notifications.length) {
+ actions = group.notifications[currentItem.notificationIndex]?.actions || []
+ }
+
+ if (actionIndex >= 0 && actionIndex < actions.length) {
+ const action = actions[actionIndex]
+ if (action.invoke) {
+ action.invoke()
+ if (onClose)
+ onClose()
+ }
+ }
+ }
+
+ function clearSelected() {
+ if (flatNavigation.length === 0 || selectedFlatIndex >= flatNavigation.length)
+ return
+
+ const currentItem = flatNavigation[selectedFlatIndex]
+ const groups = NotificationService.groupedNotifications
+ const group = groups[currentItem.groupIndex]
+ if (!group)
+ return
+
+ if (currentItem.type === "group") {
+ NotificationService.dismissGroup(group.key)
+ } else if (currentItem.type === "notification") {
+ const notification = group.notifications[currentItem.notificationIndex]
+ NotificationService.dismissNotification(notification)
+ }
+
+ rebuildFlatNavigation()
+
+ if (flatNavigation.length === 0) {
+ keyboardNavigationActive = false
+ if (listView) {
+ listView.keyboardActive = false
+ }
+ } else {
+ selectedFlatIndex = Math.min(selectedFlatIndex, flatNavigation.length - 1)
+ updateSelectedIdFromIndex()
+ ensureVisible()
+ }
+ }
+
+ function ensureVisible() {
+ if (flatNavigation.length === 0 || selectedFlatIndex >= flatNavigation.length || !listView)
+ return
+
+ const currentItem = flatNavigation[selectedFlatIndex]
+
+ if (keyboardNavigationActive && currentItem && currentItem.groupIndex >= 0) {
+ // Always center the selected item for better visibility
+ // This ensures the selected item stays in view even when new notifications arrive
+ if (currentItem.type === "notification") {
+ // For individual notifications, center on the group but bias towards the notification
+ listView.positionViewAtIndex(currentItem.groupIndex, ListView.Center)
+ } else {
+ // For group headers, center on the group
+ listView.positionViewAtIndex(currentItem.groupIndex, ListView.Center)
+ }
+
+ // Force immediate update
+ listView.forceLayout()
+ }
+ }
+
+ function handleKey(event) {
+ if ((event.key === Qt.Key_Delete || event.key === Qt.Key_Backspace) && (event.modifiers & Qt.ShiftModifier)) {
+ NotificationService.clearAllNotifications()
+ rebuildFlatNavigation()
+ if (flatNavigation.length === 0) {
+ keyboardNavigationActive = false
+ if (listView) {
+ listView.keyboardActive = false
+ }
+ } else {
+ selectedFlatIndex = 0
+ updateSelectedIdFromIndex()
+ }
+ selectionVersion++
+ event.accepted = true
+ return
+ }
+
+ if (event.key === Qt.Key_Escape) {
+ if (keyboardNavigationActive) {
+ keyboardNavigationActive = false
+ event.accepted = true
+ } else {
+ if (onClose)
+ onClose()
+ event.accepted = true
+ }
+ } else if (event.key === Qt.Key_Down || event.key === 16777237) {
+ if (!keyboardNavigationActive) {
+ keyboardNavigationActive = true
+ rebuildFlatNavigation() // Ensure we have fresh navigation data
+ selectedFlatIndex = 0
+ updateSelectedIdFromIndex()
+ // Set keyboardActive on listView to show highlight
+ if (listView) {
+ listView.keyboardActive = true
+ }
+ selectionVersion++
+ ensureVisible()
+ event.accepted = true
+ } else {
+ selectNext()
+ event.accepted = true
+ }
+ } else if (event.key === Qt.Key_Up || event.key === 16777235) {
+ if (!keyboardNavigationActive) {
+ keyboardNavigationActive = true
+ rebuildFlatNavigation() // Ensure we have fresh navigation data
+ selectedFlatIndex = 0
+ updateSelectedIdFromIndex()
+ // Set keyboardActive on listView to show highlight
+ if (listView) {
+ listView.keyboardActive = true
+ }
+ selectionVersion++
+ ensureVisible()
+ event.accepted = true
+ } else if (selectedFlatIndex === 0) {
+ keyboardNavigationActive = false
+ // Reset keyboardActive when navigation is disabled
+ if (listView) {
+ listView.keyboardActive = false
+ }
+ selectionVersion++
+ event.accepted = true
+ return
+ } else {
+ selectPrevious()
+ event.accepted = true
+ }
+ } else if (keyboardNavigationActive) {
+ if (event.key === Qt.Key_Space) {
+ toggleGroupExpanded()
+ event.accepted = true
+ } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
+ handleEnterKey()
+ event.accepted = true
+ } else if (event.key === Qt.Key_E) {
+ toggleTextExpanded()
+ event.accepted = true
+ } else if (event.key === Qt.Key_Delete || event.key === Qt.Key_Backspace) {
+ clearSelected()
+ event.accepted = true
+ } else if (event.key === Qt.Key_Tab) {
+ selectNextWrapping()
+ event.accepted = true
+ } else if (event.key >= Qt.Key_1 && event.key <= Qt.Key_9) {
+ const actionIndex = event.key - Qt.Key_1
+ executeAction(actionIndex)
+ event.accepted = true
+ }
+ }
+
+ if (event.key === Qt.Key_F10) {
+ showKeyboardHints = !showKeyboardHints
+ event.accepted = true
+ }
+ }
+
+ // Get current selection info for UI
+ function getCurrentSelection() {
+ if (!keyboardNavigationActive || selectedFlatIndex < 0 || selectedFlatIndex >= flatNavigation.length) {
+ return {
+ "type": "",
+ "groupIndex": -1,
+ "notificationIndex": -1
+ }
+ }
+ const result = flatNavigation[selectedFlatIndex] || {
+ "type": "",
+ "groupIndex": -1,
+ "notificationIndex": -1
+ }
+ return result
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/Notifications/Center/NotificationKeyboardHints.qml b/quickshell/.config/quickshell/Modules/Notifications/Center/NotificationKeyboardHints.qml
new file mode 100644
index 0000000..8c9c7ff
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/Notifications/Center/NotificationKeyboardHints.qml
@@ -0,0 +1,50 @@
+import QtQuick
+import qs.Common
+import qs.Widgets
+
+Rectangle {
+ id: root
+
+ property bool showHints: false
+
+ height: 80
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.95)
+ border.color: Theme.primary
+ border.width: 2
+ opacity: showHints ? 1 : 0
+ z: 100
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.margins: Theme.spacingS
+ spacing: 2
+
+ StyledText {
+ text: "↑/↓: Nav • Space: Expand • Enter: Action/Expand • E: Text"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ width: parent.width
+ wrapMode: Text.WordWrap
+ horizontalAlignment: Text.AlignHCenter
+ }
+
+ StyledText {
+ text: "Del: Clear • Shift+Del: Clear All • 1-9: Actions • F10: Help • Esc: Close"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ width: parent.width
+ wrapMode: Text.WordWrap
+ horizontalAlignment: Text.AlignHCenter
+ }
+ }
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/Notifications/Center/NotificationSettings.qml b/quickshell/.config/quickshell/Modules/Notifications/Center/NotificationSettings.qml
new file mode 100644
index 0000000..414b9e2
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/Notifications/Center/NotificationSettings.qml
@@ -0,0 +1,255 @@
+import QtQuick
+import QtQuick.Controls
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Rectangle {
+ id: root
+
+ property bool expanded: false
+ readonly property real contentHeight: contentColumn.height + Theme.spacingL * 2
+
+ width: parent.width
+ height: expanded ? Math.min(contentHeight, 400) : 0
+ visible: expanded
+ clip: true
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.3)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.1)
+ border.width: 1
+
+ Behavior on height {
+ NumberAnimation {
+ duration: Anims.durShort
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Anims.emphasized
+ }
+ }
+
+ opacity: expanded ? 1 : 0
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Anims.durShort
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Anims.emphasized
+ }
+ }
+
+ readonly property var timeoutOptions: [{
+ "text": "Never",
+ "value": 0
+ }, {
+ "text": "1 second",
+ "value": 1000
+ }, {
+ "text": "3 seconds",
+ "value": 3000
+ }, {
+ "text": "5 seconds",
+ "value": 5000
+ }, {
+ "text": "8 seconds",
+ "value": 8000
+ }, {
+ "text": "10 seconds",
+ "value": 10000
+ }, {
+ "text": "15 seconds",
+ "value": 15000
+ }, {
+ "text": "30 seconds",
+ "value": 30000
+ }, {
+ "text": "1 minute",
+ "value": 60000
+ }, {
+ "text": "2 minutes",
+ "value": 120000
+ }, {
+ "text": "5 minutes",
+ "value": 300000
+ }, {
+ "text": "10 minutes",
+ "value": 600000
+ }]
+
+ function getTimeoutText(value) {
+ if (value === undefined || value === null || isNaN(value)) {
+ return "5 seconds"
+ }
+
+ for (let i = 0; i < timeoutOptions.length; i++) {
+ if (timeoutOptions[i].value === value) {
+ return timeoutOptions[i].text
+ }
+ }
+ if (value === 0) {
+ return "Never"
+ }
+ if (value < 1000) {
+ return value + "ms"
+ }
+ if (value < 60000) {
+ return Math.round(value / 1000) + " seconds"
+ }
+ return Math.round(value / 60000) + " minutes"
+ }
+
+ Column {
+ id: contentColumn
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingM
+
+ StyledText {
+ text: "Notification Settings"
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: Font.Bold
+ color: Theme.surfaceText
+ }
+
+ Item {
+ width: parent.width
+ height: 36
+
+ Row {
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: SessionData.doNotDisturb ? "notifications_off" : "notifications"
+ size: Theme.iconSizeSmall
+ color: SessionData.doNotDisturb ? Theme.error : Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Do Not Disturb"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ DankToggle {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ checked: SessionData.doNotDisturb
+ onToggled: SessionData.setDoNotDisturb(!SessionData.doNotDisturb)
+ }
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 1
+ color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.1)
+ }
+
+ StyledText {
+ text: "Notification Timeouts"
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ color: Theme.surfaceVariantText
+ }
+
+ DankDropdown {
+ width: parent.width
+ text: "Low Priority"
+ description: "Timeout for low priority notifications"
+ currentValue: getTimeoutText(SettingsData.notificationTimeoutLow)
+ options: timeoutOptions.map(opt => opt.text)
+ onValueChanged: value => {
+ for (let i = 0; i < timeoutOptions.length; i++) {
+ if (timeoutOptions[i].text === value) {
+ SettingsData.setNotificationTimeoutLow(timeoutOptions[i].value)
+ break
+ }
+ }
+ }
+ }
+
+ DankDropdown {
+ width: parent.width
+ text: "Normal Priority"
+ description: "Timeout for normal priority notifications"
+ currentValue: getTimeoutText(SettingsData.notificationTimeoutNormal)
+ options: timeoutOptions.map(opt => opt.text)
+ onValueChanged: value => {
+ for (let i = 0; i < timeoutOptions.length; i++) {
+ if (timeoutOptions[i].text === value) {
+ SettingsData.setNotificationTimeoutNormal(timeoutOptions[i].value)
+ break
+ }
+ }
+ }
+ }
+
+ DankDropdown {
+ width: parent.width
+ text: "Critical Priority"
+ description: "Timeout for critical priority notifications"
+ currentValue: getTimeoutText(SettingsData.notificationTimeoutCritical)
+ options: timeoutOptions.map(opt => opt.text)
+ onValueChanged: value => {
+ for (let i = 0; i < timeoutOptions.length; i++) {
+ if (timeoutOptions[i].text === value) {
+ SettingsData.setNotificationTimeoutCritical(timeoutOptions[i].value)
+ break
+ }
+ }
+ }
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 1
+ color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.1)
+ }
+
+ Item {
+ width: parent.width
+ height: 36
+
+ Row {
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "notifications_active"
+ size: Theme.iconSizeSmall
+ color: SettingsData.notificationOverlayEnabled ? Theme.primary : Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ spacing: 2
+ anchors.verticalCenter: parent.verticalCenter
+
+ StyledText {
+ text: "Notification Overlay"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ }
+
+ StyledText {
+ text: "Display all priorities over fullscreen apps"
+ font.pixelSize: Theme.fontSizeSmall - 1
+ color: Theme.surfaceVariantText
+ }
+ }
+ }
+
+ DankToggle {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ checked: SettingsData.notificationOverlayEnabled
+ onToggled: toggled => SettingsData.setNotificationOverlayEnabled(toggled)
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/Notifications/Popup/NotificationPopup.qml b/quickshell/.config/quickshell/Modules/Notifications/Popup/NotificationPopup.qml
new file mode 100644
index 0000000..79a2aeb
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/Notifications/Popup/NotificationPopup.qml
@@ -0,0 +1,586 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell
+import Quickshell.Wayland
+import Quickshell.Widgets
+import Quickshell.Services.Notifications
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+PanelWindow {
+ id: win
+
+ required property var notificationData
+ required property string notificationId
+ readonly property bool hasValidData: notificationData && notificationData.notification
+ property int screenY: 0
+ property bool exiting: false
+ property bool _isDestroying: false
+ property bool _finalized: false
+
+ signal entered
+ signal exitFinished
+
+ function startExit() {
+ if (exiting || _isDestroying) {
+ return
+ }
+ exiting = true
+ exitAnim.restart()
+ exitWatchdog.restart()
+ if (NotificationService.removeFromVisibleNotifications)
+ NotificationService.removeFromVisibleNotifications(win.notificationData)
+ }
+
+ function forceExit() {
+ if (_isDestroying) {
+ return
+ }
+ _isDestroying = true
+ exiting = true
+ visible = false
+ exitWatchdog.stop()
+ finalizeExit("forced")
+ }
+
+ function finalizeExit(reason) {
+ if (_finalized) {
+ return
+ }
+
+ _finalized = true
+ _isDestroying = true
+ exitWatchdog.stop()
+ wrapperConn.enabled = false
+ wrapperConn.target = null
+ win.exitFinished()
+ }
+
+ visible: hasValidData
+ WlrLayershell.layer: {
+ if (!notificationData)
+ return WlrLayershell.Top
+
+ SettingsData.notificationOverlayEnabled
+
+ const shouldUseOverlay = (SettingsData.notificationOverlayEnabled) || (notificationData.urgency === NotificationUrgency.Critical)
+
+ return shouldUseOverlay ? WlrLayershell.Overlay : WlrLayershell.Top
+ }
+ WlrLayershell.exclusiveZone: -1
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
+ color: "transparent"
+ implicitWidth: 400
+ implicitHeight: 122
+ onScreenYChanged: margins.top = Theme.barHeight - 4 + SettingsData.topBarSpacing + 4 + screenY
+ onHasValidDataChanged: {
+ if (!hasValidData && !exiting && !_isDestroying) {
+ forceExit()
+ }
+ }
+ Component.onCompleted: {
+ if (hasValidData) {
+ Qt.callLater(() => enterX.restart())
+ } else {
+ forceExit()
+ }
+ }
+ onNotificationDataChanged: {
+ if (!_isDestroying) {
+ wrapperConn.target = win.notificationData || null
+ notificationConn.target = (win.notificationData && win.notificationData.notification && win.notificationData.notification.Retainable) || null
+ }
+ }
+ onEntered: {
+ if (!_isDestroying) {
+ enterDelay.start()
+ }
+ }
+ Component.onDestruction: {
+ _isDestroying = true
+ exitWatchdog.stop()
+ if (notificationData && notificationData.timer) {
+ notificationData.timer.stop()
+ }
+ }
+
+ anchors {
+ top: true
+ right: true
+ }
+
+ margins {
+ top: Theme.barHeight - 4 + SettingsData.topBarSpacing + 4
+ right: 12
+ }
+
+ Item {
+ id: content
+
+ anchors.fill: parent
+ visible: win.hasValidData
+ layer.enabled: (enterX.running || exitAnim.running)
+ layer.smooth: true
+
+ Rectangle {
+ property var shadowLayers: [shadowLayer1, shadowLayer2, shadowLayer3]
+
+ anchors.fill: parent
+ anchors.margins: 4
+ radius: Theme.cornerRadius
+ color: Theme.popupBackground()
+ border.color: notificationData && notificationData.urgency === NotificationUrgency.Critical ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.3) : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
+ border.width: notificationData && notificationData.urgency === NotificationUrgency.Critical ? 2 : 1
+ clip: true
+
+ Rectangle {
+ id: shadowLayer1
+
+ anchors.fill: parent
+ anchors.margins: -3
+ color: "transparent"
+ radius: parent.radius + 3
+ border.color: Qt.rgba(0, 0, 0, 0.05)
+ border.width: 1
+ z: -3
+ }
+
+ Rectangle {
+ id: shadowLayer2
+
+ anchors.fill: parent
+ anchors.margins: -2
+ color: "transparent"
+ radius: parent.radius + 2
+ border.color: Qt.rgba(0, 0, 0, 0.08)
+ border.width: 1
+ z: -2
+ }
+
+ Rectangle {
+ id: shadowLayer3
+
+ anchors.fill: parent
+ color: "transparent"
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
+ border.width: 1
+ radius: parent.radius
+ z: -1
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ radius: parent.radius
+ visible: notificationData && notificationData.urgency === NotificationUrgency.Critical
+ opacity: 1
+
+ gradient: Gradient {
+ orientation: Gradient.Horizontal
+
+ GradientStop {
+ position: 0
+ color: Theme.primary
+ }
+
+ GradientStop {
+ position: 0.02
+ color: Theme.primary
+ }
+
+ GradientStop {
+ position: 0.021
+ color: "transparent"
+ }
+ }
+ }
+
+ Item {
+ id: notificationContent
+
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.topMargin: 12
+ anchors.leftMargin: 16
+ anchors.rightMargin: 56
+ height: 98
+
+ Rectangle {
+ id: iconContainer
+
+ readonly property bool hasNotificationImage: notificationData && notificationData.image && notificationData.image !== ""
+
+ width: 55
+ height: 55
+ radius: 27.5
+ color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1)
+ border.color: "transparent"
+ border.width: 0
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+
+ IconImage {
+ id: iconImage
+
+ anchors.fill: parent
+ anchors.margins: 2
+ asynchronous: true
+ source: {
+ if (!notificationData)
+ return ""
+
+ if (parent.hasNotificationImage)
+ return notificationData.cleanImage || ""
+
+ if (notificationData.appIcon) {
+ const appIcon = notificationData.appIcon
+ if (appIcon.startsWith("file://") || appIcon.startsWith("http://") || appIcon.startsWith("https://"))
+ return appIcon
+
+ return Quickshell.iconPath(appIcon, true)
+ }
+ return ""
+ }
+ visible: status === Image.Ready
+ }
+
+ StyledText {
+ anchors.centerIn: parent
+ visible: !parent.hasNotificationImage && (!notificationData || !notificationData.appIcon || notificationData.appIcon === "")
+ text: {
+ const appName = notificationData && notificationData.appName ? notificationData.appName : "?"
+ return appName.charAt(0).toUpperCase()
+ }
+ font.pixelSize: 20
+ font.weight: Font.Bold
+ color: Theme.primaryText
+ }
+ }
+
+ Rectangle {
+ id: textContainer
+
+ anchors.left: iconContainer.right
+ anchors.leftMargin: 12
+ anchors.right: parent.right
+ anchors.rightMargin: 0
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: 8
+ color: "transparent"
+
+ Item {
+ width: parent.width
+ height: parent.height
+ anchors.top: parent.top
+ anchors.topMargin: -2
+
+ Column {
+ width: parent.width
+ spacing: 2
+
+ StyledText {
+ width: parent.width
+ text: {
+ if (!notificationData)
+ return ""
+
+ const appName = notificationData.appName || ""
+ const timeStr = notificationData.timeStr || ""
+ if (timeStr.length > 0)
+ return appName + " • " + timeStr
+ else
+ return appName
+ }
+ color: Theme.surfaceVariantText
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ elide: Text.ElideRight
+ maximumLineCount: 1
+ }
+
+ StyledText {
+ text: notificationData ? (notificationData.summary || "") : ""
+ color: Theme.surfaceText
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: Font.Medium
+ width: parent.width
+ elide: Text.ElideRight
+ maximumLineCount: 1
+ visible: text.length > 0
+ }
+
+ StyledText {
+ text: notificationData ? (notificationData.htmlBody || "") : ""
+ color: Theme.surfaceVariantText
+ font.pixelSize: Theme.fontSizeSmall
+ width: parent.width
+ elide: Text.ElideRight
+ maximumLineCount: 2
+ wrapMode: Text.WordWrap
+ visible: text.length > 0
+ linkColor: Theme.primary
+ onLinkActivated: link => {
+ return Qt.openUrlExternally(link)
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ acceptedButtons: Qt.NoButton
+ cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor
+ }
+ }
+ }
+ }
+ }
+ }
+
+ DankActionButton {
+ id: closeButton
+
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.topMargin: 12
+ anchors.rightMargin: 16
+ iconName: "close"
+ iconSize: 18
+ buttonSize: 28
+ z: 15
+ onClicked: {
+ if (notificationData && !win.exiting)
+ notificationData.popup = false
+ }
+ }
+
+ Row {
+ anchors.right: clearButton.left
+ anchors.rightMargin: 8
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: 8
+ spacing: 8
+ z: 20
+
+ Repeater {
+ model: notificationData ? (notificationData.actions || []) : []
+
+ Rectangle {
+ property bool isHovered: false
+
+ width: Math.max(actionText.implicitWidth + 12, 50)
+ height: 24
+ radius: 4
+ color: isHovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1) : "transparent"
+
+ StyledText {
+ id: actionText
+
+ text: modelData.text || "View"
+ color: parent.isHovered ? Theme.primary : Theme.surfaceVariantText
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ anchors.centerIn: parent
+ elide: Text.ElideRight
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ acceptedButtons: Qt.LeftButton
+ onEntered: parent.isHovered = true
+ onExited: parent.isHovered = false
+ onClicked: {
+ if (modelData && modelData.invoke)
+ modelData.invoke()
+
+ if (notificationData && !win.exiting)
+ notificationData.popup = false
+ }
+ }
+ }
+ }
+ }
+
+ Rectangle {
+ id: clearButton
+
+ property bool isHovered: false
+
+ anchors.right: parent.right
+ anchors.rightMargin: 16
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: 8
+ width: Math.max(clearText.implicitWidth + 12, 50)
+ height: 24
+ radius: 4
+ color: isHovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1) : "transparent"
+ z: 20
+
+ StyledText {
+ id: clearText
+
+ text: "Clear"
+ color: clearButton.isHovered ? Theme.primary : Theme.surfaceVariantText
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ anchors.centerIn: parent
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ acceptedButtons: Qt.LeftButton
+ onEntered: clearButton.isHovered = true
+ onExited: clearButton.isHovered = false
+ onClicked: {
+ if (notificationData && !win.exiting)
+ NotificationService.dismissNotification(notificationData)
+ }
+ }
+ }
+
+ MouseArea {
+ id: cardHoverArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ acceptedButtons: Qt.LeftButton
+ propagateComposedEvents: true
+ z: -1
+ onEntered: {
+ if (notificationData && notificationData.timer)
+ notificationData.timer.stop()
+ }
+ onExited: {
+ if (notificationData && notificationData.popup && notificationData.timer)
+ notificationData.timer.restart()
+ }
+ onClicked: {
+ if (notificationData && !win.exiting)
+ notificationData.popup = false
+ }
+ }
+ }
+
+ transform: Translate {
+ id: tx
+
+ x: Anims.slidePx
+ }
+ }
+
+ NumberAnimation {
+ id: enterX
+
+ target: tx
+ property: "x"
+ from: Anims.slidePx
+ to: 0
+ duration: Anims.durMed
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Anims.emphasizedDecel
+ onStopped: {
+ if (!win.exiting && !win._isDestroying && Math.abs(tx.x) < 0.5) {
+ win.entered()
+ }
+ }
+ }
+
+ ParallelAnimation {
+ id: exitAnim
+
+ onStopped: finalizeExit("animStopped")
+
+ PropertyAnimation {
+ target: tx
+ property: "x"
+ from: 0
+ to: Anims.slidePx
+ duration: Anims.durShort
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Anims.emphasizedAccel
+ }
+
+ NumberAnimation {
+ target: content
+ property: "opacity"
+ from: 1
+ to: 0
+ duration: Anims.durShort
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Anims.standardAccel
+ }
+
+ NumberAnimation {
+ target: content
+ property: "scale"
+ from: 1
+ to: 0.98
+ duration: Anims.durShort
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Anims.emphasizedAccel
+ }
+ }
+
+ Connections {
+ id: wrapperConn
+
+ function onPopupChanged() {
+ if (!win.notificationData || win._isDestroying)
+ return
+
+ if (!win.notificationData.popup && !win.exiting)
+ startExit()
+ }
+
+ target: win.notificationData || null
+ ignoreUnknownSignals: true
+ enabled: !win._isDestroying
+ }
+
+ Connections {
+ id: notificationConn
+
+ function onDropped() {
+ if (!win._isDestroying && !win.exiting)
+ forceExit()
+ }
+
+ target: (win.notificationData && win.notificationData.notification && win.notificationData.notification.Retainable) || null
+ ignoreUnknownSignals: true
+ enabled: !win._isDestroying
+ }
+
+ Timer {
+ id: enterDelay
+
+ interval: 160
+ repeat: false
+ onTriggered: {
+ if (notificationData && notificationData.timer && !exiting && !_isDestroying)
+ notificationData.timer.start()
+ }
+ }
+
+ Timer {
+ id: exitWatchdog
+
+ interval: 600
+ repeat: false
+ onTriggered: finalizeExit("watchdog")
+ }
+
+ Behavior on screenY {
+ id: screenYAnim
+
+ enabled: !exiting && !_isDestroying
+
+ NumberAnimation {
+ duration: Anims.durShort
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Anims.standardDecel
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/Notifications/Popup/NotificationPopupManager.qml b/quickshell/.config/quickshell/Modules/Notifications/Popup/NotificationPopupManager.qml
new file mode 100644
index 0000000..aad4f7c
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/Notifications/Popup/NotificationPopupManager.qml
@@ -0,0 +1,271 @@
+import QtQuick
+import Quickshell
+import qs.Common
+import qs.Services
+
+QtObject {
+ id: manager
+
+ property var modelData
+ property int topMargin: 0
+ property int baseNotificationHeight: 120
+ property int maxTargetNotifications: 4
+ property var popupWindows: [] // strong refs to windows (live until exitFinished)
+ property var destroyingWindows: new Set()
+ property Component popupComponent
+
+ popupComponent: Component {
+ NotificationPopup {
+ onEntered: manager._onPopupEntered(this)
+ onExitFinished: manager._onPopupExitFinished(this)
+ }
+ }
+
+ property Connections notificationConnections
+
+ notificationConnections: Connections {
+ function onVisibleNotificationsChanged() {
+ manager._sync(NotificationService.visibleNotifications)
+ }
+
+ target: NotificationService
+ }
+
+ property Timer sweeper
+
+ sweeper: Timer {
+ interval: 500
+ running: false
+ repeat: true
+ onTriggered: {
+ const toRemove = []
+ for (const p of popupWindows) {
+ if (!p) {
+ toRemove.push(p)
+ continue
+ }
+ const isZombie = p.status === Component.Null || (!p.visible && !p.exiting) || (!p.notificationData && !p._isDestroying) || (!p.hasValidData && !p._isDestroying)
+ if (isZombie) {
+ toRemove.push(p)
+ if (p.forceExit) {
+ p.forceExit()
+ } else if (p.destroy) {
+ try {
+ p.destroy()
+ } catch (e) {
+
+ }
+ }
+ }
+ }
+ if (toRemove.length) {
+ popupWindows = popupWindows.filter(p => toRemove.indexOf(p) === -1)
+ const survivors = _active().sort((a, b) => a.screenY - b.screenY)
+ for (let k = 0; k < survivors.length; ++k) {
+ survivors[k].screenY = topMargin + k * baseNotificationHeight
+ }
+ }
+ if (popupWindows.length === 0) {
+ sweeper.stop()
+ }
+ }
+ }
+
+ function _hasWindowFor(w) {
+ return popupWindows.some(p => p && p.notificationData === w && !p._isDestroying && p.status !== Component.Null)
+ }
+
+ function _isValidWindow(p) {
+ return p && p.status !== Component.Null && !p._isDestroying && p.hasValidData
+ }
+
+ function _canMakeRoomFor(wrapper) {
+ const activeWindows = _active()
+ if (activeWindows.length < maxTargetNotifications) {
+ return true
+ }
+ if (!wrapper || !wrapper.notification) {
+ return false
+ }
+ const incomingUrgency = wrapper.notification.urgency || 0
+ for (const p of activeWindows) {
+ if (!p.notificationData || !p.notificationData.notification) {
+ continue
+ }
+ const existingUrgency = p.notificationData.notification.urgency || 0
+ if (existingUrgency < incomingUrgency) {
+ return true
+ }
+ if (existingUrgency === incomingUrgency) {
+ const timer = p.notificationData.timer
+ if (timer && !timer.running) {
+ return true
+ }
+ }
+ }
+ return false
+ }
+
+ function _makeRoomForNew(wrapper) {
+ const activeWindows = _active()
+ if (activeWindows.length < maxTargetNotifications) {
+ return
+ }
+ const toRemove = _selectPopupToRemove(activeWindows, wrapper)
+ if (toRemove && !toRemove.exiting) {
+ toRemove.notificationData.removedByLimit = true
+ toRemove.notificationData.popup = false
+ if (toRemove.notificationData.timer) {
+ toRemove.notificationData.timer.stop()
+ }
+ }
+ }
+
+ function _selectPopupToRemove(activeWindows, incomingWrapper) {
+ const incomingUrgency = (incomingWrapper && incomingWrapper.notification) ? incomingWrapper.notification.urgency || 0 : 0
+ const sortedWindows = activeWindows.slice().sort((a, b) => {
+ const aUrgency = (a.notificationData && a.notificationData.notification) ? a.notificationData.notification.urgency || 0 : 0
+ const bUrgency = (b.notificationData && b.notificationData.notification) ? b.notificationData.notification.urgency || 0 : 0
+ if (aUrgency !== bUrgency) {
+ return aUrgency - bUrgency
+ }
+ const aTimer = a.notificationData && a.notificationData.timer
+ const bTimer = b.notificationData && b.notificationData.timer
+ const aRunning = aTimer && aTimer.running
+ const bRunning = bTimer && bTimer.running
+ if (aRunning !== bRunning) {
+ return aRunning ? 1 : -1
+ }
+ return b.screenY - a.screenY
+ })
+ return sortedWindows[0]
+ }
+
+ function _sync(newWrappers) {
+ for (const w of newWrappers) {
+ if (w && !_hasWindowFor(w)) {
+ insertNewestAtTop(w)
+ }
+ }
+ for (const p of popupWindows.slice()) {
+ if (!_isValidWindow(p)) {
+ continue
+ }
+ if (p.notificationData && newWrappers.indexOf(p.notificationData) === -1 && !p.exiting) {
+ p.notificationData.removedByLimit = true
+ p.notificationData.popup = false
+ }
+ }
+ }
+
+ function insertNewestAtTop(wrapper) {
+ if (!wrapper) {
+ return
+ }
+ for (const p of popupWindows) {
+ if (!_isValidWindow(p)) {
+ continue
+ }
+ if (p.exiting) {
+ continue
+ }
+ p.screenY = p.screenY + baseNotificationHeight
+ }
+ const notificationId = wrapper && wrapper.notification ? wrapper.notification.id : ""
+ const win = popupComponent.createObject(null, {
+ "notificationData": wrapper,
+ "notificationId": notificationId,
+ "screenY": topMargin,
+ "screen": manager.modelData
+ })
+ if (!win) {
+ return
+ }
+ if (!win.hasValidData) {
+ win.destroy()
+ return
+ }
+ popupWindows.push(win)
+ if (!sweeper.running) {
+ sweeper.start()
+ }
+ }
+
+ function _active() {
+ return popupWindows.filter(p => _isValidWindow(p) && p.notificationData && p.notificationData.popup && !p.exiting)
+ }
+
+ function _bottom() {
+ let b = null
+ let maxY = -1
+ for (const p of _active()) {
+ if (p.screenY > maxY) {
+ maxY = p.screenY
+ b = p
+ }
+ }
+ return b
+ }
+
+ function _onPopupEntered(p) {}
+
+ function _onPopupExitFinished(p) {
+ if (!p) {
+ return
+ }
+ const windowId = p.toString()
+ if (destroyingWindows.has(windowId)) {
+ return
+ }
+ destroyingWindows.add(windowId)
+ const i = popupWindows.indexOf(p)
+ if (i !== -1) {
+ popupWindows.splice(i, 1)
+ popupWindows = popupWindows.slice()
+ }
+ if (NotificationService.releaseWrapper && p.notificationData) {
+ NotificationService.releaseWrapper(p.notificationData)
+ }
+ Qt.callLater(() => {
+ if (p && p.destroy) {
+ try {
+ p.destroy()
+ } catch (e) {
+
+ }
+ }
+ Qt.callLater(() => destroyingWindows.delete(windowId))
+ })
+ const survivors = _active().sort((a, b) => a.screenY - b.screenY)
+ for (let k = 0; k < survivors.length; ++k) {
+ survivors[k].screenY = topMargin + k * baseNotificationHeight
+ }
+ }
+
+ function cleanupAllWindows() {
+ sweeper.stop()
+ for (const p of popupWindows.slice()) {
+ if (p) {
+ try {
+ if (p.forceExit) {
+ p.forceExit()
+ } else if (p.destroy) {
+ p.destroy()
+ }
+ } catch (e) {
+
+ }
+ }
+ }
+ popupWindows = []
+ destroyingWindows.clear()
+ }
+
+ onPopupWindowsChanged: {
+ if (popupWindows.length > 0 && !sweeper.running) {
+ sweeper.start()
+ } else if (popupWindows.length === 0 && sweeper.running) {
+ sweeper.stop()
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/OSD/BrightnessOSD.qml b/quickshell/.config/quickshell/Modules/OSD/BrightnessOSD.qml
new file mode 100644
index 0000000..8e32f0d
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/OSD/BrightnessOSD.qml
@@ -0,0 +1,134 @@
+import QtQuick
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+DankOSD {
+ id: root
+
+ osdWidth: Math.min(260, Screen.width - Theme.spacingM * 2)
+ osdHeight: 40 + Theme.spacingS * 2
+ autoHideInterval: 3000
+ enableMouseInteraction: true
+
+ property var brightnessDebounceTimer: Timer {
+ property int pendingValue: 0
+
+ interval: {
+ const deviceInfo = DisplayService.getCurrentDeviceInfo()
+ return (deviceInfo && deviceInfo.class === "ddc") ? 200 : 50
+ }
+ repeat: false
+ onTriggered: {
+ DisplayService.setBrightnessInternal(pendingValue, DisplayService.lastIpcDevice)
+ }
+ }
+
+ Connections {
+ target: DisplayService
+ function onBrightnessChanged() {
+ root.show()
+ }
+ }
+
+ content: Item {
+ anchors.fill: parent
+
+ Item {
+ property int gap: Theme.spacingS
+
+ anchors.centerIn: parent
+ width: parent.width - Theme.spacingS * 2
+ height: 40
+
+ Rectangle {
+ width: Theme.iconSize
+ height: Theme.iconSize
+ radius: Theme.iconSize / 2
+ color: "transparent"
+ x: parent.gap
+ anchors.verticalCenter: parent.verticalCenter
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: {
+ const deviceInfo = DisplayService.getCurrentDeviceInfo()
+ if (!deviceInfo || deviceInfo.class === "backlight" || deviceInfo.class === "ddc") {
+ return "brightness_medium"
+ } else if (deviceInfo.name.includes("kbd")) {
+ return "keyboard"
+ } else {
+ return "lightbulb"
+ }
+ }
+ size: Theme.iconSize
+ color: Theme.primary
+ }
+ }
+
+ DankSlider {
+ id: brightnessSlider
+
+ width: parent.width - Theme.iconSize - parent.gap * 3
+ height: 40
+ x: parent.gap * 2 + Theme.iconSize
+ anchors.verticalCenter: parent.verticalCenter
+ minimum: 1
+ maximum: 100
+ enabled: DisplayService.brightnessAvailable
+ showValue: true
+ unit: "%"
+
+ Component.onCompleted: {
+ if (DisplayService.brightnessAvailable) {
+ value = DisplayService.brightnessLevel
+ }
+ }
+
+ onSliderValueChanged: newValue => {
+ if (DisplayService.brightnessAvailable) {
+ root.brightnessDebounceTimer.pendingValue = newValue
+ root.brightnessDebounceTimer.restart()
+ resetHideTimer()
+ }
+ }
+
+ onContainsMouseChanged: {
+ setChildHovered(containsMouse)
+ }
+
+ onSliderDragFinished: finalValue => {
+ if (DisplayService.brightnessAvailable) {
+ root.brightnessDebounceTimer.stop()
+ DisplayService.setBrightnessInternal(finalValue, DisplayService.lastIpcDevice)
+ }
+ }
+
+ Connections {
+ target: DisplayService
+
+ function onBrightnessChanged() {
+ if (!brightnessSlider.pressed) {
+ brightnessSlider.value = DisplayService.brightnessLevel
+ }
+ }
+
+ function onDeviceSwitched() {
+ if (!brightnessSlider.pressed) {
+ brightnessSlider.value = DisplayService.brightnessLevel
+ }
+ }
+ }
+ }
+ }
+ }
+
+ onOsdShown: {
+ if (DisplayService.brightnessAvailable && contentLoader.item) {
+ const slider = contentLoader.item.children[0].children[1]
+ if (slider) {
+ slider.value = DisplayService.brightnessLevel
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/OSD/IdleInhibitorOSD.qml b/quickshell/.config/quickshell/Modules/OSD/IdleInhibitorOSD.qml
new file mode 100644
index 0000000..cbf4ab4
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/OSD/IdleInhibitorOSD.qml
@@ -0,0 +1,27 @@
+import QtQuick
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+DankOSD {
+ id: root
+
+ osdWidth: Theme.iconSize + Theme.spacingS * 2
+ osdHeight: Theme.iconSize + Theme.spacingS * 2
+ autoHideInterval: 2000
+ enableMouseInteraction: false
+
+ Connections {
+ target: SessionService
+ function onInhibitorChanged() {
+ root.show()
+ }
+ }
+
+ content: DankIcon {
+ anchors.centerIn: parent
+ name: SessionService.idleInhibited ? "motion_sensor_active" : "motion_sensor_idle"
+ size: Theme.iconSize
+ color: SessionService.idleInhibited ? Theme.primary : Theme.outline
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/OSD/MicMuteOSD.qml b/quickshell/.config/quickshell/Modules/OSD/MicMuteOSD.qml
new file mode 100644
index 0000000..d60db81
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/OSD/MicMuteOSD.qml
@@ -0,0 +1,27 @@
+import QtQuick
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+DankOSD {
+ id: root
+
+ osdWidth: Theme.iconSize + Theme.spacingS * 2
+ osdHeight: Theme.iconSize + Theme.spacingS * 2
+ autoHideInterval: 2000
+ enableMouseInteraction: false
+
+ Connections {
+ target: AudioService
+ function onMicMuteChanged() {
+ root.show()
+ }
+ }
+
+ content: DankIcon {
+ anchors.centerIn: parent
+ name: AudioService.source && AudioService.source.audio && AudioService.source.audio.muted ? "mic_off" : "mic"
+ size: Theme.iconSize
+ color: AudioService.source && AudioService.source.audio && AudioService.source.audio.muted ? Theme.error : Theme.primary
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/OSD/VolumeOSD.qml b/quickshell/.config/quickshell/Modules/OSD/VolumeOSD.qml
new file mode 100644
index 0000000..9550595
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/OSD/VolumeOSD.qml
@@ -0,0 +1,120 @@
+import QtQuick
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+DankOSD {
+ id: root
+
+ osdWidth: Math.min(260, Screen.width - Theme.spacingM * 2)
+ osdHeight: 40 + Theme.spacingS * 2
+ autoHideInterval: 3000
+ enableMouseInteraction: true
+
+ Connections {
+ target: AudioService
+
+ function onVolumeChanged() {
+ root.show()
+ }
+
+ function onSinkChanged() {
+ if (root.shouldBeVisible) {
+ root.show()
+ }
+ }
+ }
+
+ content: Item {
+ anchors.fill: parent
+
+ Item {
+ property int gap: Theme.spacingS
+
+ anchors.centerIn: parent
+ width: parent.width - Theme.spacingS * 2
+ height: 40
+
+ Rectangle {
+ width: Theme.iconSize
+ height: Theme.iconSize
+ radius: Theme.iconSize / 2
+ color: "transparent"
+ x: parent.gap
+ anchors.verticalCenter: parent.verticalCenter
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: AudioService.sink && AudioService.sink.audio && AudioService.sink.audio.muted ? "volume_off" : "volume_up"
+ size: Theme.iconSize
+ color: muteButton.containsMouse ? Theme.primary : Theme.surfaceText
+ }
+
+ MouseArea {
+ id: muteButton
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ AudioService.toggleMute()
+ resetHideTimer()
+ }
+ onContainsMouseChanged: {
+ setChildHovered(containsMouse || volumeSlider.containsMouse)
+ }
+ }
+ }
+
+ DankSlider {
+ id: volumeSlider
+
+ width: parent.width - Theme.iconSize - parent.gap * 3
+ height: 40
+ x: parent.gap * 2 + Theme.iconSize
+ anchors.verticalCenter: parent.verticalCenter
+ minimum: 0
+ maximum: 100
+ enabled: AudioService.sink && AudioService.sink.audio
+ showValue: true
+ unit: "%"
+
+ Component.onCompleted: {
+ if (AudioService.sink && AudioService.sink.audio) {
+ value = Math.round(AudioService.sink.audio.volume * 100)
+ }
+ }
+
+ onSliderValueChanged: newValue => {
+ if (AudioService.sink && AudioService.sink.audio) {
+ AudioService.sink.audio.volume = newValue / 100
+ resetHideTimer()
+ }
+ }
+
+ onContainsMouseChanged: {
+ setChildHovered(containsMouse || muteButton.containsMouse)
+ }
+
+ Connections {
+ target: AudioService.sink && AudioService.sink.audio ? AudioService.sink.audio : null
+
+ function onVolumeChanged() {
+ if (volumeSlider && !volumeSlider.pressed) {
+ volumeSlider.value = Math.round(AudioService.sink.audio.volume * 100)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ onOsdShown: {
+ if (AudioService.sink && AudioService.sink.audio && contentLoader.item) {
+ const slider = contentLoader.item.children[0].children[1]
+ if (slider) {
+ slider.value = Math.round(AudioService.sink.audio.volume * 100)
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/ProcessList/PerformanceTab.qml b/quickshell/.config/quickshell/Modules/ProcessList/PerformanceTab.qml
new file mode 100644
index 0000000..e18c314
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/ProcessList/PerformanceTab.qml
@@ -0,0 +1,483 @@
+import QtQuick
+import QtQuick.Controls
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Column {
+ function formatNetworkSpeed(bytesPerSec) {
+ if (bytesPerSec < 1024) {
+ return bytesPerSec.toFixed(0) + " B/s";
+ } else if (bytesPerSec < 1024 * 1024) {
+ return (bytesPerSec / 1024).toFixed(1) + " KB/s";
+ } else if (bytesPerSec < 1024 * 1024 * 1024) {
+ return (bytesPerSec / (1024 * 1024)).toFixed(1) + " MB/s";
+ } else {
+ return (bytesPerSec / (1024 * 1024 * 1024)).toFixed(1) + " GB/s";
+ }
+ }
+
+ function formatDiskSpeed(bytesPerSec) {
+ if (bytesPerSec < 1024 * 1024) {
+ return (bytesPerSec / 1024).toFixed(1) + " KB/s";
+ } else if (bytesPerSec < 1024 * 1024 * 1024) {
+ return (bytesPerSec / (1024 * 1024)).toFixed(1) + " MB/s";
+ } else {
+ return (bytesPerSec / (1024 * 1024 * 1024)).toFixed(1) + " GB/s";
+ }
+ }
+
+ anchors.fill: parent
+ spacing: Theme.spacingM
+ Component.onCompleted: {
+ DgopService.addRef(["cpu", "memory", "network", "disk"]);
+ }
+ Component.onDestruction: {
+ DgopService.removeRef(["cpu", "memory", "network", "disk"]);
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 200
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.04)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.06)
+ border.width: 1
+
+ Column {
+ anchors.fill: parent
+ anchors.margins: Theme.spacingM
+ spacing: Theme.spacingS
+
+ Row {
+ width: parent.width
+ height: 32
+ spacing: Theme.spacingM
+
+ StyledText {
+ text: "CPU"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Bold
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Rectangle {
+ width: 80
+ height: 24
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12)
+ anchors.verticalCenter: parent.verticalCenter
+
+ StyledText {
+ text: `${DgopService.cpuUsage.toFixed(1)}%`
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Bold
+ color: Theme.primary
+ anchors.centerIn: parent
+ }
+
+ }
+
+ Item {
+ width: parent.width - 280
+ height: 1
+ }
+
+ StyledText {
+ text: `${DgopService.cpuCores} cores`
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ }
+
+ DankFlickable {
+ clip: true
+ width: parent.width
+ height: parent.height - 40
+ contentHeight: coreUsageColumn.implicitHeight
+
+ Column {
+ id: coreUsageColumn
+
+ width: parent.width
+ spacing: 6
+
+ Repeater {
+ model: DgopService.perCoreCpuUsage
+
+ Row {
+ width: parent.width
+ height: 20
+ spacing: Theme.spacingS
+
+ StyledText {
+ text: `C${index}`
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ width: 24
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Rectangle {
+ width: parent.width - 80
+ height: 6
+ radius: 3
+ color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
+ anchors.verticalCenter: parent.verticalCenter
+
+ Rectangle {
+ width: parent.width * Math.min(1, modelData / 100)
+ height: parent.height
+ radius: parent.radius
+ color: {
+ const usage = modelData;
+ if (usage > 80) {
+ return Theme.error;
+ }
+ if (usage > 60) {
+ return Theme.warning;
+ }
+ return Theme.primary;
+ }
+
+ Behavior on width {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ }
+
+ }
+
+ }
+
+ }
+
+ StyledText {
+ text: modelData ? `${modelData.toFixed(0)}%` : "0%"
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ width: 32
+ horizontalAlignment: Text.AlignRight
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 80
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.04)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.06)
+ border.width: 1
+
+ Row {
+ anchors.centerIn: parent
+ anchors.margins: Theme.spacingM
+ spacing: Theme.spacingM
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 4
+
+ StyledText {
+ text: "Memory"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Bold
+ color: Theme.surfaceText
+ }
+
+ StyledText {
+ text: `${DgopService.formatSystemMemory(DgopService.usedMemoryKB)} / ${DgopService.formatSystemMemory(DgopService.totalMemoryKB)}`
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ }
+
+ }
+
+ Item {
+ width: Theme.spacingL
+ height: 1
+ }
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 4
+ width: 200
+
+ Rectangle {
+ width: parent.width
+ height: 16
+ radius: 8
+ color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
+
+ Rectangle {
+ width: DgopService.totalMemoryKB > 0 ? parent.width * (DgopService.usedMemoryKB / DgopService.totalMemoryKB) : 0
+ height: parent.height
+ radius: parent.radius
+ color: {
+ const usage = DgopService.totalMemoryKB > 0 ? (DgopService.usedMemoryKB / DgopService.totalMemoryKB) : 0;
+ if (usage > 0.9) {
+ return Theme.error;
+ }
+ if (usage > 0.7) {
+ return Theme.warning;
+ }
+ return Theme.secondary;
+ }
+
+ Behavior on width {
+ NumberAnimation {
+ duration: Theme.mediumDuration
+ }
+
+ }
+
+ }
+
+ }
+
+ StyledText {
+ text: DgopService.totalMemoryKB > 0 ? `${((DgopService.usedMemoryKB / DgopService.totalMemoryKB) * 100).toFixed(1)}% used` : "No data"
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Bold
+ color: Theme.surfaceText
+ }
+
+ }
+
+ Item {
+ width: Theme.spacingL
+ height: 1
+ }
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 4
+
+ StyledText {
+ text: "Swap"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Bold
+ color: Theme.surfaceText
+ }
+
+ StyledText {
+ text: DgopService.totalSwapKB > 0 ? `${DgopService.formatSystemMemory(DgopService.usedSwapKB)} / ${DgopService.formatSystemMemory(DgopService.totalSwapKB)}` : "No swap configured"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ }
+
+ }
+
+ Item {
+ width: Theme.spacingL
+ height: 1
+ }
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 4
+ width: 200
+
+ Rectangle {
+ width: parent.width
+ height: 16
+ radius: 8
+ color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
+
+ Rectangle {
+ width: DgopService.totalSwapKB > 0 ? parent.width * (DgopService.usedSwapKB / DgopService.totalSwapKB) : 0
+ height: parent.height
+ radius: parent.radius
+ color: {
+ if (!DgopService.totalSwapKB) {
+ return Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.3);
+ }
+ const usage = DgopService.usedSwapKB / DgopService.totalSwapKB;
+ if (usage > 0.9) {
+ return Theme.error;
+ }
+ if (usage > 0.7) {
+ return Theme.warning;
+ }
+ return Theme.info;
+ }
+
+ Behavior on width {
+ NumberAnimation {
+ duration: Theme.mediumDuration
+ }
+
+ }
+
+ }
+
+ }
+
+ StyledText {
+ text: DgopService.totalSwapKB > 0 ? `${((DgopService.usedSwapKB / DgopService.totalSwapKB) * 100).toFixed(1)}% used` : "Not available"
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Bold
+ color: Theme.surfaceText
+ }
+
+ }
+
+ }
+
+ }
+
+ Row {
+ width: parent.width
+ height: 80
+ spacing: Theme.spacingM
+
+ Rectangle {
+ width: (parent.width - Theme.spacingM) / 2
+ height: 80
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.04)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.06)
+ border.width: 1
+
+ Column {
+ anchors.centerIn: parent
+ spacing: Theme.spacingXS
+
+ StyledText {
+ text: "Network"
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: Font.Bold
+ color: Theme.surfaceText
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+
+ Row {
+ spacing: Theme.spacingS
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ Row {
+ spacing: 4
+
+ StyledText {
+ text: "↓"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.info
+ }
+
+ StyledText {
+ text: DgopService.networkRxRate > 0 ? formatNetworkSpeed(DgopService.networkRxRate) : "0 B/s"
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Bold
+ color: Theme.surfaceText
+ }
+
+ }
+
+ Row {
+ spacing: 4
+
+ StyledText {
+ text: "↑"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.error
+ }
+
+ StyledText {
+ text: DgopService.networkTxRate > 0 ? formatNetworkSpeed(DgopService.networkTxRate) : "0 B/s"
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Bold
+ color: Theme.surfaceText
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ Rectangle {
+ width: (parent.width - Theme.spacingM) / 2
+ height: 80
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.04)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.06)
+ border.width: 1
+
+ Column {
+ anchors.centerIn: parent
+ spacing: Theme.spacingXS
+
+ StyledText {
+ text: "Disk"
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: Font.Bold
+ color: Theme.surfaceText
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+
+ Row {
+ spacing: Theme.spacingS
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ Row {
+ spacing: 4
+
+ StyledText {
+ text: "R"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.primary
+ }
+
+ StyledText {
+ text: formatDiskSpeed(DgopService.diskReadRate)
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Bold
+ color: Theme.surfaceText
+ }
+
+ }
+
+ Row {
+ spacing: 4
+
+ StyledText {
+ text: "W"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.warning
+ }
+
+ StyledText {
+ text: formatDiskSpeed(DgopService.diskWriteRate)
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Bold
+ color: Theme.surfaceText
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modules/ProcessList/ProcessContextMenu.qml b/quickshell/.config/quickshell/Modules/ProcessList/ProcessContextMenu.qml
new file mode 100644
index 0000000..14b8f1a
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/ProcessList/ProcessContextMenu.qml
@@ -0,0 +1,235 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell
+import Quickshell.Io
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Popup {
+ id: processContextMenu
+
+ property var processData: null
+
+ function show(x, y) {
+ if (!processContextMenu.parent && typeof Overlay !== "undefined" && Overlay.overlay) {
+ processContextMenu.parent = Overlay.overlay;
+ }
+
+ const menuWidth = 180;
+ const menuHeight = menuColumn.implicitHeight + Theme.spacingS * 2;
+ const screenWidth = Screen.width;
+ const screenHeight = Screen.height;
+ let finalX = x;
+ let finalY = y;
+ if (x + menuWidth > screenWidth - 20) {
+ finalX = x - menuWidth;
+ }
+
+ if (y + menuHeight > screenHeight - 20) {
+ finalY = y - menuHeight;
+ }
+
+ processContextMenu.x = Math.max(20, finalX);
+ processContextMenu.y = Math.max(20, finalY);
+ open();
+ }
+
+ width: 180
+ height: menuColumn.implicitHeight + Theme.spacingS * 2
+ padding: 0
+ modal: false
+ closePolicy: Popup.CloseOnEscape
+ onClosed: {
+ closePolicy = Popup.CloseOnEscape;
+ }
+ onOpened: {
+ outsideClickTimer.start();
+ }
+
+ Timer {
+ id: outsideClickTimer
+
+ interval: 100
+ onTriggered: {
+ processContextMenu.closePolicy = Popup.CloseOnEscape | Popup.CloseOnPressOutside;
+ }
+ }
+
+ background: Rectangle {
+ color: "transparent"
+ }
+
+ contentItem: Rectangle {
+ id: menuContent
+
+ color: Theme.popupBackground()
+ radius: Theme.cornerRadius
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
+ border.width: 1
+
+ Column {
+ id: menuColumn
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingS
+ spacing: 1
+
+ Rectangle {
+ width: parent.width
+ height: 28
+ radius: Theme.cornerRadius
+ color: copyPidArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
+
+ StyledText {
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingS
+ anchors.verticalCenter: parent.verticalCenter
+ text: "Copy PID"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ font.weight: Font.Normal
+ }
+
+ MouseArea {
+ id: copyPidArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ if (processContextMenu.processData) {
+ Quickshell.execDetached(["wl-copy", processContextMenu.processData.pid.toString()]);
+ }
+
+ processContextMenu.close();
+ }
+ }
+
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 28
+ radius: Theme.cornerRadius
+ color: copyNameArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
+
+ StyledText {
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingS
+ anchors.verticalCenter: parent.verticalCenter
+ text: "Copy Process Name"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ font.weight: Font.Normal
+ }
+
+ MouseArea {
+ id: copyNameArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ if (processContextMenu.processData) {
+ const processName = processContextMenu.processData.displayName || processContextMenu.processData.command;
+ Quickshell.execDetached(["wl-copy", processName]);
+ }
+ processContextMenu.close();
+ }
+ }
+
+ }
+
+ Rectangle {
+ width: parent.width - Theme.spacingS * 2
+ height: 5
+ anchors.horizontalCenter: parent.horizontalCenter
+ color: "transparent"
+
+ Rectangle {
+ anchors.centerIn: parent
+ width: parent.width
+ height: 1
+ color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
+ }
+
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 28
+ radius: Theme.cornerRadius
+ color: killArea.containsMouse ? Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12) : "transparent"
+ enabled: processContextMenu.processData
+ opacity: enabled ? 1 : 0.5
+
+ StyledText {
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingS
+ anchors.verticalCenter: parent.verticalCenter
+ text: "Kill Process"
+ font.pixelSize: Theme.fontSizeSmall
+ color: parent.enabled ? (killArea.containsMouse ? Theme.error : Theme.surfaceText) : Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.5)
+ font.weight: Font.Normal
+ }
+
+ MouseArea {
+ id: killArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: parent.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
+ enabled: parent.enabled
+ onClicked: {
+ if (processContextMenu.processData) {
+ Quickshell.execDetached(["kill", processContextMenu.processData.pid.toString()]);
+ }
+
+ processContextMenu.close();
+ }
+ }
+
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 28
+ radius: Theme.cornerRadius
+ color: forceKillArea.containsMouse ? Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12) : "transparent"
+ enabled: processContextMenu.processData && processContextMenu.processData.pid > 1000
+ opacity: enabled ? 1 : 0.5
+
+ StyledText {
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingS
+ anchors.verticalCenter: parent.verticalCenter
+ text: "Force Kill Process"
+ font.pixelSize: Theme.fontSizeSmall
+ color: parent.enabled ? (forceKillArea.containsMouse ? Theme.error : Theme.surfaceText) : Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.5)
+ font.weight: Font.Normal
+ }
+
+ MouseArea {
+ id: forceKillArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: parent.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
+ enabled: parent.enabled
+ onClicked: {
+ if (processContextMenu.processData) {
+ Quickshell.execDetached(["kill", "-9", processContextMenu.processData.pid.toString()]);
+ }
+
+ processContextMenu.close();
+ }
+ }
+
+ }
+
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modules/ProcessList/ProcessListItem.qml b/quickshell/.config/quickshell/Modules/ProcessList/ProcessListItem.qml
new file mode 100644
index 0000000..fbfb472
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/ProcessList/ProcessListItem.qml
@@ -0,0 +1,200 @@
+import QtQuick
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Rectangle {
+ id: processItem
+
+ property var process: null
+ property var contextMenu: null
+
+ width: parent ? parent.width : 0
+ height: 40
+ radius: Theme.cornerRadius
+ color: processMouseArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : "transparent"
+ border.color: processMouseArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
+ border.width: 1
+
+ MouseArea {
+ id: processMouseArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ acceptedButtons: Qt.LeftButton | Qt.RightButton
+ onClicked: (mouse) => {
+ if (mouse.button === Qt.RightButton) {
+ if (process && process.pid > 0 && contextMenu) {
+ contextMenu.processData = process;
+ const globalPos = processMouseArea.mapToGlobal(mouse.x, mouse.y);
+ const localPos = contextMenu.parent ? contextMenu.parent.mapFromGlobal(globalPos.x, globalPos.y) : globalPos;
+ contextMenu.show(localPos.x, localPos.y);
+ }
+ }
+ }
+ onPressAndHold: {
+ if (process && process.pid > 0 && contextMenu) {
+ contextMenu.processData = process;
+ const globalPos = processMouseArea.mapToGlobal(processMouseArea.width / 2, processMouseArea.height / 2);
+ contextMenu.show(globalPos.x, globalPos.y);
+ }
+ }
+ }
+
+ Item {
+ anchors.fill: parent
+ anchors.margins: 8
+
+ DankIcon {
+ id: processIcon
+
+ name: DgopService.getProcessIcon(process ? process.command : "")
+ size: Theme.iconSize - 4
+ color: {
+ if (process && process.cpu > 80) {
+ return Theme.error;
+ }
+ return Theme.surfaceText;
+ }
+ opacity: 0.8
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: process ? process.displayName : ""
+ font.pixelSize: Theme.fontSizeSmall
+ font.family: SettingsData.monoFontFamily
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ width: 250
+ elide: Text.ElideRight
+ anchors.left: processIcon.right
+ anchors.leftMargin: 8
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Rectangle {
+ id: cpuBadge
+
+ width: 80
+ height: 20
+ radius: Theme.cornerRadius
+ color: {
+ if (process && process.cpu > 80) {
+ return Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12);
+ }
+ return Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08);
+ }
+ anchors.right: parent.right
+ anchors.rightMargin: 194
+ anchors.verticalCenter: parent.verticalCenter
+
+ StyledText {
+ text: DgopService.formatCpuUsage(process ? process.cpu : 0)
+ font.pixelSize: Theme.fontSizeSmall
+ font.family: SettingsData.monoFontFamily
+ font.weight: Font.Bold
+ color: {
+ if (process && process.cpu > 80) {
+ return Theme.error;
+ }
+ return Theme.surfaceText;
+ }
+ anchors.centerIn: parent
+ }
+
+ }
+
+ Rectangle {
+ id: memoryBadge
+
+ width: 80
+ height: 20
+ radius: Theme.cornerRadius
+ color: {
+ if (process && process.memoryKB > 1024 * 1024) {
+ return Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12);
+ }
+ return Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08);
+ }
+ anchors.right: parent.right
+ anchors.rightMargin: 102
+ anchors.verticalCenter: parent.verticalCenter
+
+ StyledText {
+ text: DgopService.formatMemoryUsage(process ? process.memoryKB : 0)
+ font.pixelSize: Theme.fontSizeSmall
+ font.family: SettingsData.monoFontFamily
+ font.weight: Font.Bold
+ color: {
+ if (process && process.memoryKB > 1024 * 1024) {
+ return Theme.error;
+ }
+ return Theme.surfaceText;
+ }
+ anchors.centerIn: parent
+ }
+
+ }
+
+ StyledText {
+ text: process ? process.pid.toString() : ""
+ font.pixelSize: Theme.fontSizeSmall
+ font.family: SettingsData.monoFontFamily
+ color: Theme.surfaceText
+ opacity: 0.7
+ width: 50
+ horizontalAlignment: Text.AlignRight
+ anchors.right: parent.right
+ anchors.rightMargin: 40
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Rectangle {
+ id: menuButton
+
+ width: 28
+ height: 28
+ radius: Theme.cornerRadius
+ color: menuButtonArea.containsMouse ? Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : "transparent"
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+
+ DankIcon {
+ name: "more_vert"
+ size: Theme.iconSize - 2
+ color: Theme.surfaceText
+ opacity: 0.6
+ anchors.centerIn: parent
+ }
+
+ MouseArea {
+ id: menuButtonArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ if (process && process.pid > 0 && contextMenu) {
+ contextMenu.processData = process;
+ const globalPos = menuButtonArea.mapToGlobal(menuButtonArea.width / 2, menuButtonArea.height);
+ const localPos = contextMenu.parent ? contextMenu.parent.mapFromGlobal(globalPos.x, globalPos.y) : globalPos;
+ contextMenu.show(localPos.x, localPos.y);
+ }
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ }
+
+ }
+
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modules/ProcessList/ProcessListPopout.qml b/quickshell/.config/quickshell/Modules/ProcessList/ProcessListPopout.qml
new file mode 100644
index 0000000..0ae5770
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/ProcessList/ProcessListPopout.qml
@@ -0,0 +1,138 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Effects
+import QtQuick.Layouts
+import Quickshell
+import Quickshell.Io
+import Quickshell.Wayland
+import Quickshell.Widgets
+import qs.Common
+import qs.Modules.ProcessList
+import qs.Services
+import qs.Widgets
+
+DankPopout {
+ id: processListPopout
+
+ property var parentWidget: null
+ property string triggerSection: "right"
+ property var triggerScreen: null
+
+ function setTriggerPosition(x, y, width, section, screen) {
+ triggerX = x;
+ triggerY = y;
+ triggerWidth = width;
+ triggerSection = section;
+ triggerScreen = screen;
+ }
+
+ function hide() {
+ close();
+ if (processContextMenu.visible) {
+ processContextMenu.close();
+ }
+ }
+
+ function show() {
+ open();
+ }
+
+ popupWidth: 600
+ popupHeight: 600
+ triggerX: Screen.width - 600 - Theme.spacingL
+ triggerY: Theme.barHeight - 4 + SettingsData.topBarSpacing + Theme.spacingXS
+ triggerWidth: 55
+ positioning: "center"
+ screen: triggerScreen
+ visible: shouldBeVisible
+ shouldBeVisible: false
+
+ Ref {
+ service: DgopService
+ }
+
+ ProcessContextMenu {
+ id: processContextMenu
+ }
+
+ content: Component {
+ Rectangle {
+ id: processListContent
+
+ radius: Theme.cornerRadius
+ color: Theme.popupBackground()
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
+ border.width: 1
+ clip: true
+ antialiasing: true
+ smooth: true
+ focus: true
+ Component.onCompleted: {
+ if (processListPopout.shouldBeVisible) {
+ forceActiveFocus();
+ }
+ }
+ Keys.onPressed: (event) => {
+ if (event.key === Qt.Key_Escape) {
+ processListPopout.close();
+ event.accepted = true;
+ }
+ }
+
+ Connections {
+ function onShouldBeVisibleChanged() {
+ if (processListPopout.shouldBeVisible) {
+ Qt.callLater(() => {
+ processListContent.forceActiveFocus();
+ });
+ }
+ }
+
+ target: processListPopout
+ }
+
+ ColumnLayout {
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingL
+
+ Rectangle {
+ Layout.fillWidth: true
+ height: systemOverview.height + Theme.spacingM * 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.2)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
+ border.width: 1
+
+ SystemOverview {
+ id: systemOverview
+
+ anchors.centerIn: parent
+ width: parent.width - Theme.spacingM * 2
+ }
+
+ }
+
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.1)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.05)
+ border.width: 1
+
+ ProcessListView {
+ anchors.fill: parent
+ anchors.margins: Theme.spacingS
+ contextMenu: processContextMenu
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modules/ProcessList/ProcessListView.qml b/quickshell/.config/quickshell/Modules/ProcessList/ProcessListView.qml
new file mode 100644
index 0000000..063fd6c
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/ProcessList/ProcessListView.qml
@@ -0,0 +1,260 @@
+import QtQuick
+import QtQuick.Controls
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Column {
+ id: root
+
+ property var contextMenu: null
+
+ Component.onCompleted: {
+ DgopService.addRef(["processes"]);
+ }
+ Component.onDestruction: {
+ DgopService.removeRef(["processes"]);
+ }
+
+ Item {
+ id: columnHeaders
+
+ width: parent.width
+ anchors.leftMargin: 8
+ height: 24
+
+ Rectangle {
+ width: 60
+ height: 20
+ color: {
+ if (DgopService.currentSort === "name") {
+ return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12);
+ }
+ return processHeaderArea.containsMouse ? Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : "transparent";
+ }
+ radius: Theme.cornerRadius
+ anchors.left: parent.left
+ anchors.leftMargin: 0
+ anchors.verticalCenter: parent.verticalCenter
+
+ StyledText {
+ text: "Process"
+ font.pixelSize: Theme.fontSizeSmall
+ font.family: SettingsData.monoFontFamily
+ font.weight: DgopService.currentSort === "name" ? Font.Bold : Font.Medium
+ color: Theme.surfaceText
+ opacity: DgopService.currentSort === "name" ? 1 : 0.7
+ anchors.centerIn: parent
+ }
+
+ MouseArea {
+ id: processHeaderArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ DgopService.setSortBy("name");
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ }
+
+ }
+
+ }
+
+ Rectangle {
+ width: 80
+ height: 20
+ color: {
+ if (DgopService.currentSort === "cpu") {
+ return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12);
+ }
+ return cpuHeaderArea.containsMouse ? Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : "transparent";
+ }
+ radius: Theme.cornerRadius
+ anchors.right: parent.right
+ anchors.rightMargin: 200
+ anchors.verticalCenter: parent.verticalCenter
+
+ StyledText {
+ text: "CPU"
+ font.pixelSize: Theme.fontSizeSmall
+ font.family: SettingsData.monoFontFamily
+ font.weight: DgopService.currentSort === "cpu" ? Font.Bold : Font.Medium
+ color: Theme.surfaceText
+ opacity: DgopService.currentSort === "cpu" ? 1 : 0.7
+ anchors.centerIn: parent
+ }
+
+ MouseArea {
+ id: cpuHeaderArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ DgopService.setSortBy("cpu");
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ }
+
+ }
+
+ }
+
+ Rectangle {
+ width: 80
+ height: 20
+ color: {
+ if (DgopService.currentSort === "memory") {
+ return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12);
+ }
+ return memoryHeaderArea.containsMouse ? Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : "transparent";
+ }
+ radius: Theme.cornerRadius
+ anchors.right: parent.right
+ anchors.rightMargin: 112
+ anchors.verticalCenter: parent.verticalCenter
+
+ StyledText {
+ text: "RAM"
+ font.pixelSize: Theme.fontSizeSmall
+ font.family: SettingsData.monoFontFamily
+ font.weight: DgopService.currentSort === "memory" ? Font.Bold : Font.Medium
+ color: Theme.surfaceText
+ opacity: DgopService.currentSort === "memory" ? 1 : 0.7
+ anchors.centerIn: parent
+ }
+
+ MouseArea {
+ id: memoryHeaderArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ DgopService.setSortBy("memory");
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ }
+
+ }
+
+ }
+
+ Rectangle {
+ width: 50
+ height: 20
+ color: {
+ if (DgopService.currentSort === "pid") {
+ return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12);
+ }
+ return pidHeaderArea.containsMouse ? Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : "transparent";
+ }
+ radius: Theme.cornerRadius
+ anchors.right: parent.right
+ anchors.rightMargin: 53
+ anchors.verticalCenter: parent.verticalCenter
+
+ StyledText {
+ text: "PID"
+ font.pixelSize: Theme.fontSizeSmall
+ font.family: SettingsData.monoFontFamily
+ font.weight: DgopService.currentSort === "pid" ? Font.Bold : Font.Medium
+ color: Theme.surfaceText
+ opacity: DgopService.currentSort === "pid" ? 1 : 0.7
+ horizontalAlignment: Text.AlignHCenter
+ anchors.centerIn: parent
+ }
+
+ MouseArea {
+ id: pidHeaderArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ DgopService.setSortBy("pid");
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ }
+
+ }
+
+ }
+
+ Rectangle {
+ width: 28
+ height: 28
+ radius: Theme.cornerRadius
+ color: sortOrderArea.containsMouse ? Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : "transparent"
+ anchors.right: parent.right
+ anchors.rightMargin: 8
+ anchors.verticalCenter: parent.verticalCenter
+
+ StyledText {
+ text: DgopService.sortDescending ? "↓" : "↑"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ anchors.centerIn: parent
+ }
+
+ MouseArea {
+ // TODO: Re-implement sort order toggle
+
+ id: sortOrderArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ }
+
+ }
+
+ }
+
+ }
+
+ DankListView {
+ id: processListView
+
+ property string keyRoleName: "pid"
+
+ width: parent.width
+ height: parent.height - columnHeaders.height
+ clip: true
+ spacing: 4
+ model: DgopService.processes
+
+ delegate: ProcessListItem {
+ process: modelData
+ contextMenu: root.contextMenu
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modules/ProcessList/ProcessesTab.qml b/quickshell/.config/quickshell/Modules/ProcessList/ProcessesTab.qml
new file mode 100644
index 0000000..987dd90
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/ProcessList/ProcessesTab.qml
@@ -0,0 +1,28 @@
+import QtQuick
+import QtQuick.Layouts
+import qs.Common
+import qs.Modules.ProcessList
+import qs.Services
+
+ColumnLayout {
+ id: processesTab
+
+ property var contextMenu: null
+
+ anchors.fill: parent
+ spacing: Theme.spacingM
+
+ SystemOverview {
+ Layout.fillWidth: true
+ }
+
+ ProcessListView {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ contextMenu: processesTab.contextMenu
+ }
+
+ ProcessContextMenu {
+ id: localContextMenu
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/ProcessList/SystemOverview.qml b/quickshell/.config/quickshell/Modules/ProcessList/SystemOverview.qml
new file mode 100644
index 0000000..cf813a9
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/ProcessList/SystemOverview.qml
@@ -0,0 +1,435 @@
+import QtQuick
+import QtQuick.Controls
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Row {
+ width: parent.width
+ spacing: Theme.spacingM
+ Component.onCompleted: {
+ DgopService.addRef(["cpu", "memory", "system"]);
+ }
+ Component.onDestruction: {
+ DgopService.removeRef(["cpu", "memory", "system"]);
+ }
+
+ Rectangle {
+ width: (parent.width - Theme.spacingM * 2) / 3
+ height: 80
+ radius: Theme.cornerRadius
+ color: {
+ if (DgopService.sortBy === "cpu") {
+ return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.16);
+ } else if (cpuCardMouseArea.containsMouse) {
+ return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12);
+ } else {
+ return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08);
+ }
+ }
+ border.color: DgopService.sortBy === "cpu" ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.4) : Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.2)
+ border.width: DgopService.sortBy === "cpu" ? 2 : 1
+
+ MouseArea {
+ id: cpuCardMouseArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ DgopService.setSortBy("cpu");
+ }
+ }
+
+ Column {
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingM
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 2
+
+ StyledText {
+ text: "CPU"
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ color: DgopService.sortBy === "cpu" ? Theme.primary : Theme.secondary
+ opacity: DgopService.sortBy === "cpu" ? 1 : 0.8
+ }
+
+ Row {
+ spacing: Theme.spacingS
+
+ StyledText {
+ text: {
+ if (DgopService.cpuUsage === undefined || DgopService.cpuUsage === null) {
+ return "--%";
+ }
+ return DgopService.cpuUsage.toFixed(1) + "%";
+ }
+ font.pixelSize: Theme.fontSizeLarge
+ font.family: SettingsData.monoFontFamily
+ font.weight: Font.Bold
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Rectangle {
+ width: 1
+ height: 20
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.3)
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: {
+ if (DgopService.cpuTemperature === undefined || DgopService.cpuTemperature === null || DgopService.cpuTemperature <= 0) {
+ return "--°";
+ }
+ return Math.round(DgopService.cpuTemperature) + "°";
+ }
+ font.pixelSize: Theme.fontSizeMedium
+ font.family: SettingsData.monoFontFamily
+ font.weight: Font.Medium
+ color: {
+ if (DgopService.cpuTemperature > 80) {
+ return Theme.error;
+ }
+ if (DgopService.cpuTemperature > 60) {
+ return Theme.warning;
+ }
+ return Theme.surfaceText;
+ }
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ }
+
+ StyledText {
+ text: `${DgopService.cpuCores} cores`
+ font.pixelSize: Theme.fontSizeSmall
+ font.family: SettingsData.monoFontFamily
+ color: Theme.surfaceText
+ opacity: 0.7
+ }
+
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ }
+
+ }
+
+ Behavior on border.color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ }
+
+ }
+
+ }
+
+ Rectangle {
+ width: (parent.width - Theme.spacingM * 2) / 3
+ height: 80
+ radius: Theme.cornerRadius
+ color: {
+ if (DgopService.sortBy === "memory") {
+ return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.16);
+ } else if (memoryCardMouseArea.containsMouse) {
+ return Qt.rgba(Theme.secondary.r, Theme.secondary.g, Theme.secondary.b, 0.12);
+ } else {
+ return Qt.rgba(Theme.secondary.r, Theme.secondary.g, Theme.secondary.b, 0.08);
+ }
+ }
+ border.color: DgopService.sortBy === "memory" ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.4) : Qt.rgba(Theme.secondary.r, Theme.secondary.g, Theme.secondary.b, 0.2)
+ border.width: DgopService.sortBy === "memory" ? 2 : 1
+
+ MouseArea {
+ id: memoryCardMouseArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ DgopService.setSortBy("memory");
+ }
+ }
+
+ Column {
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingM
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 2
+
+ StyledText {
+ text: "Memory"
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ color: DgopService.sortBy === "memory" ? Theme.primary : Theme.secondary
+ opacity: DgopService.sortBy === "memory" ? 1 : 0.8
+ }
+
+ Row {
+ spacing: Theme.spacingS
+
+ StyledText {
+ text: DgopService.formatSystemMemory(DgopService.usedMemoryKB)
+ font.pixelSize: Theme.fontSizeLarge
+ font.family: SettingsData.monoFontFamily
+ font.weight: Font.Bold
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Rectangle {
+ width: 1
+ height: 20
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.3)
+ anchors.verticalCenter: parent.verticalCenter
+ visible: DgopService.totalSwapKB > 0
+ }
+
+ StyledText {
+ text: DgopService.totalSwapKB > 0 ? DgopService.formatSystemMemory(DgopService.usedSwapKB) : ""
+ font.pixelSize: Theme.fontSizeMedium
+ font.family: SettingsData.monoFontFamily
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ visible: DgopService.totalSwapKB > 0
+ }
+
+ }
+
+ StyledText {
+ text: {
+ if (DgopService.totalSwapKB > 0) {
+ return "of " + DgopService.formatSystemMemory(DgopService.totalMemoryKB) + " + swap";
+ }
+ return "of " + DgopService.formatSystemMemory(DgopService.totalMemoryKB);
+ }
+ font.pixelSize: Theme.fontSizeSmall
+ font.family: SettingsData.monoFontFamily
+ color: Theme.surfaceText
+ opacity: 0.7
+ }
+
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ }
+
+ }
+
+ Behavior on border.color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ }
+
+ }
+
+ }
+
+ Rectangle {
+ width: (parent.width - Theme.spacingM * 2) / 3
+ height: 80
+ radius: Theme.cornerRadius
+ color: {
+ if (!DgopService.availableGpus || DgopService.availableGpus.length === 0) {
+ if (gpuCardMouseArea.containsMouse && DgopService.availableGpus.length > 1) {
+ return Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.16);
+ } else {
+ return Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08);
+ }
+ }
+ const gpu = DgopService.availableGpus[Math.min(SessionData.selectedGpuIndex, DgopService.availableGpus.length - 1)];
+ const vendor = gpu.vendor.toLowerCase();
+ if (vendor.includes("nvidia")) {
+ if (gpuCardMouseArea.containsMouse && DgopService.availableGpus.length > 1) {
+ return Qt.rgba(Theme.success.r, Theme.success.g, Theme.success.b, 0.2);
+ } else {
+ return Qt.rgba(Theme.success.r, Theme.success.g, Theme.success.b, 0.12);
+ }
+ } else if (vendor.includes("amd")) {
+ if (gpuCardMouseArea.containsMouse && DgopService.availableGpus.length > 1) {
+ return Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.2);
+ } else {
+ return Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12);
+ }
+ } else if (vendor.includes("intel")) {
+ if (gpuCardMouseArea.containsMouse && DgopService.availableGpus.length > 1) {
+ return Qt.rgba(Theme.info.r, Theme.info.g, Theme.info.b, 0.2);
+ } else {
+ return Qt.rgba(Theme.info.r, Theme.info.g, Theme.info.b, 0.12);
+ }
+ }
+ if (gpuCardMouseArea.containsMouse && DgopService.availableGpus.length > 1) {
+ return Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.16);
+ } else {
+ return Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08);
+ }
+ }
+ border.color: {
+ if (!DgopService.availableGpus || DgopService.availableGpus.length === 0) {
+ return Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.2);
+ }
+ const gpu = DgopService.availableGpus[Math.min(SessionData.selectedGpuIndex, DgopService.availableGpus.length - 1)];
+ const vendor = gpu.vendor.toLowerCase();
+ if (vendor.includes("nvidia")) {
+ return Qt.rgba(Theme.success.r, Theme.success.g, Theme.success.b, 0.3);
+ } else if (vendor.includes("amd")) {
+ return Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.3);
+ } else if (vendor.includes("intel")) {
+ return Qt.rgba(Theme.info.r, Theme.info.g, Theme.info.b, 0.3);
+ }
+ return Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.2);
+ }
+ border.width: 1
+
+ MouseArea {
+ id: gpuCardMouseArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ acceptedButtons: Qt.LeftButton | Qt.RightButton
+ cursorShape: DgopService.availableGpus.length > 1 ? Qt.PointingHandCursor : Qt.ArrowCursor
+ onClicked: (mouse) => {
+ if (mouse.button === Qt.LeftButton) {
+ if (DgopService.availableGpus.length > 1) {
+ const nextIndex = (SessionData.selectedGpuIndex + 1) % DgopService.availableGpus.length;
+ SessionData.setSelectedGpuIndex(nextIndex);
+ }
+ } else if (mouse.button === Qt.RightButton) {
+ gpuContextMenu.popup();
+ }
+ }
+ }
+
+ Column {
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingM
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 2
+
+ StyledText {
+ text: "GPU"
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ color: Theme.secondary
+ opacity: 0.8
+ }
+
+ StyledText {
+ text: {
+ if (!DgopService.availableGpus || DgopService.availableGpus.length === 0) {
+ return "No GPU";
+ }
+ const gpu = DgopService.availableGpus[Math.min(SessionData.selectedGpuIndex, DgopService.availableGpus.length - 1)];
+ // Check if temperature monitoring is enabled for this GPU
+ const tempEnabled = SessionData.enabledGpuPciIds && SessionData.enabledGpuPciIds.indexOf(gpu.pciId) !== -1;
+ const temp = gpu.temperature;
+ const hasTemp = tempEnabled && temp !== undefined && temp !== null && temp !== 0;
+ if (hasTemp) {
+ return Math.round(temp) + "°";
+ } else {
+ return gpu.vendor;
+ }
+ }
+ font.pixelSize: Theme.fontSizeLarge
+ font.family: SettingsData.monoFontFamily
+ font.weight: Font.Bold
+ color: {
+ if (!DgopService.availableGpus || DgopService.availableGpus.length === 0) {
+ return Theme.surfaceText;
+ }
+ const gpu = DgopService.availableGpus[Math.min(SessionData.selectedGpuIndex, DgopService.availableGpus.length - 1)];
+ const tempEnabled = SessionData.enabledGpuPciIds && SessionData.enabledGpuPciIds.indexOf(gpu.pciId) !== -1;
+ const temp = gpu.temperature || 0;
+ if (tempEnabled && temp > 80) {
+ return Theme.error;
+ }
+ if (tempEnabled && temp > 60) {
+ return Theme.warning;
+ }
+ return Theme.surfaceText;
+ }
+ }
+
+ StyledText {
+ text: {
+ if (!DgopService.availableGpus || DgopService.availableGpus.length === 0) {
+ return "No GPUs detected";
+ }
+ const gpu = DgopService.availableGpus[Math.min(SessionData.selectedGpuIndex, DgopService.availableGpus.length - 1)];
+ const tempEnabled = SessionData.enabledGpuPciIds && SessionData.enabledGpuPciIds.indexOf(gpu.pciId) !== -1;
+ const temp = gpu.temperature;
+ const hasTemp = tempEnabled && temp !== undefined && temp !== null && temp !== 0;
+ if (hasTemp) {
+ return gpu.vendor + " " + gpu.displayName;
+ } else {
+ return gpu.displayName;
+ }
+ }
+ font.pixelSize: Theme.fontSizeSmall
+ font.family: SettingsData.monoFontFamily
+ color: Theme.surfaceText
+ opacity: 0.7
+ width: parent.parent.width - Theme.spacingM * 2
+ elide: Text.ElideRight
+ maximumLineCount: 1
+ }
+
+ }
+
+ Menu {
+ id: gpuContextMenu
+
+ MenuItem {
+ text: "Enable GPU Temperature"
+ checkable: true
+ checked: {
+ if (!DgopService.availableGpus || DgopService.availableGpus.length === 0) {
+ return false;
+ }
+ const gpu = DgopService.availableGpus[Math.min(SessionData.selectedGpuIndex, DgopService.availableGpus.length - 1)];
+ if (!gpu.pciId) {
+ return false;
+ }
+ return SessionData.enabledGpuPciIds ? SessionData.enabledGpuPciIds.indexOf(gpu.pciId) !== -1 : false;
+ }
+ onTriggered: {
+ if (!DgopService.availableGpus || DgopService.availableGpus.length === 0) {
+ return;
+ }
+ const gpu = DgopService.availableGpus[Math.min(SessionData.selectedGpuIndex, DgopService.availableGpus.length - 1)];
+ if (!gpu.pciId) {
+ return;
+ }
+ const enabledIds = SessionData.enabledGpuPciIds ? SessionData.enabledGpuPciIds.slice() : [];
+ const index = enabledIds.indexOf(gpu.pciId);
+ if (checked && index === -1) {
+ enabledIds.push(gpu.pciId);
+ DgopService.addGpuPciId(gpu.pciId);
+ } else if (!checked && index !== -1) {
+ enabledIds.splice(index, 1);
+ DgopService.removeGpuPciId(gpu.pciId);
+ }
+ SessionData.setEnabledGpuPciIds(enabledIds);
+ }
+ }
+
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ }
+
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modules/ProcessList/SystemTab.qml b/quickshell/.config/quickshell/Modules/ProcessList/SystemTab.qml
new file mode 100644
index 0000000..6c1c7a2
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/ProcessList/SystemTab.qml
@@ -0,0 +1,613 @@
+import QtQuick
+import QtQuick.Controls
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+DankFlickable {
+ anchors.fill: parent
+ contentHeight: systemColumn.implicitHeight
+ Component.onCompleted: {
+ DgopService.addRef(["system", "hardware", "diskmounts"]);
+ }
+ Component.onDestruction: {
+ DgopService.removeRef(["system", "hardware", "diskmounts"]);
+ }
+
+ Column {
+ id: systemColumn
+
+ width: parent.width
+ spacing: Theme.spacingM
+
+ Rectangle {
+ width: parent.width
+ height: systemInfoColumn.implicitHeight + 2 * Theme.spacingL
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.6)
+ border.width: 0
+
+ Column {
+ id: systemInfoColumn
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingL
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingL
+
+ SystemLogo {
+ width: 80
+ height: 80
+ }
+
+ Column {
+ width: parent.width - 80 - Theme.spacingL
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingS
+
+ StyledText {
+ text: DgopService.hostname
+ font.pixelSize: Theme.fontSizeXLarge
+ font.family: SettingsData.monoFontFamily
+ font.weight: Font.Light
+ color: Theme.surfaceText
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ StyledText {
+ text: `${DgopService.distribution} • ${DgopService.architecture} • ${DgopService.kernelVersion}`
+ font.pixelSize: Theme.fontSizeMedium
+ font.family: SettingsData.monoFontFamily
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ StyledText {
+ text: `Up ${UserInfoService.uptime} • Boot: ${DgopService.bootTime}`
+ font.pixelSize: Theme.fontSizeSmall
+ font.family: SettingsData.monoFontFamily
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.6)
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ StyledText {
+ text: `Load: ${DgopService.loadAverage} • ${DgopService.processCount} processes, ${DgopService.threadCount} threads`
+ font.pixelSize: Theme.fontSizeSmall
+ font.family: SettingsData.monoFontFamily
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.6)
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ }
+
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 1
+ color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
+ }
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingXL
+
+ Rectangle {
+ width: (parent.width - Theme.spacingXL) / 2
+ height: hardwareColumn.implicitHeight + Theme.spacingL
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceContainerHigh.r, Theme.surfaceContainerHigh.g, Theme.surfaceContainerHigh.b, 0.4)
+ border.width: 1
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.1)
+
+ Column {
+ id: hardwareColumn
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.margins: Theme.spacingM
+ spacing: Theme.spacingXS
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingS
+
+ DankIcon {
+ name: "memory"
+ size: Theme.iconSizeSmall
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "System"
+ font.pixelSize: Theme.fontSizeSmall
+ font.family: SettingsData.monoFontFamily
+ font.weight: Font.Bold
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ }
+
+ StyledText {
+ text: DgopService.cpuModel
+ font.pixelSize: Theme.fontSizeSmall
+ font.family: SettingsData.monoFontFamily
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ width: parent.width
+ elide: Text.ElideRight
+ wrapMode: Text.NoWrap
+ maximumLineCount: 1
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ StyledText {
+ text: DgopService.motherboard
+ font.pixelSize: Theme.fontSizeSmall
+ font.family: SettingsData.monoFontFamily
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.8)
+ width: parent.width
+ elide: Text.ElideRight
+ wrapMode: Text.NoWrap
+ maximumLineCount: 1
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ StyledText {
+ text: `BIOS ${DgopService.biosVersion}`
+ font.pixelSize: Theme.fontSizeSmall
+ font.family: SettingsData.monoFontFamily
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
+ width: parent.width
+ elide: Text.ElideRight
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ StyledText {
+ text: `${DgopService.formatSystemMemory(DgopService.totalMemoryKB)} RAM`
+ font.pixelSize: Theme.fontSizeSmall
+ font.family: SettingsData.monoFontFamily
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.8)
+ width: parent.width
+ elide: Text.ElideRight
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ }
+
+ }
+
+ Rectangle {
+ width: (parent.width - Theme.spacingXL) / 2
+ height: gpuColumn.implicitHeight + Theme.spacingL
+ radius: Theme.cornerRadius
+ color: {
+ const baseColor = Qt.rgba(Theme.surfaceContainerHigh.r, Theme.surfaceContainerHigh.g, Theme.surfaceContainerHigh.b, 0.4);
+ const hoverColor = Qt.rgba(Theme.surfaceContainerHigh.r, Theme.surfaceContainerHigh.g, Theme.surfaceContainerHigh.b, 0.6);
+ if (!DgopService.availableGpus || DgopService.availableGpus.length === 0) {
+ return gpuCardMouseArea.containsMouse && DgopService.availableGpus.length > 1 ? hoverColor : baseColor;
+ }
+
+ const gpu = DgopService.availableGpus[Math.min(SessionData.selectedGpuIndex, DgopService.availableGpus.length - 1)];
+ const vendor = gpu.fullName.split(' ')[0].toLowerCase();
+ let tintColor;
+ if (vendor.includes("nvidia")) {
+ tintColor = Theme.success;
+ } else if (vendor.includes("amd")) {
+ tintColor = Theme.error;
+ } else if (vendor.includes("intel")) {
+ tintColor = Theme.info;
+ } else {
+ return gpuCardMouseArea.containsMouse && DgopService.availableGpus.length > 1 ? hoverColor : baseColor;
+ }
+ if (gpuCardMouseArea.containsMouse && DgopService.availableGpus.length > 1) {
+ return Qt.rgba((hoverColor.r + tintColor.r * 0.1) / 1.1, (hoverColor.g + tintColor.g * 0.1) / 1.1, (hoverColor.b + tintColor.b * 0.1) / 1.1, 0.6);
+ } else {
+ return Qt.rgba((baseColor.r + tintColor.r * 0.08) / 1.08, (baseColor.g + tintColor.g * 0.08) / 1.08, (baseColor.b + tintColor.b * 0.08) / 1.08, 0.4);
+ }
+ }
+ border.width: 1
+ border.color: {
+ if (!DgopService.availableGpus || DgopService.availableGpus.length === 0) {
+ return Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.1);
+ }
+
+ const gpu = DgopService.availableGpus[Math.min(SessionData.selectedGpuIndex, DgopService.availableGpus.length - 1)];
+ const vendor = gpu.fullName.split(' ')[0].toLowerCase();
+ if (vendor.includes("nvidia")) {
+ return Qt.rgba(Theme.success.r, Theme.success.g, Theme.success.b, 0.3);
+ } else if (vendor.includes("amd")) {
+ return Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.3);
+ } else if (vendor.includes("intel")) {
+ return Qt.rgba(Theme.info.r, Theme.info.g, Theme.info.b, 0.3);
+ }
+ return Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.1);
+ }
+
+ MouseArea {
+ id: gpuCardMouseArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: DgopService.availableGpus.length > 1 ? Qt.PointingHandCursor : Qt.ArrowCursor
+ onClicked: {
+ if (DgopService.availableGpus.length > 1) {
+ const nextIndex = (SessionData.selectedGpuIndex + 1) % DgopService.availableGpus.length;
+ SessionData.setSelectedGpuIndex(nextIndex);
+ }
+ }
+ }
+
+ Column {
+ id: gpuColumn
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.margins: Theme.spacingM
+ spacing: Theme.spacingXS
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingS
+
+ DankIcon {
+ name: "auto_awesome_mosaic"
+ size: Theme.iconSizeSmall
+ color: Theme.secondary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "GPU"
+ font.pixelSize: Theme.fontSizeSmall
+ font.family: SettingsData.monoFontFamily
+ font.weight: Font.Bold
+ color: Theme.secondary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ }
+
+ StyledText {
+ text: {
+ if (!DgopService.availableGpus || DgopService.availableGpus.length === 0) {
+ return "No GPUs detected";
+ }
+
+ const gpu = DgopService.availableGpus[Math.min(SessionData.selectedGpuIndex, DgopService.availableGpus.length - 1)];
+ return gpu.fullName;
+ }
+ font.pixelSize: Theme.fontSizeSmall
+ font.family: SettingsData.monoFontFamily
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ width: parent.width
+ elide: Text.ElideRight
+ maximumLineCount: 1
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ StyledText {
+ text: {
+ if (!DgopService.availableGpus || DgopService.availableGpus.length === 0) {
+ return "Device: N/A";
+ }
+
+ const gpu = DgopService.availableGpus[Math.min(SessionData.selectedGpuIndex, DgopService.availableGpus.length - 1)];
+ return `Device: ${gpu.pciId}`;
+ }
+ font.pixelSize: Theme.fontSizeSmall
+ font.family: SettingsData.monoFontFamily
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.8)
+ width: parent.width
+ elide: Text.ElideRight
+ verticalAlignment: Text.AlignVCenter
+ textFormat: Text.RichText
+ }
+
+ StyledText {
+ text: {
+ if (!DgopService.availableGpus || DgopService.availableGpus.length === 0) {
+ return "Driver: N/A";
+ }
+
+ const gpu = DgopService.availableGpus[Math.min(SessionData.selectedGpuIndex, DgopService.availableGpus.length - 1)];
+ return `Driver: ${gpu.driver}`;
+ }
+ font.pixelSize: Theme.fontSizeSmall
+ font.family: SettingsData.monoFontFamily
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.8)
+ width: parent.width
+ elide: Text.ElideRight
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ StyledText {
+ text: {
+ if (!DgopService.availableGpus || DgopService.availableGpus.length === 0) {
+ return "Temp: --°";
+ }
+
+ const gpu = DgopService.availableGpus[Math.min(SessionData.selectedGpuIndex, DgopService.availableGpus.length - 1)];
+ const temp = gpu.temperature;
+ return `Temp: ${(temp === undefined || temp === null || temp === 0) ? '--°' : `${Math.round(temp)}°C`}`;
+ }
+ font.pixelSize: Theme.fontSizeSmall
+ font.family: SettingsData.monoFontFamily
+ color: {
+ if (!DgopService.availableGpus || DgopService.availableGpus.length === 0) {
+ return Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7);
+ }
+
+ const gpu = DgopService.availableGpus[Math.min(SessionData.selectedGpuIndex, DgopService.availableGpus.length - 1)];
+ const temp = gpu.temperature || 0;
+ if (temp > 80) {
+ return Theme.error;
+ }
+
+ if (temp > 60) {
+ return Theme.warning;
+ }
+
+ return Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7);
+ }
+ width: parent.width
+ elide: Text.ElideRight
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ Rectangle {
+ width: parent.width
+ height: storageColumn.implicitHeight + 2 * Theme.spacingL
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.6)
+ border.width: 0
+
+ Column {
+ id: storageColumn
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingS
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingS
+
+ DankIcon {
+ name: "storage"
+ size: Theme.iconSize
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Storage & Disks"
+ font.pixelSize: Theme.fontSizeLarge
+ font.family: SettingsData.monoFontFamily
+ font.weight: Font.Bold
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ }
+
+ Column {
+ width: parent.width
+ spacing: 2
+
+ Row {
+ width: parent.width
+ height: 24
+ spacing: Theme.spacingS
+
+ StyledText {
+ text: "Device"
+ font.pixelSize: Theme.fontSizeSmall
+ font.family: SettingsData.monoFontFamily
+ font.weight: Font.Bold
+ color: Theme.surfaceText
+ width: parent.width * 0.25
+ elide: Text.ElideRight
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ StyledText {
+ text: "Mount"
+ font.pixelSize: Theme.fontSizeSmall
+ font.family: SettingsData.monoFontFamily
+ font.weight: Font.Bold
+ color: Theme.surfaceText
+ width: parent.width * 0.2
+ elide: Text.ElideRight
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ StyledText {
+ text: "Size"
+ font.pixelSize: Theme.fontSizeSmall
+ font.family: SettingsData.monoFontFamily
+ font.weight: Font.Bold
+ color: Theme.surfaceText
+ width: parent.width * 0.15
+ elide: Text.ElideRight
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ StyledText {
+ text: "Used"
+ font.pixelSize: Theme.fontSizeSmall
+ font.family: SettingsData.monoFontFamily
+ font.weight: Font.Bold
+ color: Theme.surfaceText
+ width: parent.width * 0.15
+ elide: Text.ElideRight
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ StyledText {
+ text: "Available"
+ font.pixelSize: Theme.fontSizeSmall
+ font.family: SettingsData.monoFontFamily
+ font.weight: Font.Bold
+ color: Theme.surfaceText
+ width: parent.width * 0.15
+ elide: Text.ElideRight
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ StyledText {
+ text: "Use%"
+ font.pixelSize: Theme.fontSizeSmall
+ font.family: SettingsData.monoFontFamily
+ font.weight: Font.Bold
+ color: Theme.surfaceText
+ width: parent.width * 0.1
+ elide: Text.ElideRight
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ }
+
+ Repeater {
+ id: diskMountRepeater
+
+ model: DgopService.diskMounts
+
+ Rectangle {
+ width: parent.width
+ height: 24
+ radius: Theme.cornerRadius
+ color: diskMouseArea.containsMouse ? Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.04) : "transparent"
+
+ MouseArea {
+ id: diskMouseArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ }
+
+ Row {
+ anchors.fill: parent
+ spacing: Theme.spacingS
+
+ StyledText {
+ text: modelData.device
+ font.pixelSize: Theme.fontSizeSmall
+ font.family: SettingsData.monoFontFamily
+ color: Theme.surfaceText
+ width: parent.width * 0.25
+ elide: Text.ElideRight
+ anchors.verticalCenter: parent.verticalCenter
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ StyledText {
+ text: modelData.mount
+ font.pixelSize: Theme.fontSizeSmall
+ font.family: SettingsData.monoFontFamily
+ color: Theme.surfaceText
+ width: parent.width * 0.2
+ elide: Text.ElideRight
+ anchors.verticalCenter: parent.verticalCenter
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ StyledText {
+ text: modelData.size
+ font.pixelSize: Theme.fontSizeSmall
+ font.family: SettingsData.monoFontFamily
+ color: Theme.surfaceText
+ width: parent.width * 0.15
+ elide: Text.ElideRight
+ anchors.verticalCenter: parent.verticalCenter
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ StyledText {
+ text: modelData.used
+ font.pixelSize: Theme.fontSizeSmall
+ font.family: SettingsData.monoFontFamily
+ color: Theme.surfaceText
+ width: parent.width * 0.15
+ elide: Text.ElideRight
+ anchors.verticalCenter: parent.verticalCenter
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ StyledText {
+ text: modelData.avail
+ font.pixelSize: Theme.fontSizeSmall
+ font.family: SettingsData.monoFontFamily
+ color: Theme.surfaceText
+ width: parent.width * 0.15
+ elide: Text.ElideRight
+ anchors.verticalCenter: parent.verticalCenter
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ StyledText {
+ text: modelData.percent
+ font.pixelSize: Theme.fontSizeSmall
+ font.family: SettingsData.monoFontFamily
+ color: {
+ const percent = parseInt(modelData.percent);
+ if (percent > 90) {
+ return Theme.error;
+ }
+
+ if (percent > 75) {
+ return Theme.warning;
+ }
+
+ return Theme.surfaceText;
+ }
+ width: parent.width * 0.1
+ elide: Text.ElideRight
+ anchors.verticalCenter: parent.verticalCenter
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modules/Settings/AboutTab.qml b/quickshell/.config/quickshell/Modules/Settings/AboutTab.qml
new file mode 100644
index 0000000..feff20a
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/Settings/AboutTab.qml
@@ -0,0 +1,535 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Effects
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Item {
+ id: aboutTab
+
+ property bool isHyprland: CompositorService.isHyprland
+
+ DankFlickable {
+ anchors.fill: parent
+ anchors.topMargin: Theme.spacingL
+ clip: true
+ contentHeight: mainColumn.height
+ contentWidth: width
+
+ Column {
+ id: mainColumn
+
+ width: parent.width
+ spacing: Theme.spacingXL
+
+ // ASCII Art Header
+ StyledRect {
+ width: parent.width
+ height: asciiSection.implicitHeight + Theme.spacingL * 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b, 0.3)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g,
+ Theme.outline.b, 0.2)
+ border.width: 1
+
+ Column {
+ id: asciiSection
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingM
+
+ Item {
+ width: parent.width
+ height: asciiText.implicitHeight
+
+ StyledText {
+ id: asciiText
+
+ text: "██████╗ █████╗ ███╗ ██╗██╗ ██╗\n██╔══██╗██╔══██╗████╗ ██║██║ ██╔╝\n██║ ██║███████║██╔██╗ ██║█████╔╝ \n██║ ██║██╔══██║██║╚██╗██║██╔═██╗ \n██████╔╝██║ ██║██║ ╚████║██║ ██╗\n╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝"
+ isMonospace: true
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.primary
+ anchors.centerIn: parent
+ }
+ }
+
+ StyledText {
+ text: "DankMaterialShell"
+ font.pixelSize: Theme.fontSizeXLarge
+ font.weight: Font.Bold
+ color: Theme.surfaceText
+ horizontalAlignment: Text.AlignHCenter
+ width: parent.width
+ }
+
+ Item {
+ id: communityIcons
+ anchors.horizontalCenter: parent.horizontalCenter
+ height: 24
+ width: {
+ if (isHyprland) {
+ return compositorButton.width + discordButton.width + Theme.spacingM + redditButton.width + Theme.spacingM
+ } else {
+ return compositorButton.width + matrixButton.width + 4 + discordButton.width + Theme.spacingM + redditButton.width + Theme.spacingM
+ }
+ }
+
+ // Compositor logo (Niri or Hyprland)
+ Item {
+ id: compositorButton
+ width: 24
+ height: 24
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.verticalCenterOffset: -2
+ x: 0
+
+ property bool hovered: false
+ property string tooltipText: isHyprland ? "Hyprland Website" : "niri GitHub"
+
+ Image {
+ anchors.fill: parent
+ source: Qt.resolvedUrl(".").toString().replace(
+ "file://", "").replace(
+ "/Modules/Settings/",
+ "") + (isHyprland ? "/assets/hyprland.svg" : "/assets/niri.svg")
+ sourceSize: Qt.size(24, 24)
+ smooth: true
+ fillMode: Image.PreserveAspectFit
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ hoverEnabled: true
+ onEntered: parent.hovered = true
+ onExited: parent.hovered = false
+ onClicked: Qt.openUrlExternally(
+ isHyprland ? "https://hypr.land" : "https://github.com/YaLTeR/niri")
+ }
+ }
+
+ // Matrix button (only for Niri)
+ Item {
+ id: matrixButton
+ width: 30
+ height: 24
+ x: compositorButton.x + compositorButton.width + 4
+ visible: !isHyprland
+
+ property bool hovered: false
+ property string tooltipText: "niri Matrix Chat"
+
+ Image {
+ anchors.fill: parent
+ source: Qt.resolvedUrl(".").toString().replace(
+ "file://", "").replace(
+ "/Modules/Settings/",
+ "") + "/assets/matrix-logo-white.svg"
+ sourceSize: Qt.size(28, 18)
+ smooth: true
+ fillMode: Image.PreserveAspectFit
+ layer.enabled: true
+
+ layer.effect: MultiEffect {
+ colorization: 1
+ colorizationColor: Theme.surfaceText
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ hoverEnabled: true
+ onEntered: parent.hovered = true
+ onExited: parent.hovered = false
+ onClicked: Qt.openUrlExternally(
+ "https://matrix.to/#/#niri:matrix.org")
+ }
+ }
+
+ // Discord button
+ Item {
+ id: discordButton
+ width: 20
+ height: 20
+ x: isHyprland ? compositorButton.x + compositorButton.width + Theme.spacingM : matrixButton.x + matrixButton.width + Theme.spacingM
+ anchors.verticalCenter: parent.verticalCenter
+
+ property bool hovered: false
+ property string tooltipText: isHyprland ? "Hyprland Discord Server" : "niri Discord Server"
+
+ Image {
+ anchors.fill: parent
+ source: Qt.resolvedUrl(".").toString().replace(
+ "file://", "").replace(
+ "/Modules/Settings/",
+ "") + "/assets/discord.svg"
+ sourceSize: Qt.size(20, 20)
+ smooth: true
+ fillMode: Image.PreserveAspectFit
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ hoverEnabled: true
+ onEntered: parent.hovered = true
+ onExited: parent.hovered = false
+ onClicked: Qt.openUrlExternally(
+ isHyprland ? "https://discord.com/invite/hQ9XvMUjjr" : "https://discord.gg/vT8Sfjy7sx")
+ }
+ }
+
+ // Reddit button
+ Item {
+ id: redditButton
+ width: 20
+ height: 20
+ x: discordButton.x + discordButton.width + Theme.spacingM
+ anchors.verticalCenter: parent.verticalCenter
+
+ property bool hovered: false
+ property string tooltipText: isHyprland ? "r/hyprland Subreddit" : "r/niri Subreddit"
+
+ Image {
+ anchors.fill: parent
+ source: Qt.resolvedUrl(".").toString().replace(
+ "file://", "").replace(
+ "/Modules/Settings/",
+ "") + "/assets/reddit.svg"
+ sourceSize: Qt.size(20, 20)
+ smooth: true
+ fillMode: Image.PreserveAspectFit
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ hoverEnabled: true
+ onEntered: parent.hovered = true
+ onExited: parent.hovered = false
+ onClicked: Qt.openUrlExternally(
+ isHyprland ? "https://reddit.com/r/hyprland" : "https://reddit.com/r/niri")
+ }
+ }
+ }
+ }
+ }
+
+
+ // Project Information
+ StyledRect {
+ width: parent.width
+ height: projectSection.implicitHeight + Theme.spacingL * 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b, 0.3)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g,
+ Theme.outline.b, 0.2)
+ border.width: 1
+
+ Column {
+ id: projectSection
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingM
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "info"
+ size: Theme.iconSize
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "About"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ StyledText {
+ text: `DankMaterialShell is a modern desktop inspired by MUI 3.
+
The goal is to provide a high level of functionality and customization so that it can be a suitable replacement for complete desktop environments like Gnome, KDE, or Cosmic.
+ `
+ textFormat: Text.RichText
+ font.pixelSize: Theme.fontSizeMedium
+ linkColor: Theme.primary
+ onLinkActivated: url => Qt.openUrlExternally(url)
+ color: Theme.surfaceVariantText
+ width: parent.width
+ wrapMode: Text.WordWrap
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor
+ acceptedButtons: Qt.NoButton
+ propagateComposedEvents: true
+ }
+ }
+ }
+ }
+
+ // Technical Details
+ StyledRect {
+ width: parent.width
+ height: techSection.implicitHeight + Theme.spacingL * 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b, 0.3)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g,
+ Theme.outline.b, 0.2)
+ border.width: 1
+
+ Column {
+ id: techSection
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingM
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "code"
+ size: Theme.iconSize
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Technical Details"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ Grid {
+ width: parent.width
+ columns: 2
+ columnSpacing: Theme.spacingL
+ rowSpacing: Theme.spacingS
+
+ StyledText {
+ text: "Framework:"
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ }
+
+ StyledText {
+ text: `Quickshell`
+ linkColor: Theme.primary
+ textFormat: Text.RichText
+ onLinkActivated: url => Qt.openUrlExternally(url)
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceVariantText
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor
+ acceptedButtons: Qt.NoButton
+ propagateComposedEvents: true
+ }
+ }
+
+ StyledText {
+ text: "Language:"
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ }
+
+ StyledText {
+ text: "QML (Qt Modeling Language)"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceVariantText
+ }
+
+ StyledText {
+ text: "Compositor:"
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ }
+
+ Row {
+ spacing: 4
+
+ StyledText {
+ text: `niri`
+ font.pixelSize: Theme.fontSizeMedium
+ linkColor: Theme.primary
+ textFormat: Text.RichText
+ color: Theme.surfaceVariantText
+ onLinkActivated: url => Qt.openUrlExternally(url)
+ anchors.verticalCenter: parent.verticalCenter
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor
+ acceptedButtons: Qt.NoButton
+ propagateComposedEvents: true
+ }
+ }
+
+ StyledText {
+ text: "&"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceVariantText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: `hyprland`
+ font.pixelSize: Theme.fontSizeMedium
+ linkColor: Theme.primary
+ textFormat: Text.RichText
+ color: Theme.surfaceVariantText
+ onLinkActivated: url => Qt.openUrlExternally(url)
+ anchors.verticalCenter: parent.verticalCenter
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor
+ acceptedButtons: Qt.NoButton
+ propagateComposedEvents: true
+ }
+ }
+ }
+
+ StyledText {
+ text: "Github:"
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ }
+
+ Row {
+ spacing: 4
+
+ StyledText {
+ text: `DankMaterialShell`
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceVariantText
+ linkColor: Theme.primary
+ textFormat: Text.RichText
+ onLinkActivated: url => Qt.openUrlExternally(url)
+ anchors.verticalCenter: parent.verticalCenter
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor
+ acceptedButtons: Qt.NoButton
+ propagateComposedEvents: true
+ }
+ }
+
+ StyledText {
+ text: "- Support Us With a Star ⭐"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceVariantText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ StyledText {
+ text: "System Monitoring:"
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ }
+
+ Row {
+ spacing: 4
+
+ StyledText {
+ text: `dgop`
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceVariantText
+ linkColor: Theme.primary
+ textFormat: Text.RichText
+ onLinkActivated: url => Qt.openUrlExternally(url)
+ anchors.verticalCenter: parent.verticalCenter
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor
+ acceptedButtons: Qt.NoButton
+ propagateComposedEvents: true
+ }
+ }
+
+ StyledText {
+ text: "- Stateless System Monitoring"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceVariantText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+ }
+ }
+ }
+
+ }
+ }
+
+ // Community tooltip - positioned absolutely above everything
+ Rectangle {
+ id: communityTooltip
+ parent: aboutTab
+ z: 1000
+
+ property var hoveredButton: {
+ if (compositorButton.hovered) return compositorButton
+ if (matrixButton.visible && matrixButton.hovered) return matrixButton
+ if (discordButton.hovered) return discordButton
+ if (redditButton.hovered) return redditButton
+ return null
+ }
+
+ property string tooltipText: hoveredButton ? hoveredButton.tooltipText : ""
+
+ visible: hoveredButton !== null && tooltipText !== ""
+ width: tooltipLabel.implicitWidth + 24
+ height: tooltipLabel.implicitHeight + 12
+
+ color: Theme.surfaceContainer
+ radius: Theme.cornerRadius
+ border.width: 1
+ border.color: Theme.outlineMedium
+
+ x: hoveredButton ? hoveredButton.mapToItem(aboutTab, hoveredButton.width / 2, 0).x - width / 2 : 0
+ y: hoveredButton ? communityIcons.mapToItem(aboutTab, 0, 0).y - height - 8 : 0
+
+ layer.enabled: true
+ layer.effect: MultiEffect {
+ shadowEnabled: true
+ shadowOpacity: 0.15
+ shadowVerticalOffset: 2
+ shadowBlur: 0.5
+ }
+
+ StyledText {
+ id: tooltipLabel
+ anchors.centerIn: parent
+ text: communityTooltip.tooltipText
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/Settings/DisplaysTab.qml b/quickshell/.config/quickshell/Modules/Settings/DisplaysTab.qml
new file mode 100644
index 0000000..add096f
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/Settings/DisplaysTab.qml
@@ -0,0 +1,369 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import Quickshell
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Item {
+ id: displaysTab
+
+ property var variantComponents: [{
+ "id": "topBar",
+ "name": "Top Bar",
+ "description": "System bar with widgets and system information",
+ "icon": "toolbar"
+ }, {
+ "id": "dock",
+ "name": "Application Dock",
+ "description": "Bottom dock for pinned and running applications",
+ "icon": "dock"
+ }, {
+ "id": "notifications",
+ "name": "Notification Popups",
+ "description": "Notification toast popups",
+ "icon": "notifications"
+ }, {
+ "id": "wallpaper",
+ "name": "Wallpaper",
+ "description": "Desktop background images",
+ "icon": "wallpaper"
+ }, {
+ "id": "osd",
+ "name": "On-Screen Displays",
+ "description": "Volume, brightness, and other system OSDs",
+ "icon": "picture_in_picture"
+ }, {
+ "id": "toast",
+ "name": "Toast Messages",
+ "description": "System toast notifications",
+ "icon": "campaign"
+ }, {
+ "id": "notepad",
+ "name": "Notepad Slideout",
+ "description": "Quick note-taking slideout panel",
+ "icon": "sticky_note_2"
+ }, {
+ "id": "systemTray",
+ "name": "System Tray",
+ "description": "System tray icons",
+ "icon": "notifications"
+ }]
+
+ function getScreenPreferences(componentId) {
+ return SettingsData.screenPreferences && SettingsData.screenPreferences[componentId] || ["all"];
+ }
+
+ function setScreenPreferences(componentId, screenNames) {
+ var prefs = SettingsData.screenPreferences || {
+ };
+ prefs[componentId] = screenNames;
+ SettingsData.setScreenPreferences(prefs);
+ }
+
+ DankFlickable {
+ anchors.fill: parent
+ anchors.topMargin: Theme.spacingL
+ anchors.bottomMargin: Theme.spacingS
+ clip: true
+ contentHeight: mainColumn.height
+ contentWidth: width
+
+ Column {
+ id: mainColumn
+
+ width: parent.width
+ spacing: Theme.spacingXL
+
+ StyledRect {
+ width: parent.width
+ height: screensInfoSection.implicitHeight + Theme.spacingL * 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
+ border.width: 1
+
+ Column {
+ id: screensInfoSection
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingM
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "monitor"
+ size: Theme.iconSize
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ width: parent.width - Theme.iconSize - Theme.spacingM
+ spacing: Theme.spacingXS
+ anchors.verticalCenter: parent.verticalCenter
+
+ StyledText {
+ text: "Connected Displays"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ }
+
+ StyledText {
+ text: "Configure which displays show shell components"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ wrapMode: Text.WordWrap
+ width: parent.width
+ }
+
+ }
+
+ }
+
+ Column {
+ width: parent.width
+ spacing: Theme.spacingS
+
+ StyledText {
+ text: "Available Screens (" + Quickshell.screens.length + ")"
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ }
+
+ Repeater {
+ model: Quickshell.screens
+
+ delegate: Rectangle {
+ width: parent.width
+ height: screenRow.implicitHeight + Theme.spacingS * 2
+ radius: Theme.cornerRadius
+ color: Theme.surfaceContainerHigh
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.3)
+ border.width: 1
+
+ Row {
+ id: screenRow
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingS
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "desktop_windows"
+ size: Theme.iconSize - 4
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ width: parent.width - Theme.iconSize - Theme.spacingM * 2
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingXS / 2
+
+ StyledText {
+ text: modelData.name
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ }
+
+ Row {
+ spacing: Theme.spacingS
+
+ StyledText {
+ text: modelData.width + "×" + modelData.height
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ }
+
+ StyledText {
+ text: "•"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ }
+
+ StyledText {
+ text: modelData.model || "Unknown Model"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ Column {
+ width: parent.width
+ spacing: Theme.spacingL
+
+ Repeater {
+ model: displaysTab.variantComponents
+
+ delegate: StyledRect {
+ width: parent.width
+ height: componentSection.implicitHeight + Theme.spacingL * 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
+ border.width: 1
+
+ Column {
+ id: componentSection
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingM
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: modelData.icon
+ size: Theme.iconSize
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ width: parent.width - Theme.iconSize - Theme.spacingM
+ spacing: Theme.spacingXS
+ anchors.verticalCenter: parent.verticalCenter
+
+ StyledText {
+ text: modelData.name
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ }
+
+ StyledText {
+ text: modelData.description
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ wrapMode: Text.WordWrap
+ width: parent.width
+ }
+
+ }
+
+ }
+
+ Column {
+ width: parent.width
+ spacing: Theme.spacingS
+
+ StyledText {
+ text: "Show on screens:"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ }
+
+ Column {
+ property string componentId: modelData.id
+ property var selectedScreens: displaysTab.getScreenPreferences(componentId)
+
+ width: parent.width
+ spacing: Theme.spacingXS
+
+ DankToggle {
+ width: parent.width
+ text: "All displays"
+ description: "Show on all connected displays"
+ checked: parent.selectedScreens.includes("all")
+ onToggled: (checked) => {
+ if (checked)
+ displaysTab.setScreenPreferences(parent.componentId, ["all"]);
+ else
+ displaysTab.setScreenPreferences(parent.componentId, []);
+ }
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 1
+ color: Theme.outline
+ opacity: 0.2
+ visible: !parent.selectedScreens.includes("all")
+ }
+
+ Column {
+ width: parent.width
+ spacing: Theme.spacingXS
+ visible: !parent.selectedScreens.includes("all")
+
+ Repeater {
+ model: Quickshell.screens
+
+ delegate: DankToggle {
+ property string screenName: modelData.name
+ property string componentId: parent.parent.componentId
+
+ width: parent.width
+ text: screenName
+ description: modelData.width + "×" + modelData.height + " • " + (modelData.model || "Unknown Model")
+ checked: {
+ var prefs = displaysTab.getScreenPreferences(componentId);
+ return !prefs.includes("all") && prefs.includes(screenName);
+ }
+ onToggled: (checked) => {
+ var currentPrefs = displaysTab.getScreenPreferences(componentId);
+ if (currentPrefs.includes("all"))
+ currentPrefs = [];
+
+ var newPrefs = currentPrefs.slice();
+ if (checked) {
+ if (!newPrefs.includes(screenName))
+ newPrefs.push(screenName);
+
+ } else {
+ var index = newPrefs.indexOf(screenName);
+ if (index > -1)
+ newPrefs.splice(index, 1);
+
+ }
+ displaysTab.setScreenPreferences(componentId, newPrefs);
+ }
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modules/Settings/PersonalizationTab.qml b/quickshell/.config/quickshell/Modules/Settings/PersonalizationTab.qml
new file mode 100644
index 0000000..0559671
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/Settings/PersonalizationTab.qml
@@ -0,0 +1,1439 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Effects
+import Quickshell
+import qs.Common
+import qs.Modals
+import qs.Modals.FileBrowser
+import qs.Services
+import qs.Widgets
+
+Item {
+ id: personalizationTab
+
+ property alias wallpaperBrowser: wallpaperBrowser
+ property var parentModal: null
+ property var cachedFontFamilies: []
+ property var cachedMonoFamilies: []
+ property bool fontsEnumerated: false
+ property string selectedMonitorName: {
+ var screens = Quickshell.screens
+ return screens.length > 0 ? screens[0].name : ""
+ }
+
+ function enumerateFonts() {
+ var fonts = ["Default"]
+ var availableFonts = Qt.fontFamilies()
+ var rootFamilies = []
+ var seenFamilies = new Set()
+ for (var i = 0; i < availableFonts.length; i++) {
+ var fontName = availableFonts[i]
+ if (fontName.startsWith("."))
+ continue
+
+ if (fontName === SettingsData.defaultFontFamily)
+ continue
+
+ var rootName = fontName.replace(/ (Thin|Extra Light|Light|Regular|Medium|Semi Bold|Demi Bold|Bold|Extra Bold|Black|Heavy)$/i, "").replace(/ (Italic|Oblique|Condensed|Extended|Narrow|Wide)$/i,
+ "").replace(/ (UI|Display|Text|Mono|Sans|Serif)$/i, function (match, suffix) {
+ return match
+ }).trim()
+ if (!seenFamilies.has(rootName) && rootName !== "") {
+ seenFamilies.add(rootName)
+ rootFamilies.push(rootName)
+ }
+ }
+ cachedFontFamilies = fonts.concat(rootFamilies.sort())
+ var monoFonts = ["Default"]
+ var monoFamilies = []
+ var seenMonoFamilies = new Set()
+ for (var j = 0; j < availableFonts.length; j++) {
+ var fontName2 = availableFonts[j]
+ if (fontName2.startsWith("."))
+ continue
+
+ if (fontName2 === SettingsData.defaultMonoFontFamily)
+ continue
+
+ var lowerName = fontName2.toLowerCase()
+ if (lowerName.includes("mono") || lowerName.includes("code") || lowerName.includes("console") || lowerName.includes("terminal") || lowerName.includes("courier") || lowerName.includes("dejavu sans mono") || lowerName.includes(
+ "jetbrains") || lowerName.includes("fira") || lowerName.includes("hack") || lowerName.includes("source code") || lowerName.includes("ubuntu mono") || lowerName.includes("cascadia")) {
+ var rootName2 = fontName2.replace(/ (Thin|Extra Light|Light|Regular|Medium|Semi Bold|Demi Bold|Bold|Extra Bold|Black|Heavy)$/i, "").replace(/ (Italic|Oblique|Condensed|Extended|Narrow|Wide)$/i, "").trim()
+ if (!seenMonoFamilies.has(rootName2) && rootName2 !== "") {
+ seenMonoFamilies.add(rootName2)
+ monoFamilies.push(rootName2)
+ }
+ }
+ }
+ cachedMonoFamilies = monoFonts.concat(monoFamilies.sort())
+ }
+
+ Component.onCompleted: {
+ // Access WallpaperCyclingService to ensure it's initialized
+ WallpaperCyclingService.cyclingActive
+ if (!fontsEnumerated) {
+ enumerateFonts()
+ fontsEnumerated = true
+ }
+ }
+
+ DankFlickable {
+ anchors.fill: parent
+ anchors.topMargin: Theme.spacingL
+ clip: true
+ contentHeight: mainColumn.height
+ contentWidth: width
+
+ Column {
+ id: mainColumn
+
+ width: parent.width
+ spacing: Theme.spacingXL
+
+ // Wallpaper Section
+ StyledRect {
+ width: parent.width
+ height: wallpaperSection.implicitHeight + Theme.spacingL * 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
+ border.width: 1
+
+ Column {
+ id: wallpaperSection
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingM
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "wallpaper"
+ size: Theme.iconSize
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Wallpaper"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingL
+
+ StyledRect {
+ width: 160
+ height: 90
+ radius: Theme.cornerRadius
+ color: Theme.surfaceVariant
+ border.color: Theme.outline
+ border.width: 1
+
+ CachingImage {
+ anchors.fill: parent
+ anchors.margins: 1
+ source: {
+ var currentWallpaper = SessionData.perMonitorWallpaper ? SessionData.getMonitorWallpaper(selectedMonitorName) : SessionData.wallpaperPath
+ return (currentWallpaper !== "" && !currentWallpaper.startsWith("#")) ? "file://" + currentWallpaper : ""
+ }
+ fillMode: Image.PreserveAspectCrop
+ visible: {
+ var currentWallpaper = SessionData.perMonitorWallpaper ? SessionData.getMonitorWallpaper(selectedMonitorName) : SessionData.wallpaperPath
+ return currentWallpaper !== "" && !currentWallpaper.startsWith("#")
+ }
+ maxCacheSize: 160
+ layer.enabled: true
+
+ layer.effect: MultiEffect {
+ maskEnabled: true
+ maskSource: wallpaperMask
+ maskThresholdMin: 0.5
+ maskSpreadAtMin: 1
+ }
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ anchors.margins: 1
+ radius: Theme.cornerRadius - 1
+ color: {
+ var currentWallpaper = SessionData.perMonitorWallpaper ? SessionData.getMonitorWallpaper(selectedMonitorName) : SessionData.wallpaperPath
+ return currentWallpaper.startsWith("#") ? currentWallpaper : "transparent"
+ }
+ visible: {
+ var currentWallpaper = SessionData.perMonitorWallpaper ? SessionData.getMonitorWallpaper(selectedMonitorName) : SessionData.wallpaperPath
+ return currentWallpaper !== "" && currentWallpaper.startsWith("#")
+ }
+ }
+
+ Rectangle {
+ id: wallpaperMask
+
+ anchors.fill: parent
+ anchors.margins: 1
+ radius: Theme.cornerRadius - 1
+ color: "black"
+ visible: false
+ layer.enabled: true
+ }
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "image"
+ size: Theme.iconSizeLarge + 8
+ color: Theme.surfaceVariantText
+ visible: {
+ var currentWallpaper = SessionData.perMonitorWallpaper ? SessionData.getMonitorWallpaper(selectedMonitorName) : SessionData.wallpaperPath
+ return currentWallpaper === ""
+ }
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ anchors.margins: 1
+ radius: Theme.cornerRadius - 1
+ color: Qt.rgba(0, 0, 0, 0.7)
+ visible: wallpaperMouseArea.containsMouse
+
+ Row {
+ anchors.centerIn: parent
+ spacing: 4
+
+ Rectangle {
+ width: 32
+ height: 32
+ radius: 16
+ color: Qt.rgba(255, 255, 255, 0.9)
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "folder_open"
+ size: 18
+ color: "black"
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ if (parentModal) {
+ parentModal.allowFocusOverride = true
+ parentModal.shouldHaveFocus = false
+ }
+ wallpaperBrowser.open()
+ }
+ }
+ }
+
+ Rectangle {
+ width: 32
+ height: 32
+ radius: 16
+ color: Qt.rgba(255, 255, 255, 0.9)
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "palette"
+ size: 18
+ color: "black"
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ colorPicker.open()
+ }
+ }
+ }
+
+ Rectangle {
+ width: 32
+ height: 32
+ radius: 16
+ color: Qt.rgba(255, 255, 255, 0.9)
+ visible: {
+ var currentWallpaper = SessionData.perMonitorWallpaper ? SessionData.getMonitorWallpaper(selectedMonitorName) : SessionData.wallpaperPath
+ return currentWallpaper !== ""
+ }
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "clear"
+ size: 18
+ color: "black"
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ if (SessionData.perMonitorWallpaper) {
+ SessionData.setMonitorWallpaper(selectedMonitorName, "")
+ } else {
+ if (Theme.currentTheme === Theme.dynamic)
+ Theme.switchTheme("blue")
+ SessionData.clearWallpaper()
+ }
+ }
+ }
+ }
+ }
+ }
+
+ MouseArea {
+ id: wallpaperMouseArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ propagateComposedEvents: true
+ acceptedButtons: Qt.NoButton
+ }
+ }
+
+ Column {
+ width: parent.width - 160 - Theme.spacingL
+ spacing: Theme.spacingS
+ anchors.verticalCenter: parent.verticalCenter
+
+ StyledText {
+ text: {
+ var currentWallpaper = SessionData.perMonitorWallpaper ? SessionData.getMonitorWallpaper(selectedMonitorName) : SessionData.wallpaperPath
+ return currentWallpaper ? currentWallpaper.split('/').pop() : "No wallpaper selected"
+ }
+ font.pixelSize: Theme.fontSizeLarge
+ color: Theme.surfaceText
+ elide: Text.ElideMiddle
+ maximumLineCount: 1
+ width: parent.width
+ }
+
+ StyledText {
+ text: {
+ var currentWallpaper = SessionData.perMonitorWallpaper ? SessionData.getMonitorWallpaper(selectedMonitorName) : SessionData.wallpaperPath
+ return currentWallpaper ? currentWallpaper : ""
+ }
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ elide: Text.ElideMiddle
+ maximumLineCount: 1
+ width: parent.width
+ visible: {
+ var currentWallpaper = SessionData.perMonitorWallpaper ? SessionData.getMonitorWallpaper(selectedMonitorName) : SessionData.wallpaperPath
+ return currentWallpaper !== ""
+ }
+ }
+
+ Row {
+ spacing: Theme.spacingS
+ visible: {
+ var currentWallpaper = SessionData.perMonitorWallpaper ? SessionData.getMonitorWallpaper(selectedMonitorName) : SessionData.wallpaperPath
+ return currentWallpaper !== ""
+ }
+
+ DankActionButton {
+ buttonSize: 32
+ iconName: "skip_previous"
+ iconSize: Theme.iconSizeSmall
+ enabled: {
+ var currentWallpaper = SessionData.perMonitorWallpaper ? SessionData.getMonitorWallpaper(selectedMonitorName) : SessionData.wallpaperPath
+ return currentWallpaper && !currentWallpaper.startsWith("#")
+ }
+ opacity: {
+ var currentWallpaper = SessionData.perMonitorWallpaper ? SessionData.getMonitorWallpaper(selectedMonitorName) : SessionData.wallpaperPath
+ return (currentWallpaper && !currentWallpaper.startsWith("#")) ? 1 : 0.5
+ }
+ backgroundColor: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.5)
+ iconColor: Theme.surfaceText
+ onClicked: {
+ if (SessionData.perMonitorWallpaper) {
+ WallpaperCyclingService.cyclePrevForMonitor(selectedMonitorName)
+ } else {
+ WallpaperCyclingService.cyclePrevManually()
+ }
+ }
+ }
+
+ DankActionButton {
+ buttonSize: 32
+ iconName: "skip_next"
+ iconSize: Theme.iconSizeSmall
+ enabled: {
+ var currentWallpaper = SessionData.perMonitorWallpaper ? SessionData.getMonitorWallpaper(selectedMonitorName) : SessionData.wallpaperPath
+ return currentWallpaper && !currentWallpaper.startsWith("#")
+ }
+ opacity: {
+ var currentWallpaper = SessionData.perMonitorWallpaper ? SessionData.getMonitorWallpaper(selectedMonitorName) : SessionData.wallpaperPath
+ return (currentWallpaper && !currentWallpaper.startsWith("#")) ? 1 : 0.5
+ }
+ backgroundColor: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.5)
+ iconColor: Theme.surfaceText
+ onClicked: {
+ if (SessionData.perMonitorWallpaper) {
+ WallpaperCyclingService.cycleNextForMonitor(selectedMonitorName)
+ } else {
+ WallpaperCyclingService.cycleNextManually()
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Per-Monitor Wallpaper Section - Full Width
+ Rectangle {
+ width: parent.width
+ height: 1
+ color: Theme.outline
+ opacity: 0.2
+ visible: SessionData.wallpaperPath !== ""
+ }
+
+ Column {
+ width: parent.width
+ spacing: Theme.spacingM
+ visible: SessionData.wallpaperPath !== ""
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "monitor"
+ size: Theme.iconSize
+ color: SessionData.perMonitorWallpaper ? Theme.primary : Theme.surfaceVariantText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ width: parent.width - Theme.iconSize - Theme.spacingM - perMonitorToggle.width - Theme.spacingM
+ spacing: Theme.spacingXS
+ anchors.verticalCenter: parent.verticalCenter
+
+ StyledText {
+ text: "Per-Monitor Wallpapers"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ }
+
+ StyledText {
+ text: "Set different wallpapers for each connected monitor"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ width: parent.width
+ }
+ }
+
+ DankToggle {
+ id: perMonitorToggle
+
+ anchors.verticalCenter: parent.verticalCenter
+ checked: SessionData.perMonitorWallpaper
+ onToggled: toggled => {
+ return SessionData.setPerMonitorWallpaper(toggled)
+ }
+ }
+ }
+
+ Column {
+ width: parent.width
+ spacing: Theme.spacingS
+ visible: SessionData.perMonitorWallpaper
+ leftPadding: Theme.iconSize + Theme.spacingM
+
+ StyledText {
+ text: "Monitor Selection:"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ }
+
+ DankDropdown {
+ id: monitorDropdown
+
+ width: parent.width - parent.leftPadding
+ text: "Monitor"
+ description: "Select monitor to configure wallpaper"
+ currentValue: selectedMonitorName || "No monitors"
+ options: {
+ var screenNames = []
+ var screens = Quickshell.screens
+ for (var i = 0; i < screens.length; i++) {
+ screenNames.push(screens[i].name)
+ }
+ return screenNames
+ }
+ onValueChanged: value => {
+ selectedMonitorName = value
+ }
+ }
+ }
+ }
+
+ // Wallpaper Cycling Section - Full Width
+ Rectangle {
+ width: parent.width
+ height: 1
+ color: Theme.outline
+ opacity: 0.2
+ visible: SessionData.wallpaperPath !== "" && !SessionData.perMonitorWallpaper
+ }
+
+ Column {
+ width: parent.width
+ spacing: Theme.spacingM
+ visible: SessionData.wallpaperPath !== "" && !SessionData.perMonitorWallpaper
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "schedule"
+ size: Theme.iconSize
+ color: SessionData.wallpaperCyclingEnabled ? Theme.primary : Theme.surfaceVariantText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ width: parent.width - Theme.iconSize - Theme.spacingM - cyclingToggle.width - Theme.spacingM
+ spacing: Theme.spacingXS
+ anchors.verticalCenter: parent.verticalCenter
+
+ StyledText {
+ text: "Automatic Cycling"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ }
+
+ StyledText {
+ text: "Automatically cycle through wallpapers in the same folder"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ width: parent.width
+ }
+ }
+
+ DankToggle {
+ id: cyclingToggle
+
+ anchors.verticalCenter: parent.verticalCenter
+ checked: SessionData.wallpaperCyclingEnabled
+ enabled: !SessionData.perMonitorWallpaper
+ onToggled: toggled => {
+ return SessionData.setWallpaperCyclingEnabled(toggled)
+ }
+ }
+ }
+
+ // Cycling mode and settings
+ Column {
+ width: parent.width
+ spacing: Theme.spacingS
+ visible: SessionData.wallpaperCyclingEnabled
+ leftPadding: Theme.iconSize + Theme.spacingM
+
+ Row {
+ spacing: Theme.spacingL
+ width: parent.width - parent.leftPadding
+
+ StyledText {
+ text: "Mode:"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Item {
+ width: 200
+ height: 45 + Theme.spacingM
+
+ DankTabBar {
+ id: modeTabBar
+
+ width: 200
+ height: 45
+ model: [{
+ "text": "Interval",
+ "icon": "schedule"
+ }, {
+ "text": "Time",
+ "icon": "access_time"
+ }]
+ currentIndex: SessionData.wallpaperCyclingMode === "time" ? 1 : 0
+ onTabClicked: index => {
+ SessionData.setWallpaperCyclingMode(index === 1 ? "time" : "interval")
+ }
+ }
+ }
+ }
+
+ // Interval settings
+ DankDropdown {
+ property var intervalOptions: ["1 minute", "5 minutes", "15 minutes", "30 minutes", "1 hour", "1.5 hours", "2 hours", "3 hours", "4 hours", "6 hours", "8 hours", "12 hours"]
+ property var intervalValues: [60, 300, 900, 1800, 3600, 5400, 7200, 10800, 14400, 21600, 28800, 43200]
+
+ width: parent.width - parent.leftPadding
+ visible: SessionData.wallpaperCyclingMode === "interval"
+ text: "Interval"
+ description: "How often to change wallpaper"
+ options: intervalOptions
+ currentValue: {
+ const currentSeconds = SessionData.wallpaperCyclingInterval
+ const index = intervalValues.indexOf(currentSeconds)
+ return index >= 0 ? intervalOptions[index] : "5 minutes"
+ }
+ onValueChanged: value => {
+ const index = intervalOptions.indexOf(value)
+ if (index >= 0)
+ SessionData.setWallpaperCyclingInterval(intervalValues[index])
+ }
+ }
+
+ // Time settings
+ Row {
+ spacing: Theme.spacingM
+ visible: SessionData.wallpaperCyclingMode === "time"
+ width: parent.width - parent.leftPadding
+
+ StyledText {
+ text: "Daily at:"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ DankTextField {
+ width: 100
+ height: 40
+ text: SessionData.wallpaperCyclingTime
+ placeholderText: "00:00"
+ maximumLength: 5
+ topPadding: Theme.spacingS
+ bottomPadding: Theme.spacingS
+ onAccepted: {
+ var isValid = /^([0-1][0-9]|2[0-3]):[0-5][0-9]$/.test(text)
+ if (isValid)
+ SessionData.setWallpaperCyclingTime(text)
+ else
+ text = SessionData.wallpaperCyclingTime
+ }
+ onEditingFinished: {
+ var isValid = /^([0-1][0-9]|2[0-3]):[0-5][0-9]$/.test(text)
+ if (isValid)
+ SessionData.setWallpaperCyclingTime(text)
+ else
+ text = SessionData.wallpaperCyclingTime
+ }
+ anchors.verticalCenter: parent.verticalCenter
+
+ validator: RegularExpressionValidator {
+ regularExpression: /^([0-1][0-9]|2[0-3]):[0-5][0-9]$/
+ }
+ }
+
+ StyledText {
+ text: "24-hour format"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Dynamic Theme Section
+ StyledRect {
+ width: parent.width
+ height: dynamicThemeSection.implicitHeight + Theme.spacingL * 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
+ border.width: 1
+
+ Column {
+ id: dynamicThemeSection
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingM
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "palette"
+ size: Theme.iconSize
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ width: parent.width - Theme.iconSize - Theme.spacingM - toggle.width - Theme.spacingM
+ spacing: Theme.spacingXS
+ anchors.verticalCenter: parent.verticalCenter
+
+ StyledText {
+ text: "Dynamic Theming"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ }
+
+ StyledText {
+ text: "Automatically extract colors from wallpaper"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ wrapMode: Text.WordWrap
+ width: parent.width
+ }
+ }
+
+ DankToggle {
+ id: toggle
+
+ anchors.verticalCenter: parent.verticalCenter
+ checked: Theme.wallpaperPath !== "" && Theme.currentTheme === Theme.dynamic
+ enabled: ToastService.wallpaperErrorStatus !== "matugen_missing" && Theme.wallpaperPath !== ""
+ onToggled: toggled => {
+ if (toggled)
+ Theme.switchTheme(Theme.dynamic)
+ else
+ Theme.switchTheme("blue")
+ }
+ }
+ }
+
+ StyledText {
+ text: "matugen not detected - dynamic theming unavailable"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.error
+ visible: ToastService.wallpaperErrorStatus === "matugen_missing"
+ width: parent.width
+ leftPadding: Theme.iconSize + Theme.spacingM
+ }
+ }
+ }
+
+ // Display Settings
+ StyledRect {
+ width: parent.width
+ height: displaySection.implicitHeight + Theme.spacingL * 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
+ border.width: 1
+
+ Column {
+ id: displaySection
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingM
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "monitor"
+ size: Theme.iconSize
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Display Settings"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ DankToggle {
+ width: parent.width
+ text: "Light Mode"
+ description: "Use light theme instead of dark theme"
+ checked: SessionData.isLightMode
+ onToggled: checked => {
+ Theme.setLightMode(checked)
+ }
+ }
+
+ DankToggle {
+ width: parent.width
+ text: "Hide Brightness Slider"
+ description: "Hide the brightness slider in Control Center and make audio slider full width"
+ checked: SettingsData.hideBrightnessSlider
+ onToggled: checked => {
+ SettingsData.setHideBrightnessSlider(checked)
+ }
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 1
+ color: Theme.outline
+ opacity: 0.2
+ }
+
+ DankToggle {
+ id: nightModeToggle
+
+ width: parent.width
+ text: "Night Mode"
+ description: "Apply warm color temperature to reduce eye strain. Use automation settings below to control when it activates."
+ checked: DisplayService.nightModeEnabled
+ onToggled: checked => {
+ DisplayService.toggleNightMode()
+ }
+
+ Connections {
+ function onNightModeEnabledChanged() {
+ nightModeToggle.checked = DisplayService.nightModeEnabled
+ }
+
+ target: DisplayService
+ }
+ }
+
+ DankDropdown {
+ width: parent.width
+ text: "Temperature"
+ description: "Color temperature for night mode"
+ currentValue: SessionData.nightModeTemperature + "K"
+ options: {
+ var temps = []
+ for (var i = 2500; i <= 6000; i += 500) {
+ temps.push(i + "K")
+ }
+ return temps
+ }
+ onValueChanged: value => {
+ var temp = parseInt(value.replace("K", ""))
+ SessionData.setNightModeTemperature(temp)
+ }
+ }
+
+ DankToggle {
+ id: automaticToggle
+ width: parent.width
+ text: "Automatic Control"
+ description: "Only adjust gamma based on time or location rules."
+ checked: SessionData.nightModeAutoEnabled
+ onToggled: checked => {
+ if (checked && !DisplayService.nightModeEnabled) {
+ DisplayService.toggleNightMode()
+ } else if (!checked && DisplayService.nightModeEnabled) {
+ DisplayService.toggleNightMode()
+ }
+ SessionData.setNightModeAutoEnabled(checked)
+ }
+
+ Connections {
+ target: SessionData
+ function onNightModeAutoEnabledChanged() {
+ automaticToggle.checked = SessionData.nightModeAutoEnabled
+ }
+ }
+ }
+
+ Column {
+ id: automaticSettings
+ width: parent.width
+ spacing: Theme.spacingS
+ visible: SessionData.nightModeAutoEnabled
+ leftPadding: Theme.spacingM
+
+ Connections {
+ target: SessionData
+ function onNightModeAutoEnabledChanged() {
+ automaticSettings.visible = SessionData.nightModeAutoEnabled
+ }
+ }
+
+ Item {
+ width: 200
+ height: 45 + Theme.spacingM
+
+ DankTabBar {
+ id: modeTabBarNight
+ width: 200
+ height: 45
+ model: [{
+ "text": "Time",
+ "icon": "access_time"
+ }, {
+ "text": "Location",
+ "icon": "place"
+ }]
+
+ Component.onCompleted: {
+ currentIndex = SessionData.nightModeAutoMode === "location" ? 1 : 0
+ Qt.callLater(updateIndicator)
+ }
+
+ onTabClicked: index => {
+ console.log("Tab clicked:", index, "Setting mode to:", index === 1 ? "location" : "time")
+ DisplayService.setNightModeAutomationMode(index === 1 ? "location" : "time")
+ currentIndex = index
+ }
+
+ Connections {
+ target: SessionData
+ function onNightModeAutoModeChanged() {
+ modeTabBarNight.currentIndex = SessionData.nightModeAutoMode === "location" ? 1 : 0
+ Qt.callLater(modeTabBarNight.updateIndicator)
+ }
+ }
+ }
+ }
+
+ Column {
+ property bool isTimeMode: SessionData.nightModeAutoMode === "time"
+ visible: isTimeMode
+ spacing: Theme.spacingM
+
+ // Header row
+ Row {
+ spacing: Theme.spacingM
+ height: 20
+ leftPadding: 45
+
+ StyledText {
+ text: "Hour"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ width: 50
+ horizontalAlignment: Text.AlignHCenter
+ anchors.bottom: parent.bottom
+ }
+
+ StyledText {
+ text: "Minute"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ width: 50
+ horizontalAlignment: Text.AlignHCenter
+ anchors.bottom: parent.bottom
+ }
+ }
+
+ // Start time row
+ Row {
+ spacing: Theme.spacingM
+ height: 32
+
+ StyledText {
+ id: startLabel
+ text: "Start"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ width: 50
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ DankDropdown {
+ width: 60
+ height: 32
+ text: ""
+ currentValue: SessionData.nightModeStartHour.toString()
+ options: {
+ var hours = []
+ for (var i = 0; i < 24; i++) {
+ hours.push(i.toString())
+ }
+ return hours
+ }
+ onValueChanged: value => {
+ SessionData.setNightModeStartHour(parseInt(value))
+ }
+ }
+
+ DankDropdown {
+ width: 60
+ height: 32
+ text: ""
+ currentValue: SessionData.nightModeStartMinute.toString().padStart(2, '0')
+ options: {
+ var minutes = []
+ for (var i = 0; i < 60; i += 5) {
+ minutes.push(i.toString().padStart(2, '0'))
+ }
+ return minutes
+ }
+ onValueChanged: value => {
+ SessionData.setNightModeStartMinute(parseInt(value))
+ }
+ }
+ }
+
+ // End time row
+ Row {
+ spacing: Theme.spacingM
+ height: 32
+
+ StyledText {
+ text: "End"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ width: startLabel.width
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ DankDropdown {
+ width: 60
+ height: 32
+ text: ""
+ currentValue: SessionData.nightModeEndHour.toString()
+ options: {
+ var hours = []
+ for (var i = 0; i < 24; i++) {
+ hours.push(i.toString())
+ }
+ return hours
+ }
+ onValueChanged: value => {
+ SessionData.setNightModeEndHour(parseInt(value))
+ }
+ }
+
+ DankDropdown {
+ width: 60
+ height: 32
+ text: ""
+ currentValue: SessionData.nightModeEndMinute.toString().padStart(2, '0')
+ options: {
+ var minutes = []
+ for (var i = 0; i < 60; i += 5) {
+ minutes.push(i.toString().padStart(2, '0'))
+ }
+ return minutes
+ }
+ onValueChanged: value => {
+ SessionData.setNightModeEndMinute(parseInt(value))
+ }
+ }
+ }
+ }
+
+ Column {
+ property bool isLocationMode: SessionData.nightModeAutoMode === "location"
+ visible: isLocationMode
+ spacing: Theme.spacingM
+ width: parent.width
+
+ DankToggle {
+ width: parent.width
+ text: "Auto-location"
+ description: DisplayService.geoclueAvailable ? "Use automatic location detection (geoclue2)" : "Geoclue service not running - cannot auto-detect location"
+ checked: SessionData.nightModeLocationProvider === "geoclue2"
+ enabled: DisplayService.geoclueAvailable
+ onToggled: checked => {
+ if (checked && DisplayService.geoclueAvailable) {
+ SessionData.setNightModeLocationProvider("geoclue2")
+ SessionData.setLatitude(0.0)
+ SessionData.setLongitude(0.0)
+ } else {
+ SessionData.setNightModeLocationProvider("")
+ }
+ }
+ }
+
+ StyledText {
+ text: "Manual Coordinates"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ visible: SessionData.nightModeLocationProvider !== "geoclue2"
+ }
+
+ Row {
+ spacing: Theme.spacingM
+ visible: SessionData.nightModeLocationProvider !== "geoclue2"
+
+ Column {
+ spacing: Theme.spacingXS
+
+ StyledText {
+ text: "Latitude"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ }
+
+ DankTextField {
+ width: 120
+ height: 40
+ text: SessionData.latitude.toString()
+ placeholderText: "0.0"
+ onTextChanged: {
+ const lat = parseFloat(text) || 0.0
+ if (lat >= -90 && lat <= 90) {
+ SessionData.setLatitude(lat)
+ }
+ }
+ }
+ }
+
+ Column {
+ spacing: Theme.spacingXS
+
+ StyledText {
+ text: "Longitude"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ }
+
+ DankTextField {
+ width: 120
+ height: 40
+ text: SessionData.longitude.toString()
+ placeholderText: "0.0"
+ onTextChanged: {
+ const lon = parseFloat(text) || 0.0
+ if (lon >= -180 && lon <= 180) {
+ SessionData.setLongitude(lon)
+ }
+ }
+ }
+ }
+ }
+
+ StyledText {
+ text: "Uses sunrise/sunset times to automatically adjust night mode based on your location."
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ width: parent.width
+ wrapMode: Text.WordWrap
+ }
+ }
+ }
+ }
+ }
+
+ // Lock Screen Settings
+ StyledRect {
+ width: parent.width
+ height: lockScreenSection.implicitHeight + Theme.spacingL * 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
+ border.width: 1
+
+ Column {
+ id: lockScreenSection
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingM
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "lock"
+ size: Theme.iconSize
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Lock Screen"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ DankToggle {
+ width: parent.width
+ text: "Show Power Actions"
+ description: "Show power, restart, and logout buttons on the lock screen"
+ checked: SettingsData.lockScreenShowPowerActions
+ onToggled: checked => {
+ SettingsData.setLockScreenShowPowerActions(checked)
+ }
+ }
+ }
+ }
+
+ // Font Settings
+ StyledRect {
+ width: parent.width
+ height: fontSection.implicitHeight + Theme.spacingL * 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
+ border.width: 1
+
+ Column {
+ id: fontSection
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingM
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "font_download"
+ size: Theme.iconSize
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Font Settings"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ DankDropdown {
+ width: parent.width
+ text: "Font Family"
+ description: "Select system font family"
+ currentValue: {
+ if (SettingsData.fontFamily === SettingsData.defaultFontFamily)
+ return "Default"
+ else
+ return SettingsData.fontFamily || "Default"
+ }
+ enableFuzzySearch: true
+ popupWidthOffset: 100
+ maxPopupHeight: 400
+ options: cachedFontFamilies
+ onValueChanged: value => {
+ if (value.startsWith("Default"))
+ SettingsData.setFontFamily(SettingsData.defaultFontFamily)
+ else
+ SettingsData.setFontFamily(value)
+ }
+ }
+
+ DankDropdown {
+ width: parent.width
+ text: "Font Weight"
+ description: "Select font weight"
+ currentValue: {
+ switch (SettingsData.fontWeight) {
+ case Font.Thin:
+ return "Thin"
+ case Font.ExtraLight:
+ return "Extra Light"
+ case Font.Light:
+ return "Light"
+ case Font.Normal:
+ return "Regular"
+ case Font.Medium:
+ return "Medium"
+ case Font.DemiBold:
+ return "Demi Bold"
+ case Font.Bold:
+ return "Bold"
+ case Font.ExtraBold:
+ return "Extra Bold"
+ case Font.Black:
+ return "Black"
+ default:
+ return "Regular"
+ }
+ }
+ options: ["Thin", "Extra Light", "Light", "Regular", "Medium", "Demi Bold", "Bold", "Extra Bold", "Black"]
+ onValueChanged: value => {
+ var weight
+ switch (value) {
+ case "Thin":
+ weight = Font.Thin
+ break
+ case "Extra Light":
+ weight = Font.ExtraLight
+ break
+ case "Light":
+ weight = Font.Light
+ break
+ case "Regular":
+ weight = Font.Normal
+ break
+ case "Medium":
+ weight = Font.Medium
+ break
+ case "Demi Bold":
+ weight = Font.DemiBold
+ break
+ case "Bold":
+ weight = Font.Bold
+ break
+ case "Extra Bold":
+ weight = Font.ExtraBold
+ break
+ case "Black":
+ weight = Font.Black
+ break
+ default:
+ weight = Font.Normal
+ break
+ }
+ SettingsData.setFontWeight(weight)
+ }
+ }
+
+ DankDropdown {
+ width: parent.width
+ text: "Monospace Font"
+ description: "Select monospace font for process list and technical displays"
+ currentValue: {
+ if (SettingsData.monoFontFamily === SettingsData.defaultMonoFontFamily)
+ return "Default"
+
+ return SettingsData.monoFontFamily || "Default"
+ }
+ enableFuzzySearch: true
+ popupWidthOffset: 100
+ maxPopupHeight: 400
+ options: cachedMonoFamilies
+ onValueChanged: value => {
+ if (value === "Default")
+ SettingsData.setMonoFontFamily(SettingsData.defaultMonoFontFamily)
+ else
+ SettingsData.setMonoFontFamily(value)
+ }
+ }
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ Column {
+ width: parent.width - fontScaleControls.width - Theme.spacingM
+ spacing: Theme.spacingXS
+
+ StyledText {
+ text: "Font Scale"
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ }
+
+ StyledText {
+ text: "Scale all font sizes (" + (SettingsData.fontScale * 100).toFixed(0) + "%)"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ width: parent.width
+ }
+ }
+
+ Row {
+ id: fontScaleControls
+
+ spacing: Theme.spacingS
+ anchors.verticalCenter: parent.verticalCenter
+
+ DankActionButton {
+ buttonSize: 32
+ iconName: "remove"
+ iconSize: Theme.iconSizeSmall
+ enabled: SettingsData.fontScale > 1.0
+ backgroundColor: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.5)
+ iconColor: Theme.surfaceText
+ onClicked: {
+ var newScale = Math.max(1.0, SettingsData.fontScale - 0.05)
+ SettingsData.setFontScale(newScale)
+ }
+ }
+
+ StyledRect {
+ width: 60
+ height: 32
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
+ border.width: 1
+
+ StyledText {
+ anchors.centerIn: parent
+ text: (SettingsData.fontScale * 100).toFixed(0) + "%"
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ }
+ }
+
+ DankActionButton {
+ buttonSize: 32
+ iconName: "add"
+ iconSize: Theme.iconSizeSmall
+ enabled: SettingsData.fontScale < 2.0
+ backgroundColor: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.5)
+ iconColor: Theme.surfaceText
+ onClicked: {
+ var newScale = Math.min(2.0, SettingsData.fontScale + 0.05)
+ SettingsData.setFontScale(newScale)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ FileBrowserModal {
+ id: wallpaperBrowser
+
+ browserTitle: "Select Wallpaper"
+ browserIcon: "wallpaper"
+ browserType: "wallpaper"
+ fileExtensions: ["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.gif", "*.webp"]
+ onFileSelected: path => {
+ if (SessionData.perMonitorWallpaper) {
+ SessionData.setMonitorWallpaper(selectedMonitorName, path)
+ } else {
+ SessionData.setWallpaper(path)
+ }
+ close()
+ }
+ onDialogClosed: {
+ if (parentModal) {
+ parentModal.allowFocusOverride = false
+ parentModal.shouldHaveFocus = Qt.binding(() => {
+ return parentModal.shouldBeVisible
+ })
+ }
+ }
+ }
+
+ DankColorPicker {
+ id: colorPicker
+
+ pickerTitle: "Choose Wallpaper Color"
+ onColorSelected: selectedColor => {
+ if (SessionData.perMonitorWallpaper) {
+ SessionData.setMonitorWallpaper(selectedMonitorName, selectedColor)
+ } else {
+ SessionData.setWallpaperColor(selectedColor)
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/Settings/RecentAppsTab.qml b/quickshell/.config/quickshell/Modules/Settings/RecentAppsTab.qml
new file mode 100644
index 0000000..daa2e39
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/Settings/RecentAppsTab.qml
@@ -0,0 +1,246 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell.Widgets
+import qs.Common
+import qs.Widgets
+
+Item {
+ id: recentAppsTab
+
+ DankFlickable {
+ anchors.fill: parent
+ anchors.topMargin: Theme.spacingL
+ clip: true
+ contentHeight: mainColumn.height
+ contentWidth: width
+
+ Column {
+ id: mainColumn
+ width: parent.width
+ spacing: Theme.spacingXL
+
+ StyledRect {
+ width: parent.width
+ height: recentlyUsedSection.implicitHeight + Theme.spacingL * 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b, 0.3)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g,
+ Theme.outline.b, 0.2)
+ border.width: 1
+
+ Column {
+ id: recentlyUsedSection
+
+ property var rankedAppsModel: {
+ var apps = []
+ for (var appId in (AppUsageHistoryData.appUsageRanking
+ || {})) {
+ var appData = (AppUsageHistoryData.appUsageRanking
+ || {})[appId]
+ apps.push({
+ "id": appId,
+ "name": appData.name,
+ "exec": appData.exec,
+ "icon": appData.icon,
+ "comment": appData.comment,
+ "usageCount": appData.usageCount,
+ "lastUsed": appData.lastUsed
+ })
+ }
+ apps.sort(function (a, b) {
+ if (a.usageCount !== b.usageCount)
+ return b.usageCount - a.usageCount
+
+ return a.name.localeCompare(b.name)
+ })
+ return apps.slice(0, 20)
+ }
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingM
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "history"
+ size: Theme.iconSize
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Recently Used Apps"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Item {
+ width: parent.width - parent.children[0].width
+ - parent.children[1].width
+ - clearAllButton.width - Theme.spacingM * 3
+ height: 1
+ }
+
+ DankActionButton {
+ id: clearAllButton
+
+ iconName: "delete_sweep"
+ iconSize: Theme.iconSize - 2
+ iconColor: Theme.error
+ anchors.verticalCenter: parent.verticalCenter
+ onClicked: {
+ AppUsageHistoryData.appUsageRanking = {}
+ SettingsData.saveSettings()
+ }
+ }
+ }
+
+ StyledText {
+ width: parent.width
+ text: "Apps are ordered by usage frequency, then last used, then alphabetically."
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ wrapMode: Text.WordWrap
+ }
+
+ Column {
+ id: rankedAppsList
+
+ width: parent.width
+ spacing: Theme.spacingS
+
+ Repeater {
+ model: recentlyUsedSection.rankedAppsModel
+
+ delegate: Rectangle {
+ width: rankedAppsList.width
+ height: 48
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceContainer.r,
+ Theme.surfaceContainer.g,
+ Theme.surfaceContainer.b, 0.3)
+ border.color: Qt.rgba(Theme.outline.r,
+ Theme.outline.g,
+ Theme.outline.b, 0.1)
+ border.width: 1
+
+ Row {
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingM
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingM
+
+ StyledText {
+ text: (index + 1).toString()
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ color: Theme.primary
+ width: 20
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Image {
+ width: 24
+ height: 24
+ source: modelData.icon ? "image://icon/" + modelData.icon : "image://icon/application-x-executable"
+ sourceSize.width: 24
+ sourceSize.height: 24
+ fillMode: Image.PreserveAspectFit
+ anchors.verticalCenter: parent.verticalCenter
+ onStatusChanged: {
+ if (status === Image.Error)
+ source = "image://icon/application-x-executable"
+ }
+ }
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 2
+
+ StyledText {
+ text: modelData.name
+ || "Unknown App"
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ }
+
+ StyledText {
+ text: {
+ if (!modelData.lastUsed)
+ return "Never used"
+
+ var date = new Date(modelData.lastUsed)
+ var now = new Date()
+ var diffMs = now - date
+ var diffMins = Math.floor(
+ diffMs / (1000 * 60))
+ var diffHours = Math.floor(
+ diffMs / (1000 * 60 * 60))
+ var diffDays = Math.floor(
+ diffMs / (1000 * 60 * 60 * 24))
+ if (diffMins < 1)
+ return "Last launched just now"
+
+ if (diffMins < 60)
+ return "Last launched " + diffMins + " minute"
+ + (diffMins === 1 ? "" : "s") + " ago"
+
+ if (diffHours < 24)
+ return "Last launched " + diffHours + " hour"
+ + (diffHours === 1 ? "" : "s") + " ago"
+
+ if (diffDays < 7)
+ return "Last launched " + diffDays + " day"
+ + (diffDays === 1 ? "" : "s") + " ago"
+
+ return "Last launched " + date.toLocaleDateString()
+ }
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ }
+ }
+ }
+
+ DankActionButton {
+ anchors.right: parent.right
+ anchors.rightMargin: Theme.spacingM
+ anchors.verticalCenter: parent.verticalCenter
+ circular: true
+ iconName: "close"
+ iconSize: 16
+ iconColor: Theme.error
+ onClicked: {
+ var currentRanking = Object.assign(
+ {},
+ AppUsageHistoryData.appUsageRanking
+ || {})
+ delete currentRanking[modelData.id]
+ AppUsageHistoryData.appUsageRanking = currentRanking
+ SettingsData.saveSettings()
+ }
+ }
+ }
+ }
+
+ StyledText {
+ width: parent.width
+ text: recentlyUsedSection.rankedAppsModel.length
+ === 0 ? "No apps have been launched yet." : ""
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceVariantText
+ horizontalAlignment: Text.AlignHCenter
+ visible: recentlyUsedSection.rankedAppsModel.length === 0
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/Settings/SettingsSection.qml b/quickshell/.config/quickshell/Modules/Settings/SettingsSection.qml
new file mode 100644
index 0000000..2e180cd
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/Settings/SettingsSection.qml
@@ -0,0 +1,106 @@
+import QtQuick
+import qs.Common
+import qs.Widgets
+
+Column {
+ id: root
+
+ property string title: ""
+ property string iconName: ""
+ property alias content: contentLoader.sourceComponent
+ property bool expanded: false
+ property bool collapsible: true
+ property bool lazyLoad: true
+
+ width: parent.width
+ spacing: expanded ? Theme.spacingM : 0
+ Component.onCompleted: {
+ if (!collapsible)
+ expanded = true
+ }
+
+ MouseArea {
+ width: parent.width
+ height: headerRow.height
+ enabled: collapsible
+ hoverEnabled: collapsible
+ onClicked: {
+ if (collapsible)
+ expanded = !expanded
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ color: parent.containsMouse ? Qt.rgba(Theme.primary.r,
+ Theme.primary.g,
+ Theme.primary.b,
+ 0.08) : "transparent"
+ radius: Theme.radiusS
+ }
+
+ Row {
+ id: headerRow
+
+ width: parent.width
+ spacing: Theme.spacingS
+ topPadding: Theme.spacingS
+ bottomPadding: Theme.spacingS
+
+ DankIcon {
+ name: root.collapsible ? (root.expanded ? "expand_less" : "expand_more") : root.iconName
+ size: Theme.iconSize - 2
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+
+ Behavior on rotation {
+ NumberAnimation {
+ duration: Appearance.anim.durations.fast
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+ }
+
+ DankIcon {
+ name: root.iconName
+ size: Theme.iconSize - 4
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ visible: root.collapsible
+ }
+
+ StyledText {
+ text: root.title
+ font.pixelSize: Theme.fontSizeLarge
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 1
+ color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
+ visible: expanded || !collapsible
+ }
+
+ Loader {
+ id: contentLoader
+
+ width: parent.width
+ active: lazyLoad ? expanded || !collapsible : true
+ visible: expanded || !collapsible
+ asynchronous: true
+ opacity: visible ? 1 : 0
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/Settings/ThemeColorsTab.qml b/quickshell/.config/quickshell/Modules/Settings/ThemeColorsTab.qml
new file mode 100644
index 0000000..f64175d
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/Settings/ThemeColorsTab.qml
@@ -0,0 +1,942 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell
+import Quickshell.Io
+import qs.Common
+import qs.Modals
+import qs.Modals.FileBrowser
+import qs.Services
+import qs.Widgets
+
+Item {
+ id: themeColorsTab
+
+ property var cachedFontFamilies: []
+ property var cachedMonoFamilies: []
+ property bool fontsEnumerated: false
+
+ function enumerateFonts() {
+ var fonts = ["Default"]
+ var availableFonts = Qt.fontFamilies()
+ var rootFamilies = []
+ var seenFamilies = new Set()
+ for (var i = 0; i < availableFonts.length; i++) {
+ var fontName = availableFonts[i]
+ if (fontName.startsWith("."))
+ continue
+
+ if (fontName === SettingsData.defaultFontFamily)
+ continue
+
+ var rootName = fontName.replace(
+ / (Thin|Extra Light|Light|Regular|Medium|Semi Bold|Demi Bold|Bold|Extra Bold|Black|Heavy)$/i,
+ "").replace(
+ / (Italic|Oblique|Condensed|Extended|Narrow|Wide)$/i,
+ "").replace(/ (UI|Display|Text|Mono|Sans|Serif)$/i,
+ function (match, suffix) {
+ return match
+ }).trim()
+ if (!seenFamilies.has(rootName) && rootName !== "") {
+ seenFamilies.add(rootName)
+ rootFamilies.push(rootName)
+ }
+ }
+ cachedFontFamilies = fonts.concat(rootFamilies.sort())
+ var monoFonts = ["Default"]
+ var monoFamilies = []
+ var seenMonoFamilies = new Set()
+ for (var j = 0; j < availableFonts.length; j++) {
+ var fontName2 = availableFonts[j]
+ if (fontName2.startsWith("."))
+ continue
+
+ if (fontName2 === SettingsData.defaultMonoFontFamily)
+ continue
+
+ var lowerName = fontName2.toLowerCase()
+ if (lowerName.includes("mono") || lowerName.includes(
+ "code") || lowerName.includes(
+ "console") || lowerName.includes(
+ "terminal") || lowerName.includes(
+ "courier") || lowerName.includes(
+ "dejavu sans mono") || lowerName.includes(
+ "jetbrains") || lowerName.includes(
+ "fira") || lowerName.includes(
+ "hack") || lowerName.includes(
+ "source code") || lowerName.includes(
+ "ubuntu mono") || lowerName.includes("cascadia")) {
+ var rootName2 = fontName2.replace(
+ / (Thin|Extra Light|Light|Regular|Medium|Semi Bold|Demi Bold|Bold|Extra Bold|Black|Heavy)$/i,
+ "").replace(
+ / (Italic|Oblique|Condensed|Extended|Narrow|Wide)$/i,
+ "").trim()
+ if (!seenMonoFamilies.has(rootName2) && rootName2 !== "") {
+ seenMonoFamilies.add(rootName2)
+ monoFamilies.push(rootName2)
+ }
+ }
+ }
+ cachedMonoFamilies = monoFonts.concat(monoFamilies.sort())
+ }
+
+ Component.onCompleted: {
+ if (!fontsEnumerated) {
+ enumerateFonts()
+ fontsEnumerated = true
+ }
+ }
+
+ DankFlickable {
+ anchors.fill: parent
+ anchors.topMargin: Theme.spacingL
+ clip: true
+ contentHeight: mainColumn.height
+ contentWidth: width
+
+ Column {
+ id: mainColumn
+
+ width: parent.width
+ spacing: Theme.spacingXL
+
+ // Theme Color
+ StyledRect {
+ width: parent.width
+ height: themeSection.implicitHeight + Theme.spacingL * 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b, 0.3)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g,
+ Theme.outline.b, 0.2)
+ border.width: 1
+
+ Column {
+ id: themeSection
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingM
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "palette"
+ size: Theme.iconSize
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Theme Color"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ Column {
+ width: parent.width
+ spacing: Theme.spacingS
+
+ StyledText {
+ text: "Current Theme: " + (Theme.currentTheme === Theme.dynamic ? "Dynamic" : Theme.getThemeColors(Theme.currentThemeName).name)
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+
+ StyledText {
+ text: {
+ if (Theme.currentTheme === Theme.dynamic)
+ return "Wallpaper-based dynamic colors"
+
+ var descriptions = {
+ "blue": "Material blue inspired by modern interfaces",
+ "deepBlue": "Deep blue inspired by material 3",
+ "purple": "Rich purple tones for elegance",
+ "green": "Natural green for productivity",
+ "orange": "Energetic orange for creativity",
+ "red": "Bold red for impact",
+ "cyan": "Cool cyan for tranquility",
+ "pink": "Vibrant pink for expression",
+ "amber": "Warm amber for comfort",
+ "coral": "Soft coral for gentle warmth"
+ }
+ return descriptions[Theme.currentThemeName] || "Select a theme"
+ }
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ anchors.horizontalCenter: parent.horizontalCenter
+ wrapMode: Text.WordWrap
+ width: Math.min(parent.width, 400)
+ horizontalAlignment: Text.AlignHCenter
+ }
+ }
+
+ Column {
+ spacing: Theme.spacingS
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ Row {
+ spacing: Theme.spacingM
+
+ Repeater {
+ model: Theme.availableThemeNames.slice(0, 5)
+
+ Rectangle {
+ property string themeName: modelData
+ width: 32
+ height: 32
+ radius: 16
+ color: Theme.getThemeColors(themeName).primary
+ border.color: Theme.outline
+ border.width: (Theme.currentThemeName === themeName
+ && Theme.currentTheme !== Theme.dynamic) ? 2 : 1
+ scale: (Theme.currentThemeName === themeName
+ && Theme.currentTheme !== Theme.dynamic) ? 1.1 : 1
+
+ Rectangle {
+ width: nameText.contentWidth + Theme.spacingS * 2
+ height: nameText.contentHeight + Theme.spacingXS * 2
+ color: Theme.surfaceContainer
+ border.color: Theme.outline
+ border.width: 1
+ radius: Theme.cornerRadius
+ anchors.bottom: parent.top
+ anchors.bottomMargin: Theme.spacingXS
+ anchors.horizontalCenter: parent.horizontalCenter
+ visible: mouseArea.containsMouse
+
+ StyledText {
+ id: nameText
+
+ text: Theme.getThemeColors(themeName).name
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ anchors.centerIn: parent
+ }
+ }
+
+ MouseArea {
+ id: mouseArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ Theme.switchTheme(themeName)
+ }
+ }
+
+ Behavior on scale {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.emphasizedEasing
+ }
+ }
+
+ Behavior on border.width {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.emphasizedEasing
+ }
+ }
+ }
+ }
+ }
+
+ Row {
+ spacing: Theme.spacingM
+
+ Repeater {
+ model: Theme.availableThemeNames.slice(5, 10)
+
+ Rectangle {
+ property string themeName: modelData
+
+ width: 32
+ height: 32
+ radius: 16
+ color: Theme.getThemeColors(themeName).primary
+ border.color: Theme.outline
+ border.width: Theme.currentThemeName === themeName ? 2 : 1
+ visible: true
+ scale: Theme.currentThemeName === themeName ? 1.1 : 1
+
+ Rectangle {
+ width: nameText2.contentWidth + Theme.spacingS * 2
+ height: nameText2.contentHeight + Theme.spacingXS * 2
+ color: Theme.surfaceContainer
+ border.color: Theme.outline
+ border.width: 1
+ radius: Theme.cornerRadius
+ anchors.bottom: parent.top
+ anchors.bottomMargin: Theme.spacingXS
+ anchors.horizontalCenter: parent.horizontalCenter
+ visible: mouseArea2.containsMouse
+
+ StyledText {
+ id: nameText2
+
+ text: Theme.getThemeColors(themeName).name
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ anchors.centerIn: parent
+ }
+ }
+
+ MouseArea {
+ id: mouseArea2
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ Theme.switchTheme(themeName)
+ }
+ }
+
+ Behavior on scale {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.emphasizedEasing
+ }
+ }
+
+ Behavior on border.width {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.emphasizedEasing
+ }
+ }
+ }
+ }
+ }
+
+ Item {
+ width: 1
+ height: Theme.spacingM
+ }
+
+ Row {
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: Theme.spacingL
+
+ Rectangle {
+ width: 120
+ height: 40
+ radius: 20
+ color: {
+ if (ToastService.wallpaperErrorStatus === "error"
+ || ToastService.wallpaperErrorStatus === "matugen_missing")
+ return Qt.rgba(Theme.error.r,
+ Theme.error.g,
+ Theme.error.b, 0.12)
+ else
+ return Qt.rgba(Theme.surfaceVariant.r,
+ Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b, 0.3)
+ }
+ border.color: {
+ if (ToastService.wallpaperErrorStatus === "error"
+ || ToastService.wallpaperErrorStatus === "matugen_missing")
+ return Qt.rgba(Theme.error.r,
+ Theme.error.g,
+ Theme.error.b, 0.5)
+ else if (Theme.currentThemeName === "dynamic")
+ return Theme.primary
+ else
+ return Theme.outline
+ }
+ border.width: (Theme.currentThemeName === "dynamic") ? 2 : 1
+ scale: (Theme.currentThemeName === "dynamic") ? 1.1 : (autoMouseArea.containsMouse ? 1.02 : 1)
+
+ Row {
+ anchors.centerIn: parent
+ spacing: Theme.spacingS
+
+ DankIcon {
+ name: {
+ if (ToastService.wallpaperErrorStatus === "error"
+ || ToastService.wallpaperErrorStatus
+ === "matugen_missing")
+ return "error"
+ else
+ return "palette"
+ }
+ size: 16
+ color: {
+ if (ToastService.wallpaperErrorStatus === "error"
+ || ToastService.wallpaperErrorStatus
+ === "matugen_missing")
+ return Theme.error
+ else
+ return Theme.surfaceText
+ }
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: {
+ if (ToastService.wallpaperErrorStatus === "error")
+ return "Error"
+ else if (ToastService.wallpaperErrorStatus
+ === "matugen_missing")
+ return "No matugen"
+ else
+ return "Auto"
+ }
+ font.pixelSize: Theme.fontSizeMedium
+ color: {
+ if (ToastService.wallpaperErrorStatus === "error"
+ || ToastService.wallpaperErrorStatus
+ === "matugen_missing")
+ return Theme.error
+ else
+ return Theme.surfaceText
+ }
+ font.weight: Font.Medium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ MouseArea {
+ id: autoMouseArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ if (ToastService.wallpaperErrorStatus === "matugen_missing")
+ ToastService.showError(
+ "matugen not found - install matugen package for dynamic theming")
+ else if (ToastService.wallpaperErrorStatus === "error")
+ ToastService.showError(
+ "Wallpaper processing failed - check wallpaper path")
+ else
+ Theme.switchTheme(Theme.dynamic)
+ }
+ }
+
+ Rectangle {
+ width: autoTooltipText.contentWidth + Theme.spacingM * 2
+ height: autoTooltipText.contentHeight + Theme.spacingS * 2
+ color: Theme.surfaceContainer
+ border.color: Theme.outline
+ border.width: 1
+ radius: Theme.cornerRadius
+ anchors.bottom: parent.top
+ anchors.bottomMargin: Theme.spacingS
+ anchors.horizontalCenter: parent.horizontalCenter
+ visible: autoMouseArea.containsMouse
+ && (Theme.currentTheme !== Theme.dynamic
+ || ToastService.wallpaperErrorStatus === "error"
+ || ToastService.wallpaperErrorStatus
+ === "matugen_missing")
+
+ StyledText {
+ id: autoTooltipText
+
+ text: {
+ if (ToastService.wallpaperErrorStatus === "matugen_missing")
+ return "Install matugen package for dynamic themes"
+ else
+ return "Dynamic wallpaper-based colors"
+ }
+ font.pixelSize: Theme.fontSizeSmall
+ color: (ToastService.wallpaperErrorStatus === "error"
+ || ToastService.wallpaperErrorStatus
+ === "matugen_missing") ? Theme.error : Theme.surfaceText
+ anchors.centerIn: parent
+ wrapMode: Text.WordWrap
+ width: Math.min(implicitWidth, 250)
+ horizontalAlignment: Text.AlignHCenter
+ }
+ }
+
+ Behavior on scale {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.emphasizedEasing
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.mediumDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+
+ Behavior on border.color {
+ ColorAnimation {
+ duration: Theme.mediumDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+ }
+
+ Rectangle {
+ width: 120
+ height: 40
+ radius: 20
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3)
+ border.color: (Theme.currentThemeName === "custom") ? Theme.primary : Theme.outline
+ border.width: (Theme.currentThemeName === "custom") ? 2 : 1
+ scale: (Theme.currentThemeName === "custom") ? 1.1 : (customMouseArea.containsMouse ? 1.02 : 1)
+
+ Row {
+ anchors.centerIn: parent
+ spacing: Theme.spacingS
+
+ DankIcon {
+ name: "folder_open"
+ size: 16
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Custom"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ MouseArea {
+ id: customMouseArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ fileBrowserModal.open()
+ }
+ }
+
+ Rectangle {
+ width: customTooltipText.contentWidth + Theme.spacingM * 2
+ height: customTooltipText.contentHeight + Theme.spacingS * 2
+ color: Theme.surfaceContainer
+ border.color: Theme.outline
+ border.width: 1
+ radius: Theme.cornerRadius
+ anchors.bottom: parent.top
+ anchors.bottomMargin: Theme.spacingS
+ anchors.horizontalCenter: parent.horizontalCenter
+ visible: customMouseArea.containsMouse
+
+ StyledText {
+ id: customTooltipText
+ text: {
+ if (Theme.currentThemeName === "custom")
+ return SettingsData.customThemeFile || "Custom theme loaded"
+ else
+ return "Load custom theme from JSON file"
+ }
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ anchors.centerIn: parent
+ wrapMode: Text.WordWrap
+ width: Math.min(implicitWidth, 250)
+ horizontalAlignment: Text.AlignHCenter
+ }
+ }
+
+ Behavior on scale {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.emphasizedEasing
+ }
+ }
+
+ Behavior on border.width {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.emphasizedEasing
+ }
+ }
+ }
+ } // Close Row
+ }
+ }
+ }
+
+ // Transparency Settings
+ StyledRect {
+ width: parent.width
+ height: transparencySection.implicitHeight + Theme.spacingL * 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b, 0.3)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g,
+ Theme.outline.b, 0.2)
+ border.width: 1
+
+ Column {
+ id: transparencySection
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingM
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "opacity"
+ size: Theme.iconSize
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Transparency Settings"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ Column {
+ width: parent.width
+ spacing: Theme.spacingS
+
+ StyledText {
+ text: "Top Bar Transparency"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ }
+
+ DankSlider {
+ width: parent.width
+ height: 24
+ value: Math.round(
+ SettingsData.topBarTransparency * 100)
+ minimum: 0
+ maximum: 100
+ unit: ""
+ showValue: true
+ wheelEnabled: false
+ onSliderValueChanged: newValue => {
+ SettingsData.setTopBarTransparency(
+ newValue / 100)
+ }
+ }
+ }
+
+ Column {
+ width: parent.width
+ spacing: Theme.spacingS
+
+ StyledText {
+ text: "Top Bar Widget Transparency"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ }
+
+ DankSlider {
+ width: parent.width
+ height: 24
+ value: Math.round(
+ SettingsData.topBarWidgetTransparency * 100)
+ minimum: 0
+ maximum: 100
+ unit: ""
+ showValue: true
+ wheelEnabled: false
+ onSliderValueChanged: newValue => {
+ SettingsData.setTopBarWidgetTransparency(
+ newValue / 100)
+ }
+ }
+ }
+
+ Column {
+ width: parent.width
+ spacing: Theme.spacingS
+
+ StyledText {
+ text: "Popup Transparency"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ }
+
+ DankSlider {
+ width: parent.width
+ height: 24
+ value: Math.round(
+ SettingsData.popupTransparency * 100)
+ minimum: 0
+ maximum: 100
+ unit: ""
+ showValue: true
+ wheelEnabled: false
+ onSliderValueChanged: newValue => {
+ SettingsData.setPopupTransparency(
+ newValue / 100)
+ }
+ }
+ }
+ }
+ }
+
+ // System Configuration Warning
+ Rectangle {
+ width: parent.width
+ height: warningText.implicitHeight + Theme.spacingM * 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.warning.r, Theme.warning.g,
+ Theme.warning.b, 0.12)
+ border.color: Qt.rgba(Theme.warning.r, Theme.warning.g,
+ Theme.warning.b, 0.3)
+ border.width: 1
+
+ Row {
+ anchors.fill: parent
+ anchors.margins: Theme.spacingM
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "info"
+ size: Theme.iconSizeSmall
+ color: Theme.warning
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ id: warningText
+ font.pixelSize: Theme.fontSizeSmall
+ text: "The below settings will modify your GTK and Qt settings. If you wish to preserve your current configurations, please back them up (qt5ct.conf|qt6ct.conf and ~/.config/gtk-3.0|gtk-4.0)."
+ wrapMode: Text.WordWrap
+ width: parent.width - Theme.iconSizeSmall - Theme.spacingM
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+ }
+
+ // Icon Theme
+ StyledRect {
+ width: parent.width
+ height: iconThemeSection.implicitHeight + Theme.spacingL * 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b, 0.3)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g,
+ Theme.outline.b, 0.2)
+ border.width: 1
+
+ Column {
+ id: iconThemeSection
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingM
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingXS
+
+ DankIcon {
+ name: "image"
+ size: Theme.iconSize
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ DankDropdown {
+ width: parent.width - Theme.iconSize - Theme.spacingXS
+ anchors.verticalCenter: parent.verticalCenter
+ text: "Icon Theme"
+ description: "DankShell & System Icons\n(requires restart)"
+ currentValue: SettingsData.iconTheme
+ enableFuzzySearch: true
+ popupWidthOffset: 100
+ maxPopupHeight: 236
+ options: {
+ SettingsData.detectAvailableIconThemes()
+ return SettingsData.availableIconThemes
+ }
+ onValueChanged: value => {
+ SettingsData.setIconTheme(value)
+ if (Quickshell.env("QT_QPA_PLATFORMTHEME") != "gtk3" &&
+ Quickshell.env("QT_QPA_PLATFORMTHEME") != "qt6ct" &&
+ Quickshell.env("QT_QPA_PLATFORMTHEME_QT6") != "qt6ct") {
+ ToastService.showError("Missing Environment Variables", "You need to set either:\nQT_QPA_PLATFORMTHEME=gtk3 OR\nQT_QPA_PLATFORMTHEME=qt6ct\nas environment variables, and then restart the shell.\n\nqt6ct requires qt6ct-kde to be installed.")
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // System App Theming
+ StyledRect {
+ width: parent.width
+ height: systemThemingSection.implicitHeight + Theme.spacingL * 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b, 0.3)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g,
+ Theme.outline.b, 0.2)
+ border.width: 1
+ visible: Theme.matugenAvailable
+
+ Column {
+ id: systemThemingSection
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingM
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "extension"
+ size: Theme.iconSize
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "System App Theming"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ Rectangle {
+ width: (parent.width - Theme.spacingM) / 2
+ height: 48
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12)
+ border.color: Theme.primary
+ border.width: 1
+
+ Row {
+ anchors.centerIn: parent
+ spacing: Theme.spacingS
+
+ DankIcon {
+ name: "folder"
+ size: 16
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Apply GTK Colors"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.primary
+ font.weight: Font.Medium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: Theme.applyGtkColors()
+ }
+ }
+
+ Rectangle {
+ width: (parent.width - Theme.spacingM) / 2
+ height: 48
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12)
+ border.color: Theme.primary
+ border.width: 1
+
+ Row {
+ anchors.centerIn: parent
+ spacing: Theme.spacingS
+
+ DankIcon {
+ name: "settings"
+ size: 16
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Apply Qt Colors"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.primary
+ font.weight: Font.Medium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: Theme.applyQtColors()
+ }
+ }
+ }
+
+ StyledText {
+ text: `Generate baseline GTK3/4 or QT5/QT6 (requires qt6ct-kde) configurations to follow DMS colors. Only needed once.
It is recommended to install Colloid GTK theme prior to applying GTK themes.`
+ textFormat: Text.RichText
+ linkColor: Theme.primary
+ onLinkActivated: url => Qt.openUrlExternally(url)
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ wrapMode: Text.WordWrap
+ width: parent.width
+ horizontalAlignment: Text.AlignHCenter
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor
+ acceptedButtons: Qt.NoButton
+ propagateComposedEvents: true
+ }
+ }
+ }
+ }
+ }
+ }
+
+ FileBrowserModal {
+ id: fileBrowserModal
+ browserTitle: "Select Custom Theme"
+ filterExtensions: ["*.json"]
+ showHiddenFiles: true
+
+ function selectCustomTheme() {
+ shouldBeVisible = true
+ }
+
+ onFileSelected: function(filePath) {
+ // Save the custom theme file path and switch to custom theme
+ if (filePath.endsWith(".json")) {
+ SettingsData.setCustomThemeFile(filePath)
+ Theme.switchTheme("custom")
+ close()
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/Settings/TimeTab.qml b/quickshell/.config/quickshell/Modules/Settings/TimeTab.qml
new file mode 100644
index 0000000..bdf017a
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/Settings/TimeTab.qml
@@ -0,0 +1,384 @@
+import QtQuick
+import QtQuick.Controls
+import qs.Common
+import qs.Widgets
+
+Item {
+ id: timeTab
+
+ DankFlickable {
+ anchors.fill: parent
+ anchors.topMargin: Theme.spacingL
+ clip: true
+ contentHeight: mainColumn.height
+ contentWidth: width
+
+ Column {
+ id: mainColumn
+
+ width: parent.width
+ spacing: Theme.spacingXL
+
+ // Time Format
+ StyledRect {
+ width: parent.width
+ height: timeSection.implicitHeight + Theme.spacingL * 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b, 0.3)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g,
+ Theme.outline.b, 0.2)
+ border.width: 1
+
+ Column {
+ id: timeSection
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingM
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "schedule"
+ size: Theme.iconSize
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ width: parent.width - Theme.iconSize - Theme.spacingM
+ - toggle.width - Theme.spacingM
+ spacing: Theme.spacingXS
+ anchors.verticalCenter: parent.verticalCenter
+
+ StyledText {
+ text: "24-Hour Format"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ }
+
+ StyledText {
+ text: "Use 24-hour time format instead of 12-hour AM/PM"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ wrapMode: Text.WordWrap
+ width: parent.width
+ }
+ }
+
+ DankToggle {
+ id: toggle
+
+ anchors.verticalCenter: parent.verticalCenter
+ checked: SettingsData.use24HourClock
+ onToggled: checked => {
+ return SettingsData.setClockFormat(
+ checked)
+ }
+ }
+ }
+ }
+ }
+
+ // Date Format Section
+ StyledRect {
+ width: parent.width
+ height: dateSection.implicitHeight + Theme.spacingL * 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b, 0.3)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g,
+ Theme.outline.b, 0.2)
+ border.width: 1
+
+ Column {
+ id: dateSection
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingM
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "calendar_today"
+ size: Theme.iconSize
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Date Format"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ DankDropdown {
+ width: parent.width
+ height: 50
+ text: "Top Bar Format"
+ description: "Preview: " + (SettingsData.clockDateFormat ? new Date().toLocaleDateString(Qt.locale(), SettingsData.clockDateFormat) : new Date().toLocaleDateString(Qt.locale(), "ddd d"))
+ currentValue: {
+ if (!SettingsData.clockDateFormat || SettingsData.clockDateFormat.length === 0) {
+ return "System Default"
+ }
+ // Find matching preset or show "Custom"
+ const presets = [{
+ "format": "ddd d",
+ "label": "Day Date"
+ }, {
+ "format": "ddd MMM d",
+ "label": "Day Month Date"
+ }, {
+ "format": "MMM d",
+ "label": "Month Date"
+ }, {
+ "format": "M/d",
+ "label": "Numeric (M/D)"
+ }, {
+ "format": "d/M",
+ "label": "Numeric (D/M)"
+ }, {
+ "format": "ddd d MMM yyyy",
+ "label": "Full with Year"
+ }, {
+ "format": "yyyy-MM-dd",
+ "label": "ISO Date"
+ }, {
+ "format": "dddd, MMMM d",
+ "label": "Full Day & Month"
+ }]
+ const match = presets.find(p => {
+ return p.format
+ === SettingsData.clockDateFormat
+ })
+ return match ? match.label : "Custom: " + SettingsData.clockDateFormat
+ }
+ options: ["System Default", "Day Date", "Day Month Date", "Month Date", "Numeric (M/D)", "Numeric (D/M)", "Full with Year", "ISO Date", "Full Day & Month", "Custom..."]
+ onValueChanged: value => {
+ const formatMap = {
+ "System Default": "",
+ "Day Date": "ddd d",
+ "Day Month Date": "ddd MMM d",
+ "Month Date": "MMM d",
+ "Numeric (M/D)": "M/d",
+ "Numeric (D/M)": "d/M",
+ "Full with Year": "ddd d MMM yyyy",
+ "ISO Date": "yyyy-MM-dd",
+ "Full Day & Month": "dddd, MMMM d"
+ }
+ if (value === "Custom...") {
+ customFormatInput.visible = true
+ } else {
+ customFormatInput.visible = false
+ SettingsData.setClockDateFormat(
+ formatMap[value])
+ }
+ }
+ }
+
+ DankDropdown {
+ width: parent.width
+ height: 50
+ text: "Lock Screen Format"
+ description: "Preview: " + (SettingsData.lockDateFormat ? new Date().toLocaleDateString(Qt.locale(), SettingsData.lockDateFormat) : new Date().toLocaleDateString(Qt.locale(), Locale.LongFormat))
+ currentValue: {
+ if (!SettingsData.lockDateFormat || SettingsData.lockDateFormat.length === 0) {
+ return "System Default"
+ }
+ // Find matching preset or show "Custom"
+ const presets = [{
+ "format": "ddd d",
+ "label": "Day Date"
+ }, {
+ "format": "ddd MMM d",
+ "label": "Day Month Date"
+ }, {
+ "format": "MMM d",
+ "label": "Month Date"
+ }, {
+ "format": "M/d",
+ "label": "Numeric (M/D)"
+ }, {
+ "format": "d/M",
+ "label": "Numeric (D/M)"
+ }, {
+ "format": "ddd d MMM yyyy",
+ "label": "Full with Year"
+ }, {
+ "format": "yyyy-MM-dd",
+ "label": "ISO Date"
+ }, {
+ "format": "dddd, MMMM d",
+ "label": "Full Day & Month"
+ }]
+ const match = presets.find(p => {
+ return p.format
+ === SettingsData.lockDateFormat
+ })
+ return match ? match.label : "Custom: " + SettingsData.lockDateFormat
+ }
+ options: ["System Default", "Day Date", "Day Month Date", "Month Date", "Numeric (M/D)", "Numeric (D/M)", "Full with Year", "ISO Date", "Full Day & Month", "Custom..."]
+ onValueChanged: value => {
+ const formatMap = {
+ "System Default": "",
+ "Day Date": "ddd d",
+ "Day Month Date": "ddd MMM d",
+ "Month Date": "MMM d",
+ "Numeric (M/D)": "M/d",
+ "Numeric (D/M)": "d/M",
+ "Full with Year": "ddd d MMM yyyy",
+ "ISO Date": "yyyy-MM-dd",
+ "Full Day & Month": "dddd, MMMM d"
+ }
+ if (value === "Custom...") {
+ customLockFormatInput.visible = true
+ } else {
+ customLockFormatInput.visible = false
+ SettingsData.setLockDateFormat(
+ formatMap[value])
+ }
+ }
+ }
+
+ DankTextField {
+ id: customFormatInput
+
+ width: parent.width
+ visible: false
+ placeholderText: "Enter custom top bar format (e.g., ddd MMM d)"
+ text: SettingsData.clockDateFormat
+ onTextChanged: {
+ if (visible && text)
+ SettingsData.setClockDateFormat(text)
+ }
+ }
+
+ DankTextField {
+ id: customLockFormatInput
+
+ width: parent.width
+ visible: false
+ placeholderText: "Enter custom lock screen format (e.g., dddd, MMMM d)"
+ text: SettingsData.lockDateFormat
+ onTextChanged: {
+ if (visible && text)
+ SettingsData.setLockDateFormat(text)
+ }
+ }
+
+ Rectangle {
+ width: parent.width
+ height: formatHelp.implicitHeight + Theme.spacingM * 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r,
+ Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b, 0.2)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g,
+ Theme.outline.b, 0.1)
+ border.width: 1
+
+ Column {
+ id: formatHelp
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingM
+ spacing: Theme.spacingXS
+
+ StyledText {
+ text: "Format Legend"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.primary
+ font.weight: Font.Medium
+ }
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingL
+
+ Column {
+ width: (parent.width - Theme.spacingL) / 2
+ spacing: 2
+
+ StyledText {
+ text: "• d - Day (1-31)"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ }
+
+ StyledText {
+ text: "• dd - Day (01-31)"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ }
+
+ StyledText {
+ text: "• ddd - Day name (Mon)"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ }
+
+ StyledText {
+ text: "• dddd - Day name (Monday)"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ }
+
+ StyledText {
+ text: "• M - Month (1-12)"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ }
+ }
+
+ Column {
+ width: (parent.width - Theme.spacingL) / 2
+ spacing: 2
+
+ StyledText {
+ text: "• MM - Month (01-12)"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ }
+
+ StyledText {
+ text: "• MMM - Month (Jan)"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ }
+
+ StyledText {
+ text: "• MMMM - Month (January)"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ }
+
+ StyledText {
+ text: "• yy - Year (24)"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ }
+
+ StyledText {
+ text: "• yyyy - Year (2024)"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/Settings/TopBarTab.qml b/quickshell/.config/quickshell/Modules/Settings/TopBarTab.qml
new file mode 100644
index 0000000..8921d49
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/Settings/TopBarTab.qml
@@ -0,0 +1,1206 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Item {
+ id: topBarTab
+
+ property var baseWidgetDefinitions: [{
+ "id": "launcherButton",
+ "text": "App Launcher",
+ "description": "Quick access to application launcher",
+ "icon": "apps",
+ "enabled": true
+ }, {
+ "id": "workspaceSwitcher",
+ "text": "Workspace Switcher",
+ "description": "Shows current workspace and allows switching",
+ "icon": "view_module",
+ "enabled": true
+ }, {
+ "id": "focusedWindow",
+ "text": "Focused Window",
+ "description": "Display currently focused application title",
+ "icon": "window",
+ "enabled": true
+ }, {
+ "id": "runningApps",
+ "text": "Running Apps",
+ "description": "Shows all running applications with focus indication",
+ "icon": "apps",
+ "enabled": true
+ }, {
+ "id": "clock",
+ "text": "Clock",
+ "description": "Current time and date display",
+ "icon": "schedule",
+ "enabled": true
+ }, {
+ "id": "weather",
+ "text": "Weather Widget",
+ "description": "Current weather conditions and temperature",
+ "icon": "wb_sunny",
+ "enabled": true
+ }, {
+ "id": "music",
+ "text": "Media Controls",
+ "description": "Control currently playing media",
+ "icon": "music_note",
+ "enabled": true
+ }, {
+ "id": "clipboard",
+ "text": "Clipboard Manager",
+ "description": "Access clipboard history",
+ "icon": "content_paste",
+ "enabled": true
+ }, {
+ "id": "cpuUsage",
+ "text": "CPU Usage",
+ "description": "CPU usage indicator",
+ "icon": "memory",
+ "enabled": DgopService.dgopAvailable,
+ "warning": !DgopService.dgopAvailable ? "Requires 'dgop' tool" : undefined
+ }, {
+ "id": "memUsage",
+ "text": "Memory Usage",
+ "description": "Memory usage indicator",
+ "icon": "storage",
+ "enabled": DgopService.dgopAvailable,
+ "warning": !DgopService.dgopAvailable ? "Requires 'dgop' tool" : undefined
+ }, {
+ "id": "cpuTemp",
+ "text": "CPU Temperature",
+ "description": "CPU temperature display",
+ "icon": "device_thermostat",
+ "enabled": DgopService.dgopAvailable,
+ "warning": !DgopService.dgopAvailable ? "Requires 'dgop' tool" : undefined
+ }, {
+ "id": "gpuTemp",
+ "text": "GPU Temperature",
+ "description": "GPU temperature display",
+ "icon": "auto_awesome_mosaic",
+ "warning": !DgopService.dgopAvailable ? "Requires 'dgop' tool" : "This widget prevents GPU power off states, which can significantly impact battery life on laptops. It is not recommended to use this on laptops with hybrid graphics.",
+ "enabled": DgopService.dgopAvailable
+ }, {
+ "id": "systemTray",
+ "text": "System Tray",
+ "description": "System notification area icons",
+ "icon": "notifications",
+ "enabled": true
+ }, {
+ "id": "privacyIndicator",
+ "text": "Privacy Indicator",
+ "description": "Shows when microphone, camera, or screen sharing is active",
+ "icon": "privacy_tip",
+ "enabled": true
+ }, {
+ "id": "controlCenterButton",
+ "text": "Control Center",
+ "description": "Access to system controls and settings",
+ "icon": "settings",
+ "enabled": true
+ }, {
+ "id": "notificationButton",
+ "text": "Notification Center",
+ "description": "Access to notifications and do not disturb",
+ "icon": "notifications",
+ "enabled": true
+ }, {
+ "id": "battery",
+ "text": "Battery",
+ "description": "Battery level and power management",
+ "icon": "battery_std",
+ "enabled": true
+ }, {
+ "id": "vpn",
+ "text": "VPN",
+ "description": "VPN status and quick connect",
+ "icon": "vpn_lock",
+ "enabled": true
+ }, {
+ "id": "idleInhibitor",
+ "text": "Idle Inhibitor",
+ "description": "Prevent screen timeout",
+ "icon": "motion_sensor_active",
+ "enabled": true
+ }, {
+ "id": "spacer",
+ "text": "Spacer",
+ "description": "Customizable empty space",
+ "icon": "more_horiz",
+ "enabled": true
+ }, {
+ "id": "separator",
+ "text": "Separator",
+ "description": "Visual divider between widgets",
+ "icon": "remove",
+ "enabled": true
+ },
+ {
+ "id": "network_speed_monitor",
+ "text": "Network Speed Monitor",
+ "description": "Network download and upload speed display",
+ "icon": "network_check",
+ "warning": !DgopService.dgopAvailable ? "Requires 'dgop' tool" : undefined,
+ "enabled": DgopService.dgopAvailable
+ }, {
+ "id": "keyboard_layout_name",
+ "text": "Keyboard Layout Name",
+ "description": "Displays the active keyboard layout and allows switching",
+ "icon": "keyboard",
+ }, {
+ "id": "notepadButton",
+ "text": "Notepad",
+ "description": "Quick access to notepad",
+ "icon": "assignment",
+ "enabled": true
+ }]
+ property var defaultLeftWidgets: [{
+ "id": "launcherButton",
+ "enabled": true
+ }, {
+ "id": "workspaceSwitcher",
+ "enabled": true
+ }, {
+ "id": "focusedWindow",
+ "enabled": true
+ }]
+ property var defaultCenterWidgets: [{
+ "id": "music",
+ "enabled": true
+ }, {
+ "id": "clock",
+ "enabled": true
+ }, {
+ "id": "weather",
+ "enabled": true
+ }]
+ property var defaultRightWidgets: [{
+ "id": "privacyIndicator",
+ "enabled": true
+ }, {
+ "id": "systemTray",
+ "enabled": true
+ }, {
+ "id": "clipboard",
+ "enabled": true
+ }, {
+ "id": "notificationButton",
+ "enabled": true
+ }, {
+ "id": "battery",
+ "enabled": true
+ }, {
+ "id": "controlCenterButton",
+ "enabled": true
+ }]
+
+ function addWidgetToSection(widgetId, targetSection) {
+ var widgetObj = {
+ "id": widgetId,
+ "enabled": true
+ }
+ if (widgetId === "spacer")
+ widgetObj.size = 20
+ if (widgetId === "gpuTemp") {
+ widgetObj.selectedGpuIndex = 0
+ widgetObj.pciId = ""
+ }
+ if (widgetId === "controlCenterButton") {
+ widgetObj.showNetworkIcon = true
+ widgetObj.showBluetoothIcon = true
+ widgetObj.showAudioIcon = true
+ }
+
+ var widgets = []
+ if (targetSection === "left") {
+ widgets = SettingsData.topBarLeftWidgets.slice()
+ widgets.push(widgetObj)
+ SettingsData.setTopBarLeftWidgets(widgets)
+ } else if (targetSection === "center") {
+ widgets = SettingsData.topBarCenterWidgets.slice()
+ widgets.push(widgetObj)
+ SettingsData.setTopBarCenterWidgets(widgets)
+ } else if (targetSection === "right") {
+ widgets = SettingsData.topBarRightWidgets.slice()
+ widgets.push(widgetObj)
+ SettingsData.setTopBarRightWidgets(widgets)
+ }
+ }
+
+ function removeWidgetFromSection(sectionId, widgetIndex) {
+ var widgets = []
+ if (sectionId === "left") {
+ widgets = SettingsData.topBarLeftWidgets.slice()
+ if (widgetIndex >= 0 && widgetIndex < widgets.length) {
+ widgets.splice(widgetIndex, 1)
+ }
+ SettingsData.setTopBarLeftWidgets(widgets)
+ } else if (sectionId === "center") {
+ widgets = SettingsData.topBarCenterWidgets.slice()
+ if (widgetIndex >= 0 && widgetIndex < widgets.length) {
+ widgets.splice(widgetIndex, 1)
+ }
+ SettingsData.setTopBarCenterWidgets(widgets)
+ } else if (sectionId === "right") {
+ widgets = SettingsData.topBarRightWidgets.slice()
+ if (widgetIndex >= 0 && widgetIndex < widgets.length) {
+ widgets.splice(widgetIndex, 1)
+ }
+ SettingsData.setTopBarRightWidgets(widgets)
+ }
+ }
+
+ function handleItemEnabledChanged(sectionId, itemId, enabled) {
+ var widgets = []
+ if (sectionId === "left")
+ widgets = SettingsData.topBarLeftWidgets.slice()
+ else if (sectionId === "center")
+ widgets = SettingsData.topBarCenterWidgets.slice()
+ else if (sectionId === "right")
+ widgets = SettingsData.topBarRightWidgets.slice()
+ for (var i = 0; i < widgets.length; i++) {
+ var widget = widgets[i]
+ var widgetId = typeof widget === "string" ? widget : widget.id
+ if (widgetId === itemId) {
+ if (typeof widget === "string") {
+ widgets[i] = {
+ "id": widget,
+ "enabled": enabled
+ }
+ } else {
+ var newWidget = {
+ "id": widget.id,
+ "enabled": enabled
+ }
+ if (widget.size !== undefined)
+ newWidget.size = widget.size
+ if (widget.selectedGpuIndex !== undefined)
+ newWidget.selectedGpuIndex = widget.selectedGpuIndex
+ else if (widget.id === "gpuTemp")
+ newWidget.selectedGpuIndex = 0
+ if (widget.pciId !== undefined)
+ newWidget.pciId = widget.pciId
+ else if (widget.id === "gpuTemp")
+ newWidget.pciId = ""
+ if (widget.id === "controlCenterButton") {
+ newWidget.showNetworkIcon = widget.showNetworkIcon !== undefined ? widget.showNetworkIcon : true
+ newWidget.showBluetoothIcon = widget.showBluetoothIcon !== undefined ? widget.showBluetoothIcon : true
+ newWidget.showAudioIcon = widget.showAudioIcon !== undefined ? widget.showAudioIcon : true
+ }
+ widgets[i] = newWidget
+ }
+ break
+ }
+ }
+ if (sectionId === "left")
+ SettingsData.setTopBarLeftWidgets(widgets)
+ else if (sectionId === "center")
+ SettingsData.setTopBarCenterWidgets(widgets)
+ else if (sectionId === "right")
+ SettingsData.setTopBarRightWidgets(widgets)
+ }
+
+ function handleItemOrderChanged(sectionId, newOrder) {
+ if (sectionId === "left")
+ SettingsData.setTopBarLeftWidgets(newOrder)
+ else if (sectionId === "center")
+ SettingsData.setTopBarCenterWidgets(newOrder)
+ else if (sectionId === "right")
+ SettingsData.setTopBarRightWidgets(newOrder)
+ }
+
+ function handleSpacerSizeChanged(sectionId, widgetIndex, newSize) {
+ var widgets = []
+ if (sectionId === "left")
+ widgets = SettingsData.topBarLeftWidgets.slice()
+ else if (sectionId === "center")
+ widgets = SettingsData.topBarCenterWidgets.slice()
+ else if (sectionId === "right")
+ widgets = SettingsData.topBarRightWidgets.slice()
+
+ if (widgetIndex >= 0 && widgetIndex < widgets.length) {
+ var widget = widgets[widgetIndex]
+ var widgetId = typeof widget === "string" ? widget : widget.id
+ if (widgetId === "spacer") {
+ if (typeof widget === "string") {
+ widgets[widgetIndex] = {
+ "id": widget,
+ "enabled": true,
+ "size": newSize
+ }
+ } else {
+ var newWidget = {
+ "id": widget.id,
+ "enabled": widget.enabled,
+ "size": newSize
+ }
+ if (widget.selectedGpuIndex !== undefined)
+ newWidget.selectedGpuIndex = widget.selectedGpuIndex
+ if (widget.pciId !== undefined)
+ newWidget.pciId = widget.pciId
+ if (widget.id === "controlCenterButton") {
+ newWidget.showNetworkIcon = widget.showNetworkIcon !== undefined ? widget.showNetworkIcon : true
+ newWidget.showBluetoothIcon = widget.showBluetoothIcon !== undefined ? widget.showBluetoothIcon : true
+ newWidget.showAudioIcon = widget.showAudioIcon !== undefined ? widget.showAudioIcon : true
+ }
+ widgets[widgetIndex] = newWidget
+ }
+ }
+ }
+
+ if (sectionId === "left")
+ SettingsData.setTopBarLeftWidgets(widgets)
+ else if (sectionId === "center")
+ SettingsData.setTopBarCenterWidgets(widgets)
+ else if (sectionId === "right")
+ SettingsData.setTopBarRightWidgets(widgets)
+ }
+
+ function handleGpuSelectionChanged(sectionId, widgetIndex, selectedGpuIndex) {
+ var widgets = []
+ if (sectionId === "left")
+ widgets = SettingsData.topBarLeftWidgets.slice()
+ else if (sectionId === "center")
+ widgets = SettingsData.topBarCenterWidgets.slice()
+ else if (sectionId === "right")
+ widgets = SettingsData.topBarRightWidgets.slice()
+
+ if (widgetIndex >= 0 && widgetIndex < widgets.length) {
+ var widget = widgets[widgetIndex]
+ if (typeof widget === "string") {
+ widgets[widgetIndex] = {
+ "id": widget,
+ "enabled": true,
+ "selectedGpuIndex": selectedGpuIndex,
+ "pciId": DgopService.availableGpus
+ && DgopService.availableGpus.length
+ > selectedGpuIndex ? DgopService.availableGpus[selectedGpuIndex].pciId : ""
+ }
+ } else {
+ var newWidget = {
+ "id": widget.id,
+ "enabled": widget.enabled,
+ "selectedGpuIndex": selectedGpuIndex,
+ "pciId": DgopService.availableGpus
+ && DgopService.availableGpus.length
+ > selectedGpuIndex ? DgopService.availableGpus[selectedGpuIndex].pciId : ""
+ }
+ if (widget.size !== undefined)
+ newWidget.size = widget.size
+ widgets[widgetIndex] = newWidget
+ }
+ }
+
+ if (sectionId === "left")
+ SettingsData.setTopBarLeftWidgets(widgets)
+ else if (sectionId === "center")
+ SettingsData.setTopBarCenterWidgets(widgets)
+ else if (sectionId === "right")
+ SettingsData.setTopBarRightWidgets(widgets)
+ }
+
+ function handleControlCenterSettingChanged(sectionId, widgetIndex, settingName, value) {
+ // Control Center settings are global, not per-widget instance
+ if (settingName === "showNetworkIcon") {
+ SettingsData.setControlCenterShowNetworkIcon(value)
+ } else if (settingName === "showBluetoothIcon") {
+ SettingsData.setControlCenterShowBluetoothIcon(value)
+ } else if (settingName === "showAudioIcon") {
+ SettingsData.setControlCenterShowAudioIcon(value)
+ }
+ }
+
+ function getItemsForSection(sectionId) {
+ var widgets = []
+ var widgetData = []
+ if (sectionId === "left")
+ widgetData = SettingsData.topBarLeftWidgets || []
+ else if (sectionId === "center")
+ widgetData = SettingsData.topBarCenterWidgets || []
+ else if (sectionId === "right")
+ widgetData = SettingsData.topBarRightWidgets || []
+ widgetData.forEach(widget => {
+ var widgetId = typeof widget === "string" ? widget : widget.id
+ var widgetEnabled = typeof widget
+ === "string" ? true : widget.enabled
+ var widgetSize = typeof widget === "string" ? undefined : widget.size
+ var widgetSelectedGpuIndex = typeof widget
+ === "string" ? undefined : widget.selectedGpuIndex
+ var widgetPciId = typeof widget
+ === "string" ? undefined : widget.pciId
+ var widgetShowNetworkIcon = typeof widget === "string" ? undefined : widget.showNetworkIcon
+ var widgetShowBluetoothIcon = typeof widget === "string" ? undefined : widget.showBluetoothIcon
+ var widgetShowAudioIcon = typeof widget === "string" ? undefined : widget.showAudioIcon
+ var widgetDef = baseWidgetDefinitions.find(w => {
+ return w.id === widgetId
+ })
+ if (widgetDef) {
+ var item = Object.assign({}, widgetDef)
+ item.enabled = widgetEnabled
+ if (widgetSize !== undefined)
+ item.size = widgetSize
+ if (widgetSelectedGpuIndex !== undefined)
+ item.selectedGpuIndex = widgetSelectedGpuIndex
+ if (widgetPciId !== undefined)
+ item.pciId = widgetPciId
+ if (widgetShowNetworkIcon !== undefined)
+ item.showNetworkIcon = widgetShowNetworkIcon
+ if (widgetShowBluetoothIcon !== undefined)
+ item.showBluetoothIcon = widgetShowBluetoothIcon
+ if (widgetShowAudioIcon !== undefined)
+ item.showAudioIcon = widgetShowAudioIcon
+
+ widgets.push(item)
+ }
+ })
+ return widgets
+ }
+
+ Component.onCompleted: {
+ // Only set defaults if widgets have never been configured (null/undefined, not empty array)
+ if (!SettingsData.topBarLeftWidgets)
+ SettingsData.setTopBarLeftWidgets(defaultLeftWidgets)
+
+ if (!SettingsData.topBarCenterWidgets)
+ SettingsData.setTopBarCenterWidgets(defaultCenterWidgets)
+
+ if (!SettingsData.topBarRightWidgets)
+ SettingsData.setTopBarRightWidgets(defaultRightWidgets)
+ const sections = ["left", "center", "right"]
+ sections.forEach(sectionId => {
+ var widgets = []
+ if (sectionId === "left")
+ widgets = SettingsData.topBarLeftWidgets.slice()
+ else if (sectionId === "center")
+ widgets = SettingsData.topBarCenterWidgets.slice()
+ else if (sectionId === "right")
+ widgets = SettingsData.topBarRightWidgets.slice()
+ var updated = false
+ for (var i = 0; i < widgets.length; i++) {
+ var widget = widgets[i]
+ if (typeof widget === "object"
+ && widget.id === "spacer"
+ && !widget.size) {
+ widgets[i] = Object.assign({}, widget, {
+ "size": 20
+ })
+ updated = true
+ }
+ }
+ if (updated) {
+ if (sectionId === "left")
+ SettingsData.setTopBarLeftWidgets(widgets)
+ else if (sectionId === "center")
+ SettingsData.setTopBarCenterWidgets(widgets)
+ else if (sectionId === "right")
+ SettingsData.setTopBarRightWidgets(widgets)
+ }
+ })
+ }
+
+ DankFlickable {
+ anchors.fill: parent
+ anchors.topMargin: Theme.spacingL
+ anchors.bottomMargin: Theme.spacingS
+ clip: true
+ contentHeight: mainColumn.height
+ contentWidth: width
+
+ Column {
+ id: mainColumn
+ width: parent.width
+ spacing: Theme.spacingXL
+
+ // TopBar Auto-hide Section
+ StyledRect {
+ width: parent.width
+ height: topBarAutoHideSection.implicitHeight + Theme.spacingL * 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b, 0.3)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g,
+ Theme.outline.b, 0.2)
+ border.width: 1
+
+ Column {
+ id: topBarAutoHideSection
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingM
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "visibility_off"
+ size: Theme.iconSize
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ width: parent.width - Theme.iconSize - Theme.spacingM
+ - autoHideToggle.width - Theme.spacingM
+ spacing: Theme.spacingXS
+ anchors.verticalCenter: parent.verticalCenter
+
+ StyledText {
+ text: "Auto-hide"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ }
+
+ StyledText {
+ text: "Automatically hide the top bar to expand screen real estate"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ wrapMode: Text.WordWrap
+ width: parent.width
+ }
+ }
+
+ DankToggle {
+ id: autoHideToggle
+
+ anchors.verticalCenter: parent.verticalCenter
+ checked: SettingsData.topBarAutoHide
+ onToggled: toggled => {
+ return SettingsData.setTopBarAutoHide(
+ toggled)
+ }
+ }
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 1
+ color: Theme.outline
+ opacity: 0.2
+ }
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "visibility"
+ size: Theme.iconSize
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ width: parent.width - Theme.iconSize - Theme.spacingM
+ - visibilityToggle.width - Theme.spacingM
+ spacing: Theme.spacingXS
+ anchors.verticalCenter: parent.verticalCenter
+
+ StyledText {
+ text: "Manual Show/Hide"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ }
+
+ StyledText {
+ text: "Toggle top bar visibility manually (can be controlled via IPC)"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ wrapMode: Text.WordWrap
+ width: parent.width
+ }
+ }
+
+ DankToggle {
+ id: visibilityToggle
+
+ anchors.verticalCenter: parent.verticalCenter
+ checked: SettingsData.topBarVisible
+ onToggled: toggled => {
+ return SettingsData.setTopBarVisible(
+ toggled)
+ }
+ }
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 1
+ color: Theme.outline
+ opacity: 0.2
+ visible: CompositorService.isNiri
+ }
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+ visible: CompositorService.isNiri
+
+ DankIcon {
+ name: "fullscreen"
+ size: Theme.iconSize
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ width: parent.width - Theme.iconSize - Theme.spacingM
+ - overviewToggle.width - Theme.spacingM
+ spacing: Theme.spacingXS
+ anchors.verticalCenter: parent.verticalCenter
+
+ StyledText {
+ text: "Show on Overview"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ }
+
+ StyledText {
+ text: "Always show the top bar when niri's overview is open"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ wrapMode: Text.WordWrap
+ width: parent.width
+ }
+ }
+
+ DankToggle {
+ id: overviewToggle
+
+ anchors.verticalCenter: parent.verticalCenter
+ checked: SettingsData.topBarOpenOnOverview
+ onToggled: toggled => {
+ return SettingsData.setTopBarOpenOnOverview(
+ toggled)
+ }
+ }
+ }
+ }
+ }
+
+
+ // Spacing
+ StyledRect {
+ width: parent.width
+ height: topBarSpacingSection.implicitHeight + Theme.spacingL * 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b, 0.3)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g,
+ Theme.outline.b, 0.2)
+ border.width: 1
+
+ Column {
+ id: topBarSpacingSection
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingM
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "space_bar"
+ size: Theme.iconSize
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Spacing"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ Column {
+ width: parent.width
+ spacing: Theme.spacingS
+
+ StyledText {
+ text: "Top/Left/Right Gaps (0 = edge-to-edge)"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ }
+
+ DankSlider {
+ width: parent.width
+ height: 24
+ value: SettingsData.topBarSpacing
+ minimum: 0
+ maximum: 32
+ unit: ""
+ showValue: true
+ wheelEnabled: false
+ onSliderValueChanged: newValue => {
+ SettingsData.setTopBarSpacing(
+ newValue)
+ }
+ }
+ }
+
+ Column {
+ width: parent.width
+ spacing: Theme.spacingS
+
+ StyledText {
+ text: "Bottom Gap (Exclusive Zone)"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ }
+
+ DankSlider {
+ width: parent.width
+ height: 24
+ value: SettingsData.topBarBottomGap
+ minimum: -100
+ maximum: 100
+ unit: ""
+ showValue: true
+ wheelEnabled: false
+ onSliderValueChanged: newValue => {
+ SettingsData.setTopBarBottomGap(
+ newValue)
+ }
+ }
+ }
+
+ Column {
+ width: parent.width
+ spacing: Theme.spacingS
+
+ StyledText {
+ text: "Size"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ }
+
+ DankSlider {
+ width: parent.width
+ height: 24
+ value: SettingsData.topBarInnerPadding
+ minimum: 0
+ maximum: 24
+ unit: ""
+ showValue: true
+ wheelEnabled: false
+ onSliderValueChanged: newValue => {
+ SettingsData.setTopBarInnerPadding(
+ newValue)
+ }
+ }
+ }
+
+ Column {
+ width: parent.width
+ spacing: Theme.spacingS
+
+ StyledText {
+ text: "Corner Radius (0 = square corners)"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ }
+
+ DankSlider {
+ width: parent.width
+ height: 24
+ value: SettingsData.cornerRadius
+ minimum: 0
+ maximum: 32
+ unit: ""
+ showValue: true
+ wheelEnabled: false
+ onSliderValueChanged: newValue => {
+ SettingsData.setCornerRadius(
+ newValue)
+ }
+ }
+ }
+
+ DankToggle {
+ width: parent.width
+ text: "Square Corners"
+ description: "Removes rounded corners from bar container."
+ checked: SettingsData.topBarSquareCorners
+ onToggled: checked => {
+ SettingsData.setTopBarSquareCorners(
+ checked)
+ }
+ }
+
+ DankToggle {
+ width: parent.width
+ text: "No Background"
+ description: "Remove widget backgrounds for a minimal look with tighter spacing."
+ checked: SettingsData.topBarNoBackground
+ onToggled: checked => {
+ SettingsData.setTopBarNoBackground(
+ checked)
+ }
+ }
+ }
+ }
+
+ // Widget Management Section
+ StyledRect {
+ width: parent.width
+ height: widgetManagementSection.implicitHeight + Theme.spacingL * 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b, 0.3)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g,
+ Theme.outline.b, 0.2)
+ border.width: 1
+
+ Column {
+ id: widgetManagementSection
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingM
+
+ RowLayout {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ DankIcon {
+ id: widgetIcon
+ name: "widgets"
+ size: Theme.iconSize
+ color: Theme.primary
+ Layout.alignment: Qt.AlignVCenter
+ }
+
+ StyledText {
+ id: widgetTitle
+ text: "Widget Management"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ Layout.alignment: Qt.AlignVCenter
+ }
+
+ Item {
+ height: 1
+ Layout.fillWidth: true
+ }
+
+ Rectangle {
+ id: resetButton
+ width: 80
+ height: 28
+ radius: Theme.cornerRadius
+ color: resetArea.containsMouse ? Theme.surfacePressed : Theme.surfaceVariant
+ Layout.alignment: Qt.AlignVCenter
+ border.width: 1
+ border.color: resetArea.containsMouse ? Theme.outline : Qt.rgba(
+ Theme.outline.r,
+ Theme.outline.g,
+ Theme.outline.b,
+ 0.5)
+
+ Row {
+ anchors.centerIn: parent
+ spacing: Theme.spacingXS
+
+ DankIcon {
+ name: "refresh"
+ size: 14
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Reset"
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ MouseArea {
+ id: resetArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ SettingsData.setTopBarLeftWidgets(
+ defaultLeftWidgets)
+ SettingsData.setTopBarCenterWidgets(
+ defaultCenterWidgets)
+ SettingsData.setTopBarRightWidgets(
+ defaultRightWidgets)
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+
+ Behavior on border.color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+ }
+ }
+
+ StyledText {
+ width: parent.width
+ text: "Drag widgets to reorder within sections. Use the eye icon to hide/show widgets (maintains spacing), or X to remove them completely."
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ wrapMode: Text.WordWrap
+ }
+ }
+ }
+
+ Column {
+ width: parent.width
+ spacing: Theme.spacingL
+
+ // Left Section
+ StyledRect {
+ width: parent.width
+ height: leftSection.implicitHeight + Theme.spacingL * 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r,
+ Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b, 0.3)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g,
+ Theme.outline.b, 0.2)
+ border.width: 1
+
+ WidgetsTabSection {
+ id: leftSection
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ title: "Left Section"
+ titleIcon: "format_align_left"
+ sectionId: "left"
+ allWidgets: topBarTab.baseWidgetDefinitions
+ items: topBarTab.getItemsForSection("left")
+ onItemEnabledChanged: (sectionId, itemId, enabled) => {
+ topBarTab.handleItemEnabledChanged(
+ sectionId,
+ itemId, enabled)
+ }
+ onItemOrderChanged: newOrder => {
+ topBarTab.handleItemOrderChanged(
+ "left", newOrder)
+ }
+ onAddWidget: sectionId => {
+ widgetSelectionPopup.allWidgets
+ = topBarTab.baseWidgetDefinitions
+ widgetSelectionPopup.targetSection = sectionId
+ widgetSelectionPopup.safeOpen()
+ }
+ onRemoveWidget: (sectionId, widgetIndex) => {
+ topBarTab.removeWidgetFromSection(
+ sectionId, widgetIndex)
+ }
+ onSpacerSizeChanged: (sectionId, widgetIndex, newSize) => {
+ topBarTab.handleSpacerSizeChanged(
+ sectionId, widgetIndex, newSize)
+ }
+ onCompactModeChanged: (widgetId, value) => {
+ if (widgetId === "clock") {
+ SettingsData.setClockCompactMode(
+ value)
+ } else if (widgetId === "music") {
+ SettingsData.setMediaSize(
+ value)
+ } else if (widgetId === "focusedWindow") {
+ SettingsData.setFocusedWindowCompactMode(
+ value)
+ } else if (widgetId === "runningApps") {
+ SettingsData.setRunningAppsCompactMode(
+ value)
+ }
+ }
+ onControlCenterSettingChanged: (sectionId, widgetIndex, settingName, value) => {
+ handleControlCenterSettingChanged(sectionId, widgetIndex, settingName, value)
+ }
+ onGpuSelectionChanged: (sectionId, widgetIndex, selectedIndex) => {
+ topBarTab.handleGpuSelectionChanged(
+ sectionId, widgetIndex,
+ selectedIndex)
+ }
+ }
+ }
+
+ // Center Section
+ StyledRect {
+ width: parent.width
+ height: centerSection.implicitHeight + Theme.spacingL * 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r,
+ Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b, 0.3)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g,
+ Theme.outline.b, 0.2)
+ border.width: 1
+
+ WidgetsTabSection {
+ id: centerSection
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ title: "Center Section"
+ titleIcon: "format_align_center"
+ sectionId: "center"
+ allWidgets: topBarTab.baseWidgetDefinitions
+ items: topBarTab.getItemsForSection("center")
+ onItemEnabledChanged: (sectionId, itemId, enabled) => {
+ topBarTab.handleItemEnabledChanged(
+ sectionId,
+ itemId, enabled)
+ }
+ onItemOrderChanged: newOrder => {
+ topBarTab.handleItemOrderChanged(
+ "center", newOrder)
+ }
+ onAddWidget: sectionId => {
+ widgetSelectionPopup.allWidgets
+ = topBarTab.baseWidgetDefinitions
+ widgetSelectionPopup.targetSection = sectionId
+ widgetSelectionPopup.safeOpen()
+ }
+ onRemoveWidget: (sectionId, widgetIndex) => {
+ topBarTab.removeWidgetFromSection(
+ sectionId, widgetIndex)
+ }
+ onSpacerSizeChanged: (sectionId, widgetIndex, newSize) => {
+ topBarTab.handleSpacerSizeChanged(
+ sectionId, widgetIndex, newSize)
+ }
+ onCompactModeChanged: (widgetId, value) => {
+ if (widgetId === "clock") {
+ SettingsData.setClockCompactMode(
+ value)
+ } else if (widgetId === "music") {
+ SettingsData.setMediaSize(
+ value)
+ } else if (widgetId === "focusedWindow") {
+ SettingsData.setFocusedWindowCompactMode(
+ value)
+ } else if (widgetId === "runningApps") {
+ SettingsData.setRunningAppsCompactMode(
+ value)
+ }
+ }
+ onControlCenterSettingChanged: (sectionId, widgetIndex, settingName, value) => {
+ handleControlCenterSettingChanged(sectionId, widgetIndex, settingName, value)
+ }
+ onGpuSelectionChanged: (sectionId, widgetIndex, selectedIndex) => {
+ topBarTab.handleGpuSelectionChanged(
+ sectionId, widgetIndex,
+ selectedIndex)
+ }
+ }
+ }
+
+ // Right Section
+ StyledRect {
+ width: parent.width
+ height: rightSection.implicitHeight + Theme.spacingL * 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r,
+ Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b, 0.3)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g,
+ Theme.outline.b, 0.2)
+ border.width: 1
+
+ WidgetsTabSection {
+ id: rightSection
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ title: "Right Section"
+ titleIcon: "format_align_right"
+ sectionId: "right"
+ allWidgets: topBarTab.baseWidgetDefinitions
+ items: topBarTab.getItemsForSection("right")
+ onItemEnabledChanged: (sectionId, itemId, enabled) => {
+ topBarTab.handleItemEnabledChanged(
+ sectionId,
+ itemId, enabled)
+ }
+ onItemOrderChanged: newOrder => {
+ topBarTab.handleItemOrderChanged(
+ "right", newOrder)
+ }
+ onAddWidget: sectionId => {
+ widgetSelectionPopup.allWidgets
+ = topBarTab.baseWidgetDefinitions
+ widgetSelectionPopup.targetSection = sectionId
+ widgetSelectionPopup.safeOpen()
+ }
+ onRemoveWidget: (sectionId, widgetIndex) => {
+ topBarTab.removeWidgetFromSection(
+ sectionId, widgetIndex)
+ }
+ onSpacerSizeChanged: (sectionId, widgetIndex, newSize) => {
+ topBarTab.handleSpacerSizeChanged(
+ sectionId, widgetIndex, newSize)
+ }
+ onCompactModeChanged: (widgetId, value) => {
+ if (widgetId === "clock") {
+ SettingsData.setClockCompactMode(
+ value)
+ } else if (widgetId === "music") {
+ SettingsData.setMediaSize(
+ value)
+ } else if (widgetId === "focusedWindow") {
+ SettingsData.setFocusedWindowCompactMode(
+ value)
+ } else if (widgetId === "runningApps") {
+ SettingsData.setRunningAppsCompactMode(
+ value)
+ }
+ }
+ onControlCenterSettingChanged: (sectionId, widgetIndex, settingName, value) => {
+ handleControlCenterSettingChanged(sectionId, widgetIndex, settingName, value)
+ }
+ onGpuSelectionChanged: (sectionId, widgetIndex, selectedIndex) => {
+ topBarTab.handleGpuSelectionChanged(
+ sectionId, widgetIndex,
+ selectedIndex)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ WidgetSelectionPopup {
+ id: widgetSelectionPopup
+
+ anchors.centerIn: parent
+ onWidgetSelected: (widgetId, targetSection) => {
+ topBarTab.addWidgetToSection(widgetId,
+ targetSection)
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/Settings/WeatherTab.qml b/quickshell/.config/quickshell/Modules/Settings/WeatherTab.qml
new file mode 100644
index 0000000..eb9c2f2
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/Settings/WeatherTab.qml
@@ -0,0 +1,308 @@
+import QtQuick
+import QtQuick.Controls
+import qs.Common
+import qs.Widgets
+
+Item {
+ id: weatherTab
+
+ DankFlickable {
+ anchors.fill: parent
+ anchors.topMargin: Theme.spacingL
+ clip: true
+ contentHeight: mainColumn.height
+ contentWidth: width
+
+ Column {
+ id: mainColumn
+
+ width: parent.width
+ spacing: Theme.spacingXL
+
+ // Enable Weather
+ StyledRect {
+ width: parent.width
+ height: enableWeatherSection.implicitHeight + Theme.spacingL * 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b, 0.3)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g,
+ Theme.outline.b, 0.2)
+ border.width: 1
+
+ Column {
+ id: enableWeatherSection
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingM
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "cloud"
+ size: Theme.iconSize
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ width: parent.width - Theme.iconSize - Theme.spacingM
+ - enableToggle.width - Theme.spacingM
+ spacing: Theme.spacingXS
+ anchors.verticalCenter: parent.verticalCenter
+
+ StyledText {
+ text: "Enable Weather"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ }
+
+ StyledText {
+ text: "Show weather information in top bar and control center"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ wrapMode: Text.WordWrap
+ width: parent.width
+ }
+ }
+
+ DankToggle {
+ id: enableToggle
+
+ anchors.verticalCenter: parent.verticalCenter
+ checked: SettingsData.weatherEnabled
+ onToggled: checked => {
+ return SettingsData.setWeatherEnabled(
+ checked)
+ }
+ }
+ }
+ }
+ }
+
+ // Temperature Unit
+ StyledRect {
+ width: parent.width
+ height: temperatureSection.implicitHeight + Theme.spacingL * 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b, 0.3)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g,
+ Theme.outline.b, 0.2)
+ border.width: 1
+ visible: SettingsData.weatherEnabled
+ opacity: visible ? 1 : 0
+
+ Column {
+ id: temperatureSection
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingM
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "thermostat"
+ size: Theme.iconSize
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ width: parent.width - Theme.iconSize - Theme.spacingM
+ - temperatureToggle.width - Theme.spacingM
+ spacing: Theme.spacingXS
+ anchors.verticalCenter: parent.verticalCenter
+
+ StyledText {
+ text: "Use Fahrenheit"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ }
+
+ StyledText {
+ text: "Use Fahrenheit instead of Celsius for temperature"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ wrapMode: Text.WordWrap
+ width: parent.width
+ }
+ }
+
+ DankToggle {
+ id: temperatureToggle
+
+ anchors.verticalCenter: parent.verticalCenter
+ checked: SettingsData.useFahrenheit
+ onToggled: checked => {
+ return SettingsData.setTemperatureUnit(
+ checked)
+ }
+ }
+ }
+ }
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Theme.mediumDuration
+ easing.type: Theme.emphasizedEasing
+ }
+ }
+ }
+
+ // Location Settings
+ StyledRect {
+ width: parent.width
+ height: locationSection.implicitHeight + Theme.spacingL * 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b, 0.3)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g,
+ Theme.outline.b, 0.2)
+ border.width: 1
+ visible: SettingsData.weatherEnabled
+ opacity: visible ? 1 : 0
+
+ Column {
+ id: locationSection
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingM
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "location_on"
+ size: Theme.iconSize
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ width: parent.width - Theme.iconSize - Theme.spacingM
+ - autoLocationToggle.width - Theme.spacingM
+ spacing: Theme.spacingXS
+ anchors.verticalCenter: parent.verticalCenter
+
+ StyledText {
+ text: "Auto Location"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ }
+
+ StyledText {
+ text: "Automatically determine your location using your IP address"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ wrapMode: Text.WordWrap
+ width: parent.width
+ }
+ }
+
+ DankToggle {
+ id: autoLocationToggle
+
+ anchors.verticalCenter: parent.verticalCenter
+ checked: SettingsData.useAutoLocation
+ onToggled: checked => {
+ return SettingsData.setAutoLocation(
+ checked)
+ }
+ }
+ }
+
+ Column {
+ width: parent.width
+ spacing: Theme.spacingXS
+ visible: !SettingsData.useAutoLocation
+
+ Rectangle {
+ width: parent.width
+ height: 1
+ color: Theme.outline
+ opacity: 0.2
+ }
+
+ StyledText {
+ text: "Custom Location"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ }
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ DankTextField {
+ id: latitudeInput
+ width: (parent.width - Theme.spacingM) / 2
+ height: 48
+ placeholderText: "Latitude"
+ text: SettingsData.weatherCoordinates ? SettingsData.weatherCoordinates.split(',')[0] : ""
+ backgroundColor: Theme.surfaceVariant
+ normalBorderColor: Theme.primarySelected
+ focusedBorderColor: Theme.primary
+ onTextEdited: {
+ if (text && longitudeInput.text) {
+ const coords = text + "," + longitudeInput.text
+ const displayName = `${text}, ${longitudeInput.text}`
+ SettingsData.setWeatherLocation(displayName, coords)
+ }
+ }
+ }
+
+ DankTextField {
+ id: longitudeInput
+ width: (parent.width - Theme.spacingM) / 2
+ height: 48
+ placeholderText: "Longitude"
+ text: SettingsData.weatherCoordinates ? SettingsData.weatherCoordinates.split(',')[1] : ""
+ backgroundColor: Theme.surfaceVariant
+ normalBorderColor: Theme.primarySelected
+ focusedBorderColor: Theme.primary
+ onTextEdited: {
+ if (text && latitudeInput.text) {
+ const coords = latitudeInput.text + "," + text
+ const displayName = `${latitudeInput.text}, ${text}`
+ SettingsData.setWeatherLocation(displayName, coords)
+ }
+ }
+ }
+ }
+
+ DankLocationSearch {
+ width: parent.width
+ currentLocation: SettingsData.weatherLocation
+ placeholderText: "New York, NY"
+ onLocationSelected: (displayName, coordinates) => {
+ SettingsData.setWeatherLocation(
+ displayName,
+ coordinates)
+ }
+ }
+ }
+ }
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Theme.mediumDuration
+ easing.type: Theme.emphasizedEasing
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/Settings/WidgetSelectionPopup.qml b/quickshell/.config/quickshell/Modules/Settings/WidgetSelectionPopup.qml
new file mode 100644
index 0000000..2b3b4d7
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/Settings/WidgetSelectionPopup.qml
@@ -0,0 +1,310 @@
+import QtQuick
+import QtQuick.Controls
+import qs.Common
+import qs.Widgets
+
+Popup {
+ id: root
+
+ property var allWidgets: []
+ property string targetSection: ""
+ property bool isOpening: false
+ property string searchQuery: ""
+ property var filteredWidgets: []
+ property int selectedIndex: -1
+ property bool keyboardNavigationActive: false
+
+ signal widgetSelected(string widgetId, string targetSection)
+
+ function updateFilteredWidgets() {
+ if (!searchQuery || searchQuery.length === 0) {
+ filteredWidgets = allWidgets.slice()
+ return
+ }
+
+ var filtered = []
+ var query = searchQuery.toLowerCase()
+
+ for (var i = 0; i < allWidgets.length; i++) {
+ var widget = allWidgets[i]
+ var text = widget.text ? widget.text.toLowerCase() : ""
+ var description = widget.description ? widget.description.toLowerCase() : ""
+ var id = widget.id ? widget.id.toLowerCase() : ""
+
+ if (text.indexOf(query) !== -1 ||
+ description.indexOf(query) !== -1 ||
+ id.indexOf(query) !== -1) {
+ filtered.push(widget)
+ }
+ }
+
+ filteredWidgets = filtered
+ selectedIndex = -1
+ keyboardNavigationActive = false
+ }
+
+ onAllWidgetsChanged: {
+ updateFilteredWidgets()
+ }
+
+ function selectNext() {
+ if (filteredWidgets.length === 0) return
+ keyboardNavigationActive = true
+ selectedIndex = Math.min(selectedIndex + 1, filteredWidgets.length - 1)
+ }
+
+ function selectPrevious() {
+ if (filteredWidgets.length === 0) return
+ keyboardNavigationActive = true
+ selectedIndex = Math.max(selectedIndex - 1, -1)
+ if (selectedIndex === -1) {
+ keyboardNavigationActive = false
+ }
+ }
+
+ function selectWidget() {
+ if (selectedIndex >= 0 && selectedIndex < filteredWidgets.length) {
+ var widget = filteredWidgets[selectedIndex]
+ root.widgetSelected(widget.id, root.targetSection)
+ root.close()
+ }
+ }
+
+ function safeOpen() {
+ if (!isOpening && !visible) {
+ isOpening = true
+ open()
+ }
+ }
+
+ width: 500
+ height: 550
+ modal: true
+ closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
+ onOpened: {
+ isOpening = false
+ Qt.callLater(() => {
+ searchField.forceActiveFocus()
+ })
+ }
+ onClosed: {
+ isOpening = false
+ allWidgets = []
+ targetSection = ""
+ searchQuery = ""
+ filteredWidgets = []
+ selectedIndex = -1
+ keyboardNavigationActive = false
+ }
+
+ background: Rectangle {
+ color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g,
+ Theme.surfaceContainer.b, 1)
+ border.color: Theme.primarySelected
+ border.width: 1
+ radius: Theme.cornerRadius
+ }
+
+ contentItem: Item {
+ anchors.fill: parent
+ focus: true
+ Keys.onPressed: event => {
+ if (event.key === Qt.Key_Escape) {
+ root.close()
+ event.accepted = true
+ } else if (event.key === Qt.Key_Down) {
+ root.selectNext()
+ event.accepted = true
+ } else if (event.key === Qt.Key_Up) {
+ root.selectPrevious()
+ event.accepted = true
+ } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
+ if (root.keyboardNavigationActive) {
+ root.selectWidget()
+ } else if (root.filteredWidgets.length > 0) {
+ var firstWidget = root.filteredWidgets[0]
+ root.widgetSelected(firstWidget.id, root.targetSection)
+ root.close()
+ }
+ event.accepted = true
+ } else if (!searchField.activeFocus && event.text && event.text.length > 0 && event.text.match(/[a-zA-Z0-9\\s]/)) {
+ searchField.forceActiveFocus()
+ searchField.insertText(event.text)
+ event.accepted = true
+ }
+ }
+
+ DankActionButton {
+ iconName: "close"
+ iconSize: Theme.iconSize - 2
+ iconColor: Theme.outline
+ anchors.top: parent.top
+ anchors.topMargin: Theme.spacingM
+ anchors.right: parent.right
+ anchors.rightMargin: Theme.spacingM
+ onClicked: root.close()
+ }
+
+ Column {
+ id: contentColumn
+
+ spacing: Theme.spacingM
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ anchors.topMargin: Theme.spacingL + 30 // Space for close button
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "add_circle"
+ size: Theme.iconSize
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Add Widget to " + root.targetSection + " Section"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ StyledText {
+ text: "Select a widget to add to the " + root.targetSection.toLowerCase(
+ ) + " section of the top bar. You can add multiple instances of the same widget if needed."
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.outline
+ width: parent.width
+ wrapMode: Text.WordWrap
+ }
+
+ DankTextField {
+ id: searchField
+ width: parent.width
+ height: 48
+ cornerRadius: Theme.cornerRadius
+ backgroundColor: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3)
+ normalBorderColor: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
+ focusedBorderColor: Theme.primary
+ leftIconName: "search"
+ leftIconSize: Theme.iconSize - 2
+ leftIconColor: Theme.outline
+ leftIconFocusedColor: Theme.primary
+ showClearButton: true
+ textColor: Theme.surfaceText
+ font.pixelSize: Theme.fontSizeMedium
+ placeholderText: "Search widgets..."
+ text: root.searchQuery
+ ignoreLeftRightKeys: true
+ keyForwardTargets: [root.contentItem]
+ onTextEdited: {
+ root.searchQuery = text
+ updateFilteredWidgets()
+ }
+ Keys.onPressed: event => {
+ if (event.key === Qt.Key_Escape) {
+ root.close()
+ event.accepted = true
+ } else if (event.key === Qt.Key_Down || event.key === Qt.Key_Up ||
+ ((event.key === Qt.Key_Return || event.key === Qt.Key_Enter) && text.length === 0)) {
+ event.accepted = false
+ }
+ }
+ }
+
+ DankListView {
+ id: widgetList
+
+ width: parent.width
+ height: parent.height - y
+ spacing: Theme.spacingS
+ model: root.filteredWidgets
+ clip: true
+
+ delegate: Rectangle {
+ width: widgetList.width
+ height: 60
+ radius: Theme.cornerRadius
+ property bool isSelected: root.keyboardNavigationActive && index === root.selectedIndex
+ color: isSelected ? Theme.primarySelected :
+ widgetArea.containsMouse ? Theme.primaryHover : Qt.rgba(
+ Theme.surfaceVariant.r,
+ Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b,
+ 0.3)
+ border.color: isSelected ? Theme.primary : Qt.rgba(Theme.outline.r, Theme.outline.g,
+ Theme.outline.b, 0.2)
+ border.width: isSelected ? 2 : 1
+
+ Row {
+ anchors.fill: parent
+ anchors.margins: Theme.spacingM
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: modelData.icon
+ size: Theme.iconSize
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 2
+ width: parent.width - Theme.iconSize - Theme.spacingM * 3
+
+ StyledText {
+ text: modelData.text
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ elide: Text.ElideRight
+ width: parent.width
+ }
+
+ StyledText {
+ text: modelData.description
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.outline
+ elide: Text.ElideRight
+ width: parent.width
+ wrapMode: Text.WordWrap
+ }
+ }
+
+ DankIcon {
+ name: "add"
+ size: Theme.iconSize - 4
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ MouseArea {
+ id: widgetArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ root.widgetSelected(modelData.id,
+ root.targetSection)
+ root.close()
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/Settings/WidgetTweaksTab.qml b/quickshell/.config/quickshell/Modules/Settings/WidgetTweaksTab.qml
new file mode 100644
index 0000000..61ebe10
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/Settings/WidgetTweaksTab.qml
@@ -0,0 +1,510 @@
+import QtQuick
+import QtQuick.Controls
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Item {
+ id: widgetTweaksTab
+
+ DankFlickable {
+ anchors.fill: parent
+ anchors.topMargin: Theme.spacingL
+ clip: true
+ contentHeight: mainColumn.height
+ contentWidth: width
+
+ Column {
+ id: mainColumn
+ width: parent.width
+ spacing: Theme.spacingXL
+
+ // Launcher Button Section
+ StyledRect {
+ width: parent.width
+ height: launcherButtonSection.implicitHeight + Theme.spacingL * 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b, 0.3)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g,
+ Theme.outline.b, 0.2)
+ border.width: 1
+
+ Column {
+ id: launcherButtonSection
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingM
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "apps"
+ size: Theme.iconSize
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Launcher Button"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ DankToggle {
+ width: parent.width
+ text: "Use OS Logo"
+ description: "Display operating system logo instead of apps icon"
+ checked: SettingsData.useOSLogo
+ onToggled: checked => {
+ return SettingsData.setUseOSLogo(checked)
+ }
+ }
+
+ Row {
+ width: parent.width - Theme.spacingL
+ spacing: Theme.spacingL
+ visible: SettingsData.useOSLogo
+ opacity: visible ? 1 : 0
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingL
+
+ Column {
+ width: 120
+ spacing: Theme.spacingS
+
+ StyledText {
+ text: "Color Override"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ }
+
+ DankTextField {
+ width: 100
+ height: 28
+ placeholderText: "#ffffff"
+ text: SettingsData.osLogoColorOverride
+ maximumLength: 7
+ font.pixelSize: Theme.fontSizeSmall
+ topPadding: Theme.spacingXS
+ bottomPadding: Theme.spacingXS
+ onEditingFinished: {
+ var color = text.trim()
+ if (color === ""
+ || /^#[0-9A-Fa-f]{6}$/.test(color))
+ SettingsData.setOSLogoColorOverride(
+ color)
+ else
+ text = SettingsData.osLogoColorOverride
+ }
+ }
+ }
+
+ Column {
+ width: 120
+ spacing: Theme.spacingS
+
+ StyledText {
+ text: "Brightness"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ }
+
+ DankSlider {
+ width: 100
+ height: 20
+ minimum: 0
+ maximum: 100
+ value: Math.round(
+ SettingsData.osLogoBrightness * 100)
+ unit: "%"
+ showValue: true
+ wheelEnabled: false
+ onSliderValueChanged: newValue => {
+ SettingsData.setOSLogoBrightness(
+ newValue / 100)
+ }
+ }
+ }
+
+ Column {
+ width: 120
+ spacing: Theme.spacingS
+
+ StyledText {
+ text: "Contrast"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ }
+
+ DankSlider {
+ width: 100
+ height: 20
+ minimum: 0
+ maximum: 200
+ value: Math.round(
+ SettingsData.osLogoContrast * 100)
+ unit: "%"
+ showValue: true
+ wheelEnabled: false
+ onSliderValueChanged: newValue => {
+ SettingsData.setOSLogoContrast(
+ newValue / 100)
+ }
+ }
+ }
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Theme.mediumDuration
+ easing.type: Theme.emphasizedEasing
+ }
+ }
+ }
+ }
+ }
+
+ StyledRect {
+ width: parent.width
+ height: workspaceSection.implicitHeight + Theme.spacingL * 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b, 0.3)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g,
+ Theme.outline.b, 0.2)
+ border.width: 1
+
+ Column {
+ id: workspaceSection
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingM
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "view_module"
+ size: Theme.iconSize
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Workspace Settings"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ DankToggle {
+ width: parent.width
+ text: "Workspace Index Numbers"
+ description: "Show workspace index numbers in the top bar workspace switcher"
+ checked: SettingsData.showWorkspaceIndex
+ onToggled: checked => {
+ return SettingsData.setShowWorkspaceIndex(
+ checked)
+ }
+ }
+
+ DankToggle {
+ width: parent.width
+ text: "Workspace Padding"
+ description: "Always show a minimum of 3 workspaces, even if fewer are available"
+ checked: SettingsData.showWorkspacePadding
+ onToggled: checked => {
+ return SettingsData.setShowWorkspacePadding(
+ checked)
+ }
+ }
+
+ DankToggle {
+ width: parent.width
+ text: "Show Workspace Apps"
+ description: "Display application icons in workspace indicators"
+ checked: SettingsData.showWorkspaceApps
+ onToggled: checked => {
+ return SettingsData.setShowWorkspaceApps(
+ checked)
+ }
+ }
+
+ Row {
+ width: parent.width - Theme.spacingL
+ spacing: Theme.spacingL
+ visible: SettingsData.showWorkspaceApps
+ opacity: visible ? 1 : 0
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingL
+
+ Column {
+ width: 120
+ spacing: Theme.spacingS
+
+ StyledText {
+ text: "Max apps to show"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ }
+
+ DankTextField {
+ width: 100
+ height: 28
+ placeholderText: "#ffffff"
+ text: SettingsData.maxWorkspaceIcons
+ maximumLength: 7
+ font.pixelSize: Theme.fontSizeSmall
+ topPadding: Theme.spacingXS
+ bottomPadding: Theme.spacingXS
+ onEditingFinished: {
+ SettingsData.setMaxWorkspaceIcons(parseInt(text, 10))
+ }
+ }
+ }
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Theme.mediumDuration
+ easing.type: Theme.emphasizedEasing
+ }
+ }
+ }
+
+ DankToggle {
+ width: parent.width
+ text: "Per-Monitor Workspaces"
+ description: "Show only workspaces belonging to each specific monitor."
+ checked: SettingsData.workspacesPerMonitor
+ onToggled: checked => {
+ return SettingsData.setWorkspacesPerMonitor(checked);
+ }
+ }
+ }
+ }
+
+ StyledRect {
+ width: parent.width
+ height: runningAppsSection.implicitHeight + Theme.spacingL * 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b, 0.3)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g,
+ Theme.outline.b, 0.2)
+ border.width: 1
+
+ Column {
+ id: runningAppsSection
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingM
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "apps"
+ size: Theme.iconSize
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Running Apps Settings"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ DankToggle {
+ width: parent.width
+ text: "Running Apps Only In Current Workspace"
+ description: "Show only apps running in current workspace"
+ checked: SettingsData.runningAppsCurrentWorkspace
+ onToggled: checked => {
+ return SettingsData.setRunningAppsCurrentWorkspace(
+ checked)
+ }
+ }
+ }
+ }
+
+ StyledRect {
+ width: parent.width
+ height: workspaceIconsSection.implicitHeight + Theme.spacingL * 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b, 0.3)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g,
+ Theme.outline.b, 0.2)
+ border.width: 1
+ visible: SettingsData.hasNamedWorkspaces()
+
+ Column {
+ id: workspaceIconsSection
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingM
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "label"
+ size: Theme.iconSize
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Named Workspace Icons"
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ StyledText {
+ width: parent.width
+ text: "Configure icons for named workspaces. Icons take priority over numbers when both are enabled."
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.outline
+ wrapMode: Text.WordWrap
+ }
+
+ Repeater {
+ model: SettingsData.getNamedWorkspaces()
+
+ Rectangle {
+ width: parent.width
+ height: workspaceIconRow.implicitHeight + Theme.spacingM
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceContainer.r,
+ Theme.surfaceContainer.g,
+ Theme.surfaceContainer.b, 0.5)
+ border.color: Qt.rgba(Theme.outline.r,
+ Theme.outline.g,
+ Theme.outline.b, 0.3)
+ border.width: 1
+
+ Row {
+ id: workspaceIconRow
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.leftMargin: Theme.spacingM
+ anchors.rightMargin: Theme.spacingM
+ spacing: Theme.spacingM
+
+ StyledText {
+ text: "\"" + modelData + "\""
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ width: 150
+ elide: Text.ElideRight
+ }
+
+ DankIconPicker {
+ id: iconPicker
+ anchors.verticalCenter: parent.verticalCenter
+
+ Component.onCompleted: {
+ var iconData = SettingsData.getWorkspaceNameIcon(
+ modelData)
+ if (iconData) {
+ setIcon(iconData.value,
+ iconData.type)
+ }
+ }
+
+ onIconSelected: (iconName, iconType) => {
+ SettingsData.setWorkspaceNameIcon(
+ modelData, {
+ "type": iconType,
+ "value": iconName
+ })
+ setIcon(iconName,
+ iconType)
+ }
+
+ Connections {
+ target: SettingsData
+ function onWorkspaceIconsUpdated() {
+ var iconData = SettingsData.getWorkspaceNameIcon(
+ modelData)
+ if (iconData) {
+ iconPicker.setIcon(
+ iconData.value,
+ iconData.type)
+ } else {
+ iconPicker.setIcon("", "icon")
+ }
+ }
+ }
+ }
+
+ Rectangle {
+ width: 28
+ height: 28
+ radius: Theme.cornerRadius
+ color: clearMouseArea.containsMouse ? Theme.errorHover : Theme.surfaceContainer
+ border.color: clearMouseArea.containsMouse ? Theme.error : Theme.outline
+ border.width: 1
+ anchors.verticalCenter: parent.verticalCenter
+
+ DankIcon {
+ name: "close"
+ size: 16
+ color: clearMouseArea.containsMouse ? Theme.error : Theme.outline
+ anchors.centerIn: parent
+ }
+
+ MouseArea {
+ id: clearMouseArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ SettingsData.removeWorkspaceNameIcon(
+ modelData)
+ }
+ }
+ }
+
+ Item {
+ width: parent.width - 150 - 240 - 28 - Theme.spacingM * 4
+ height: 1
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/Settings/WidgetsTabSection.qml b/quickshell/.config/quickshell/Modules/Settings/WidgetsTabSection.qml
new file mode 100644
index 0000000..e7340d0
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/Settings/WidgetsTabSection.qml
@@ -0,0 +1,761 @@
+import QtQuick
+import QtQuick.Controls
+import qs.Common
+import qs.Widgets
+import qs.Services
+
+Column {
+ id: root
+
+ property var items: []
+ property var allWidgets: []
+ property string title: ""
+ property string titleIcon: "widgets"
+ property string sectionId: ""
+
+ signal itemEnabledChanged(string sectionId, string itemId, bool enabled)
+ signal itemOrderChanged(var newOrder)
+ signal addWidget(string sectionId)
+ signal removeWidget(string sectionId, int widgetIndex)
+ signal spacerSizeChanged(string sectionId, int widgetIndex, int newSize)
+ signal compactModeChanged(string widgetId, var value)
+ signal gpuSelectionChanged(string sectionId, int widgetIndex, int selectedIndex)
+ signal controlCenterSettingChanged(string sectionId, int widgetIndex, string settingName, bool value)
+
+ width: parent.width
+ height: implicitHeight
+ spacing: Theme.spacingM
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: root.titleIcon
+ size: Theme.iconSize
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: root.title
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Item {
+ width: parent.width - 60
+ height: 1
+ }
+ }
+
+ Column {
+ id: itemsList
+
+ width: parent.width
+ spacing: Theme.spacingS
+
+ Repeater {
+ model: root.items
+
+ delegate: Item {
+ id: delegateItem
+
+ property bool held: dragArea.pressed
+ property real originalY: y
+
+ width: itemsList.width
+ height: 70
+ z: held ? 2 : 1
+
+ Rectangle {
+ id: itemBackground
+
+ anchors.fill: parent
+ anchors.margins: 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceContainer.r,
+ Theme.surfaceContainer.g,
+ Theme.surfaceContainer.b, 0.8)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g,
+ Theme.outline.b, 0.2)
+ border.width: 1
+
+ DankIcon {
+ name: "drag_indicator"
+ size: Theme.iconSize - 4
+ color: Theme.outline
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingM + 8
+ anchors.verticalCenter: parent.verticalCenter
+ opacity: 0.8
+ }
+
+ DankIcon {
+ name: modelData.icon
+ size: Theme.iconSize
+ color: modelData.enabled ? Theme.primary : Theme.outline
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingM * 2 + 40
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingM * 3 + 40 + Theme.iconSize
+ anchors.right: actionButtons.left
+ anchors.rightMargin: Theme.spacingM
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 2
+
+ StyledText {
+ text: modelData.text
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: Font.Medium
+ color: modelData.enabled ? Theme.surfaceText : Theme.outline
+ elide: Text.ElideRight
+ width: parent.width
+ }
+
+ StyledText {
+ text: modelData.description
+ font.pixelSize: Theme.fontSizeSmall
+ color: modelData.enabled ? Theme.outline : Qt.rgba(
+ Theme.outline.r,
+ Theme.outline.g,
+ Theme.outline.b, 0.6)
+ elide: Text.ElideRight
+ width: parent.width
+ wrapMode: Text.WordWrap
+ }
+ }
+
+ Row {
+ id: actionButtons
+
+ anchors.right: parent.right
+ anchors.rightMargin: Theme.spacingM
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingXS
+
+ Item {
+ width: 120
+ height: 32
+ visible: modelData.id === "gpuTemp"
+
+ DankDropdown {
+ id: gpuDropdown
+ anchors.fill: parent
+ currentValue: {
+ var selectedIndex = modelData.selectedGpuIndex
+ !== undefined ? modelData.selectedGpuIndex : 0
+ if (DgopService.availableGpus
+ && DgopService.availableGpus.length > selectedIndex
+ && selectedIndex >= 0) {
+ var gpu = DgopService.availableGpus[selectedIndex]
+ return gpu.driver.toUpperCase()
+ }
+ return DgopService.availableGpus
+ && DgopService.availableGpus.length
+ > 0 ? DgopService.availableGpus[0].driver.toUpperCase(
+ ) : ""
+ }
+ options: {
+ var gpuOptions = []
+ if (DgopService.availableGpus
+ && DgopService.availableGpus.length > 0) {
+ for (var i = 0; i < DgopService.availableGpus.length; i++) {
+ var gpu = DgopService.availableGpus[i]
+ gpuOptions.push(
+ gpu.driver.toUpperCase(
+ ))
+ }
+ }
+ return gpuOptions
+ }
+ onValueChanged: value => {
+ var gpuIndex = options.indexOf(
+ value)
+ if (gpuIndex >= 0) {
+ root.gpuSelectionChanged(
+ root.sectionId,
+ index, gpuIndex)
+ }
+ }
+ }
+ }
+
+ Item {
+ width: 32
+ height: 32
+ visible: (modelData.warning !== undefined
+ && modelData.warning !== "")
+ && (modelData.id === "cpuUsage"
+ || modelData.id === "memUsage"
+ || modelData.id === "cpuTemp"
+ || modelData.id === "gpuTemp")
+
+ DankIcon {
+ name: "warning"
+ size: 20
+ color: Theme.error
+ anchors.centerIn: parent
+ opacity: warningArea.containsMouse ? 1.0 : 0.8
+ }
+
+ MouseArea {
+ id: warningArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ }
+
+ Rectangle {
+ id: warningTooltip
+
+ property string warningText: (modelData.warning !== undefined
+ && modelData.warning
+ !== "") ? modelData.warning : ""
+
+ width: Math.min(
+ 250,
+ warningTooltipText.implicitWidth) + Theme.spacingM * 2
+ height: warningTooltipText.implicitHeight + Theme.spacingS * 2
+ radius: Theme.cornerRadius
+ color: Theme.surfaceContainer
+ border.color: Theme.outline
+ border.width: 1
+ visible: warningArea.containsMouse
+ && warningText !== ""
+ opacity: visible ? 1 : 0
+ x: -width - Theme.spacingS
+ y: (parent.height - height) / 2
+ z: 100
+
+ StyledText {
+ id: warningTooltipText
+ anchors.centerIn: parent
+ anchors.margins: Theme.spacingS
+ text: warningTooltip.warningText
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ width: Math.min(250, implicitWidth)
+ wrapMode: Text.WordWrap
+ }
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+ }
+ }
+
+ Row {
+ spacing: Theme.spacingXS
+ visible: modelData.id === "clock"
+ || modelData.id === "music"
+ || modelData.id === "focusedWindow"
+ || modelData.id === "runningApps"
+
+ DankActionButton {
+ id: smallSizeButton
+ buttonSize: 28
+ visible: modelData.id === "music"
+ iconName: "photo_size_select_small"
+ iconSize: 16
+ iconColor: SettingsData.mediaSize
+ === 0 ? Theme.primary : Theme.outline
+ onClicked: {
+ root.compactModeChanged("music", 0)
+ }
+ }
+
+ DankActionButton {
+ id: mediumSizeButton
+ buttonSize: 28
+ visible: modelData.id === "music"
+ iconName: "photo_size_select_actual"
+ iconSize: 16
+ iconColor: SettingsData.mediaSize
+ === 1 ? Theme.primary : Theme.outline
+ onClicked: {
+ root.compactModeChanged("music", 1)
+ }
+ }
+
+ DankActionButton {
+ id: largeSizeButton
+ buttonSize: 28
+ visible: modelData.id === "music"
+ iconName: "photo_size_select_large"
+ iconSize: 16
+ iconColor: SettingsData.mediaSize
+ === 2 ? Theme.primary : Theme.outline
+ onClicked: {
+ root.compactModeChanged("music", 2)
+ }
+ }
+
+ DankActionButton {
+ id: compactModeButton
+ buttonSize: 28
+ visible: modelData.id === "clock"
+ || modelData.id === "focusedWindow"
+ || modelData.id === "runningApps"
+ iconName: {
+ if (modelData.id === "clock")
+ return SettingsData.clockCompactMode ? "zoom_out" : "zoom_in"
+ if (modelData.id === "focusedWindow")
+ return SettingsData.focusedWindowCompactMode ? "zoom_out" : "zoom_in"
+ if (modelData.id === "runningApps")
+ return SettingsData.runningAppsCompactMode ? "zoom_out" : "zoom_in"
+ return "zoom_in"
+ }
+ iconSize: 16
+ iconColor: {
+ if (modelData.id === "clock")
+ return SettingsData.clockCompactMode ? Theme.primary : Theme.outline
+ if (modelData.id === "focusedWindow")
+ return SettingsData.focusedWindowCompactMode ? Theme.primary : Theme.outline
+ if (modelData.id === "runningApps")
+ return SettingsData.runningAppsCompactMode ? Theme.primary : Theme.outline
+ return Theme.outline
+ }
+ onClicked: {
+ if (modelData.id === "clock") {
+ root.compactModeChanged(
+ "clock",
+ !SettingsData.clockCompactMode)
+ } else if (modelData.id === "focusedWindow") {
+ root.compactModeChanged(
+ "focusedWindow",
+ !SettingsData.focusedWindowCompactMode)
+ } else if (modelData.id === "runningApps") {
+ root.compactModeChanged(
+ "runningApps",
+ !SettingsData.runningAppsCompactMode)
+ }
+ }
+ }
+
+ Rectangle {
+ id: compactModeTooltip
+ width: tooltipText.contentWidth + Theme.spacingM * 2
+ height: tooltipText.contentHeight + Theme.spacingS * 2
+ radius: Theme.cornerRadius
+ color: Theme.surfaceContainer
+ border.color: Theme.outline
+ border.width: 1
+ visible: false
+ opacity: visible ? 1 : 0
+ x: -width - Theme.spacingS
+ y: (parent.height - height) / 2
+ z: 100
+
+ StyledText {
+ id: tooltipText
+ anchors.centerIn: parent
+ text: "Compact Mode"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ }
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+ }
+ }
+
+ DankActionButton {
+ visible: modelData.id === "controlCenterButton"
+ buttonSize: 32
+ iconName: "more_vert"
+ iconSize: 18
+ iconColor: Theme.outline
+ onClicked: {
+ console.log("Control Center three-dot button clicked for widget:", modelData.id)
+ controlCenterContextMenu.widgetData = modelData
+ controlCenterContextMenu.sectionId = root.sectionId
+ controlCenterContextMenu.widgetIndex = index
+ // Position relative to the action buttons row, not the specific button
+ var parentPos = parent.mapToItem(root, 0, 0)
+ controlCenterContextMenu.x = parentPos.x - 210 // Position to the left with margin
+ controlCenterContextMenu.y = parentPos.y - 10 // Slightly above
+ controlCenterContextMenu.open()
+ }
+ }
+
+ DankActionButton {
+ visible: modelData.id !== "spacer"
+ buttonSize: 32
+ iconName: modelData.enabled ? "visibility" : "visibility_off"
+ iconSize: 18
+ iconColor: modelData.enabled ? Theme.primary : Theme.outline
+ onClicked: {
+ root.itemEnabledChanged(root.sectionId,
+ modelData.id,
+ !modelData.enabled)
+ }
+ }
+
+ Row {
+ visible: modelData.id === "spacer"
+ spacing: Theme.spacingXS
+ anchors.verticalCenter: parent.verticalCenter
+
+ DankActionButton {
+ buttonSize: 24
+ iconName: "remove"
+ iconSize: 14
+ iconColor: Theme.outline
+ onClicked: {
+ var currentSize = modelData.size || 20
+ var newSize = Math.max(5, currentSize - 5)
+ root.spacerSizeChanged(root.sectionId,
+ index,
+ newSize)
+ }
+ }
+
+ StyledText {
+ text: (modelData.size || 20).toString()
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ DankActionButton {
+ buttonSize: 24
+ iconName: "add"
+ iconSize: 14
+ iconColor: Theme.outline
+ onClicked: {
+ var currentSize = modelData.size || 20
+ var newSize = Math.min(5000,
+ currentSize + 5)
+ root.spacerSizeChanged(root.sectionId,
+ index,
+ newSize)
+ }
+ }
+ }
+
+ DankActionButton {
+ buttonSize: 32
+ iconName: "close"
+ iconSize: 18
+ iconColor: Theme.error
+ onClicked: {
+ root.removeWidget(root.sectionId, index)
+ }
+ }
+ }
+
+ MouseArea {
+ id: dragArea
+
+ anchors.left: parent.left
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ width: 60
+ hoverEnabled: true
+ cursorShape: Qt.SizeVerCursor
+ drag.target: held ? delegateItem : undefined
+ drag.axis: Drag.YAxis
+ drag.minimumY: -delegateItem.height
+ drag.maximumY: itemsList.height
+ preventStealing: true
+ onPressed: {
+ delegateItem.z = 2
+ delegateItem.originalY = delegateItem.y
+ }
+ onReleased: {
+ delegateItem.z = 1
+ if (drag.active) {
+ var newIndex = Math.round(
+ delegateItem.y / (delegateItem.height
+ + itemsList.spacing))
+ newIndex = Math.max(
+ 0, Math.min(newIndex,
+ root.items.length - 1))
+ if (newIndex !== index) {
+ var newItems = root.items.slice()
+ var draggedItem = newItems.splice(index,
+ 1)[0]
+ newItems.splice(newIndex, 0, draggedItem)
+ root.itemOrderChanged(newItems.map(item => {
+ return ({
+ "id": item.id,
+ "enabled": item.enabled,
+ "size": item.size
+ })
+ }))
+ }
+ }
+ delegateItem.x = 0
+ delegateItem.y = delegateItem.originalY
+ }
+ }
+
+ Behavior on y {
+ enabled: !dragArea.held && !dragArea.drag.active
+
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Rectangle {
+ width: 200
+ height: 40
+ radius: Theme.cornerRadius
+ color: addButtonArea.containsMouse ? Theme.primaryContainer : Qt.rgba(
+ Theme.surfaceVariant.r,
+ Theme.surfaceVariant.g,
+ Theme.surfaceVariant.b, 0.3)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g,
+ Theme.outline.b, 0.2)
+ border.width: 1
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ StyledText {
+ text: "Add Widget"
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.centerIn: parent
+ }
+
+ MouseArea {
+ id: addButtonArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ root.addWidget(root.sectionId)
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+ }
+
+ Popup {
+ id: controlCenterContextMenu
+
+ property var widgetData: null
+ property string sectionId: ""
+ property int widgetIndex: -1
+
+
+ width: 200
+ height: 120
+ padding: 0
+ modal: true
+ focus: true
+ closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
+
+ onOpened: {
+ console.log("Control Center context menu opened")
+ }
+
+ onClosed: {
+ console.log("Control Center context menu closed")
+ }
+
+ background: Rectangle {
+ color: Theme.popupBackground()
+ radius: Theme.cornerRadius
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
+ border.width: 1
+ }
+
+ contentItem: Item {
+
+ Column {
+ id: menuColumn
+ anchors.fill: parent
+ anchors.margins: Theme.spacingS
+ spacing: 2
+
+ Rectangle {
+ width: parent.width
+ height: 32
+ radius: Theme.cornerRadius
+ color: networkToggleArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
+
+ Row {
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingS
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingS
+
+ DankIcon {
+ name: "lan"
+ size: 16
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Network Icon"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ font.weight: Font.Normal
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ DankToggle {
+ id: networkToggle
+ anchors.right: parent.right
+ anchors.rightMargin: Theme.spacingS
+ anchors.verticalCenter: parent.verticalCenter
+ width: 40
+ height: 20
+ checked: SettingsData.controlCenterShowNetworkIcon
+ onToggled: {
+ root.controlCenterSettingChanged(controlCenterContextMenu.sectionId, controlCenterContextMenu.widgetIndex, "showNetworkIcon", toggled)
+ }
+ }
+
+ MouseArea {
+ id: networkToggleArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onPressed: {
+ networkToggle.checked = !networkToggle.checked
+ root.controlCenterSettingChanged(controlCenterContextMenu.sectionId, controlCenterContextMenu.widgetIndex, "showNetworkIcon", networkToggle.checked)
+ }
+ }
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 32
+ radius: Theme.cornerRadius
+ color: bluetoothToggleArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
+
+ Row {
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingS
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingS
+
+ DankIcon {
+ name: "bluetooth"
+ size: 16
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Bluetooth Icon"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ font.weight: Font.Normal
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ DankToggle {
+ id: bluetoothToggle
+ anchors.right: parent.right
+ anchors.rightMargin: Theme.spacingS
+ anchors.verticalCenter: parent.verticalCenter
+ width: 40
+ height: 20
+ checked: SettingsData.controlCenterShowBluetoothIcon
+ onToggled: {
+ root.controlCenterSettingChanged(controlCenterContextMenu.sectionId, controlCenterContextMenu.widgetIndex, "showBluetoothIcon", toggled)
+ }
+ }
+
+ MouseArea {
+ id: bluetoothToggleArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onPressed: {
+ bluetoothToggle.checked = !bluetoothToggle.checked
+ root.controlCenterSettingChanged(controlCenterContextMenu.sectionId, controlCenterContextMenu.widgetIndex, "showBluetoothIcon", bluetoothToggle.checked)
+ }
+ }
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 32
+ radius: Theme.cornerRadius
+ color: audioToggleArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
+
+ Row {
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingS
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingS
+
+ DankIcon {
+ name: "volume_up"
+ size: 16
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "Audio Icon"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ font.weight: Font.Normal
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ DankToggle {
+ id: audioToggle
+ anchors.right: parent.right
+ anchors.rightMargin: Theme.spacingS
+ anchors.verticalCenter: parent.verticalCenter
+ width: 40
+ height: 20
+ checked: SettingsData.controlCenterShowAudioIcon
+ onToggled: {
+ root.controlCenterSettingChanged(controlCenterContextMenu.sectionId, controlCenterContextMenu.widgetIndex, "showAudioIcon", toggled)
+ }
+ }
+
+ MouseArea {
+ id: audioToggleArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onPressed: {
+ audioToggle.checked = !audioToggle.checked
+ root.controlCenterSettingChanged(controlCenterContextMenu.sectionId, controlCenterContextMenu.widgetIndex, "showAudioIcon", audioToggle.checked)
+ }
+ }
+ }
+ }
+
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/Toast.qml b/quickshell/.config/quickshell/Modules/Toast.qml
new file mode 100644
index 0000000..e7bf33d
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/Toast.qml
@@ -0,0 +1,336 @@
+import QtQuick
+import QtQuick.Effects
+import Quickshell
+import Quickshell.Wayland
+import Quickshell.Widgets
+import Quickshell.Io
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+PanelWindow {
+ id: root
+
+ property var modelData
+ property bool shouldBeVisible: false
+ property real frozenWidth: 0
+
+ Connections {
+ target: ToastService
+ function onToastVisibleChanged() {
+ if (ToastService.toastVisible) {
+ shouldBeVisible = true
+ visible = true
+ } else {
+ // Freeze the width before starting exit animation
+ frozenWidth = toast.width
+ shouldBeVisible = false
+ closeTimer.restart()
+ }
+ }
+ }
+
+ Timer {
+ id: closeTimer
+ interval: Theme.mediumDuration + 50
+ onTriggered: {
+ if (!shouldBeVisible) {
+ visible = false
+ }
+ }
+ }
+
+ screen: modelData
+ visible: shouldBeVisible
+ WlrLayershell.layer: WlrLayershell.Overlay
+ WlrLayershell.exclusiveZone: -1
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
+ color: "transparent"
+
+ anchors {
+ top: true
+ left: true
+ right: true
+ bottom: true
+ }
+
+ Rectangle {
+ id: toast
+
+ property bool expanded: false
+
+ Connections {
+ target: ToastService
+ function onResetToastState() {
+ toast.expanded = false
+ }
+ }
+
+ width: shouldBeVisible ? (ToastService.hasDetails ? 380 : 350) : frozenWidth
+ height: toastContent.height + Theme.spacingL * 2
+ anchors.horizontalCenter: parent.horizontalCenter
+ y: Theme.barHeight - 4 + SettingsData.topBarSpacing + 2
+ color: {
+ switch (ToastService.currentLevel) {
+ case ToastService.levelError:
+ return Theme.error
+ case ToastService.levelWarn:
+ return Theme.warning
+ case ToastService.levelInfo:
+ return Theme.surfaceContainer
+ default:
+ return Theme.surfaceContainer
+ }
+ }
+ radius: Theme.cornerRadius
+ layer.enabled: true
+ opacity: shouldBeVisible ? 1 : 0
+
+ Column {
+ id: toastContent
+
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.topMargin: Theme.spacingL
+ anchors.leftMargin: Theme.spacingL
+ anchors.rightMargin: Theme.spacingL
+ spacing: Theme.spacingS
+
+ Item {
+ width: parent.width
+ height: Theme.iconSize + 8
+
+ DankIcon {
+ id: statusIcon
+ name: {
+ switch (ToastService.currentLevel) {
+ case ToastService.levelError:
+ return "error"
+ case ToastService.levelWarn:
+ return "warning"
+ case ToastService.levelInfo:
+ return "info"
+ default:
+ return "info"
+ }
+ }
+ size: Theme.iconSize
+ color: {
+ switch (ToastService.currentLevel) {
+ case ToastService.levelError:
+ case ToastService.levelWarn:
+ return SessionData.isLightMode ? Theme.surfaceText : Theme.background
+ default:
+ return Theme.surfaceText
+ }
+ }
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ id: messageText
+ text: ToastService.currentMessage
+ font.pixelSize: Theme.fontSizeMedium
+ color: {
+ switch (ToastService.currentLevel) {
+ case ToastService.levelError:
+ case ToastService.levelWarn:
+ return SessionData.isLightMode ? Theme.surfaceText : Theme.background
+ default:
+ return Theme.surfaceText
+ }
+ }
+ font.weight: Font.Medium
+ anchors.left: statusIcon.right
+ anchors.leftMargin: Theme.spacingM
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.right: ToastService.hasDetails ? expandButton.left : closeButton.left
+ anchors.rightMargin: Theme.spacingM
+ wrapMode: Text.NoWrap
+ elide: Text.ElideRight
+ }
+
+ DankActionButton {
+ id: expandButton
+ iconName: toast.expanded ? "expand_less" : "expand_more"
+ iconSize: Theme.iconSize
+ iconColor: {
+ switch (ToastService.currentLevel) {
+ case ToastService.levelError:
+ case ToastService.levelWarn:
+ return SessionData.isLightMode ? Theme.surfaceText : Theme.background
+ default:
+ return Theme.surfaceText
+ }
+ }
+ buttonSize: Theme.iconSize + 8
+ anchors.right: closeButton.left
+ anchors.rightMargin: 2
+ anchors.verticalCenter: parent.verticalCenter
+ visible: ToastService.hasDetails
+
+ onClicked: {
+ toast.expanded = !toast.expanded
+ if (toast.expanded) {
+ ToastService.stopTimer()
+ } else {
+ ToastService.restartTimer()
+ }
+ }
+ }
+
+ DankActionButton {
+ id: closeButton
+ iconName: "close"
+ iconSize: Theme.iconSize
+ iconColor: {
+ switch (ToastService.currentLevel) {
+ case ToastService.levelError:
+ case ToastService.levelWarn:
+ return SessionData.isLightMode ? Theme.surfaceText : Theme.background
+ default:
+ return Theme.surfaceText
+ }
+ }
+ buttonSize: Theme.iconSize + 8
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ visible: ToastService.hasDetails
+
+ onClicked: {
+ ToastService.hideToast()
+ }
+ }
+ }
+
+ Rectangle {
+ width: parent.width
+ height: detailsText.height + Theme.spacingS * 2
+ color: Qt.rgba(0, 0, 0, 0.2)
+ radius: Theme.cornerRadius / 2
+ visible: toast.expanded && ToastService.hasDetails
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ StyledText {
+ id: detailsText
+ text: ToastService.currentDetails
+ font.pixelSize: Theme.fontSizeSmall
+ color: {
+ switch (ToastService.currentLevel) {
+ case ToastService.levelError:
+ case ToastService.levelWarn:
+ return SessionData.isLightMode ? Theme.surfaceText : Theme.background
+ default:
+ return Theme.surfaceText
+ }
+ }
+ isMonospace: true
+ anchors.left: parent.left
+ anchors.right: copyButton.left
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Theme.spacingS
+ anchors.rightMargin: Theme.spacingS
+ wrapMode: Text.Wrap
+ }
+
+ DankActionButton {
+ id: copyButton
+ iconName: "content_copy"
+ iconSize: Theme.iconSizeSmall
+ iconColor: {
+ switch (ToastService.currentLevel) {
+ case ToastService.levelError:
+ case ToastService.levelWarn:
+ return SessionData.isLightMode ? Theme.surfaceText : Theme.background
+ default:
+ return Theme.surfaceText
+ }
+ }
+ buttonSize: Theme.iconSizeSmall + 8
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.rightMargin: Theme.spacingS
+
+ property bool showTooltip: false
+
+ onClicked: {
+ Quickshell.execDetached(
+ ["wl-copy", ToastService.currentDetails])
+ showTooltip = true
+ tooltipTimer.start()
+ }
+
+ Timer {
+ id: tooltipTimer
+ interval: 1500
+ onTriggered: copyButton.showTooltip = false
+ }
+
+ Rectangle {
+ visible: copyButton.showTooltip
+ width: tooltipLabel.implicitWidth + 16
+ height: tooltipLabel.implicitHeight + 8
+ color: Theme.surfaceContainer
+ radius: Theme.cornerRadius
+ border.width: 1
+ border.color: Theme.outlineMedium
+ y: -height - 4
+ x: -width / 2 + copyButton.width / 2
+
+ StyledText {
+ id: tooltipLabel
+ anchors.centerIn: parent
+ text: "Copied!"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ }
+ }
+ }
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ visible: !ToastService.hasDetails
+ onClicked: ToastService.hideToast()
+ }
+
+ layer.effect: MultiEffect {
+ shadowEnabled: true
+ shadowHorizontalOffset: 0
+ shadowVerticalOffset: 4
+ shadowBlur: 0.8
+ shadowColor: Qt.rgba(0, 0, 0, 0.3)
+ shadowOpacity: 0.3
+ }
+
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Theme.mediumDuration
+ easing.type: Theme.emphasizedEasing
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+
+ Behavior on height {
+ enabled: false
+ }
+
+ Behavior on width {
+ enabled: false
+ }
+ }
+
+ mask: Region {
+ item: toast
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/TopBar/AudioVisualization.qml b/quickshell/.config/quickshell/Modules/TopBar/AudioVisualization.qml
new file mode 100644
index 0000000..8c471d4
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/TopBar/AudioVisualization.qml
@@ -0,0 +1,77 @@
+import QtQuick
+import Quickshell.Services.Mpris
+import qs.Common
+import qs.Services
+
+Item {
+ id: root
+
+ readonly property MprisPlayer activePlayer: MprisController.activePlayer
+ readonly property bool hasActiveMedia: activePlayer !== null
+ readonly property bool isPlaying: hasActiveMedia && activePlayer && activePlayer.playbackState === MprisPlaybackState.Playing
+
+ width: 20
+ height: Theme.iconSize
+
+ Loader {
+ active: isPlaying
+
+ sourceComponent: Component {
+ Ref {
+ service: CavaService
+ }
+
+ }
+
+ }
+
+ Timer {
+ id: fallbackTimer
+
+ running: !CavaService.cavaAvailable && isPlaying
+ interval: 256
+ repeat: true
+ onTriggered: {
+ CavaService.values = [Math.random() * 40 + 10, Math.random() * 60 + 20, Math.random() * 50 + 15, Math.random() * 35 + 20, Math.random() * 45 + 15, Math.random() * 55 + 25];
+ }
+ }
+
+ Row {
+ anchors.centerIn: parent
+ spacing: 1.5
+
+ Repeater {
+ model: 6
+
+ Rectangle {
+ width: 2
+ height: {
+ if (root.isPlaying && CavaService.values.length > index) {
+ const rawLevel = CavaService.values[index] || 0;
+ const scaledLevel = Math.sqrt(Math.min(Math.max(rawLevel, 0), 100) / 100) * 100;
+ const maxHeight = Theme.iconSize - 2;
+ const minHeight = 3;
+ return minHeight + (scaledLevel / 100) * (maxHeight - minHeight);
+ }
+ return 3;
+ }
+ radius: 1.5
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+
+ Behavior on height {
+ NumberAnimation {
+ duration: Anims.durShort
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Anims.standardDecel
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modules/TopBar/Battery.qml b/quickshell/.config/quickshell/Modules/TopBar/Battery.qml
new file mode 100644
index 0000000..98e5d66
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/TopBar/Battery.qml
@@ -0,0 +1,259 @@
+import QtQuick
+import Quickshell.Services.UPower
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Rectangle {
+ id: battery
+
+ property bool batteryPopupVisible: false
+ property string section: "right"
+ property var popupTarget: null
+ property var parentScreen: null
+ property real widgetHeight: 30
+ property real barHeight: 48
+ readonly property real horizontalPadding: SettingsData.topBarNoBackground ? 0 : Math.max(Theme.spacingXS, Theme.spacingS * (widgetHeight / 30))
+
+ signal toggleBatteryPopup()
+
+ width: batteryContent.implicitWidth + horizontalPadding * 2
+ height: widgetHeight
+ radius: SettingsData.topBarNoBackground ? 0 : Theme.cornerRadius
+ color: {
+ if (SettingsData.topBarNoBackground)
+ return "transparent";
+
+ const baseColor = batteryArea.containsMouse || batteryPopupVisible ? Theme.primaryPressed : Theme.secondaryHover;
+ return Qt.rgba(baseColor.r, baseColor.g, baseColor.b, baseColor.a * Theme.widgetTransparency);
+ }
+ visible: true
+
+ Row {
+ id: batteryContent
+
+ anchors.centerIn: parent
+ spacing: SettingsData.topBarNoBackground ? 1 : 2
+
+ DankIcon {
+ name: {
+ if (!BatteryService.batteryAvailable) {
+ return "power";
+ }
+
+ if (BatteryService.isCharging) {
+ if (BatteryService.batteryLevel >= 90) {
+ return "battery_charging_full";
+ }
+
+ if (BatteryService.batteryLevel >= 80) {
+ return "battery_charging_90";
+ }
+
+ if (BatteryService.batteryLevel >= 60) {
+ return "battery_charging_80";
+ }
+
+ if (BatteryService.batteryLevel >= 50) {
+ return "battery_charging_60";
+ }
+
+ if (BatteryService.batteryLevel >= 30) {
+ return "battery_charging_50";
+ }
+
+ if (BatteryService.batteryLevel >= 20) {
+ return "battery_charging_30";
+ }
+
+ return "battery_charging_20";
+ }
+ // Check if plugged in but not charging (like at 80% charge limit)
+ if (BatteryService.isPluggedIn) {
+ if (BatteryService.batteryLevel >= 90) {
+ return "battery_charging_full";
+ }
+
+ if (BatteryService.batteryLevel >= 80) {
+ return "battery_charging_90";
+ }
+
+ if (BatteryService.batteryLevel >= 60) {
+ return "battery_charging_80";
+ }
+
+ if (BatteryService.batteryLevel >= 50) {
+ return "battery_charging_60";
+ }
+
+ if (BatteryService.batteryLevel >= 30) {
+ return "battery_charging_50";
+ }
+
+ if (BatteryService.batteryLevel >= 20) {
+ return "battery_charging_30";
+ }
+
+ return "battery_charging_20";
+ }
+ // On battery power
+ if (BatteryService.batteryLevel >= 95) {
+ return "battery_full";
+ }
+
+ if (BatteryService.batteryLevel >= 85) {
+ return "battery_6_bar";
+ }
+
+ if (BatteryService.batteryLevel >= 70) {
+ return "battery_5_bar";
+ }
+
+ if (BatteryService.batteryLevel >= 55) {
+ return "battery_4_bar";
+ }
+
+ if (BatteryService.batteryLevel >= 40) {
+ return "battery_3_bar";
+ }
+
+ if (BatteryService.batteryLevel >= 25) {
+ return "battery_2_bar";
+ }
+
+ return "battery_1_bar";
+ }
+ size: Theme.iconSize - 6
+ color: {
+ if (!BatteryService.batteryAvailable) {
+ return Theme.surfaceText;
+ }
+
+ if (BatteryService.isLowBattery && !BatteryService.isCharging) {
+ return Theme.error;
+ }
+
+ if (BatteryService.isCharging || BatteryService.isPluggedIn) {
+ return Theme.primary;
+ }
+
+ return Theme.surfaceText;
+ }
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: `${BatteryService.batteryLevel}%`
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ color: {
+ if (!BatteryService.batteryAvailable) {
+ return Theme.surfaceText;
+ }
+
+ if (BatteryService.isLowBattery && !BatteryService.isCharging) {
+ return Theme.error;
+ }
+
+ if (BatteryService.isCharging) {
+ return Theme.primary;
+ }
+
+ return Theme.surfaceText;
+ }
+ anchors.verticalCenter: parent.verticalCenter
+ visible: BatteryService.batteryAvailable
+ }
+
+ }
+
+ MouseArea {
+ id: batteryArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onPressed: {
+ if (popupTarget && popupTarget.setTriggerPosition) {
+ const globalPos = mapToGlobal(0, 0);
+ const currentScreen = parentScreen || Screen;
+ const screenX = currentScreen.x || 0;
+ const relativeX = globalPos.x - screenX;
+ popupTarget.setTriggerPosition(relativeX, barHeight + Theme.spacingXS, width, section, currentScreen);
+ }
+ toggleBatteryPopup();
+ }
+ }
+
+ Rectangle {
+ id: batteryTooltip
+
+ width: Math.max(120, tooltipText.contentWidth + Theme.spacingM * 2)
+ height: tooltipText.contentHeight + Theme.spacingS * 2
+ radius: Theme.cornerRadius
+ color: Theme.surfaceContainer
+ border.color: Theme.surfaceVariantAlpha
+ border.width: 1
+ visible: batteryArea.containsMouse && !batteryPopupVisible
+ anchors.bottom: parent.top
+ anchors.bottomMargin: Theme.spacingS
+ anchors.horizontalCenter: parent.horizontalCenter
+ opacity: batteryArea.containsMouse ? 1 : 0
+
+ Column {
+ anchors.centerIn: parent
+ spacing: 2
+
+ StyledText {
+ id: tooltipText
+
+ text: {
+ if (!BatteryService.batteryAvailable) {
+ if (typeof PowerProfiles === "undefined") {
+ return "Power Management";
+ }
+
+ switch (PowerProfiles.profile) {
+ case PowerProfile.PowerSaver:
+ return "Power Profile: Power Saver";
+ case PowerProfile.Performance:
+ return "Power Profile: Performance";
+ default:
+ return "Power Profile: Balanced";
+ }
+ }
+ const status = BatteryService.batteryStatus;
+ const level = `${BatteryService.batteryLevel}%`;
+ const time = BatteryService.formatTimeRemaining();
+ if (time !== "Unknown") {
+ return `${status} • ${level} • ${time}`;
+ } else {
+ return `${status} • ${level}`;
+ }
+ }
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ horizontalAlignment: Text.AlignHCenter
+ }
+
+ }
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+
+ }
+
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modules/TopBar/BatteryPopout.qml b/quickshell/.config/quickshell/Modules/TopBar/BatteryPopout.qml
new file mode 100644
index 0000000..a7ecafd
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/TopBar/BatteryPopout.qml
@@ -0,0 +1,591 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell
+import Quickshell.Services.UPower
+import Quickshell.Wayland
+import Quickshell.Widgets
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+DankPopout {
+ id: root
+
+ property string triggerSection: "right"
+ property var triggerScreen: null
+
+ function setTriggerPosition(x, y, width, section, screen) {
+ triggerX = x;
+ triggerY = y;
+ triggerWidth = width;
+ triggerSection = section;
+ triggerScreen = screen;
+ }
+
+ function isActiveProfile(profile) {
+ if (typeof PowerProfiles === "undefined") {
+ return false;
+ }
+
+ return PowerProfiles.profile === profile;
+ }
+
+ function setProfile(profile) {
+ if (typeof PowerProfiles === "undefined") {
+ ToastService.showError("power-profiles-daemon not available");
+ return ;
+ }
+ PowerProfiles.profile = profile;
+ if (PowerProfiles.profile !== profile) {
+ ToastService.showError("Failed to set power profile");
+ }
+
+ }
+
+ popupWidth: 400
+ popupHeight: contentLoader.item ? contentLoader.item.implicitHeight : 400
+ triggerX: Screen.width - 380 - Theme.spacingL
+ triggerY: Theme.barHeight - 4 + SettingsData.topBarSpacing + Theme.spacingS
+ triggerWidth: 70
+ positioning: "center"
+ screen: triggerScreen
+ shouldBeVisible: false
+ visible: shouldBeVisible
+
+ content: Component {
+ Rectangle {
+ id: batteryContent
+
+ implicitHeight: contentColumn.height + Theme.spacingL * 2
+ color: Theme.popupBackground()
+ radius: Theme.cornerRadius
+ border.color: Theme.outlineMedium
+ border.width: 1
+ antialiasing: true
+ smooth: true
+ focus: true
+ Component.onCompleted: {
+ if (root.shouldBeVisible) {
+ forceActiveFocus();
+ }
+
+ }
+ Keys.onPressed: function(event) {
+ if (event.key === Qt.Key_Escape) {
+ root.close();
+ event.accepted = true;
+ }
+ }
+
+ Connections {
+ function onShouldBeVisibleChanged() {
+ if (root.shouldBeVisible) {
+ Qt.callLater(function() {
+ batteryContent.forceActiveFocus();
+ });
+ }
+
+ }
+
+ target: root
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ anchors.margins: -3
+ color: "transparent"
+ radius: parent.radius + 3
+ border.color: Qt.rgba(0, 0, 0, 0.05)
+ border.width: 1
+ z: -3
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ anchors.margins: -2
+ color: "transparent"
+ radius: parent.radius + 2
+ border.color: Theme.shadowMedium
+ border.width: 1
+ z: -2
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ color: "transparent"
+ border.color: Theme.outlineStrong
+ border.width: 1
+ radius: parent.radius
+ z: -1
+ }
+
+ Column {
+ id: contentColumn
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingL
+
+ Item {
+ width: parent.width
+ height: 32
+
+ StyledText {
+ text: BatteryService.batteryAvailable ? "Battery Information" : "Power Management"
+ font.pixelSize: Theme.fontSizeLarge
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Rectangle {
+ width: 32
+ height: 32
+ radius: 16
+ color: closeBatteryArea.containsMouse ? Theme.errorHover : "transparent"
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "close"
+ size: Theme.iconSize - 4
+ color: closeBatteryArea.containsMouse ? Theme.error : Theme.surfaceText
+ }
+
+ MouseArea {
+ id: closeBatteryArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onPressed: {
+ root.close();
+ }
+ }
+
+ }
+
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 80
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, Theme.getContentBackgroundAlpha() * 0.4)
+ border.color: BatteryService.isCharging ? Theme.primary : (BatteryService.isLowBattery ? Theme.error : Theme.outlineMedium)
+ border.width: BatteryService.isCharging || BatteryService.isLowBattery ? 2 : 1
+ visible: BatteryService.batteryAvailable
+
+ Row {
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingL
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingL
+
+ DankIcon {
+ name: {
+ if (!BatteryService.batteryAvailable)
+ return "power";
+
+ // Check if plugged in but not charging (like at 80% charge limit)
+ if (!BatteryService.isCharging && BatteryService.isPluggedIn) {
+ if (BatteryService.batteryLevel >= 90) {
+ return "battery_charging_full";
+ }
+
+ if (BatteryService.batteryLevel >= 80) {
+ return "battery_charging_90";
+ }
+
+ if (BatteryService.batteryLevel >= 60) {
+ return "battery_charging_80";
+ }
+
+ if (BatteryService.batteryLevel >= 50) {
+ return "battery_charging_60";
+ }
+
+ if (BatteryService.batteryLevel >= 30) {
+ return "battery_charging_50";
+ }
+
+ if (BatteryService.batteryLevel >= 20) {
+ return "battery_charging_30";
+ }
+
+ return "battery_charging_20";
+ }
+ if (BatteryService.isCharging) {
+ if (BatteryService.batteryLevel >= 90) {
+ return "battery_charging_full";
+ }
+
+ if (BatteryService.batteryLevel >= 80) {
+ return "battery_charging_90";
+ }
+
+ if (BatteryService.batteryLevel >= 60) {
+ return "battery_charging_80";
+ }
+
+ if (BatteryService.batteryLevel >= 50) {
+ return "battery_charging_60";
+ }
+
+ if (BatteryService.batteryLevel >= 30) {
+ return "battery_charging_50";
+ }
+
+ if (BatteryService.batteryLevel >= 20) {
+ return "battery_charging_30";
+ }
+
+ return "battery_charging_20";
+ } else {
+ if (BatteryService.batteryLevel >= 95) {
+ return "battery_full";
+ }
+
+ if (BatteryService.batteryLevel >= 85) {
+ return "battery_6_bar";
+ }
+
+ if (BatteryService.batteryLevel >= 70) {
+ return "battery_5_bar";
+ }
+
+ if (BatteryService.batteryLevel >= 55) {
+ return "battery_4_bar";
+ }
+
+ if (BatteryService.batteryLevel >= 40) {
+ return "battery_3_bar";
+ }
+
+ if (BatteryService.batteryLevel >= 25) {
+ return "battery_2_bar";
+ }
+
+ return "battery_1_bar";
+ }
+ }
+ size: Theme.iconSizeLarge
+ color: {
+ if (BatteryService.isLowBattery && !BatteryService.isCharging)
+ return Theme.error;
+
+ if (BatteryService.isCharging || BatteryService.isPluggedIn)
+ return Theme.primary;
+
+ return Theme.surfaceText;
+ }
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ spacing: 2
+ anchors.verticalCenter: parent.verticalCenter
+
+ Row {
+ spacing: Theme.spacingM
+
+ StyledText {
+ text: `${BatteryService.batteryLevel}%`
+ font.pixelSize: Theme.fontSizeLarge
+ color: {
+ if (BatteryService.isLowBattery && !BatteryService.isCharging) {
+ return Theme.error;
+ }
+
+ if (BatteryService.isCharging) {
+ return Theme.primary;
+ }
+
+ return Theme.surfaceText;
+ }
+ font.weight: Font.Bold
+ }
+
+ StyledText {
+ text: BatteryService.batteryStatus
+ font.pixelSize: Theme.fontSizeMedium
+ color: {
+ if (BatteryService.isLowBattery && !BatteryService.isCharging) {
+ return Theme.error;
+ }
+
+ if (BatteryService.isCharging) {
+ return Theme.primary;
+ }
+
+ return Theme.surfaceText;
+ }
+ font.weight: Font.Medium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ }
+
+ StyledText {
+ text: {
+ const time = BatteryService.formatTimeRemaining();
+ if (time !== "Unknown") {
+ return BatteryService.isCharging ? `Time until full: ${time}` : `Time remaining: ${time}`;
+ }
+
+ return "";
+ }
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceTextMedium
+ visible: text.length > 0
+ }
+
+ }
+
+ }
+
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 80
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, Theme.getContentBackgroundAlpha() * 0.4)
+ border.color: Theme.outlineMedium
+ border.width: 1
+ visible: !BatteryService.batteryAvailable
+
+ Row {
+ anchors.centerIn: parent
+ spacing: Theme.spacingL
+
+ DankIcon {
+ name: "power"
+ size: 36
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ spacing: Theme.spacingS
+ anchors.verticalCenter: parent.verticalCenter
+
+ StyledText {
+ text: "No Battery Detected"
+ font.pixelSize: Theme.fontSizeLarge
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ }
+
+ StyledText {
+ text: "Power profile management is available"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceTextMedium
+ }
+
+ }
+
+ }
+
+ }
+
+ Column {
+ width: parent.width
+ spacing: Theme.spacingM
+ visible: BatteryService.batteryAvailable
+
+ StyledText {
+ text: "Battery Details"
+ font.pixelSize: Theme.fontSizeLarge
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ }
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingXL
+
+ Column {
+ spacing: 2
+ width: (parent.width - Theme.spacingXL) / 2
+
+ StyledText {
+ text: "Health"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceTextMedium
+ font.weight: Font.Medium
+ }
+
+ StyledText {
+ text: BatteryService.batteryHealth
+ font.pixelSize: Theme.fontSizeMedium
+ color: {
+ if (BatteryService.batteryHealth === "N/A") {
+ return Theme.surfaceText;
+ }
+
+ const healthNum = parseInt(BatteryService.batteryHealth);
+ return healthNum < 80 ? Theme.error : Theme.surfaceText;
+ }
+ }
+
+ }
+
+ Column {
+ spacing: 2
+ width: (parent.width - Theme.spacingXL) / 2
+
+ StyledText {
+ text: "Capacity"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceTextMedium
+ font.weight: Font.Medium
+ }
+
+ StyledText {
+ text: BatteryService.batteryCapacity > 0 ? `${BatteryService.batteryCapacity.toFixed(1)} Wh` : "Unknown"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ }
+
+ }
+
+ }
+
+ }
+
+ Column {
+ width: parent.width
+ spacing: Theme.spacingM
+ visible: true
+
+ StyledText {
+ text: "Power Profile"
+ font.pixelSize: Theme.fontSizeLarge
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ }
+
+ Column {
+ width: parent.width
+ spacing: Theme.spacingS
+
+ Repeater {
+ model: (typeof PowerProfiles !== "undefined") ? [PowerProfile.PowerSaver, PowerProfile.Balanced].concat(PowerProfiles.hasPerformanceProfile ? [PowerProfile.Performance] : []) : [PowerProfile.PowerSaver, PowerProfile.Balanced, PowerProfile.Performance]
+
+ Rectangle {
+ width: parent.width
+ height: 50
+ radius: Theme.cornerRadius
+ color: profileArea.containsMouse ? Theme.primaryHoverLight : (root.isActiveProfile(modelData) ? Theme.primaryPressed : Theme.surfaceLight)
+ border.color: root.isActiveProfile(modelData) ? Theme.primary : Theme.outlineLight
+ border.width: root.isActiveProfile(modelData) ? 2 : 1
+
+ Row {
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingL
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: Theme.getPowerProfileIcon(modelData)
+ size: Theme.iconSize
+ color: root.isActiveProfile(modelData) ? Theme.primary : Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ spacing: 2
+ anchors.verticalCenter: parent.verticalCenter
+
+ StyledText {
+ text: Theme.getPowerProfileLabel(modelData)
+ font.pixelSize: Theme.fontSizeMedium
+ color: root.isActiveProfile(modelData) ? Theme.primary : Theme.surfaceText
+ font.weight: root.isActiveProfile(modelData) ? Font.Medium : Font.Normal
+ }
+
+ StyledText {
+ text: Theme.getPowerProfileDescription(modelData)
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceTextMedium
+ }
+
+ }
+
+ }
+
+ MouseArea {
+ id: profileArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onPressed: {
+ root.setProfile(modelData);
+ }
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 60
+ radius: Theme.cornerRadius
+ color: Theme.errorHover
+ border.color: Theme.primarySelected
+ border.width: 1
+ visible: (typeof PowerProfiles !== "undefined") && PowerProfiles.degradationReason !== PerformanceDegradationReason.None
+
+ Row {
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingL
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "warning"
+ size: Theme.iconSize
+ color: Theme.error
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ spacing: 2
+ anchors.verticalCenter: parent.verticalCenter
+
+ StyledText {
+ text: "Power Profile Degradation"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.error
+ font.weight: Font.Medium
+ }
+
+ StyledText {
+ text: (typeof PowerProfiles !== "undefined") ? PerformanceDegradationReason.toString(PowerProfiles.degradationReason) : ""
+ font.pixelSize: Theme.fontSizeSmall
+ color: Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.8)
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modules/TopBar/Clock.qml b/quickshell/.config/quickshell/Modules/TopBar/Clock.qml
new file mode 100644
index 0000000..ac64b71
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/TopBar/Clock.qml
@@ -0,0 +1,99 @@
+import QtQuick
+import Quickshell
+import qs.Common
+import qs.Widgets
+
+Rectangle {
+ id: root
+
+ property bool compactMode: false
+ property string section: "center"
+ property var popupTarget: null
+ property var parentScreen: null
+ property real barHeight: 48
+ property real widgetHeight: 30
+ readonly property real horizontalPadding: SettingsData.topBarNoBackground ? 2 : Theme.spacingS
+
+ signal clockClicked
+
+ width: clockRow.implicitWidth + horizontalPadding * 2
+ height: widgetHeight
+ radius: SettingsData.topBarNoBackground ? 0 : Theme.cornerRadius
+ color: {
+ if (SettingsData.topBarNoBackground) {
+ return "transparent"
+ }
+
+ const baseColor = clockMouseArea.containsMouse ? Theme.primaryHover : Theme.surfaceTextHover
+ return Qt.rgba(baseColor.r, baseColor.g, baseColor.b, baseColor.a * Theme.widgetTransparency)
+ }
+
+ Row {
+ id: clockRow
+
+ anchors.centerIn: parent
+ spacing: Theme.spacingS
+
+ StyledText {
+ text: {
+ const format = SettingsData.use24HourClock ? "HH:mm" : "h:mm AP"
+ return systemClock?.date?.toLocaleTimeString(Qt.locale(), format)
+ }
+ font.pixelSize: Theme.fontSizeMedium - 1
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "•"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.outlineButton
+ anchors.verticalCenter: parent.verticalCenter
+ visible: !SettingsData.clockCompactMode
+ }
+
+ StyledText {
+ text: {
+ if (SettingsData.clockDateFormat && SettingsData.clockDateFormat.length > 0) {
+ return systemClock?.date?.toLocaleDateString(Qt.locale(), SettingsData.clockDateFormat)
+ }
+
+ return systemClock?.date?.toLocaleDateString(Qt.locale(), "ddd d")
+ }
+ font.pixelSize: Theme.fontSizeMedium - 1
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ visible: !SettingsData.clockCompactMode
+ }
+ }
+
+ SystemClock {
+ id: systemClock
+ precision: SystemClock.Seconds
+ }
+
+ MouseArea {
+ id: clockMouseArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onPressed: {
+ if (popupTarget && popupTarget.setTriggerPosition) {
+ const globalPos = mapToGlobal(0, 0)
+ const currentScreen = parentScreen || Screen
+ const screenX = currentScreen.x || 0
+ const relativeX = globalPos.x - screenX
+ popupTarget.setTriggerPosition(relativeX, barHeight + Theme.spacingXS, width, section, currentScreen)
+ }
+ root.clockClicked()
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/TopBar/ControlCenterButton.qml b/quickshell/.config/quickshell/Modules/TopBar/ControlCenterButton.qml
new file mode 100644
index 0000000..2645645
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/TopBar/ControlCenterButton.qml
@@ -0,0 +1,184 @@
+import QtQuick
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Rectangle {
+ id: root
+
+ property bool isActive: false
+ property string section: "right"
+ property var popupTarget: null
+ property var parentScreen: null
+ property var widgetData: null
+ property bool showNetworkIcon: SettingsData.controlCenterShowNetworkIcon
+ property bool showBluetoothIcon: SettingsData.controlCenterShowBluetoothIcon
+ property bool showAudioIcon: SettingsData.controlCenterShowAudioIcon
+ property real widgetHeight: 30
+ property real barHeight: 48
+ readonly property real horizontalPadding: SettingsData.topBarNoBackground ? 0 : Math.max(Theme.spacingXS, Theme.spacingS * (widgetHeight / 30))
+
+ signal clicked()
+
+ width: controlIndicators.implicitWidth + horizontalPadding * 2
+ height: widgetHeight
+ radius: SettingsData.topBarNoBackground ? 0 : Theme.cornerRadius
+ color: {
+ if (SettingsData.topBarNoBackground) {
+ return "transparent";
+ }
+
+ const baseColor = controlCenterArea.containsMouse || root.isActive ? Theme.primaryPressed : Theme.secondaryHover;
+ return Qt.rgba(baseColor.r, baseColor.g, baseColor.b, baseColor.a * Theme.widgetTransparency);
+ }
+
+ Row {
+ id: controlIndicators
+
+ anchors.centerIn: parent
+ spacing: Theme.spacingXS
+
+ DankIcon {
+ id: networkIcon
+
+ name: {
+ if (NetworkService.wifiToggling) {
+ return "sync";
+ }
+
+ if (NetworkService.networkStatus === "ethernet") {
+ return "lan";
+ }
+
+ return NetworkService.wifiSignalIcon;
+ }
+ size: Theme.iconSize - 8
+ color: {
+ if (NetworkService.wifiToggling) {
+ return Theme.primary;
+ }
+
+ return NetworkService.networkStatus !== "disconnected" ? Theme.primary : Theme.outlineButton;
+ }
+ anchors.verticalCenter: parent.verticalCenter
+ visible: root.showNetworkIcon
+
+ RotationAnimation on rotation {
+ running: NetworkService.wifiToggling
+ loops: Animation.Infinite
+ from: 0
+ to: 360
+ duration: 1000
+ }
+
+ }
+
+ DankIcon {
+ id: bluetoothIcon
+
+ name: "bluetooth"
+ size: Theme.iconSize - 8
+ color: BluetoothService.enabled ? Theme.primary : Theme.outlineButton
+ anchors.verticalCenter: parent.verticalCenter
+ visible: root.showBluetoothIcon && BluetoothService.available && BluetoothService.enabled
+ }
+
+ Rectangle {
+ width: audioIcon.implicitWidth + 4
+ height: audioIcon.implicitHeight + 4
+ color: "transparent"
+ anchors.verticalCenter: parent.verticalCenter
+ visible: root.showAudioIcon
+
+ DankIcon {
+ id: audioIcon
+
+ name: {
+ if (AudioService.sink && AudioService.sink.audio) {
+ if (AudioService.sink.audio.muted || AudioService.sink.audio.volume === 0) {
+ return "volume_off";
+ } else if (AudioService.sink.audio.volume * 100 < 33) {
+ return "volume_down";
+ } else {
+ return "volume_up";
+ }
+ }
+ return "volume_up";
+ }
+ size: Theme.iconSize - 8
+ color: Theme.surfaceText
+ anchors.centerIn: parent
+ }
+
+ MouseArea {
+ id: audioWheelArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ acceptedButtons: Qt.NoButton
+ onWheel: function(wheelEvent) {
+ let delta = wheelEvent.angleDelta.y;
+ let currentVolume = (AudioService.sink && AudioService.sink.audio && AudioService.sink.audio.volume * 100) || 0;
+ let newVolume;
+ if (delta > 0) {
+ newVolume = Math.min(100, currentVolume + 5);
+ } else {
+ newVolume = Math.max(0, currentVolume - 5);
+ }
+ if (AudioService.sink && AudioService.sink.audio) {
+ AudioService.sink.audio.muted = false;
+ AudioService.sink.audio.volume = newVolume / 100;
+ AudioService.volumeChanged();
+ }
+ wheelEvent.accepted = true;
+ }
+ }
+
+ }
+
+ DankIcon {
+ name: "mic"
+ size: Theme.iconSize - 8
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ visible: false // TODO: Add mic detection
+ }
+
+ // Fallback settings icon when all other icons are hidden
+ DankIcon {
+ name: "settings"
+ size: Theme.iconSize - 8
+ color: controlCenterArea.containsMouse || root.isActive ? Theme.primary : Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ visible: !root.showNetworkIcon && !root.showBluetoothIcon && !root.showAudioIcon
+ }
+
+ }
+
+ MouseArea {
+ id: controlCenterArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onPressed: {
+ if (popupTarget && popupTarget.setTriggerPosition) {
+ const globalPos = mapToGlobal(0, 0);
+ const currentScreen = parentScreen || Screen;
+ const screenX = currentScreen.x || 0;
+ const relativeX = globalPos.x - screenX;
+ popupTarget.setTriggerPosition(relativeX, barHeight + Theme.spacingXS, width, section, currentScreen);
+ }
+ root.clicked();
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modules/TopBar/CpuMonitor.qml b/quickshell/.config/quickshell/Modules/TopBar/CpuMonitor.qml
new file mode 100644
index 0000000..e854333
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/TopBar/CpuMonitor.qml
@@ -0,0 +1,99 @@
+import QtQuick
+import QtQuick.Controls
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Rectangle {
+ id: root
+
+ property bool showPercentage: true
+ property bool showIcon: true
+ property var toggleProcessList
+ property string section: "right"
+ property var popupTarget: null
+ property var parentScreen: null
+ property real barHeight: 48
+ property real widgetHeight: 30
+ readonly property real horizontalPadding: SettingsData.topBarNoBackground ? 0 : Math.max(Theme.spacingXS, Theme.spacingS * (widgetHeight / 30))
+
+ width: cpuContent.implicitWidth + horizontalPadding * 2
+ height: widgetHeight
+ radius: SettingsData.topBarNoBackground ? 0 : Theme.cornerRadius
+ color: {
+ if (SettingsData.topBarNoBackground) {
+ return "transparent";
+ }
+
+ const baseColor = cpuArea.containsMouse ? Theme.primaryPressed : Theme.secondaryHover;
+ return Qt.rgba(baseColor.r, baseColor.g, baseColor.b, baseColor.a * Theme.widgetTransparency);
+ }
+ Component.onCompleted: {
+ DgopService.addRef(["cpu"]);
+ }
+ Component.onDestruction: {
+ DgopService.removeRef(["cpu"]);
+ }
+
+ MouseArea {
+ id: cpuArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onPressed: {
+ if (popupTarget && popupTarget.setTriggerPosition) {
+ const globalPos = mapToGlobal(0, 0);
+ const currentScreen = parentScreen || Screen;
+ const screenX = currentScreen.x || 0;
+ const relativeX = globalPos.x - screenX;
+ popupTarget.setTriggerPosition(relativeX, barHeight + Theme.spacingXS, width, section, currentScreen);
+ }
+ DgopService.setSortBy("cpu");
+ if (root.toggleProcessList) {
+ root.toggleProcessList();
+ }
+
+ }
+ }
+
+ Row {
+ id: cpuContent
+
+ anchors.centerIn: parent
+ spacing: 3
+
+ DankIcon {
+ name: "memory"
+ size: Theme.iconSize - 8
+ color: {
+ if (DgopService.cpuUsage > 80) {
+ return Theme.tempDanger;
+ }
+
+ if (DgopService.cpuUsage > 60) {
+ return Theme.tempWarning;
+ }
+
+ return Theme.surfaceText;
+ }
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: {
+ if (DgopService.cpuUsage === undefined || DgopService.cpuUsage === null || DgopService.cpuUsage === 0) {
+ return "--%";
+ }
+
+ return DgopService.cpuUsage.toFixed(0) + "%";
+ }
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modules/TopBar/CpuTemperature.qml b/quickshell/.config/quickshell/Modules/TopBar/CpuTemperature.qml
new file mode 100644
index 0000000..19a0beb
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/TopBar/CpuTemperature.qml
@@ -0,0 +1,107 @@
+import QtQuick
+import QtQuick.Controls
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Rectangle {
+ id: root
+
+ property bool showPercentage: true
+ property bool showIcon: true
+ property var toggleProcessList
+ property string section: "right"
+ property var popupTarget: null
+ property var parentScreen: null
+ property real barHeight: 48
+ property real widgetHeight: 30
+ readonly property real horizontalPadding: SettingsData.topBarNoBackground ? 0 : Math.max(Theme.spacingXS, Theme.spacingS * (widgetHeight / 30))
+
+ width: cpuTempContent.implicitWidth + horizontalPadding * 2
+ height: widgetHeight
+ radius: SettingsData.topBarNoBackground ? 0 : Theme.cornerRadius
+ color: {
+ if (SettingsData.topBarNoBackground) {
+ return "transparent";
+ }
+
+ const baseColor = cpuTempArea.containsMouse ? Theme.primaryPressed : Theme.secondaryHover;
+ return Qt.rgba(baseColor.r, baseColor.g, baseColor.b, baseColor.a * Theme.widgetTransparency);
+ }
+ Component.onCompleted: {
+ DgopService.addRef(["cpu"]);
+ }
+ Component.onDestruction: {
+ DgopService.removeRef(["cpu"]);
+ }
+
+ MouseArea {
+ id: cpuTempArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onPressed: {
+ if (popupTarget && popupTarget.setTriggerPosition) {
+ const globalPos = mapToGlobal(0, 0);
+ const currentScreen = parentScreen || Screen;
+ const screenX = currentScreen.x || 0;
+ const relativeX = globalPos.x - screenX;
+ popupTarget.setTriggerPosition(relativeX, barHeight + Theme.spacingXS, width, section, currentScreen);
+ }
+ DgopService.setSortBy("cpu");
+ if (root.toggleProcessList) {
+ root.toggleProcessList();
+ }
+
+ }
+ }
+
+ Row {
+ id: cpuTempContent
+
+ anchors.centerIn: parent
+ spacing: 3
+
+ DankIcon {
+ name: "memory"
+ size: Theme.iconSize - 8
+ color: {
+ if (DgopService.cpuTemperature > 85) {
+ return Theme.tempDanger;
+ }
+
+ if (DgopService.cpuTemperature > 69) {
+ return Theme.tempWarning;
+ }
+
+ return Theme.surfaceText;
+ }
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: {
+ if (DgopService.cpuTemperature === undefined || DgopService.cpuTemperature === null || DgopService.cpuTemperature < 0) {
+ return "--°";
+ }
+
+ return Math.round(DgopService.cpuTemperature) + "°";
+ }
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modules/TopBar/FocusedApp.qml b/quickshell/.config/quickshell/Modules/TopBar/FocusedApp.qml
new file mode 100644
index 0000000..eb44ed6
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/TopBar/FocusedApp.qml
@@ -0,0 +1,129 @@
+import QtQuick
+import Quickshell
+import Quickshell.Wayland
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Rectangle {
+ id: root
+
+ property bool compactMode: SettingsData.focusedWindowCompactMode
+ property int availableWidth: 400
+ property real widgetHeight: 30
+ readonly property real horizontalPadding: SettingsData.topBarNoBackground ? 2 : Theme.spacingS
+ readonly property int baseWidth: contentRow.implicitWidth + horizontalPadding * 2
+ readonly property int maxNormalWidth: 456
+ readonly property int maxCompactWidth: 288
+ readonly property Toplevel activeWindow: ToplevelManager.activeToplevel
+
+ width: compactMode ? Math.min(baseWidth, maxCompactWidth) : Math.min(baseWidth, maxNormalWidth)
+ height: widgetHeight
+ radius: SettingsData.topBarNoBackground ? 0 : Theme.cornerRadius
+ color: {
+ if (!activeWindow || !activeWindow.title) {
+ return "transparent";
+ }
+
+ if (SettingsData.topBarNoBackground) {
+ return "transparent";
+ }
+
+ const baseColor = mouseArea.containsMouse ? Theme.primaryHover : Theme.surfaceTextHover;
+ return Qt.rgba(baseColor.r, baseColor.g, baseColor.b, baseColor.a * Theme.widgetTransparency);
+ }
+ clip: true
+ visible: activeWindow && activeWindow.title
+
+ Row {
+ id: contentRow
+
+ anchors.centerIn: parent
+ spacing: Theme.spacingS
+
+ StyledText {
+ id: appText
+
+ text: {
+ if (!activeWindow || !activeWindow.appId) {
+ return "";
+ }
+
+ const desktopEntry = DesktopEntries.heuristicLookup(activeWindow.appId);
+ return desktopEntry && desktopEntry.name ? desktopEntry.name : activeWindow.appId;
+ }
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ elide: Text.ElideRight
+ maximumLineCount: 1
+ width: Math.min(implicitWidth, compactMode ? 80 : 180)
+ visible: !compactMode && text.length > 0
+ }
+
+ StyledText {
+ text: "•"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.outlineButton
+ anchors.verticalCenter: parent.verticalCenter
+ visible: !compactMode && appText.text && titleText.text
+ }
+
+ StyledText {
+ id: titleText
+
+ text: {
+ const title = activeWindow && activeWindow.title ? activeWindow.title : "";
+ const appName = appText.text;
+ if (!title || !appName) {
+ return title;
+ }
+
+ // Remove app name from end of title if it exists there
+ if (title.endsWith(" - " + appName)) {
+ return title.substring(0, title.length - (" - " + appName).length);
+ }
+
+ if (title.endsWith(appName)) {
+ return title.substring(0, title.length - appName.length).replace(/ - $/, "");
+ }
+
+ return title;
+ }
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ elide: Text.ElideRight
+ maximumLineCount: 1
+ width: Math.min(implicitWidth, compactMode ? 280 : 250)
+ visible: text.length > 0
+ }
+
+ }
+
+ MouseArea {
+ id: mouseArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+
+ }
+
+ Behavior on width {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modules/TopBar/GpuTemperature.qml b/quickshell/.config/quickshell/Modules/TopBar/GpuTemperature.qml
new file mode 100644
index 0000000..0c00322
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/TopBar/GpuTemperature.qml
@@ -0,0 +1,198 @@
+import QtQuick
+import QtQuick.Controls
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Rectangle {
+ id: root
+
+ property bool showPercentage: true
+ property bool showIcon: true
+ property var toggleProcessList
+ property string section: "right"
+ property var popupTarget: null
+ property var parentScreen: null
+ property var widgetData: null
+ property real barHeight: 48
+ property real widgetHeight: 30
+ property int selectedGpuIndex: (widgetData && widgetData.selectedGpuIndex !== undefined) ? widgetData.selectedGpuIndex : 0
+ readonly property real horizontalPadding: SettingsData.topBarNoBackground ? 0 : Math.max(Theme.spacingXS, Theme.spacingS * (widgetHeight / 30))
+ property real displayTemp: {
+ if (!DgopService.availableGpus || DgopService.availableGpus.length === 0) {
+ return 0;
+ }
+
+ if (selectedGpuIndex >= 0 && selectedGpuIndex < DgopService.availableGpus.length) {
+ return DgopService.availableGpus[selectedGpuIndex].temperature || 0;
+ }
+
+ return 0;
+ }
+
+ function updateWidgetPciId(pciId) {
+ // Find and update this widget's pciId in the settings
+ const sections = ["left", "center", "right"];
+ for (let s = 0; s < sections.length; s++) {
+ const sectionId = sections[s];
+ let widgets = [];
+ if (sectionId === "left") {
+ widgets = SettingsData.topBarLeftWidgets.slice();
+ } else if (sectionId === "center") {
+ widgets = SettingsData.topBarCenterWidgets.slice();
+ } else if (sectionId === "right") {
+ widgets = SettingsData.topBarRightWidgets.slice();
+ }
+ for (let i = 0; i < widgets.length; i++) {
+ const widget = widgets[i];
+ if (typeof widget === "object" && widget.id === "gpuTemp" && (!widget.pciId || widget.pciId === "")) {
+ widgets[i] = {
+ "id": widget.id,
+ "enabled": widget.enabled !== undefined ? widget.enabled : true,
+ "selectedGpuIndex": 0,
+ "pciId": pciId
+ };
+ if (sectionId === "left") {
+ SettingsData.setTopBarLeftWidgets(widgets);
+ } else if (sectionId === "center") {
+ SettingsData.setTopBarCenterWidgets(widgets);
+ } else if (sectionId === "right") {
+ SettingsData.setTopBarRightWidgets(widgets);
+ }
+ return ;
+ }
+ }
+ }
+ }
+
+ width: gpuTempContent.implicitWidth + horizontalPadding * 2
+ height: widgetHeight
+ radius: SettingsData.topBarNoBackground ? 0 : Theme.cornerRadius
+ color: {
+ if (SettingsData.topBarNoBackground) {
+ return "transparent";
+ }
+
+ const baseColor = gpuArea.containsMouse ? Theme.primaryPressed : Theme.secondaryHover;
+ return Qt.rgba(baseColor.r, baseColor.g, baseColor.b, baseColor.a * Theme.widgetTransparency);
+ }
+ Component.onCompleted: {
+ DgopService.addRef(["gpu"]);
+ console.log("GpuTemperature widget - pciId:", widgetData ? widgetData.pciId : "no widgetData", "selectedGpuIndex:", widgetData ? widgetData.selectedGpuIndex : "no widgetData");
+ // Add this widget's PCI ID to the service
+ if (widgetData && widgetData.pciId) {
+ console.log("Adding GPU PCI ID to service:", widgetData.pciId);
+ DgopService.addGpuPciId(widgetData.pciId);
+ } else {
+ console.log("No PCI ID in widget data, starting auto-detection");
+ // No PCI ID saved, auto-detect and save the first GPU
+ autoSaveTimer.running = true;
+ }
+ }
+ Component.onDestruction: {
+ DgopService.removeRef(["gpu"]);
+ // Remove this widget's PCI ID from the service
+ if (widgetData && widgetData.pciId) {
+ DgopService.removeGpuPciId(widgetData.pciId);
+ }
+
+ }
+
+ Connections {
+ function onWidgetDataChanged() {
+ // Force property re-evaluation by triggering change detection
+ root.selectedGpuIndex = Qt.binding(() => {
+ return (root.widgetData && root.widgetData.selectedGpuIndex !== undefined) ? root.widgetData.selectedGpuIndex : 0;
+ });
+ }
+
+ target: SettingsData
+ }
+
+ MouseArea {
+ id: gpuArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onPressed: {
+ if (popupTarget && popupTarget.setTriggerPosition) {
+ const globalPos = mapToGlobal(0, 0);
+ const currentScreen = parentScreen || Screen;
+ const screenX = currentScreen.x || 0;
+ const relativeX = globalPos.x - screenX;
+ popupTarget.setTriggerPosition(relativeX, barHeight + Theme.spacingXS, width, section, currentScreen);
+ }
+ DgopService.setSortBy("cpu");
+ if (root.toggleProcessList) {
+ root.toggleProcessList();
+ }
+
+ }
+ }
+
+ Row {
+ id: gpuTempContent
+
+ anchors.centerIn: parent
+ spacing: 3
+
+ DankIcon {
+ name: "auto_awesome_mosaic"
+ size: Theme.iconSize - 8
+ color: {
+ if (root.displayTemp > 80) {
+ return Theme.tempDanger;
+ }
+
+ if (root.displayTemp > 65) {
+ return Theme.tempWarning;
+ }
+
+ return Theme.surfaceText;
+ }
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: {
+ if (root.displayTemp === undefined || root.displayTemp === null || root.displayTemp === 0) {
+ return "--°";
+ }
+
+ return Math.round(root.displayTemp) + "°";
+ }
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ }
+
+ Timer {
+ id: autoSaveTimer
+
+ interval: 100
+ running: false
+ onTriggered: {
+ if (DgopService.availableGpus && DgopService.availableGpus.length > 0) {
+ const firstGpu = DgopService.availableGpus[0];
+ if (firstGpu && firstGpu.pciId) {
+ // Save the first GPU's PCI ID to this widget's settings
+ updateWidgetPciId(firstGpu.pciId);
+ DgopService.addGpuPciId(firstGpu.pciId);
+ }
+ }
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modules/TopBar/IdleInhibitor.qml b/quickshell/.config/quickshell/Modules/TopBar/IdleInhibitor.qml
new file mode 100644
index 0000000..8d073a0
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/TopBar/IdleInhibitor.qml
@@ -0,0 +1,57 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Rectangle {
+ id: root
+
+ property string section: "right"
+ property var popupTarget: null
+ property var parentScreen: null
+ property real widgetHeight: 30
+ readonly property real horizontalPadding: SettingsData.topBarNoBackground ? 0 : Math.max(Theme.spacingXS, Theme.spacingS * (widgetHeight / 30))
+
+ width: idleIcon.width + horizontalPadding * 2
+ height: widgetHeight
+ radius: SettingsData.topBarNoBackground ? 0 : Theme.cornerRadius
+ color: {
+ if (SettingsData.topBarNoBackground) {
+ return "transparent";
+ }
+
+ const baseColor = mouseArea.containsMouse ? Theme.primaryPressed : (SessionService.idleInhibited ? Theme.primaryHover : Theme.secondaryHover);
+ return Qt.rgba(baseColor.r, baseColor.g, baseColor.b, baseColor.a * Theme.widgetTransparency);
+ }
+
+ DankIcon {
+ id: idleIcon
+
+ anchors.centerIn: parent
+ name: SessionService.idleInhibited ? "motion_sensor_active" : "motion_sensor_idle"
+ size: Theme.iconSize - 6
+ color: Theme.surfaceText
+ }
+
+ MouseArea {
+ id: mouseArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ SessionService.toggleIdleInhibit();
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modules/TopBar/KeyboardLayoutName.qml b/quickshell/.config/quickshell/Modules/TopBar/KeyboardLayoutName.qml
new file mode 100644
index 0000000..f45f152
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/TopBar/KeyboardLayoutName.qml
@@ -0,0 +1,59 @@
+import QtQuick
+import QtQuick.Controls
+import qs.Common
+import qs.Modules.ProcessList
+import qs.Services
+import qs.Widgets
+
+Rectangle {
+ id: root
+
+ readonly property real horizontalPadding: SettingsData.topBarNoBackground ? 0 : Math.max(Theme.spacingXS, Theme.spacingS * (widgetHeight / 30))
+
+ width: contentRow.implicitWidth + horizontalPadding * 2
+ height: widgetHeight
+ radius: SettingsData.topBarNoBackground ? 0 : Theme.cornerRadius
+ color: {
+ if (SettingsData.topBarNoBackground) {
+ return "transparent";
+ }
+
+ const baseColor = mouseArea.containsMouse ? Theme.primaryPressed : Theme.secondaryHover;
+ return Qt.rgba(baseColor.r, baseColor.g, baseColor.b, baseColor.a * Theme.widgetTransparency);
+ }
+
+ MouseArea {
+ id: mouseArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ NiriService.cycleKeyboardLayout();
+ }
+ }
+
+ Row {
+ id: contentRow
+
+ anchors.centerIn: parent
+ spacing: Theme.spacingS
+
+ StyledText {
+ text: NiriService.getCurrentKeyboardLayoutName()
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modules/TopBar/LauncherButton.qml b/quickshell/.config/quickshell/Modules/TopBar/LauncherButton.qml
new file mode 100644
index 0000000..d872136
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/TopBar/LauncherButton.qml
@@ -0,0 +1,83 @@
+import QtQuick
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Item {
+ id: root
+
+ property bool isActive: false
+ property string section: "left"
+ property var popupTarget: null
+ property var parentScreen: null
+ property real widgetHeight: 30
+ property real barHeight: 48
+ readonly property real horizontalPadding: SettingsData.topBarNoBackground ? 0 : Math.max(Theme.spacingXS, Theme.spacingS * (widgetHeight / 30))
+
+ signal clicked()
+
+ width: Theme.iconSize + horizontalPadding * 2
+ height: widgetHeight
+
+ MouseArea {
+ id: launcherArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ acceptedButtons: Qt.LeftButton
+ onPressed: {
+ if (popupTarget && popupTarget.setTriggerPosition) {
+ const globalPos = mapToGlobal(0, 0);
+ const currentScreen = parentScreen || Screen;
+ const screenX = currentScreen.x || 0;
+ const relativeX = globalPos.x - screenX;
+ popupTarget.setTriggerPosition(relativeX, barHeight + Theme.spacingXS, width, section, currentScreen);
+ }
+ root.clicked();
+ }
+ }
+
+ Rectangle {
+ id: launcherContent
+
+ anchors.fill: parent
+ radius: SettingsData.topBarNoBackground ? 0 : Theme.cornerRadius
+ color: {
+ if (SettingsData.topBarNoBackground) {
+ return "transparent";
+ }
+
+ const baseColor = launcherArea.containsMouse ? Theme.primaryPressed : (SessionService.idleInhibited ? Theme.primaryHover : Theme.secondaryHover);
+ return Qt.rgba(baseColor.r, baseColor.g, baseColor.b, baseColor.a * Theme.widgetTransparency);
+ }
+
+ SystemLogo {
+ visible: SettingsData.useOSLogo
+ anchors.centerIn: parent
+ width: Theme.iconSize - 3
+ height: Theme.iconSize - 3
+ colorOverride: SettingsData.osLogoColorOverride
+ brightnessOverride: SettingsData.osLogoBrightness
+ contrastOverride: SettingsData.osLogoContrast
+ }
+
+ DankIcon {
+ visible: !SettingsData.useOSLogo
+ anchors.centerIn: parent
+ name: "apps"
+ size: Theme.iconSize - 6
+ color: Theme.surfaceText
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modules/TopBar/Media.qml b/quickshell/.config/quickshell/Modules/TopBar/Media.qml
new file mode 100644
index 0000000..266266f
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/TopBar/Media.qml
@@ -0,0 +1,343 @@
+import QtQuick
+import Quickshell.Services.Mpris
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Rectangle {
+ id: root
+
+ readonly property MprisPlayer activePlayer: MprisController.activePlayer
+ readonly property bool playerAvailable: activePlayer !== null
+ property bool compactMode: false
+ readonly property int textWidth: {
+ switch (SettingsData.mediaSize) {
+ case 0:
+ return 0; // No text in small mode
+ case 2:
+ return 180; // Large text area
+ default:
+ return 120; // Medium text area
+ }
+ }
+ readonly property int currentContentWidth: {
+ // Calculate actual content width:
+ // AudioViz (20) + spacing + [text + spacing] + controls (prev:20 + spacing + play:24 + spacing + next:20) + padding
+ const controlsWidth = 20 + Theme.spacingXS + 24 + Theme.spacingXS + 20;
+ // ~72px total
+ const audioVizWidth = 20;
+ const contentWidth = audioVizWidth + Theme.spacingXS + controlsWidth;
+ return contentWidth + (textWidth > 0 ? textWidth + Theme.spacingXS : 0) + horizontalPadding * 2;
+ }
+ property string section: "center"
+ property var popupTarget: null
+ property var parentScreen: null
+ property real barHeight: 48
+ property real widgetHeight: 30
+ readonly property real horizontalPadding: SettingsData.topBarNoBackground ? 0 : Math.max(Theme.spacingXS, Theme.spacingS * (widgetHeight / 30))
+
+ signal clicked()
+
+ height: widgetHeight
+ radius: SettingsData.topBarNoBackground ? 0 : Theme.cornerRadius
+ color: {
+ if (SettingsData.topBarNoBackground) {
+ return "transparent";
+ }
+
+ const baseColor = Theme.surfaceTextHover;
+ return Qt.rgba(baseColor.r, baseColor.g, baseColor.b, baseColor.a * Theme.widgetTransparency);
+ }
+ states: [
+ State {
+ name: "shown"
+ when: playerAvailable
+
+ PropertyChanges {
+ target: root
+ opacity: 1
+ width: currentContentWidth
+ }
+
+ },
+ State {
+ name: "hidden"
+ when: !playerAvailable
+
+ PropertyChanges {
+ target: root
+ opacity: 0
+ width: 0
+ }
+
+ }
+ ]
+ transitions: [
+ Transition {
+ from: "shown"
+ to: "hidden"
+
+ SequentialAnimation {
+ PauseAnimation {
+ duration: 500
+ }
+
+ NumberAnimation {
+ properties: "opacity,width"
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+
+ }
+
+ },
+ Transition {
+ from: "hidden"
+ to: "shown"
+
+ NumberAnimation {
+ properties: "opacity,width"
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+
+ }
+ ]
+
+ Row {
+ id: mediaRow
+
+ anchors.centerIn: parent
+ spacing: Theme.spacingXS
+
+ Row {
+ id: mediaInfo
+
+ spacing: Theme.spacingXS
+
+ AudioVisualization {
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Rectangle {
+ id: textContainer
+
+ property string displayText: {
+ if (!activePlayer || !activePlayer.trackTitle) {
+ return "";
+ }
+
+ let identity = activePlayer.identity || "";
+ let isWebMedia = identity.toLowerCase().includes("firefox") || identity.toLowerCase().includes("chrome") || identity.toLowerCase().includes("chromium") || identity.toLowerCase().includes("edge") || identity.toLowerCase().includes("safari");
+ let title = "";
+ let subtitle = "";
+ if (isWebMedia && activePlayer.trackTitle) {
+ title = activePlayer.trackTitle;
+ subtitle = activePlayer.trackArtist || identity;
+ } else {
+ title = activePlayer.trackTitle || "Unknown Track";
+ subtitle = activePlayer.trackArtist || "";
+ }
+ return subtitle.length > 0 ? title + " • " + subtitle : title;
+ }
+
+ anchors.verticalCenter: parent.verticalCenter
+ width: textWidth
+ height: 20
+ visible: SettingsData.mediaSize > 0
+ clip: true
+ color: "transparent"
+
+ StyledText {
+ id: mediaText
+
+ property bool needsScrolling: implicitWidth > textContainer.width
+ property real scrollOffset: 0
+
+ anchors.verticalCenter: parent.verticalCenter
+ text: textContainer.displayText
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ wrapMode: Text.NoWrap
+ x: needsScrolling ? -scrollOffset : 0
+ onTextChanged: {
+ scrollOffset = 0;
+ scrollAnimation.restart();
+ }
+
+ SequentialAnimation {
+ id: scrollAnimation
+
+ running: mediaText.needsScrolling && textContainer.visible
+ loops: Animation.Infinite
+
+ PauseAnimation {
+ duration: 2000
+ }
+
+ NumberAnimation {
+ target: mediaText
+ property: "scrollOffset"
+ from: 0
+ to: mediaText.implicitWidth - textContainer.width + 5
+ duration: Math.max(1000, (mediaText.implicitWidth - textContainer.width + 5) * 60)
+ easing.type: Easing.Linear
+ }
+
+ PauseAnimation {
+ duration: 2000
+ }
+
+ NumberAnimation {
+ target: mediaText
+ property: "scrollOffset"
+ to: 0
+ duration: Math.max(1000, (mediaText.implicitWidth - textContainer.width + 5) * 60)
+ easing.type: Easing.Linear
+ }
+
+ }
+
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ enabled: root.playerAvailable && root.opacity > 0 && root.width > 0 && textContainer.visible
+ hoverEnabled: enabled
+ cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
+ onPressed: {
+ if (root.popupTarget && root.popupTarget.setTriggerPosition) {
+ const globalPos = mapToGlobal(0, 0);
+ const currentScreen = root.parentScreen || Screen;
+ const screenX = currentScreen.x || 0;
+ const relativeX = globalPos.x - screenX;
+ root.popupTarget.setTriggerPosition(relativeX, barHeight + Theme.spacingXS, root.width, root.section, currentScreen);
+ }
+ root.clicked();
+ }
+ }
+
+ }
+
+ }
+
+ Row {
+ spacing: Theme.spacingXS
+ anchors.verticalCenter: parent.verticalCenter
+
+ Rectangle {
+ width: 20
+ height: 20
+ radius: 10
+ anchors.verticalCenter: parent.verticalCenter
+ color: prevArea.containsMouse ? Theme.primaryHover : "transparent"
+ visible: root.playerAvailable
+ opacity: (activePlayer && activePlayer.canGoPrevious) ? 1 : 0.3
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "skip_previous"
+ size: 12
+ color: Theme.surfaceText
+ }
+
+ MouseArea {
+ id: prevArea
+
+ anchors.fill: parent
+ enabled: root.playerAvailable && root.width > 0
+ hoverEnabled: enabled
+ cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
+ onClicked: {
+ if (activePlayer) {
+ activePlayer.previous();
+ }
+ }
+ }
+
+ }
+
+ Rectangle {
+ width: 24
+ height: 24
+ radius: 12
+ anchors.verticalCenter: parent.verticalCenter
+ color: activePlayer && activePlayer.playbackState === 1 ? Theme.primary : Theme.primaryHover
+ visible: root.playerAvailable
+ opacity: activePlayer ? 1 : 0.3
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: activePlayer && activePlayer.playbackState === 1 ? "pause" : "play_arrow"
+ size: 14
+ color: activePlayer && activePlayer.playbackState === 1 ? Theme.background : Theme.primary
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ enabled: root.playerAvailable && root.width > 0
+ hoverEnabled: enabled
+ cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
+ onClicked: {
+ if (activePlayer) {
+ activePlayer.togglePlaying();
+ }
+ }
+ }
+
+ }
+
+ Rectangle {
+ width: 20
+ height: 20
+ radius: 10
+ anchors.verticalCenter: parent.verticalCenter
+ color: nextArea.containsMouse ? Theme.primaryHover : "transparent"
+ visible: playerAvailable
+ opacity: (activePlayer && activePlayer.canGoNext) ? 1 : 0.3
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "skip_next"
+ size: 12
+ color: Theme.surfaceText
+ }
+
+ MouseArea {
+ id: nextArea
+
+ anchors.fill: parent
+ enabled: root.playerAvailable && root.width > 0
+ hoverEnabled: enabled
+ cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
+ onClicked: {
+ if (activePlayer) {
+ activePlayer.next();
+ }
+ }
+ }
+
+ }
+
+ }
+
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+
+ }
+
+ Behavior on width {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modules/TopBar/NetworkMonitor.qml b/quickshell/.config/quickshell/Modules/TopBar/NetworkMonitor.qml
new file mode 100644
index 0000000..a10b9be
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/TopBar/NetworkMonitor.qml
@@ -0,0 +1,117 @@
+import QtQuick
+import QtQuick.Controls
+import qs.Common
+import qs.Modules.ProcessList
+import qs.Services
+import qs.Widgets
+
+Rectangle {
+ id: root
+
+ property int availableWidth: 400
+ readonly property int baseWidth: contentRow.implicitWidth + Theme.spacingS * 2
+ readonly property int maxNormalWidth: 456
+ readonly property real horizontalPadding: SettingsData.topBarNoBackground ? 0 : Math.max(Theme.spacingXS, Theme.spacingS * (widgetHeight / 30))
+
+ function formatNetworkSpeed(bytesPerSec) {
+ if (bytesPerSec < 1024) {
+ return bytesPerSec.toFixed(0) + " B/s";
+ } else if (bytesPerSec < 1024 * 1024) {
+ return (bytesPerSec / 1024).toFixed(1) + " KB/s";
+ } else if (bytesPerSec < 1024 * 1024 * 1024) {
+ return (bytesPerSec / (1024 * 1024)).toFixed(1) + " MB/s";
+ } else {
+ return (bytesPerSec / (1024 * 1024 * 1024)).toFixed(1) + " GB/s";
+ }
+ }
+
+ width: contentRow.implicitWidth + horizontalPadding * 2
+ height: widgetHeight
+ radius: SettingsData.topBarNoBackground ? 0 : Theme.cornerRadius
+ color: {
+ if (SettingsData.topBarNoBackground) {
+ return "transparent";
+ }
+
+ const baseColor = networkArea.containsMouse ? Theme.primaryPressed : Theme.secondaryHover;
+ return Qt.rgba(baseColor.r, baseColor.g, baseColor.b, baseColor.a * Theme.widgetTransparency);
+ }
+ Component.onCompleted: {
+ DgopService.addRef(["network"]);
+ }
+ Component.onDestruction: {
+ DgopService.removeRef(["network"]);
+ }
+
+ MouseArea {
+ id: networkArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ }
+
+ Row {
+ id: contentRow
+
+ anchors.centerIn: parent
+ spacing: Theme.spacingS
+
+ DankIcon {
+ name: "network_check"
+ size: Theme.iconSize - 8
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Row {
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 4
+
+ StyledText {
+ text: "↓"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.info
+ }
+
+ StyledText {
+ text: DgopService.networkRxRate > 0 ? formatNetworkSpeed(DgopService.networkRxRate) : "0 B/s"
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ }
+
+ Row {
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 4
+
+ StyledText {
+ text: "↑"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.error
+ }
+
+ StyledText {
+ text: DgopService.networkTxRate > 0 ? formatNetworkSpeed(DgopService.networkTxRate) : "0 B/s"
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ }
+
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modules/TopBar/NotepadButton.qml b/quickshell/.config/quickshell/Modules/TopBar/NotepadButton.qml
new file mode 100644
index 0000000..25ae8b4
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/TopBar/NotepadButton.qml
@@ -0,0 +1,71 @@
+import QtQuick
+import qs.Common
+import qs.Widgets
+
+Rectangle {
+ id: root
+
+ property bool isActive: false
+ property string section: "right"
+ property var popupTarget: null
+ property var parentScreen: null
+ property real widgetHeight: 30
+ property real barHeight: 48
+ readonly property real horizontalPadding: SettingsData.topBarNoBackground ? 0 : Math.max(Theme.spacingXS, Theme.spacingS * (widgetHeight / 30))
+
+ signal clicked()
+
+ width: notepadIcon.width + horizontalPadding * 2
+ height: widgetHeight
+ radius: SettingsData.topBarNoBackground ? 0 : Theme.cornerRadius
+ color: {
+ if (SettingsData.topBarNoBackground) {
+ return "transparent";
+ }
+
+ const baseColor = notepadArea.containsMouse || root.isActive ? Theme.primaryPressed : Theme.secondaryHover;
+ return Qt.rgba(baseColor.r, baseColor.g, baseColor.b, baseColor.a * Theme.widgetTransparency);
+ }
+
+ DankIcon {
+ id: notepadIcon
+
+ anchors.centerIn: parent
+ name: "assignment"
+ size: Theme.iconSize - 6
+ color: notepadArea.containsMouse || root.isActive ? Theme.primary : Theme.surfaceText
+ }
+
+ Rectangle {
+ width: 6
+ height: 6
+ radius: 3
+ color: Theme.primary
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.rightMargin: SettingsData.topBarNoBackground ? 0 : 4
+ anchors.topMargin: SettingsData.topBarNoBackground ? 0 : 4
+ visible: SessionData.notepadContent.length > 0
+ opacity: 0.8
+ }
+
+ MouseArea {
+ id: notepadArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onPressed: {
+ root.clicked();
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modules/TopBar/NotificationCenterButton.qml b/quickshell/.config/quickshell/Modules/TopBar/NotificationCenterButton.qml
new file mode 100644
index 0000000..c70c8f6
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/TopBar/NotificationCenterButton.qml
@@ -0,0 +1,78 @@
+import QtQuick
+import qs.Common
+import qs.Widgets
+
+Rectangle {
+ id: root
+
+ property bool hasUnread: false
+ property bool isActive: false
+ property string section: "right"
+ property var popupTarget: null
+ property var parentScreen: null
+ property real widgetHeight: 30
+ property real barHeight: 48
+ readonly property real horizontalPadding: SettingsData.topBarNoBackground ? 0 : Math.max(Theme.spacingXS, Theme.spacingS * (widgetHeight / 30))
+
+ signal clicked()
+
+ width: notificationIcon.width + horizontalPadding * 2
+ height: widgetHeight
+ radius: SettingsData.topBarNoBackground ? 0 : Theme.cornerRadius
+ color: {
+ if (SettingsData.topBarNoBackground) {
+ return "transparent";
+ }
+
+ const baseColor = notificationArea.containsMouse || root.isActive ? Theme.primaryPressed : Theme.secondaryHover;
+ return Qt.rgba(baseColor.r, baseColor.g, baseColor.b, baseColor.a * Theme.widgetTransparency);
+ }
+
+ DankIcon {
+ id: notificationIcon
+
+ anchors.centerIn: parent
+ name: SessionData.doNotDisturb ? "notifications_off" : "notifications"
+ size: Theme.iconSize - 6
+ color: SessionData.doNotDisturb ? Theme.error : (notificationArea.containsMouse || root.isActive ? Theme.primary : Theme.surfaceText)
+ }
+
+ Rectangle {
+ width: 8
+ height: 8
+ radius: 4
+ color: Theme.error
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.rightMargin: SettingsData.topBarNoBackground ? 0 : 6
+ anchors.topMargin: SettingsData.topBarNoBackground ? 0 : 6
+ visible: root.hasUnread
+ }
+
+ MouseArea {
+ id: notificationArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onPressed: {
+ if (popupTarget && popupTarget.setTriggerPosition) {
+ const globalPos = mapToGlobal(0, 0);
+ const currentScreen = parentScreen || Screen;
+ const screenX = currentScreen.x || 0;
+ const relativeX = globalPos.x - screenX;
+ popupTarget.setTriggerPosition(relativeX, barHeight + Theme.spacingXS, width, section, currentScreen);
+ }
+ root.clicked();
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modules/TopBar/PrivacyIndicator.qml b/quickshell/.config/quickshell/Modules/TopBar/PrivacyIndicator.qml
new file mode 100644
index 0000000..1029692
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/TopBar/PrivacyIndicator.qml
@@ -0,0 +1,170 @@
+import QtQuick
+import QtQuick.Controls
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Rectangle {
+ id: root
+
+ property string section: "right"
+ property var popupTarget: null
+ property var parentScreen: null
+ property real widgetHeight: 30
+ readonly property real horizontalPadding: SettingsData.topBarNoBackground ? 2 : Theme.spacingS
+ readonly property bool hasActivePrivacy: PrivacyService.anyPrivacyActive
+ readonly property int activeCount: PrivacyService.microphoneActive + PrivacyService.cameraActive + PrivacyService.screensharingActive
+ readonly property real contentWidth: hasActivePrivacy ? (activeCount * 18 + (activeCount - 1) * Theme.spacingXS) : 0
+
+ width: hasActivePrivacy ? (contentWidth + horizontalPadding * 2) : 0
+ height: hasActivePrivacy ? widgetHeight : 0
+ radius: SettingsData.topBarNoBackground ? 0 : Theme.cornerRadius
+ visible: hasActivePrivacy
+ opacity: hasActivePrivacy ? 1 : 0
+ enabled: hasActivePrivacy
+ color: {
+ if (SettingsData.topBarNoBackground) {
+ return "transparent";
+ }
+
+ return Qt.rgba(privacyArea.containsMouse ? Theme.errorPressed.r : Theme.errorHover.r, privacyArea.containsMouse ? Theme.errorPressed.g : Theme.errorHover.g, privacyArea.containsMouse ? Theme.errorPressed.b : Theme.errorHover.b, (privacyArea.containsMouse ? Theme.errorPressed.a : Theme.errorHover.a) * Theme.widgetTransparency);
+ }
+
+ MouseArea {
+ // Privacy indicator click handler
+
+ id: privacyArea
+
+ anchors.fill: parent
+ hoverEnabled: hasActivePrivacy
+ enabled: hasActivePrivacy
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ }
+ }
+
+ Row {
+ anchors.centerIn: parent
+ spacing: Theme.spacingXS
+ visible: hasActivePrivacy
+
+ Item {
+ width: 18
+ height: 18
+ visible: PrivacyService.microphoneActive
+ anchors.verticalCenter: parent.verticalCenter
+
+ DankIcon {
+ name: "mic"
+ size: Theme.iconSizeSmall
+ color: Theme.error
+ filled: true
+ anchors.centerIn: parent
+ }
+
+ }
+
+ Item {
+ width: 18
+ height: 18
+ visible: PrivacyService.cameraActive
+ anchors.verticalCenter: parent.verticalCenter
+
+ DankIcon {
+ name: "camera_video"
+ size: Theme.iconSizeSmall
+ color: Theme.surfaceText
+ filled: true
+ anchors.centerIn: parent
+ }
+
+ Rectangle {
+ width: 6
+ height: 6
+ radius: 3
+ color: Theme.error
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.rightMargin: -2
+ anchors.topMargin: -1
+ }
+
+ }
+
+ Item {
+ width: 18
+ height: 18
+ visible: PrivacyService.screensharingActive
+ anchors.verticalCenter: parent.verticalCenter
+
+ DankIcon {
+ name: "screen_share"
+ size: Theme.iconSizeSmall
+ color: Theme.warning
+ filled: true
+ anchors.centerIn: parent
+ }
+
+ }
+
+ }
+
+ Rectangle {
+ id: tooltip
+
+ width: tooltipText.contentWidth + Theme.spacingM * 2
+ height: tooltipText.contentHeight + Theme.spacingS * 2
+ radius: Theme.cornerRadius
+ color: Theme.popupBackground()
+ border.color: Theme.outlineMedium
+ border.width: 1
+ visible: false
+ opacity: privacyArea.containsMouse && hasActivePrivacy ? 1 : 0
+ z: 100
+ x: (parent.width - width) / 2
+ y: -height - Theme.spacingXS
+
+ StyledText {
+ id: tooltipText
+
+ anchors.centerIn: parent
+ text: PrivacyService.getPrivacySummary()
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ }
+
+ Rectangle {
+ width: 8
+ height: 8
+ color: parent.color
+ border.color: parent.border.color
+ border.width: parent.border.width
+ rotation: 45
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.top: parent.bottom
+ anchors.topMargin: -4
+ }
+
+ Behavior on opacity {
+ enabled: hasActivePrivacy && root.visible
+
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+
+ }
+
+ }
+
+ Behavior on width {
+ enabled: hasActivePrivacy && visible
+
+ NumberAnimation {
+ duration: Theme.mediumDuration
+ easing.type: Theme.emphasizedEasing
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modules/TopBar/RamMonitor.qml b/quickshell/.config/quickshell/Modules/TopBar/RamMonitor.qml
new file mode 100644
index 0000000..89bf7ae
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/TopBar/RamMonitor.qml
@@ -0,0 +1,99 @@
+import QtQuick
+import QtQuick.Controls
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Rectangle {
+ id: root
+
+ property bool showPercentage: true
+ property bool showIcon: true
+ property var toggleProcessList
+ property string section: "right"
+ property var popupTarget: null
+ property var parentScreen: null
+ property real barHeight: 48
+ property real widgetHeight: 30
+ readonly property real horizontalPadding: SettingsData.topBarNoBackground ? 0 : Math.max(Theme.spacingXS, Theme.spacingS * (widgetHeight / 30))
+
+ width: ramContent.implicitWidth + horizontalPadding * 2
+ height: widgetHeight
+ radius: SettingsData.topBarNoBackground ? 0 : Theme.cornerRadius
+ color: {
+ if (SettingsData.topBarNoBackground) {
+ return "transparent";
+ }
+
+ const baseColor = ramArea.containsMouse ? Theme.primaryPressed : Theme.secondaryHover;
+ return Qt.rgba(baseColor.r, baseColor.g, baseColor.b, baseColor.a * Theme.widgetTransparency);
+ }
+ Component.onCompleted: {
+ DgopService.addRef(["memory"]);
+ }
+ Component.onDestruction: {
+ DgopService.removeRef(["memory"]);
+ }
+
+ MouseArea {
+ id: ramArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onPressed: {
+ if (popupTarget && popupTarget.setTriggerPosition) {
+ const globalPos = mapToGlobal(0, 0);
+ const currentScreen = parentScreen || Screen;
+ const screenX = currentScreen.x || 0;
+ const relativeX = globalPos.x - screenX;
+ popupTarget.setTriggerPosition(relativeX, barHeight + Theme.spacingXS, width, section, currentScreen);
+ }
+ DgopService.setSortBy("memory");
+ if (root.toggleProcessList) {
+ root.toggleProcessList();
+ }
+
+ }
+ }
+
+ Row {
+ id: ramContent
+
+ anchors.centerIn: parent
+ spacing: 3
+
+ DankIcon {
+ name: "developer_board"
+ size: Theme.iconSize - 8
+ color: {
+ if (DgopService.memoryUsage > 90) {
+ return Theme.tempDanger;
+ }
+
+ if (DgopService.memoryUsage > 75) {
+ return Theme.tempWarning;
+ }
+
+ return Theme.surfaceText;
+ }
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: {
+ if (DgopService.memoryUsage === undefined || DgopService.memoryUsage === null || DgopService.memoryUsage === 0) {
+ return "--%";
+ }
+
+ return DgopService.memoryUsage.toFixed(0) + "%";
+ }
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modules/TopBar/RunningApps.qml b/quickshell/.config/quickshell/Modules/TopBar/RunningApps.qml
new file mode 100644
index 0000000..9cb47e6
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/TopBar/RunningApps.qml
@@ -0,0 +1,437 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell
+import Quickshell.Wayland
+import Quickshell.Widgets
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Rectangle {
+ id: root
+
+ property string section: "left"
+ property var parentScreen
+ property var hoveredItem: null
+ property var topBar: null
+ property real widgetHeight: 30
+ readonly property real horizontalPadding: SettingsData.topBarNoBackground ? 2 : Theme.spacingS
+ // The visual root for this window
+ property Item windowRoot: (Window.window ? Window.window.contentItem : null)
+ readonly property var sortedToplevels: {
+ if (SettingsData.runningAppsCurrentWorkspace) {
+ return CompositorService.filterCurrentWorkspace(CompositorService.sortedToplevels, parentScreen.name);
+ }
+ return CompositorService.sortedToplevels;
+ }
+ readonly property int windowCount: sortedToplevels.length
+ readonly property int calculatedWidth: {
+ if (windowCount === 0) {
+ return 0;
+ }
+ if (SettingsData.runningAppsCompactMode) {
+ return windowCount * 24 + (windowCount - 1) * Theme.spacingXS + horizontalPadding * 2;
+ } else {
+ return windowCount * (24 + Theme.spacingXS + 120)
+ + (windowCount - 1) * Theme.spacingXS + horizontalPadding * 2;
+ }
+ }
+
+ width: calculatedWidth
+ height: widgetHeight
+ radius: SettingsData.topBarNoBackground ? 0 : Theme.cornerRadius
+ visible: windowCount > 0
+ clip: false
+ color: {
+ if (windowCount === 0) {
+ return "transparent";
+ }
+
+ if (SettingsData.topBarNoBackground) {
+ return "transparent";
+ }
+ const baseColor = Theme.secondaryHover;
+ return Qt.rgba(baseColor.r, baseColor.g, baseColor.b,
+ baseColor.a * Theme.widgetTransparency);
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ acceptedButtons: Qt.NoButton
+
+ property real scrollAccumulator: 0
+ property real touchpadThreshold: 500
+
+ onWheel: (wheel) => {
+ const deltaY = wheel.angleDelta.y;
+ const isMouseWheel = Math.abs(deltaY) >= 120
+ && (Math.abs(deltaY) % 120) === 0;
+
+ const windows = root.sortedToplevels;
+ if (windows.length < 2) {
+ return;
+ }
+
+ if (isMouseWheel) {
+ // Direct mouse wheel action
+ let currentIndex = -1;
+ for (let i = 0; i < windows.length; i++) {
+ if (windows[i].activated) {
+ currentIndex = i;
+ break;
+ }
+ }
+
+ let nextIndex;
+ if (deltaY < 0) {
+ if (currentIndex === -1) {
+ nextIndex = 0;
+ } else {
+ nextIndex = (currentIndex + 1) % windows.length;
+ }
+ } else {
+ if (currentIndex === -1) {
+ nextIndex = windows.length - 1;
+ } else {
+ nextIndex = (currentIndex - 1 + windows.length) % windows.length;
+ }
+ }
+
+ const nextWindow = windows[nextIndex];
+ if (nextWindow) {
+ nextWindow.activate();
+ }
+ } else {
+ // Touchpad - accumulate small deltas
+ scrollAccumulator += deltaY;
+
+ if (Math.abs(scrollAccumulator) >= touchpadThreshold) {
+ let currentIndex = -1;
+ for (let i = 0; i < windows.length; i++) {
+ if (windows[i].activated) {
+ currentIndex = i;
+ break;
+ }
+ }
+
+ let nextIndex;
+ if (scrollAccumulator < 0) {
+ if (currentIndex === -1) {
+ nextIndex = 0;
+ } else {
+ nextIndex = (currentIndex + 1) % windows.length;
+ }
+ } else {
+ if (currentIndex === -1) {
+ nextIndex = windows.length - 1;
+ } else {
+ nextIndex = (currentIndex - 1 + windows.length) % windows.length;
+ }
+ }
+
+ const nextWindow = windows[nextIndex];
+ if (nextWindow) {
+ nextWindow.activate();
+ }
+
+ scrollAccumulator = 0;
+ }
+ }
+
+ wheel.accepted = true;
+ }
+ }
+
+ Row {
+ id: windowRow
+
+ anchors.centerIn: parent
+ spacing: Theme.spacingXS
+
+ Repeater {
+ id: windowRepeater
+
+ model: sortedToplevels
+
+ delegate: Item {
+ id: delegateItem
+
+ property bool isFocused: modelData.activated
+ property string appId: modelData.appId || ""
+ property string windowTitle: modelData.title || "(Unnamed)"
+ property var toplevelObject: modelData
+ property string tooltipText: {
+ let appName = "Unknown";
+ if (appId) {
+ const desktopEntry = DesktopEntries.heuristicLookup(appId);
+ appName = desktopEntry
+ && desktopEntry.name ? desktopEntry.name : appId;
+ }
+ return appName + (windowTitle ? " • " + windowTitle : "")
+ }
+
+ width: SettingsData.runningAppsCompactMode ? 24 : (24 + Theme.spacingXS + 120)
+ height: 24
+
+ Rectangle {
+ anchors.fill: parent
+ radius: Theme.cornerRadius
+ color: {
+ if (isFocused) {
+ return mouseArea.containsMouse ? Qt.rgba(
+ Theme.primary.r,
+ Theme.primary.g,
+ Theme.primary.b,
+ 0.3) : Qt.rgba(
+ Theme.primary.r,
+ Theme.primary.g,
+ Theme.primary.b,
+ 0.2);
+ } else {
+ return mouseArea.containsMouse ? Qt.rgba(
+ Theme.primaryHover.r,
+ Theme.primaryHover.g,
+ Theme.primaryHover.b,
+ 0.1) : "transparent";
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+ }
+
+ // App icon
+ IconImage {
+ id: iconImg
+ anchors.left: parent.left
+ anchors.leftMargin: SettingsData.runningAppsCompactMode ? (parent.width - 18) / 2 : Theme.spacingXS
+ anchors.verticalCenter: parent.verticalCenter
+ width: 18
+ height: 18
+ source: {
+ const moddedId = Paths.moddedAppId(appId)
+ if (moddedId.toLowerCase().includes("steam_app")) {
+ return ""
+ }
+ return Quickshell.iconPath(DesktopEntries.heuristicLookup(moddedId)?.icon, true)
+ }
+ smooth: true
+ mipmap: true
+ asynchronous: true
+ visible: status === Image.Ready
+ }
+
+ DankIcon {
+ anchors.left: parent.left
+ anchors.leftMargin: SettingsData.runningAppsCompactMode ? (parent.width - 18) / 2 : Theme.spacingXS
+ anchors.verticalCenter: parent.verticalCenter
+ size: 18
+ name: "sports_esports"
+ color: Theme.surfaceText
+ visible: {
+ const moddedId = Paths.moddedAppId(appId)
+ return moddedId.toLowerCase().includes("steam_app")
+ }
+ }
+
+ // Fallback text if no icon found
+ Text {
+ anchors.centerIn: parent
+ visible: {
+ const moddedId = Paths.moddedAppId(appId)
+ const isSteamApp = moddedId.toLowerCase().includes("steam_app")
+ return !iconImg.visible && !isSteamApp
+ }
+ text: {
+ if (!appId) {
+ return "?";
+ }
+
+ const desktopEntry = DesktopEntries.heuristicLookup(appId);
+ if (desktopEntry && desktopEntry.name) {
+ return desktopEntry.name.charAt(0).toUpperCase();
+ }
+
+ return appId.charAt(0).toUpperCase();
+ }
+ font.pixelSize: 10
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ }
+
+ // Window title text (only visible in expanded mode)
+ StyledText {
+ anchors.left: iconImg.right
+ anchors.leftMargin: Theme.spacingXS
+ anchors.right: parent.right
+ anchors.rightMargin: Theme.spacingS
+ anchors.verticalCenter: parent.verticalCenter
+ visible: !SettingsData.runningAppsCompactMode
+ text: windowTitle
+ font.pixelSize: Theme.fontSizeMedium - 1
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ elide: Text.ElideRight
+ maximumLineCount: 1
+ }
+
+ MouseArea {
+ id: mouseArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ acceptedButtons: Qt.LeftButton | Qt.RightButton
+ onClicked: (mouse) => {
+ if (mouse.button === Qt.LeftButton) {
+ if (toplevelObject) {
+ toplevelObject.activate();
+ }
+ } else if (mouse.button === Qt.RightButton) {
+ if (tooltipLoader.item) {
+ tooltipLoader.item.hideTooltip();
+ }
+ tooltipLoader.active = false;
+
+ windowContextMenuLoader.active = true;
+ if (windowContextMenuLoader.item) {
+ windowContextMenuLoader.item.currentWindow = toplevelObject;
+ const globalPos = delegateItem.mapToGlobal(delegateItem.width / 2, 0);
+ const screenX = root.parentScreen ? root.parentScreen.x : 0;
+ const screenY = root.parentScreen ? root.parentScreen.y : 0;
+ const relativeX = globalPos.x - screenX;
+ const yPos = Theme.barHeight + SettingsData.topBarSpacing - 7;
+ windowContextMenuLoader.item.showAt(relativeX, yPos);
+ }
+ }
+ }
+ onEntered: {
+ root.hoveredItem = delegateItem;
+ const globalPos = delegateItem.mapToGlobal(
+ delegateItem.width / 2, delegateItem.height);
+ tooltipLoader.active = true;
+ if (tooltipLoader.item) {
+ const tooltipY = Theme.barHeight
+ + SettingsData.topBarSpacing + Theme.spacingXS;
+ tooltipLoader.item.showTooltip(
+ delegateItem.tooltipText, globalPos.x,
+ tooltipY, root.parentScreen);
+ }
+ }
+ onExited: {
+ if (root.hoveredItem === delegateItem) {
+ root.hoveredItem = null;
+ if (tooltipLoader.item) {
+ tooltipLoader.item.hideTooltip();
+ }
+
+ tooltipLoader.active = false;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Loader {
+ id: tooltipLoader
+
+ active: false
+
+ sourceComponent: RunningAppsTooltip {}
+ }
+
+ Loader {
+ id: windowContextMenuLoader
+ active: false
+ sourceComponent: PanelWindow {
+ id: contextMenuWindow
+
+ property var currentWindow: null
+ property bool isVisible: false
+ property point anchorPos: Qt.point(0, 0)
+
+ function showAt(x, y) {
+ screen = root.parentScreen;
+ anchorPos = Qt.point(x, y);
+ isVisible = true;
+ visible = true;
+ }
+
+ function close() {
+ isVisible = false;
+ visible = false;
+ windowContextMenuLoader.active = false;
+ }
+
+ implicitWidth: 100
+ implicitHeight: 40
+ visible: false
+ color: "transparent"
+
+ WlrLayershell.layer: WlrLayershell.Overlay
+ WlrLayershell.exclusiveZone: -1
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
+
+ anchors {
+ top: true
+ left: true
+ right: true
+ bottom: true
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ onClicked: contextMenuWindow.close();
+ }
+
+ Rectangle {
+ x: {
+ const left = 10;
+ const right = contextMenuWindow.width - width - 10;
+ const want = contextMenuWindow.anchorPos.x - width / 2;
+ return Math.max(left, Math.min(right, want));
+ }
+ y: contextMenuWindow.anchorPos.y
+ width: 100
+ height: 32
+ color: Theme.popupBackground()
+ radius: Theme.cornerRadius
+ border.width: 1
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
+
+ Rectangle {
+ anchors.fill: parent
+ radius: parent.radius
+ color: closeMouseArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : "transparent"
+ }
+
+ StyledText {
+ anchors.centerIn: parent
+ text: "Close"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ font.weight: Font.Normal
+ }
+
+ MouseArea {
+ id: closeMouseArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ if (contextMenuWindow.currentWindow) {
+ contextMenuWindow.currentWindow.close();
+ }
+ contextMenuWindow.close();
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/TopBar/RunningAppsTooltip.qml b/quickshell/.config/quickshell/Modules/TopBar/RunningAppsTooltip.qml
new file mode 100644
index 0000000..f16e047
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/TopBar/RunningAppsTooltip.qml
@@ -0,0 +1,67 @@
+import QtQuick
+import Quickshell
+import Quickshell.Wayland
+import qs.Common
+
+PanelWindow {
+ id: root
+
+ property string tooltipText: ""
+ property real targetX: 0
+ property real targetY: 0
+ property var targetScreen: null
+
+ function showTooltip(text, x, y, screen) {
+ tooltipText = text;
+ targetScreen = screen;
+ const screenX = screen ? screen.x : 0;
+ targetX = x - screenX;
+ targetY = y;
+ visible = true;
+ }
+
+ function hideTooltip() {
+ visible = false;
+ }
+
+ screen: targetScreen
+ implicitWidth: Math.min(300, Math.max(120, textContent.implicitWidth + Theme.spacingM * 2))
+ implicitHeight: textContent.implicitHeight + Theme.spacingS * 2
+ color: "transparent"
+ visible: false
+ WlrLayershell.layer: WlrLayershell.Overlay
+ WlrLayershell.exclusiveZone: -1
+
+ anchors {
+ top: true
+ left: true
+ }
+
+ margins {
+ left: Math.round(targetX - implicitWidth / 2)
+ top: Math.round(targetY)
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ color: Theme.surfaceContainer
+ radius: Theme.cornerRadius
+ border.width: 1
+ border.color: Theme.outlineMedium
+
+ Text {
+ id: textContent
+
+ anchors.centerIn: parent
+ text: root.tooltipText
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ wrapMode: Text.NoWrap
+ maximumLineCount: 1
+ elide: Text.ElideRight
+ width: parent.width - Theme.spacingM * 2
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modules/TopBar/SystemTrayBar.qml b/quickshell/.config/quickshell/Modules/TopBar/SystemTrayBar.qml
new file mode 100644
index 0000000..b31e438
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/TopBar/SystemTrayBar.qml
@@ -0,0 +1,132 @@
+import QtQuick
+import Quickshell
+import Quickshell.Services.SystemTray
+import Quickshell.Widgets
+import qs.Common
+
+Rectangle {
+ id: root
+
+ property var parentWindow: null
+ property var parentScreen: null
+ property real widgetHeight: 30
+ readonly property real horizontalPadding: SettingsData.topBarNoBackground ? 2 : Theme.spacingS
+ readonly property int calculatedWidth: SystemTray.items.values.length > 0 ? SystemTray.items.values.length * 24 + horizontalPadding * 2 : 0
+
+ width: calculatedWidth
+ height: widgetHeight
+ radius: SettingsData.topBarNoBackground ? 0 : Theme.cornerRadius
+ color: {
+ if (SystemTray.items.values.length === 0) {
+ return "transparent";
+ }
+
+ if (SettingsData.topBarNoBackground) {
+ return "transparent";
+ }
+
+ const baseColor = Theme.secondaryHover;
+ return Qt.rgba(baseColor.r, baseColor.g, baseColor.b, baseColor.a * Theme.widgetTransparency);
+ }
+ visible: SystemTray.items.values.length > 0
+
+ Row {
+ id: systemTrayRow
+
+ anchors.centerIn: parent
+ spacing: 0
+
+ Repeater {
+ model: SystemTray.items.values
+
+ delegate: Item {
+ property var trayItem: modelData
+ property string iconSource: {
+ let icon = trayItem && trayItem.icon;
+ if (typeof icon === 'string' || icon instanceof String) {
+ if (icon.includes("?path=")) {
+ const split = icon.split("?path=");
+ if (split.length !== 2) {
+ return icon;
+ }
+
+ const name = split[0];
+ const path = split[1];
+ const fileName = name.substring(name.lastIndexOf("/") + 1);
+ return `file://${path}/${fileName}`;
+ }
+ return icon;
+ }
+ return "";
+ }
+
+ width: 24
+ height: 24
+
+ Rectangle {
+ anchors.fill: parent
+ radius: Theme.cornerRadius
+ color: trayItemArea.containsMouse ? Theme.primaryHover : "transparent"
+
+ Behavior on color {
+ enabled: trayItemArea.containsMouse !== undefined
+
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+
+ }
+
+ }
+
+ IconImage {
+ anchors.centerIn: parent
+ width: 16
+ height: 16
+ source: parent.iconSource
+ asynchronous: true
+ smooth: true
+ mipmap: true
+ }
+
+ 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 && !trayItem.onlyMenu) {
+ trayItem.activate();
+ return ;
+ }
+ if (trayItem.hasMenu) {
+ const globalPos = mapToGlobal(0, 0);
+ const currentScreen = parentScreen || Screen;
+ const screenX = currentScreen.x || 0;
+ const relativeX = globalPos.x - screenX;
+ menuAnchor.menu = trayItem.menu;
+ menuAnchor.anchor.window = parentWindow;
+ menuAnchor.anchor.rect = Qt.rect(relativeX, parentWindow.effectiveBarHeight + SettingsData.topBarSpacing, parent.width, 1);
+ menuAnchor.open();
+ }
+ }
+ }
+
+ }
+
+ }
+
+ }
+
+ QsMenuAnchor {
+ id: menuAnchor
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modules/TopBar/TopBar.qml b/quickshell/.config/quickshell/Modules/TopBar/TopBar.qml
new file mode 100644
index 0000000..e214b62
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/TopBar/TopBar.qml
@@ -0,0 +1,1007 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Effects
+import Quickshell
+import Quickshell.Io
+import Quickshell.Services.Mpris
+import Quickshell.Services.Notifications
+import Quickshell.Services.SystemTray
+import Quickshell.Wayland
+import Quickshell.Widgets
+import qs.Common
+import qs.Modules
+import qs.Modules.TopBar
+import qs.Services
+import qs.Widgets
+
+PanelWindow {
+ id: root
+
+ WlrLayershell.namespace: "quickshell:bar"
+
+ property var modelData
+ property var notepadVariants: null
+
+ function getNotepadInstanceForScreen() {
+ if (!notepadVariants || !notepadVariants.instances) return null
+
+ for (var i = 0; i < notepadVariants.instances.length; i++) {
+ var loader = notepadVariants.instances[i]
+ if (loader.modelData && loader.modelData.name === root.screen?.name) {
+ return loader.ensureLoaded()
+ }
+ }
+ return null
+ }
+ property string screenName: modelData.name
+ readonly property int notificationCount: NotificationService.notifications.length
+ readonly property real effectiveBarHeight: Math.max(root.widgetHeight + SettingsData.topBarInnerPadding + 4, Theme.barHeight - 4 - (8 - SettingsData.topBarInnerPadding))
+ readonly property real widgetHeight: Math.max(20, 26 + SettingsData.topBarInnerPadding * 0.6)
+
+ screen: modelData
+ implicitHeight: effectiveBarHeight + SettingsData.topBarSpacing
+ color: "transparent"
+ Component.onCompleted: {
+ const fonts = Qt.fontFamilies()
+ if (fonts.indexOf("Material Symbols Rounded") === -1) {
+ ToastService.showError("Please install Material Symbols Rounded and Restart your Shell. See README.md for instructions")
+ }
+
+ SettingsData.forceTopBarLayoutRefresh.connect(() => {
+ Qt.callLater(() => {
+ leftSection.visible = false
+ centerSection.visible = false
+ rightSection.visible = false
+ Qt.callLater(() => {
+ leftSection.visible = true
+ centerSection.visible = true
+ rightSection.visible = true
+ })
+ })
+ })
+
+ updateGpuTempConfig()
+ Qt.callLater(() => Qt.callLater(forceWidgetRefresh))
+ }
+
+ function forceWidgetRefresh() {
+ const sections = [leftSection, centerSection, rightSection]
+ sections.forEach(section => section && (section.visible = false))
+ Qt.callLater(() => sections.forEach(section => section && (section.visible = true)))
+ }
+
+ function updateGpuTempConfig() {
+ const allWidgets = [...(SettingsData.topBarLeftWidgets || []), ...(SettingsData.topBarCenterWidgets || []), ...(SettingsData.topBarRightWidgets || [])]
+
+ const hasGpuTempWidget = allWidgets.some(widget => {
+ const widgetId = typeof widget === "string" ? widget : widget.id
+ const widgetEnabled = typeof widget === "string" ? true : (widget.enabled !== false)
+ return widgetId === "gpuTemp" && widgetEnabled
+ })
+
+ DgopService.gpuTempEnabled = hasGpuTempWidget || SessionData.nvidiaGpuTempEnabled || SessionData.nonNvidiaGpuTempEnabled
+ DgopService.nvidiaGpuTempEnabled = hasGpuTempWidget || SessionData.nvidiaGpuTempEnabled
+ DgopService.nonNvidiaGpuTempEnabled = hasGpuTempWidget || SessionData.nonNvidiaGpuTempEnabled
+ }
+
+ Connections {
+ function onTopBarLeftWidgetsChanged() {
+ root.updateGpuTempConfig()
+ }
+
+ function onTopBarCenterWidgetsChanged() {
+ root.updateGpuTempConfig()
+ }
+
+ function onTopBarRightWidgetsChanged() {
+ root.updateGpuTempConfig()
+ }
+
+ target: SettingsData
+ }
+
+ Connections {
+ function onNvidiaGpuTempEnabledChanged() {
+ root.updateGpuTempConfig()
+ }
+
+ function onNonNvidiaGpuTempEnabledChanged() {
+ root.updateGpuTempConfig()
+ }
+
+ target: SessionData
+ }
+
+ Connections {
+ target: root.screen
+ function onGeometryChanged() {
+ if (centerSection?.width > 0) {
+ Qt.callLater(centerSection.updateLayout)
+ }
+ }
+ }
+
+ anchors {
+ top: true
+ left: true
+ right: true
+ }
+
+ exclusiveZone: (!SettingsData.topBarVisible || topBarCore.autoHide) ? -1 : root.effectiveBarHeight + SettingsData.topBarSpacing - 2 + SettingsData.topBarBottomGap
+
+ mask: Region {
+ item: topBarMouseArea
+ }
+
+ Item {
+ id: topBarCore
+ anchors.fill: parent
+
+ property real backgroundTransparency: SettingsData.topBarTransparency
+ property bool autoHide: SettingsData.topBarAutoHide
+ property bool reveal: {
+ // Handle Niri overview state first
+ if (CompositorService.isNiri && NiriService.inOverview) {
+ // If Show on Overview is enabled, show the bar
+ if (SettingsData.topBarOpenOnOverview) {
+ return true
+ }
+ // If Show on Overview is disabled, hide the bar
+ return false
+ }
+ // Normal visibility logic when not in overview
+ return SettingsData.topBarVisible && (!autoHide || topBarMouseArea.containsMouse || hasActivePopout)
+ }
+
+ property var notepadInstance: null
+ property bool notepadInstanceVisible: notepadInstance?.notepadVisible ?? false
+
+ readonly property bool hasActivePopout: {
+ const loaders = [{
+ "loader": appDrawerLoader,
+ "prop": "shouldBeVisible"
+ }, {
+ "loader": dankDashPopoutLoader,
+ "prop": "shouldBeVisible"
+ }, {
+ "loader": processListPopoutLoader,
+ "prop": "shouldBeVisible"
+ }, {
+ "loader": notificationCenterLoader,
+ "prop": "shouldBeVisible"
+ }, {
+ "loader": batteryPopoutLoader,
+ "prop": "shouldBeVisible"
+ }, {
+ "loader": vpnPopoutLoader,
+ "prop": "shouldBeVisible"
+ }, {
+ "loader": controlCenterLoader,
+ "prop": "shouldBeVisible"
+ }, {
+ "loader": clipboardHistoryModalPopup,
+ "prop": "visible"
+ }]
+ return notepadInstanceVisible || loaders.some(item => {
+ if (item.loader) {
+ return item.loader?.item?.[item.prop]
+ }
+ return false
+ })
+ }
+
+ Component.onCompleted: {
+ notepadInstance = root.getNotepadInstanceForScreen()
+ }
+
+ Connections {
+ function onTopBarTransparencyChanged() {
+ topBarCore.backgroundTransparency = SettingsData.topBarTransparency
+ }
+
+ target: SettingsData
+ }
+
+ MouseArea {
+ id: topBarMouseArea
+ height: parent.reveal ? root.effectiveBarHeight + SettingsData.topBarSpacing : 4
+ anchors {
+ top: parent.top
+ left: parent.left
+ right: parent.right
+ }
+ hoverEnabled: true
+
+ Behavior on height {
+ NumberAnimation {
+ duration: 200
+ easing.type: Easing.OutCubic
+ }
+ }
+
+ Item {
+ id: topBarContainer
+ anchors.fill: parent
+
+ transform: Translate {
+ id: topBarSlide
+ y: topBarCore.reveal ? 0 : -(root.effectiveBarHeight - 4)
+
+ Behavior on y {
+ NumberAnimation {
+ duration: 200
+ easing.type: Easing.OutCubic
+ }
+ }
+ }
+
+ Item {
+ anchors.fill: parent
+ anchors.topMargin: SettingsData.topBarSpacing
+ anchors.bottomMargin: 0
+ anchors.leftMargin: SettingsData.topBarSpacing
+ anchors.rightMargin: SettingsData.topBarSpacing
+
+ Rectangle {
+ anchors.fill: parent
+ radius: SettingsData.topBarSquareCorners ? 0 : Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, topBarCore.backgroundTransparency)
+ layer.enabled: true
+
+ Rectangle {
+ anchors.fill: parent
+ color: "transparent"
+ border.color: Theme.outlineMedium
+ border.width: 1
+ radius: parent.radius
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ color: Qt.rgba(Theme.surfaceTint.r, Theme.surfaceTint.g, Theme.surfaceTint.b, 0.04)
+ radius: parent.radius
+
+ SequentialAnimation on opacity {
+ running: false
+ loops: Animation.Infinite
+
+ NumberAnimation {
+ to: 0.08
+ duration: Theme.extraLongDuration
+ easing.type: Theme.standardEasing
+ }
+
+ NumberAnimation {
+ to: 0.02
+ duration: Theme.extraLongDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+ }
+
+ layer.effect: MultiEffect {
+ shadowEnabled: true
+ shadowHorizontalOffset: 0
+ shadowVerticalOffset: 4
+ shadowBlur: 0.5 // radius/32, adjusted for visual match
+ shadowColor: Qt.rgba(0, 0, 0, 0.15)
+ shadowOpacity: 0.15
+ }
+ }
+
+ Item {
+ id: topBarContent
+
+ readonly property int availableWidth: width
+ readonly property int launcherButtonWidth: 40
+ readonly property int workspaceSwitcherWidth: 120 // Approximate
+ readonly property int focusedAppMaxWidth: 456 // Fixed width since we don't have focusedApp reference
+ readonly property int estimatedLeftSectionWidth: launcherButtonWidth + workspaceSwitcherWidth + focusedAppMaxWidth + (Theme.spacingXS * 2)
+ readonly property int rightSectionWidth: rightSection.width
+ readonly property int clockWidth: 120 // Approximate clock width
+ readonly property int mediaMaxWidth: 280 // Normal max width
+ readonly property int weatherWidth: 80 // Approximate weather width
+ readonly property bool validLayout: availableWidth > 100 && estimatedLeftSectionWidth > 0 && rightSectionWidth > 0
+ readonly property int clockLeftEdge: (availableWidth - clockWidth) / 2
+ readonly property int clockRightEdge: clockLeftEdge + clockWidth
+ readonly property int leftSectionRightEdge: estimatedLeftSectionWidth
+ readonly property int mediaLeftEdge: clockLeftEdge - mediaMaxWidth - Theme.spacingS
+ readonly property int rightSectionLeftEdge: availableWidth - rightSectionWidth
+ readonly property int leftToClockGap: Math.max(0, clockLeftEdge - leftSectionRightEdge)
+ readonly property int leftToMediaGap: mediaMaxWidth > 0 ? Math.max(0, mediaLeftEdge - leftSectionRightEdge) : leftToClockGap
+ readonly property int mediaToClockGap: mediaMaxWidth > 0 ? Theme.spacingS : 0
+ readonly property int clockToRightGap: validLayout ? Math.max(0, rightSectionLeftEdge - clockRightEdge) : 1000
+ readonly property bool spacingTight: validLayout && (leftToMediaGap < 150 || clockToRightGap < 100)
+ readonly property bool overlapping: validLayout && (leftToMediaGap < 100 || clockToRightGap < 50)
+
+ function getWidgetEnabled(enabled) {
+ return enabled !== false
+ }
+
+ function getWidgetSection(parentItem) {
+ if (!parentItem?.parent) {
+ return "left"
+ }
+ if (parentItem.parent === leftSection) {
+ return "left"
+ }
+ if (parentItem.parent === rightSection) {
+ return "right"
+ }
+ if (parentItem.parent === centerSection) {
+ return "center"
+ }
+ return "left"
+ }
+
+ readonly property var widgetVisibility: ({
+ "cpuUsage": DgopService.dgopAvailable,
+ "memUsage": DgopService.dgopAvailable,
+ "cpuTemp": DgopService.dgopAvailable,
+ "gpuTemp": DgopService.dgopAvailable,
+ "network_speed_monitor": DgopService.dgopAvailable
+ })
+
+ function getWidgetVisible(widgetId) {
+ return widgetVisibility[widgetId] ?? true
+ }
+
+ readonly property var componentMap: ({
+ "launcherButton": launcherButtonComponent,
+ "workspaceSwitcher": workspaceSwitcherComponent,
+ "focusedWindow": focusedWindowComponent,
+ "runningApps": runningAppsComponent,
+ "clock": clockComponent,
+ "music": mediaComponent,
+ "weather": weatherComponent,
+ "systemTray": systemTrayComponent,
+ "privacyIndicator": privacyIndicatorComponent,
+ "clipboard": clipboardComponent,
+ "cpuUsage": cpuUsageComponent,
+ "memUsage": memUsageComponent,
+ "cpuTemp": cpuTempComponent,
+ "gpuTemp": gpuTempComponent,
+ "notificationButton": notificationButtonComponent,
+ "battery": batteryComponent,
+ "controlCenterButton": controlCenterButtonComponent,
+ "idleInhibitor": idleInhibitorComponent,
+ "spacer": spacerComponent,
+ "separator": separatorComponent,
+ "network_speed_monitor": networkComponent,
+ "keyboard_layout_name": keyboardLayoutNameComponent,
+ "vpn": vpnComponent,
+ "notepadButton": notepadButtonComponent
+ })
+
+ function getWidgetComponent(widgetId) {
+ return componentMap[widgetId] || null
+ }
+
+ anchors.fill: parent
+ anchors.leftMargin: Math.max(Theme.spacingXS, SettingsData.topBarInnerPadding * 0.8)
+ anchors.rightMargin: Math.max(Theme.spacingXS, SettingsData.topBarInnerPadding * 0.8)
+ anchors.topMargin: SettingsData.topBarInnerPadding / 2
+ anchors.bottomMargin: SettingsData.topBarInnerPadding / 2
+ clip: true
+
+ Row {
+ id: leftSection
+
+ height: parent.height
+ spacing: SettingsData.topBarNoBackground ? 2 : Theme.spacingXS
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+
+ Repeater {
+ model: SettingsData.topBarLeftWidgetsModel
+
+ Loader {
+ property string widgetId: model.widgetId
+ property var widgetData: model
+ property int spacerSize: model.size || 20
+
+ anchors.verticalCenter: parent ? parent.verticalCenter : undefined
+ active: topBarContent.getWidgetVisible(model.widgetId)
+ sourceComponent: topBarContent.getWidgetComponent(model.widgetId)
+ opacity: topBarContent.getWidgetEnabled(model.enabled) ? 1 : 0
+ asynchronous: false
+ }
+ }
+ }
+
+ Item {
+ id: centerSection
+
+ property var centerWidgets: []
+ property int totalWidgets: 0
+ property real totalWidth: 0
+ property real spacing: SettingsData.topBarNoBackground ? 2 : Theme.spacingXS
+
+ function updateLayout() {
+ if (width <= 0 || height <= 0 || !visible) {
+ Qt.callLater(updateLayout)
+ return
+ }
+
+ centerWidgets = []
+ totalWidgets = 0
+ totalWidth = 0
+
+ for (var i = 0; i < centerRepeater.count; i++) {
+ const item = centerRepeater.itemAt(i)
+ if (item?.active && item.item) {
+ centerWidgets.push(item.item)
+ totalWidgets++
+ totalWidth += item.item.width
+ }
+ }
+
+ if (totalWidgets > 1) {
+ totalWidth += spacing * (totalWidgets - 1)
+ }
+ positionWidgets()
+ }
+
+ function positionWidgets() {
+ if (totalWidgets === 0 || width <= 0) {
+ return
+ }
+
+ const parentCenterX = width / 2
+ const isOdd = totalWidgets % 2 === 1
+
+ centerWidgets.forEach(widget => widget.anchors.horizontalCenter = undefined)
+
+ if (isOdd) {
+ const middleIndex = Math.floor(totalWidgets / 2)
+ const middleWidget = centerWidgets[middleIndex]
+ middleWidget.x = parentCenterX - (middleWidget.width / 2)
+
+ let currentX = middleWidget.x
+ for (var i = middleIndex - 1; i >= 0; i--) {
+ currentX -= (spacing + centerWidgets[i].width)
+ centerWidgets[i].x = currentX
+ }
+
+ currentX = middleWidget.x + middleWidget.width
+ for (var i = middleIndex + 1; i < totalWidgets; i++) {
+ currentX += spacing
+ centerWidgets[i].x = currentX
+ currentX += centerWidgets[i].width
+ }
+ } else {
+ const leftIndex = (totalWidgets / 2) - 1
+ const rightIndex = totalWidgets / 2
+ const halfSpacing = spacing / 2
+
+ centerWidgets[leftIndex].x = parentCenterX - halfSpacing - centerWidgets[leftIndex].width
+ centerWidgets[rightIndex].x = parentCenterX + halfSpacing
+
+ let currentX = centerWidgets[leftIndex].x
+ for (var i = leftIndex - 1; i >= 0; i--) {
+ currentX -= (spacing + centerWidgets[i].width)
+ centerWidgets[i].x = currentX
+ }
+
+ currentX = centerWidgets[rightIndex].x + centerWidgets[rightIndex].width
+ for (var i = rightIndex + 1; i < totalWidgets; i++) {
+ currentX += spacing
+ centerWidgets[i].x = currentX
+ currentX += centerWidgets[i].width
+ }
+ }
+ }
+
+ height: parent.height
+ width: parent.width
+ anchors.centerIn: parent
+ Component.onCompleted: {
+ Qt.callLater(() => {
+ Qt.callLater(updateLayout)
+ })
+ }
+
+ onWidthChanged: {
+ if (width > 0) {
+ Qt.callLater(updateLayout)
+ }
+ }
+
+ onVisibleChanged: {
+ if (visible && width > 0) {
+ Qt.callLater(updateLayout)
+ }
+ }
+
+ Repeater {
+ id: centerRepeater
+
+ model: SettingsData.topBarCenterWidgetsModel
+
+ Loader {
+ property string widgetId: model.widgetId
+ property var widgetData: model
+ property int spacerSize: model.size || 20
+
+ anchors.verticalCenter: parent ? parent.verticalCenter : undefined
+ active: topBarContent.getWidgetVisible(model.widgetId)
+ sourceComponent: topBarContent.getWidgetComponent(model.widgetId)
+ opacity: topBarContent.getWidgetEnabled(model.enabled) ? 1 : 0
+ asynchronous: false
+
+ onLoaded: {
+ if (!item) {
+ return
+ }
+ item.onWidthChanged.connect(centerSection.updateLayout)
+ if (model.widgetId === "spacer") {
+ item.spacerSize = Qt.binding(() => model.size || 20)
+ }
+ Qt.callLater(centerSection.updateLayout)
+ }
+ onActiveChanged: {
+ Qt.callLater(centerSection.updateLayout)
+ }
+ }
+ }
+
+ Connections {
+ function onCountChanged() {
+ Qt.callLater(centerSection.updateLayout)
+ }
+
+ target: SettingsData.topBarCenterWidgetsModel
+ }
+ }
+
+ Row {
+ id: rightSection
+
+ height: parent.height
+ spacing: SettingsData.topBarNoBackground ? 2 : Theme.spacingXS
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+
+ Repeater {
+ model: SettingsData.topBarRightWidgetsModel
+
+ Loader {
+ property string widgetId: model.widgetId
+ property var widgetData: model
+ property int spacerSize: model.size || 20
+
+ anchors.verticalCenter: parent ? parent.verticalCenter : undefined
+ active: topBarContent.getWidgetVisible(model.widgetId)
+ sourceComponent: topBarContent.getWidgetComponent(model.widgetId)
+ opacity: topBarContent.getWidgetEnabled(model.enabled) ? 1 : 0
+ asynchronous: false
+ }
+ }
+ }
+
+ Component {
+ id: clipboardComponent
+
+ Rectangle {
+ readonly property real horizontalPadding: SettingsData.topBarNoBackground ? 0 : Math.max(Theme.spacingXS, Theme.spacingS * (root.widgetHeight / 30))
+ width: clipboardIcon.width + horizontalPadding * 2
+ height: root.widgetHeight
+ radius: SettingsData.topBarNoBackground ? 0 : Theme.cornerRadius
+ color: {
+ if (SettingsData.topBarNoBackground) {
+ return "transparent"
+ }
+ const baseColor = clipboardArea.containsMouse ? Theme.primaryHover : Theme.secondaryHover
+ return Qt.rgba(baseColor.r, baseColor.g, baseColor.b, baseColor.a * Theme.widgetTransparency)
+ }
+
+ DankIcon {
+ id: clipboardIcon
+ anchors.centerIn: parent
+ name: "content_paste"
+ size: Theme.iconSize - 6
+ color: Theme.surfaceText
+ }
+
+ MouseArea {
+ id: clipboardArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ clipboardHistoryModalPopup.toggle()
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+ }
+ }
+
+ Component {
+ id: launcherButtonComponent
+
+ LauncherButton {
+ isActive: false
+ widgetHeight: root.widgetHeight
+ barHeight: root.effectiveBarHeight
+ section: topBarContent.getWidgetSection(parent)
+ popupTarget: appDrawerLoader.item
+ parentScreen: root.screen
+ onClicked: {
+ appDrawerLoader.active = true
+ appDrawerLoader.item?.toggle()
+ }
+ }
+ }
+
+ Component {
+ id: workspaceSwitcherComponent
+
+ WorkspaceSwitcher {
+ screenName: root.screenName
+ widgetHeight: root.widgetHeight
+ }
+ }
+
+ Component {
+ id: focusedWindowComponent
+
+ FocusedApp {
+ availableWidth: topBarContent.leftToMediaGap
+ widgetHeight: root.widgetHeight
+ }
+ }
+
+ Component {
+ id: runningAppsComponent
+
+ RunningApps {
+ widgetHeight: root.widgetHeight
+ section: topBarContent.getWidgetSection(parent)
+ parentScreen: root.screen
+ topBar: topBarContent
+ }
+ }
+
+ Component {
+ id: clockComponent
+
+ Clock {
+ compactMode: topBarContent.overlapping
+ barHeight: root.effectiveBarHeight
+ widgetHeight: root.widgetHeight
+ section: topBarContent.getWidgetSection(parent) || "center"
+ popupTarget: {
+ dankDashPopoutLoader.active = true
+ return dankDashPopoutLoader.item
+ }
+ parentScreen: root.screen
+ onClockClicked: {
+ dankDashPopoutLoader.active = true
+ if (dankDashPopoutLoader.item) {
+ dankDashPopoutLoader.item.dashVisible = !dankDashPopoutLoader.item.dashVisible
+ dankDashPopoutLoader.item.currentTabIndex = 0 // Overview tab
+ }
+ }
+ }
+ }
+
+ Component {
+ id: mediaComponent
+
+ Media {
+ compactMode: topBarContent.spacingTight || topBarContent.overlapping
+ barHeight: root.effectiveBarHeight
+ widgetHeight: root.widgetHeight
+ section: topBarContent.getWidgetSection(parent) || "center"
+ popupTarget: {
+ dankDashPopoutLoader.active = true
+ return dankDashPopoutLoader.item
+ }
+ parentScreen: root.screen
+ onClicked: {
+ dankDashPopoutLoader.active = true
+ if (dankDashPopoutLoader.item) {
+ dankDashPopoutLoader.item.dashVisible = !dankDashPopoutLoader.item.dashVisible
+ dankDashPopoutLoader.item.currentTabIndex = 1 // Media tab
+ }
+ }
+ }
+ }
+
+ Component {
+ id: weatherComponent
+
+ Weather {
+ barHeight: root.effectiveBarHeight
+ widgetHeight: root.widgetHeight
+ section: topBarContent.getWidgetSection(parent) || "center"
+ popupTarget: {
+ dankDashPopoutLoader.active = true
+ return dankDashPopoutLoader.item
+ }
+ parentScreen: root.screen
+ onClicked: {
+ dankDashPopoutLoader.active = true
+ if (dankDashPopoutLoader.item) {
+ dankDashPopoutLoader.item.dashVisible = !dankDashPopoutLoader.item.dashVisible
+ dankDashPopoutLoader.item.currentTabIndex = 2 // Weather tab
+ }
+ }
+ }
+ }
+
+ Component {
+ id: systemTrayComponent
+
+ SystemTrayBar {
+ parentWindow: root
+ parentScreen: root.screen
+ widgetHeight: root.widgetHeight
+ visible: SettingsData.getFilteredScreens("systemTray").includes(root.screen)
+ }
+ }
+
+ Component {
+ id: privacyIndicatorComponent
+
+ PrivacyIndicator {
+ widgetHeight: root.widgetHeight
+ section: topBarContent.getWidgetSection(parent) || "right"
+ parentScreen: root.screen
+ }
+ }
+
+ Component {
+ id: cpuUsageComponent
+
+ CpuMonitor {
+ barHeight: root.effectiveBarHeight
+ widgetHeight: root.widgetHeight
+ section: topBarContent.getWidgetSection(parent) || "right"
+ popupTarget: {
+ processListPopoutLoader.active = true
+ return processListPopoutLoader.item
+ }
+ parentScreen: root.screen
+ toggleProcessList: () => {
+ processListPopoutLoader.active = true
+ return processListPopoutLoader.item?.toggle()
+ }
+ }
+ }
+
+ Component {
+ id: memUsageComponent
+
+ RamMonitor {
+ barHeight: root.effectiveBarHeight
+ widgetHeight: root.widgetHeight
+ section: topBarContent.getWidgetSection(parent) || "right"
+ popupTarget: {
+ processListPopoutLoader.active = true
+ return processListPopoutLoader.item
+ }
+ parentScreen: root.screen
+ toggleProcessList: () => {
+ processListPopoutLoader.active = true
+ return processListPopoutLoader.item?.toggle()
+ }
+ }
+ }
+
+ Component {
+ id: cpuTempComponent
+
+ CpuTemperature {
+ barHeight: root.effectiveBarHeight
+ widgetHeight: root.widgetHeight
+ section: topBarContent.getWidgetSection(parent) || "right"
+ popupTarget: {
+ processListPopoutLoader.active = true
+ return processListPopoutLoader.item
+ }
+ parentScreen: root.screen
+ toggleProcessList: () => {
+ processListPopoutLoader.active = true
+ return processListPopoutLoader.item?.toggle()
+ }
+ }
+ }
+
+ Component {
+ id: gpuTempComponent
+
+ GpuTemperature {
+ barHeight: root.effectiveBarHeight
+ widgetHeight: root.widgetHeight
+ section: topBarContent.getWidgetSection(parent) || "right"
+ popupTarget: {
+ processListPopoutLoader.active = true
+ return processListPopoutLoader.item
+ }
+ parentScreen: root.screen
+ widgetData: parent.widgetData
+ toggleProcessList: () => {
+ processListPopoutLoader.active = true
+ return processListPopoutLoader.item?.toggle()
+ }
+ }
+ }
+
+ Component {
+ id: networkComponent
+
+ NetworkMonitor {}
+ }
+
+ Component {
+ id: notificationButtonComponent
+
+ NotificationCenterButton {
+ hasUnread: root.notificationCount > 0
+ isActive: notificationCenterLoader.item ? notificationCenterLoader.item.shouldBeVisible : false
+ widgetHeight: root.widgetHeight
+ barHeight: root.effectiveBarHeight
+ section: topBarContent.getWidgetSection(parent) || "right"
+ popupTarget: {
+ notificationCenterLoader.active = true
+ return notificationCenterLoader.item
+ }
+ parentScreen: root.screen
+ onClicked: {
+ notificationCenterLoader.active = true
+ notificationCenterLoader.item?.toggle()
+ }
+ }
+ }
+
+ Component {
+ id: batteryComponent
+
+ Battery {
+ batteryPopupVisible: batteryPopoutLoader.item ? batteryPopoutLoader.item.shouldBeVisible : false
+ widgetHeight: root.widgetHeight
+ barHeight: root.effectiveBarHeight
+ section: topBarContent.getWidgetSection(parent) || "right"
+ popupTarget: {
+ batteryPopoutLoader.active = true
+ return batteryPopoutLoader.item
+ }
+ parentScreen: root.screen
+ onToggleBatteryPopup: {
+ batteryPopoutLoader.active = true
+ batteryPopoutLoader.item?.toggle()
+ }
+ }
+ }
+
+ Component {
+ id: vpnComponent
+
+ Vpn {
+ widgetHeight: root.widgetHeight
+ barHeight: root.effectiveBarHeight
+ section: topBarContent.getWidgetSection(parent) || "right"
+ popupTarget: {
+ vpnPopoutLoader.active = true
+ return vpnPopoutLoader.item
+ }
+ parentScreen: root.screen
+ onToggleVpnPopup: {
+ vpnPopoutLoader.active = true
+ vpnPopoutLoader.item?.toggle()
+ }
+ }
+ }
+
+ Component {
+ id: controlCenterButtonComponent
+
+ ControlCenterButton {
+ isActive: controlCenterLoader.item ? controlCenterLoader.item.shouldBeVisible : false
+ widgetHeight: root.widgetHeight
+ barHeight: root.effectiveBarHeight
+ section: topBarContent.getWidgetSection(parent) || "right"
+ popupTarget: {
+ controlCenterLoader.active = true
+ return controlCenterLoader.item
+ }
+ parentScreen: root.screen
+ widgetData: parent.widgetData
+ onClicked: {
+ controlCenterLoader.active = true
+ if (!controlCenterLoader.item) {
+ return
+ }
+ controlCenterLoader.item.triggerScreen = root.screen
+ controlCenterLoader.item.toggle()
+ if (controlCenterLoader.item.shouldBeVisible && NetworkService.wifiEnabled) {
+ NetworkService.scanWifi()
+ }
+ }
+ }
+ }
+
+ Component {
+ id: idleInhibitorComponent
+
+ IdleInhibitor {
+ widgetHeight: root.widgetHeight
+ section: topBarContent.getWidgetSection(parent) || "right"
+ parentScreen: root.screen
+ }
+ }
+
+ Component {
+ id: spacerComponent
+
+ Item {
+ width: parent.spacerSize || 20
+ height: root.widgetHeight
+
+ Rectangle {
+ anchors.fill: parent
+ color: "transparent"
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.1)
+ border.width: 1
+ radius: 2
+ visible: false
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ onEntered: parent.visible = true
+ onExited: parent.visible = false
+ }
+ }
+ }
+ }
+
+ Component {
+ id: separatorComponent
+
+ Rectangle {
+ width: 1
+ height: root.widgetHeight * 0.67
+ color: Theme.outline
+ opacity: 0.3
+ }
+ }
+
+ Component {
+ id: keyboardLayoutNameComponent
+
+ KeyboardLayoutName {}
+ }
+
+ Component {
+ id: notepadButtonComponent
+
+ NotepadButton {
+ property var notepadInstance: topBarCore.notepadInstance
+ isActive: notepadInstance?.notepadVisible ?? false
+ widgetHeight: root.widgetHeight
+ barHeight: root.effectiveBarHeight
+ section: topBarContent.getWidgetSection(parent) || "right"
+ popupTarget: notepadInstance
+ parentScreen: root.screen
+ onClicked: {
+ if (notepadInstance) {
+ notepadInstance.toggle()
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/TopBar/Vpn.qml b/quickshell/.config/quickshell/Modules/TopBar/Vpn.qml
new file mode 100644
index 0000000..4aa3626
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/TopBar/Vpn.qml
@@ -0,0 +1,113 @@
+import QtQuick
+import Quickshell
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Rectangle {
+ id: root
+
+ // Passed in by TopBar
+ property int widgetHeight: 28
+ property int barHeight: 32
+ property string section: "right"
+ property var popupTarget: null
+ property var parentScreen: null
+ readonly property real horizontalPadding: SettingsData.topBarNoBackground ? 0 : Math.max(Theme.spacingXS, Theme.spacingS * (widgetHeight / 30))
+
+ signal toggleVpnPopup()
+
+ width: Theme.iconSize + horizontalPadding * 2
+ height: widgetHeight
+ radius: SettingsData.topBarNoBackground ? 0 : Theme.cornerRadius
+ color: {
+ if (SettingsData.topBarNoBackground) {
+ return "transparent";
+ }
+
+ const base = clickArea.containsMouse || (popupTarget && popupTarget.shouldBeVisible) ? Theme.primaryPressed : Theme.secondaryHover;
+ return Qt.rgba(base.r, base.g, base.b, base.a * Theme.widgetTransparency);
+ }
+
+ DankIcon {
+ id: icon
+
+ name: VpnService.isBusy ? "sync" : (VpnService.connected ? "vpn_lock" : "vpn_key_off")
+ size: Theme.iconSize - 6
+ color: VpnService.connected ? Theme.primary : Theme.surfaceText
+ anchors.centerIn: parent
+
+ RotationAnimation on rotation {
+ running: VpnService.isBusy
+ loops: Animation.Infinite
+ from: 0
+ to: 360
+ duration: 900
+ }
+
+ }
+
+ MouseArea {
+ id: clickArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onPressed: {
+ if (popupTarget && popupTarget.setTriggerPosition) {
+ const globalPos = mapToGlobal(0, 0);
+ const currentScreen = parentScreen || Screen;
+ const screenX = currentScreen.x || 0;
+ const relativeX = globalPos.x - screenX;
+ popupTarget.setTriggerPosition(relativeX, barHeight + Theme.spacingXS, width, section, currentScreen);
+ }
+ root.toggleVpnPopup();
+ }
+ }
+
+ Rectangle {
+ id: tooltip
+
+ width: Math.max(120, tooltipText.contentWidth + Theme.spacingM * 2)
+ height: tooltipText.contentHeight + Theme.spacingS * 2
+ radius: Theme.cornerRadius
+ color: Theme.surfaceContainer
+ border.color: Theme.surfaceVariantAlpha
+ border.width: 1
+ visible: clickArea.containsMouse && !(popupTarget && popupTarget.shouldBeVisible)
+ anchors.bottom: parent.top
+ anchors.bottomMargin: Theme.spacingS
+ anchors.horizontalCenter: parent.horizontalCenter
+ opacity: clickArea.containsMouse ? 1 : 0
+
+ Text {
+ id: tooltipText
+
+ anchors.centerIn: parent
+ text: {
+ if (!VpnService.connected) {
+ return "VPN Disconnected";
+ }
+
+ const names = VpnService.activeNames || [];
+ if (names.length <= 1) {
+ return "VPN Connected • " + (names[0] || "");
+ }
+
+ return "VPN Connected • " + names[0] + " +" + (names.length - 1);
+ }
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ }
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modules/TopBar/VpnPopout.qml b/quickshell/.config/quickshell/Modules/TopBar/VpnPopout.qml
new file mode 100644
index 0000000..7145bf4
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/TopBar/VpnPopout.qml
@@ -0,0 +1,418 @@
+// No external details import; content inlined for consistency
+
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import Quickshell
+import Quickshell.Wayland
+import Quickshell.Widgets
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+DankPopout {
+ id: root
+
+ property string triggerSection: "right"
+ property var triggerScreen: null
+
+ function setTriggerPosition(x, y, width, section, screen) {
+ triggerX = x;
+ triggerY = y;
+ triggerWidth = width;
+ triggerSection = section;
+ triggerScreen = screen;
+ }
+
+ popupWidth: 360
+ popupHeight: Math.min(Screen.height - 100, contentLoader.item ? contentLoader.item.implicitHeight : 260)
+ triggerX: Screen.width - 380 - Theme.spacingL
+ triggerY: Theme.barHeight - 4 + SettingsData.topBarSpacing + Theme.spacingS
+ triggerWidth: 70
+ positioning: "center"
+ screen: triggerScreen
+ shouldBeVisible: false
+ visible: shouldBeVisible
+
+ content: Component {
+ Rectangle {
+ id: content
+
+ implicitHeight: contentColumn.height + Theme.spacingL * 2
+ color: Theme.popupBackground()
+ radius: Theme.cornerRadius
+ border.color: Theme.outlineMedium
+ border.width: 1
+ antialiasing: true
+ smooth: true
+ focus: true
+ Keys.onPressed: function(event) {
+ if (event.key === Qt.Key_Escape) {
+ root.close();
+ event.accepted = true;
+ }
+ }
+
+ // Outer subtle shadow rings to match BatteryPopout
+ Rectangle {
+ anchors.fill: parent
+ anchors.margins: -3
+ color: "transparent"
+ radius: parent.radius + 3
+ border.color: Qt.rgba(0, 0, 0, 0.05)
+ border.width: 1
+ z: -3
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ anchors.margins: -2
+ color: "transparent"
+ radius: parent.radius + 2
+ border.color: Theme.shadowMedium
+ border.width: 1
+ z: -2
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ color: "transparent"
+ border.color: Theme.outlineStrong
+ border.width: 1
+ radius: parent.radius
+ z: -1
+ }
+
+ Column {
+ id: contentColumn
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingM
+
+ Item {
+ width: parent.width
+ height: 32
+
+ StyledText {
+ text: "VPN Connections"
+ font.pixelSize: Theme.fontSizeLarge
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ // Close button (matches BatteryPopout)
+ Rectangle {
+ width: 32
+ height: 32
+ radius: 16
+ color: closeArea.containsMouse ? Theme.errorHover : "transparent"
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "close"
+ size: Theme.iconSize - 4
+ color: closeArea.containsMouse ? Theme.error : Theme.surfaceText
+ }
+
+ MouseArea {
+ id: closeArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onPressed: root.close()
+ }
+
+ }
+
+ }
+
+ // Inlined VPN details
+ Rectangle {
+ id: vpnDetail
+
+ width: parent.width
+ implicitHeight: detailsColumn.implicitHeight + Theme.spacingM * 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceContainerHigh.r, Theme.surfaceContainerHigh.g, Theme.surfaceContainerHigh.b, Theme.getContentBackgroundAlpha() * 0.6)
+ border.color: Theme.outlineStrong
+ border.width: 1
+ clip: true
+
+ Column {
+ id: detailsColumn
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingM
+ spacing: Theme.spacingS
+
+ RowLayout {
+ spacing: Theme.spacingS
+ width: parent.width
+
+ StyledText {
+ text: {
+ if (!VpnService.connected) {
+ return "Active: None";
+ }
+
+ const names = VpnService.activeNames || [];
+ if (names.length <= 1) {
+ return "Active: " + (names[0] || "VPN");
+ }
+
+ return "Active: " + names[0] + " +" + (names.length - 1);
+ }
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ }
+
+ Item {
+ Layout.fillWidth: true
+ height: 1
+ }
+
+ // Removed Quick Connect for clarity
+ Item {
+ width: 1
+ height: 1
+ }
+
+ // Disconnect all (shown only when any active)
+ Rectangle {
+ height: 28
+ radius: 14
+ color: discAllArea.containsMouse ? Theme.errorHover : Theme.surfaceLight
+ visible: VpnService.connected
+ width: 130
+ Layout.alignment: Qt.AlignVCenter | Qt.AlignRight
+ border.width: 1
+ border.color: Theme.outlineLight
+
+ Row {
+ anchors.centerIn: parent
+ spacing: Theme.spacingXS
+
+ DankIcon {
+ name: "link_off"
+ size: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ }
+
+ StyledText {
+ text: "Disconnect"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ }
+
+ }
+
+ MouseArea {
+ id: discAllArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: VpnService.disconnectAllActive()
+ }
+
+ }
+
+ }
+
+ Rectangle {
+ height: 1
+ width: parent.width
+ color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
+ }
+
+ DankFlickable {
+ width: parent.width
+ height: 160
+ contentHeight: listCol.height
+ clip: true
+
+ Column {
+ id: listCol
+
+ width: parent.width
+ spacing: Theme.spacingXS
+
+ Item {
+ width: parent.width
+ height: VpnService.profiles.length === 0 ? 120 : 0
+ visible: height > 0
+
+ Column {
+ anchors.centerIn: parent
+ spacing: Theme.spacingS
+
+ DankIcon {
+ name: "playlist_remove"
+ size: 36
+ color: Theme.surfaceVariantText
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+
+ StyledText {
+ text: "No VPN profiles found"
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceVariantText
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+
+ StyledText {
+ text: "Add a VPN in NetworkManager"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+
+ }
+
+ }
+
+ Repeater {
+ model: VpnService.profiles
+
+ delegate: Rectangle {
+ required property var modelData
+
+ width: parent ? parent.width : 300
+ height: 50
+ radius: Theme.cornerRadius
+ color: rowArea.containsMouse ? Theme.primaryHoverLight : (VpnService.isActiveUuid(modelData.uuid) ? Theme.primaryPressed : Theme.surfaceLight)
+ border.width: VpnService.isActiveUuid(modelData.uuid) ? 2 : 1
+ border.color: VpnService.isActiveUuid(modelData.uuid) ? Theme.primary : Theme.outlineLight
+
+ RowLayout {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Theme.spacingM
+ spacing: Theme.spacingS
+
+ DankIcon {
+ name: VpnService.isActiveUuid(modelData.uuid) ? "vpn_lock" : "vpn_key_off"
+ size: Theme.iconSize - 4
+ color: VpnService.isActiveUuid(modelData.uuid) ? Theme.primary : Theme.surfaceText
+ Layout.alignment: Qt.AlignVCenter
+ }
+
+ Column {
+ spacing: 2
+ Layout.alignment: Qt.AlignVCenter
+
+ StyledText {
+ text: modelData.name
+ font.pixelSize: Theme.fontSizeMedium
+ color: VpnService.isActiveUuid(modelData.uuid) ? Theme.primary : Theme.surfaceText
+ }
+
+ StyledText {
+ text: {
+ if (modelData.type === "wireguard") {
+ return "WireGuard";
+ }
+
+ const svc = modelData.serviceType || "";
+ if (svc.indexOf("openvpn") !== -1) {
+ return "OpenVPN";
+ }
+
+ if (svc.indexOf("wireguard") !== -1) {
+ return "WireGuard (plugin)";
+ }
+
+ if (svc.indexOf("openconnect") !== -1) {
+ return "OpenConnect";
+ }
+
+ if (svc.indexOf("fortissl") !== -1 || svc.indexOf("forti") !== -1) {
+ return "Fortinet";
+ }
+
+ if (svc.indexOf("strongswan") !== -1) {
+ return "IPsec (strongSwan)";
+ }
+
+ if (svc.indexOf("libreswan") !== -1) {
+ return "IPsec (Libreswan)";
+ }
+
+ if (svc.indexOf("l2tp") !== -1) {
+ return "L2TP/IPsec";
+ }
+
+ if (svc.indexOf("pptp") !== -1) {
+ return "PPTP";
+ }
+
+ if (svc.indexOf("vpnc") !== -1) {
+ return "Cisco (vpnc)";
+ }
+
+ if (svc.indexOf("sstp") !== -1) {
+ return "SSTP";
+ }
+
+ if (svc) {
+ const parts = svc.split('.');
+ return parts[parts.length - 1];
+ }
+ return "VPN";
+ }
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceTextMedium
+ }
+
+ }
+
+ Item {
+ Layout.fillWidth: true
+ height: 1
+ }
+
+ }
+
+ MouseArea {
+ id: rowArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: VpnService.toggle(modelData.uuid)
+ }
+
+ }
+
+ }
+
+ Item {
+ height: 1
+ width: 1
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modules/TopBar/Weather.qml b/quickshell/.config/quickshell/Modules/TopBar/Weather.qml
new file mode 100644
index 0000000..afd348a
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/TopBar/Weather.qml
@@ -0,0 +1,98 @@
+import QtQuick
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Rectangle {
+ id: root
+
+ property string section: "center"
+ property var popupTarget: null
+ property var parentScreen: null
+ property real barHeight: 48
+ property real widgetHeight: 30
+ readonly property real horizontalPadding: SettingsData.topBarNoBackground ? 2 : Theme.spacingS
+
+ signal clicked()
+
+ visible: SettingsData.weatherEnabled
+ width: visible ? Math.min(100, weatherRow.implicitWidth + horizontalPadding * 2) : 0
+ height: widgetHeight
+ radius: SettingsData.topBarNoBackground ? 0 : Theme.cornerRadius
+ color: {
+ if (SettingsData.topBarNoBackground) {
+ return "transparent";
+ }
+
+ const baseColor = weatherArea.containsMouse ? Theme.primaryHover : Theme.surfaceTextHover;
+ return Qt.rgba(baseColor.r, baseColor.g, baseColor.b, baseColor.a * Theme.widgetTransparency);
+ }
+
+ Ref {
+ service: WeatherService
+ }
+
+ Row {
+ id: weatherRow
+
+ anchors.centerIn: parent
+ spacing: Theme.spacingXS
+
+ DankIcon {
+ name: WeatherService.getWeatherIcon(WeatherService.weather.wCode)
+ size: Theme.iconSize - 4
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: {
+ const temp = SettingsData.useFahrenheit ? WeatherService.weather.tempF : WeatherService.weather.temp;
+ if (temp === undefined || temp === null || temp === 0) {
+ return "--°" + (SettingsData.useFahrenheit ? "F" : "C");
+ }
+
+ return temp + "°" + (SettingsData.useFahrenheit ? "F" : "C");
+ }
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ }
+
+ MouseArea {
+ id: weatherArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onPressed: {
+ if (popupTarget && popupTarget.setTriggerPosition) {
+ const globalPos = mapToGlobal(0, 0);
+ const currentScreen = parentScreen || Screen;
+ const screenX = currentScreen.x || 0;
+ const relativeX = globalPos.x - screenX;
+ popupTarget.setTriggerPosition(relativeX, barHeight + Theme.spacingXS, width, section, currentScreen);
+ }
+ root.clicked();
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+
+ }
+
+ Behavior on width {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+
+ }
+
+}
diff --git a/quickshell/.config/quickshell/Modules/TopBar/WorkspaceSwitcher.qml b/quickshell/.config/quickshell/Modules/TopBar/WorkspaceSwitcher.qml
new file mode 100644
index 0000000..59cabed
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/TopBar/WorkspaceSwitcher.qml
@@ -0,0 +1,379 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell
+import Quickshell.Widgets
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Rectangle {
+ id: root
+
+ property string screenName: ""
+ property real widgetHeight: 30
+ property int currentWorkspace: {
+ if (CompositorService.isNiri) {
+ return getNiriActiveWorkspace()
+ }
+ return 1
+ }
+ property var workspaceList: {
+ if (CompositorService.isNiri) {
+ const baseList = getNiriWorkspaces()
+ return SettingsData.showWorkspacePadding ? padWorkspaces(baseList) : baseList
+ }
+ return [1]
+ }
+
+ function getWorkspaceIcons(ws) {
+ if (!SettingsData.showWorkspaceApps || !ws) {
+ return []
+ }
+
+ let targetWorkspaceId
+ if (CompositorService.isNiri) {
+ const wsNumber = typeof ws === "number" ? ws : -1
+ if (wsNumber <= 0) {
+ return []
+ }
+ const workspace = NiriService.allWorkspaces.find(w => w.idx + 1 === wsNumber && w.output === root.screenName)
+ if (!workspace) {
+ return []
+ }
+ targetWorkspaceId = workspace.id
+ } else {
+ return []
+ }
+
+ const wins = CompositorService.isNiri ? (NiriService.windows || []) : CompositorService.sortedToplevels
+
+
+ const byApp = {}
+ const isActiveWs = CompositorService.isNiri ? NiriService.allWorkspaces.some(ws => ws.id === targetWorkspaceId && ws.is_active) : targetWorkspaceId === root.currentWorkspace
+
+ wins.forEach((w, i) => {
+ if (!w) {
+ return
+ }
+
+ let winWs = null
+ if (CompositorService.isNiri) {
+ winWs = w.workspace_id
+ }
+
+
+ if (winWs === undefined || winWs === null || winWs !== targetWorkspaceId) {
+ return
+ }
+
+ const keyBase = (w.app_id || w.appId || w.class || w.windowClass || "unknown")
+ const key = isActiveWs ? `${keyBase}_${i}` : keyBase
+
+ if (!byApp[key]) {
+ const moddedId = Paths.moddedAppId(keyBase)
+ //console.log(`moddedId = ${moddedId}`)
+ const isSteamApp = moddedId.toLowerCase().includes("steam_app")
+ const icon = isSteamApp ? "" : DesktopService.resolveIconPath(moddedId)
+ byApp[key] = {
+ "type": "icon",
+ "icon": icon,
+ "isSteamApp": isSteamApp,
+ "active": !!(w.activated || (CompositorService.isNiri && w.is_focused)),
+ "count": 1,
+ "windowId": w.address || w.id,
+ "fallbackText": w.appId || w.class || w.title || ""
+ }
+ } else {
+ byApp[key].count++
+ if (w.activated || (CompositorService.isNiri && w.is_focused)) {
+ byApp[key].active = true
+ }
+ }
+ })
+
+ return Object.values(byApp)
+ }
+
+ function padWorkspaces(list) {
+ const padded = list.slice()
+ const placeholder = -1
+ while (padded.length < 3) {
+ padded.push(placeholder)
+ }
+ return padded
+ }
+
+ function getNiriWorkspaces() {
+ if (NiriService.allWorkspaces.length === 0) {
+ return [1, 2]
+ }
+
+ if (!root.screenName || !SettingsData.workspacesPerMonitor) {
+ return NiriService.getCurrentOutputWorkspaceNumbers()
+ }
+
+ const displayWorkspaces = NiriService.allWorkspaces.filter(ws => ws.output === root.screenName).map(ws => ws.idx + 1)
+ return displayWorkspaces.length > 0 ? displayWorkspaces : [1, 2]
+ }
+
+ function getNiriActiveWorkspace() {
+ if (NiriService.allWorkspaces.length === 0) {
+ return 1
+ }
+
+ if (!root.screenName || !SettingsData.workspacesPerMonitor) {
+ return NiriService.getCurrentWorkspaceNumber()
+ }
+
+ const activeWs = NiriService.allWorkspaces.find(ws => ws.output === root.screenName && ws.is_active)
+ return activeWs ? activeWs.idx + 1 : 1
+ }
+
+ readonly property real padding: (widgetHeight - workspaceRow.implicitHeight) / 2
+
+ function getRealWorkspaces() {
+ return root.workspaceList.filter(ws => {
+ return ws !== -1
+ })
+ }
+
+ function switchWorkspace(direction) {
+ if (CompositorService.isNiri) {
+ const realWorkspaces = getRealWorkspaces()
+ if (realWorkspaces.length < 2) {
+ return
+ }
+
+ const currentIndex = realWorkspaces.findIndex(ws => ws === root.currentWorkspace)
+ const validIndex = currentIndex === -1 ? 0 : currentIndex
+ const nextIndex = direction > 0 ? (validIndex + 1) % realWorkspaces.length : (validIndex - 1 + realWorkspaces.length) % realWorkspaces.length
+
+ NiriService.switchToWorkspace(realWorkspaces[nextIndex] - 1)
+ }
+ }
+
+ width: workspaceRow.implicitWidth + padding * 2
+ height: widgetHeight
+ radius: SettingsData.topBarNoBackground ? 0 : Theme.cornerRadius
+ color: {
+ if (SettingsData.topBarNoBackground)
+ return "transparent"
+ const baseColor = Theme.surfaceTextHover
+ return Qt.rgba(baseColor.r, baseColor.g, baseColor.b, baseColor.a * Theme.widgetTransparency)
+ }
+ visible: CompositorService.isNiri
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ acceptedButtons: Qt.NoButton
+
+ property real scrollAccumulator: 0
+ property real touchpadThreshold: 500
+
+ onWheel: wheel => {
+ const deltaY = wheel.angleDelta.y
+ const isMouseWheel = Math.abs(deltaY) >= 120 && (Math.abs(deltaY) % 120) === 0
+ const direction = deltaY < 0 ? 1 : -1
+
+ if (isMouseWheel) {
+ switchWorkspace(direction)
+ } else {
+ scrollAccumulator += deltaY
+
+ if (Math.abs(scrollAccumulator) >= touchpadThreshold) {
+ const touchDirection = scrollAccumulator < 0 ? 1 : -1
+ switchWorkspace(touchDirection)
+ scrollAccumulator = 0
+ }
+ }
+
+ wheel.accepted = true
+ }
+ }
+
+ Row {
+ id: workspaceRow
+
+ anchors.centerIn: parent
+ spacing: Theme.spacingS
+
+ Repeater {
+ model: root.workspaceList
+
+ Rectangle {
+ property bool isActive: {
+ return modelData === root.currentWorkspace
+ }
+ property bool isPlaceholder: {
+ return modelData === -1
+ }
+ property bool isHovered: mouseArea.containsMouse
+ property var workspaceData: {
+ if (isPlaceholder) {
+ return null
+ }
+
+ if (CompositorService.isNiri) {
+ return NiriService.allWorkspaces.find(ws => ws.idx + 1 === modelData && ws.output === root.screenName) || null
+ }
+ return null
+ }
+ property var iconData: workspaceData?.name ? SettingsData.getWorkspaceNameIcon(workspaceData.name) : null
+ property bool hasIcon: iconData !== null
+ property var icons: SettingsData.showWorkspaceApps ? root.getWorkspaceIcons((modelData === -1 ? null : modelData)) : []
+
+ width: {
+ if (SettingsData.showWorkspaceApps) {
+ if (icons.length > 0) {
+ return isActive ? widgetHeight * 1.0 + Theme.spacingXS + contentRow.implicitWidth : widgetHeight * 0.8 + contentRow.implicitWidth
+ } else {
+ return isActive ? widgetHeight * 1.0 + Theme.spacingXS : widgetHeight * 0.8
+ }
+ }
+ return isActive ? widgetHeight * 1.2 + Theme.spacingXS : widgetHeight * 0.8
+ }
+ height: SettingsData.showWorkspaceApps ? widgetHeight * 0.8 : widgetHeight * 0.6
+ radius: height / 2
+ color: isActive ? Theme.primary : isPlaceholder ? Theme.surfaceTextLight : isHovered ? Theme.outlineButton : Theme.surfaceTextAlpha
+
+ MouseArea {
+ id: mouseArea
+
+ anchors.fill: parent
+ hoverEnabled: !isPlaceholder
+ cursorShape: isPlaceholder ? Qt.ArrowCursor : Qt.PointingHandCursor
+ enabled: !isPlaceholder
+ onClicked: {
+ if (isPlaceholder) {
+ return
+ }
+
+ if (CompositorService.isNiri) {
+ NiriService.switchToWorkspace(modelData - 1)
+ }
+ }
+ }
+
+ Row {
+ id: contentRow
+ anchors.centerIn: parent
+ spacing: 4
+ visible: SettingsData.showWorkspaceApps && icons.length > 0
+
+ Repeater {
+ model: icons.slice(0, SettingsData.maxWorkspaceIcons)
+ delegate: Item {
+ width: 18
+ height: 18
+
+ IconImage {
+ id: appIcon
+ property var windowId: modelData.windowId
+ anchors.fill: parent
+ source: modelData.icon
+ opacity: modelData.active ? 1.0 : appMouseArea.containsMouse ? 0.8 : 0.6
+ visible: !modelData.isSteamApp
+ }
+
+ DankIcon {
+ anchors.centerIn: parent
+ size: 18
+ name: "sports_esports"
+ color: Theme.surfaceText
+ opacity: modelData.active ? 1.0 : appMouseArea.containsMouse ? 0.8 : 0.6
+ visible: modelData.isSteamApp
+ }
+
+ MouseArea {
+ id: appMouseArea
+ hoverEnabled: true
+ anchors.fill: parent
+ enabled: isActive
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ if (CompositorService.isNiri) {
+ NiriService.focusWindow(appIcon.windowId)
+ }
+ }
+ }
+
+ Rectangle {
+ visible: modelData.count > 1 && !isActive
+ width: 12
+ height: 12
+ radius: 6
+ color: "black"
+ border.color: "white"
+ border.width: 1
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ z: 2
+
+ Text {
+ anchors.centerIn: parent
+ text: modelData.count
+ font.pixelSize: 8
+ color: "white"
+ }
+ }
+ }
+ }
+ }
+
+ DankIcon {
+ visible: hasIcon && iconData.type === "icon" && (!SettingsData.showWorkspaceApps || icons.length === 0)
+ anchors.centerIn: parent
+ name: (hasIcon && iconData.type === "icon") ? iconData.value : ""
+ size: Theme.fontSizeSmall
+ color: isActive ? Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.95) : Theme.surfaceTextMedium
+ weight: isActive && !isPlaceholder ? 500 : 400
+ }
+
+ StyledText {
+ visible: hasIcon && iconData.type === "text" && (!SettingsData.showWorkspaceApps || icons.length === 0)
+ anchors.centerIn: parent
+ text: (hasIcon && iconData.type === "text") ? iconData.value : ""
+ color: isActive ? Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.95) : Theme.surfaceTextMedium
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: (isActive && !isPlaceholder) ? Font.DemiBold : Font.Normal
+ }
+
+ StyledText {
+ visible: (SettingsData.showWorkspaceIndex && !hasIcon && (!SettingsData.showWorkspaceApps || icons.length === 0))
+ anchors.centerIn: parent
+ text: {
+ const isPlaceholder = (modelData === -1)
+
+ if (isPlaceholder) {
+ return index + 1
+ }
+
+ return (modelData - 1)
+ }
+ color: isActive ? Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.95) : isPlaceholder ? Theme.surfaceTextAlpha : Theme.surfaceTextMedium
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: (isActive && !isPlaceholder) ? Font.DemiBold : Font.Normal
+ }
+
+ Behavior on width {
+ // When having more icons, animation becomes clunky
+ enabled: (!SettingsData.showWorkspaceApps || SettingsData.maxWorkspaceIcons <= 3)
+ NumberAnimation {
+ duration: Theme.mediumDuration
+ easing.type: Theme.emphasizedEasing
+ }
+ }
+
+ Behavior on color {
+ // When having more icons, animation becomes clunky
+ enabled: (!SettingsData.showWorkspaceApps || SettingsData.maxWorkspaceIcons <= 3)
+ ColorAnimation {
+ duration: Theme.mediumDuration
+ easing.type: Theme.emphasizedEasing
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/Modules/WallpaperBackground.qml b/quickshell/.config/quickshell/Modules/WallpaperBackground.qml
new file mode 100644
index 0000000..83ceadf
--- /dev/null
+++ b/quickshell/.config/quickshell/Modules/WallpaperBackground.qml
@@ -0,0 +1,135 @@
+import QtQuick
+import Quickshell
+import Quickshell.Wayland
+import Quickshell.Widgets
+import qs.Common
+import qs.Widgets
+
+LazyLoader {
+ active: true
+
+ Variants {
+ model: SettingsData.getFilteredScreens("wallpaper")
+
+ PanelWindow {
+ id: wallpaperWindow
+
+ required property var modelData
+
+ screen: modelData
+
+ WlrLayershell.layer: WlrLayer.Background
+ WlrLayershell.exclusionMode: ExclusionMode.Ignore
+
+ anchors.top: true
+ anchors.bottom: true
+ anchors.left: true
+ anchors.right: true
+
+ color: "black"
+
+ Item {
+ id: root
+ anchors.fill: parent
+
+ property string source: SessionData.getMonitorWallpaper(modelData.name) || ""
+ property bool isColorSource: source.startsWith("#")
+ property Image current: one
+
+ onSourceChanged: {
+ if (!source) {
+ current = null
+ one.source = ""
+ two.source = ""
+ } else if (isColorSource) {
+ current = null
+ one.source = ""
+ two.source = ""
+ } else {
+ if (current === one)
+ two.update()
+ else
+ one.update()
+ }
+ }
+
+ onIsColorSourceChanged: {
+ if (isColorSource) {
+ current = null
+ one.source = ""
+ two.source = ""
+ } else if (source) {
+ if (current === one)
+ two.update()
+ else
+ one.update()
+ }
+ }
+
+ Loader {
+ anchors.fill: parent
+ active: !root.source || root.isColorSource
+ asynchronous: true
+
+ sourceComponent: DankBackdrop {
+ screenName: modelData.name
+ }
+ }
+
+ Img {
+ id: one
+ }
+
+ Img {
+ id: two
+ }
+
+ component Img: Image {
+ id: img
+
+ function update(): void {
+ source = ""
+ source = root.source
+ }
+
+ anchors.fill: parent
+ fillMode: Image.PreserveAspectCrop
+ smooth: true
+ asynchronous: true
+ cache: false
+
+ opacity: 0
+
+ onStatusChanged: {
+ if (status === Image.Ready) {
+ root.current = this
+ if (root.current === one && two.source) {
+ two.source = ""
+ } else if (root.current === two && one.source) {
+ one.source = ""
+ }
+ }
+ }
+
+ states: State {
+ name: "visible"
+ when: root.current === img
+
+ PropertyChanges {
+ img.opacity: 1
+ }
+ }
+
+ transitions: Transition {
+ NumberAnimation {
+ target: img
+ properties: "opacity"
+ duration: Theme.mediumDuration
+ easing.type: Easing.OutCubic
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/README.md b/quickshell/.config/quickshell/README.md
new file mode 100644
index 0000000..0c3fcba
--- /dev/null
+++ b/quickshell/.config/quickshell/README.md
@@ -0,0 +1,604 @@
+# DankMaterialShell (dms)
+
+