add crypto
This commit is contained in:
parent
90cbe489f6
commit
af6a3bce3e
120 changed files with 24616 additions and 462 deletions
515
modules/home/services/quickshell/qml/Widgets/System/Cliphist.qml
Normal file
515
modules/home/services/quickshell/qml/Widgets/System/Cliphist.qml
Normal file
|
|
@ -0,0 +1,515 @@
|
|||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Shapes
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import "root:/Data" as Data
|
||||
|
||||
// Clipboard history manager with cliphist integration
|
||||
Item {
|
||||
id: root
|
||||
required property var shell
|
||||
property string selectedWidget: "cliphist"
|
||||
|
||||
property bool isVisible: false
|
||||
property real bgOpacity: 0.0
|
||||
|
||||
transformOrigin: Item.Center
|
||||
|
||||
function show() { showAnimation.start() }
|
||||
function hide() { hideAnimation.start() }
|
||||
function toggle() { isVisible ? hide() : show() }
|
||||
|
||||
// Smooth show/hide animations
|
||||
ParallelAnimation {
|
||||
id: showAnimation
|
||||
PropertyAction { target: root; property: "isVisible"; value: true }
|
||||
PropertyAnimation { target: root; property: "opacity"; from: 0.0; to: 1.0; duration: 200; easing.type: Easing.OutCubic }
|
||||
PropertyAnimation { target: root; property: "scale"; from: 0.9; to: 1.0; duration: 200; easing.type: Easing.OutCubic }
|
||||
}
|
||||
|
||||
ParallelAnimation {
|
||||
id: hideAnimation
|
||||
PropertyAnimation { target: root; property: "opacity"; to: 0.0; duration: 150; easing.type: Easing.InCubic }
|
||||
PropertyAnimation { target: root; property: "scale"; to: 0.95; duration: 150; easing.type: Easing.InCubic }
|
||||
PropertyAction { target: root; property: "isVisible"; value: false }
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: contentColumn
|
||||
anchors.fill: parent
|
||||
spacing: 12
|
||||
|
||||
// Header
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 30
|
||||
|
||||
Label {
|
||||
text: "Clipboard History"
|
||||
font.pixelSize: 16
|
||||
font.weight: Font.Medium
|
||||
color: Data.ThemeManager.fgColor
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Button {
|
||||
id: clearButton
|
||||
text: "Clear"
|
||||
implicitWidth: 60
|
||||
implicitHeight: 25
|
||||
background: Rectangle {
|
||||
radius: 12
|
||||
color: parent.down ? Qt.darker(Data.ThemeManager.accentColor, 1.2) :
|
||||
parent.hovered ? Qt.lighter(Data.ThemeManager.accentColor, 1.1) :
|
||||
Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.8)
|
||||
}
|
||||
contentItem: Label {
|
||||
text: parent.text
|
||||
font.pixelSize: 11
|
||||
color: Data.ThemeManager.fgColor
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
onClicked: {
|
||||
clearClipboardHistory()
|
||||
clickScale.target = clearButton
|
||||
clickScale.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scrollable clipboard history list
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
|
||||
ScrollView {
|
||||
id: scrollView
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
ScrollBar.vertical: ScrollBar {
|
||||
policy: ScrollBar.AsNeeded
|
||||
interactive: true
|
||||
visible: cliphistList.contentHeight > cliphistList.height
|
||||
contentItem: Rectangle {
|
||||
implicitWidth: 6
|
||||
radius: width / 2
|
||||
color: parent.pressed ? Data.ThemeManager.accentColor
|
||||
: parent.hovered ? Qt.lighter(Data.ThemeManager.accentColor, 1.2)
|
||||
: Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.7)
|
||||
}
|
||||
}
|
||||
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
|
||||
|
||||
ListView {
|
||||
id: cliphistList
|
||||
model: cliphistModel
|
||||
spacing: 6
|
||||
cacheBuffer: 50 // Memory optimization
|
||||
reuseItems: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
maximumFlickVelocity: 2500
|
||||
flickDeceleration: 1500
|
||||
|
||||
// Smooth scrolling behavior
|
||||
property real targetY: contentY
|
||||
Behavior on targetY {
|
||||
NumberAnimation {
|
||||
duration: 200
|
||||
easing.type: Easing.OutQuad
|
||||
}
|
||||
}
|
||||
|
||||
onTargetYChanged: {
|
||||
if (!moving && !dragging) {
|
||||
contentY = targetY
|
||||
}
|
||||
}
|
||||
|
||||
delegate: Rectangle {
|
||||
width: cliphistList.width
|
||||
height: Math.max(50, contentText.contentHeight + 20)
|
||||
radius: 8
|
||||
color: mouseArea.containsMouse ? Qt.darker(Data.ThemeManager.bgColor, 1.15) : Qt.darker(Data.ThemeManager.bgColor, 1.1)
|
||||
border.color: Data.ThemeManager.accentColor
|
||||
border.width: 1
|
||||
|
||||
// View optimization - only render visible items
|
||||
visible: y + height > cliphistList.contentY - height &&
|
||||
y < cliphistList.contentY + cliphistList.height + height
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 10
|
||||
spacing: 10
|
||||
|
||||
// Content type icon
|
||||
Label {
|
||||
text: model.type === "image" ? "🖼️" : model.type === "url" ? "🔗" : "📝"
|
||||
font.pixelSize: 16
|
||||
Layout.alignment: Qt.AlignTop
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
spacing: 4
|
||||
|
||||
Label {
|
||||
id: contentText
|
||||
text: model.type === "image" ? "[Image Data]" :
|
||||
(model.content.length > 100 ? model.content.substring(0, 100) + "..." : model.content)
|
||||
font.pixelSize: 12
|
||||
color: Data.ThemeManager.fgColor
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 4
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Item { Layout.fillWidth: true }
|
||||
Label {
|
||||
text: model.type === "image" ? "Image" : (model.content.length + " chars")
|
||||
font.pixelSize: 10
|
||||
color: Qt.darker(Data.ThemeManager.fgColor, 1.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onClicked: mouse => {
|
||||
if (mouse.button === Qt.LeftButton) {
|
||||
copyToClipboard(model.id, model.type)
|
||||
clickScale.target = parent
|
||||
clickScale.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empty state message
|
||||
Label {
|
||||
anchors.centerIn: parent
|
||||
text: "No clipboard history\nCopy something to get started"
|
||||
font.pixelSize: 14
|
||||
color: Qt.darker(Data.ThemeManager.fgColor, 1.5)
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
visible: cliphistList.count === 0
|
||||
opacity: 0.7
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Click feedback animation
|
||||
NumberAnimation {
|
||||
id: clickScale
|
||||
property Item target
|
||||
properties: "scale"
|
||||
from: 0.95
|
||||
to: 1.0
|
||||
duration: 150
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
|
||||
ListModel { id: cliphistModel }
|
||||
|
||||
property var currentEntries: []
|
||||
|
||||
// Main cliphist process for fetching clipboard history
|
||||
Process {
|
||||
id: cliphistProcess
|
||||
command: ["cliphist", "list"]
|
||||
running: false
|
||||
|
||||
property var tempEntries: []
|
||||
|
||||
onRunningChanged: {
|
||||
if (running) {
|
||||
tempEntries = []
|
||||
} else {
|
||||
// Process completed, apply smart diff update
|
||||
updateModelIfChanged(tempEntries)
|
||||
}
|
||||
}
|
||||
|
||||
stdout: SplitParser {
|
||||
onRead: data => {
|
||||
try {
|
||||
const line = data.toString().trim()
|
||||
|
||||
// Skip empty lines and error messages
|
||||
if (line === "" || line.includes("ERROR") || line.includes("WARN") ||
|
||||
line.includes("error:") || line.includes("warning:")) {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse cliphist output format: ID + spaces + content
|
||||
const match = line.match(/^(\d+)\s+(.+)$/)
|
||||
if (match) {
|
||||
const id = match[1]
|
||||
const content = match[2]
|
||||
|
||||
cliphistProcess.tempEntries.push({
|
||||
id: id,
|
||||
content: content,
|
||||
type: detectContentType(content)
|
||||
})
|
||||
} else {
|
||||
console.log("Failed to parse line:", line)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error parsing cliphist line:", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear entire clipboard history
|
||||
Process {
|
||||
id: clearCliphistProcess
|
||||
command: ["cliphist", "wipe"]
|
||||
running: false
|
||||
|
||||
onRunningChanged: {
|
||||
if (!running) {
|
||||
cliphistModel.clear()
|
||||
currentEntries = []
|
||||
console.log("Clipboard history cleared")
|
||||
}
|
||||
}
|
||||
|
||||
stderr: SplitParser {
|
||||
onRead: data => {
|
||||
console.error("Clear clipboard error:", data.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete specific clipboard entry
|
||||
Process {
|
||||
id: deleteEntryProcess
|
||||
property string entryId: ""
|
||||
command: ["cliphist", "delete-query", entryId]
|
||||
running: false
|
||||
|
||||
onRunningChanged: {
|
||||
if (!running && entryId !== "") {
|
||||
// Remove deleted entry from model
|
||||
for (let i = 0; i < cliphistModel.count; i++) {
|
||||
if (cliphistModel.get(i).id === entryId) {
|
||||
cliphistModel.remove(i)
|
||||
currentEntries = currentEntries.filter(entry => entry.id !== entryId)
|
||||
break
|
||||
}
|
||||
}
|
||||
console.log("Deleted entry:", entryId)
|
||||
entryId = ""
|
||||
}
|
||||
}
|
||||
|
||||
stderr: SplitParser {
|
||||
onRead: data => {
|
||||
console.error("Delete entry error:", data.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy plain text to clipboard
|
||||
Process {
|
||||
id: copyTextProcess
|
||||
property string textToCopy: ""
|
||||
command: ["wl-copy", textToCopy]
|
||||
running: false
|
||||
|
||||
stderr: SplitParser {
|
||||
onRead: data => {
|
||||
console.error("wl-copy error:", data.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy from clipboard history
|
||||
Process {
|
||||
id: copyHistoryProcess
|
||||
property string entryId: ""
|
||||
command: ["sh", "-c", "printf '%s' '" + entryId + "' | cliphist decode | wl-copy"]
|
||||
running: false
|
||||
|
||||
stderr: SplitParser {
|
||||
onRead: data => {
|
||||
console.error("Copy history error:", data.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic refresh timer (disabled by default)
|
||||
Timer {
|
||||
id: refreshTimer
|
||||
interval: 30000
|
||||
running: false // Only enable when needed
|
||||
repeat: true
|
||||
onTriggered: {
|
||||
if (!cliphistProcess.running && root.isVisible) {
|
||||
refreshClipboardHistory()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Component initialization
|
||||
Component.onCompleted: {
|
||||
refreshClipboardHistory()
|
||||
}
|
||||
|
||||
onIsVisibleChanged: {
|
||||
if (isVisible && cliphistModel.count === 0) {
|
||||
refreshClipboardHistory()
|
||||
}
|
||||
}
|
||||
|
||||
// Smart model update - only changes when content differs
|
||||
function updateModelIfChanged(newEntries) {
|
||||
// Quick length check
|
||||
if (newEntries.length !== currentEntries.length) {
|
||||
updateModel(newEntries)
|
||||
return
|
||||
}
|
||||
|
||||
// Compare content for changes
|
||||
let hasChanges = false
|
||||
for (let i = 0; i < newEntries.length; i++) {
|
||||
if (i >= currentEntries.length ||
|
||||
newEntries[i].id !== currentEntries[i].id ||
|
||||
newEntries[i].content !== currentEntries[i].content) {
|
||||
hasChanges = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
updateModel(newEntries)
|
||||
}
|
||||
}
|
||||
|
||||
// Efficient model update with scroll position preservation
|
||||
function updateModel(newEntries) {
|
||||
const scrollPos = cliphistList.contentY
|
||||
|
||||
// Remove obsolete items
|
||||
for (let i = cliphistModel.count - 1; i >= 0; i--) {
|
||||
const modelItem = cliphistModel.get(i)
|
||||
const found = newEntries.some(entry => entry.id === modelItem.id)
|
||||
if (!found) {
|
||||
cliphistModel.remove(i)
|
||||
}
|
||||
}
|
||||
|
||||
// Add or update items
|
||||
for (let i = 0; i < newEntries.length; i++) {
|
||||
const newEntry = newEntries[i]
|
||||
let found = false
|
||||
|
||||
// Check if item exists and update position
|
||||
for (let j = 0; j < cliphistModel.count; j++) {
|
||||
const modelItem = cliphistModel.get(j)
|
||||
if (modelItem.id === newEntry.id) {
|
||||
if (modelItem.content !== newEntry.content) {
|
||||
cliphistModel.set(j, newEntry)
|
||||
}
|
||||
if (j !== i && i < cliphistModel.count) {
|
||||
cliphistModel.move(j, i, 1)
|
||||
}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Add new item
|
||||
if (!found) {
|
||||
if (i < cliphistModel.count) {
|
||||
cliphistModel.insert(i, newEntry)
|
||||
} else {
|
||||
cliphistModel.append(newEntry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore scroll position
|
||||
cliphistList.contentY = scrollPos
|
||||
currentEntries = newEntries.slice()
|
||||
}
|
||||
|
||||
// Content type detection based on patterns
|
||||
function detectContentType(content) {
|
||||
// Binary/image data detection
|
||||
if (content.includes('\x00') || content.startsWith('\x89PNG') || content.startsWith('\xFF\xD8\xFF')) {
|
||||
return "image"
|
||||
}
|
||||
if (content.includes('[[ binary data ') || content.includes('<selection>')) {
|
||||
return "image"
|
||||
}
|
||||
// URL detection
|
||||
if (/^https?:\/\/\S+$/.test(content.trim())) return "url"
|
||||
// Code detection
|
||||
if (content.includes('\n') && (content.includes('{') || content.includes('function') || content.includes('=>'))) return "code"
|
||||
// Command detection
|
||||
if (content.startsWith('sudo ') || content.startsWith('pacman ') || content.startsWith('apt ')) return "command"
|
||||
return "text"
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp) {
|
||||
const now = new Date()
|
||||
const entryDate = new Date(parseInt(timestamp))
|
||||
const diff = (now - entryDate) / 1000
|
||||
|
||||
if (diff < 60) return "Just now"
|
||||
if (diff < 3600) return Math.floor(diff / 60) + " min ago"
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + " hour" + (Math.floor(diff / 3600) === 1 ? "" : "s") + " ago"
|
||||
return Qt.formatDateTime(entryDate, "MMM d h:mm AP")
|
||||
}
|
||||
|
||||
function clearClipboardHistory() {
|
||||
clearCliphistProcess.running = true
|
||||
}
|
||||
|
||||
function deleteClipboardEntry(entryId) {
|
||||
deleteEntryProcess.entryId = entryId
|
||||
deleteEntryProcess.running = true
|
||||
}
|
||||
|
||||
function refreshClipboardHistory() {
|
||||
cliphistProcess.running = true
|
||||
}
|
||||
|
||||
// Copy handler - chooses appropriate method based on content type
|
||||
function copyToClipboard(entryIdOrText, contentType) {
|
||||
if (contentType === "image" || typeof entryIdOrText === "string" && entryIdOrText.match(/^\d+$/)) {
|
||||
// Use cliphist decode for binary data and numbered entries
|
||||
copyHistoryProcess.entryId = entryIdOrText
|
||||
copyHistoryProcess.running = true
|
||||
} else {
|
||||
// Use wl-copy for plain text
|
||||
copyTextProcess.textToCopy = entryIdOrText
|
||||
copyTextProcess.running = true
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up all processes on destruction
|
||||
Component.onDestruction: {
|
||||
cliphistProcess.running = false
|
||||
clearCliphistProcess.running = false
|
||||
deleteEntryProcess.running = false
|
||||
copyTextProcess.running = false
|
||||
copyHistoryProcess.running = false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
pragma ComponentBehavior: Bound
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import "root:/Data/" as Data
|
||||
|
||||
// Custom system tray menu
|
||||
Rectangle {
|
||||
id: trayMenu
|
||||
implicitWidth: 360
|
||||
implicitHeight: Math.max(40, listView.contentHeight + 12 + 16)
|
||||
clip: true
|
||||
color: Data.ThemeManager.bgColor
|
||||
border.color: Data.ThemeManager.accentColor
|
||||
border.width: 3
|
||||
radius: 20
|
||||
visible: false
|
||||
enabled: visible
|
||||
|
||||
property QsMenuHandle menu
|
||||
property point triggerPoint: Qt.point(0, 0)
|
||||
property Item originalParent
|
||||
|
||||
// Menu opener handles native menu integration
|
||||
QsMenuOpener {
|
||||
id: opener
|
||||
menu: trayMenu.menu
|
||||
}
|
||||
|
||||
// Full-screen overlay to capture outside clicks
|
||||
Rectangle {
|
||||
id: overlay
|
||||
x: -trayMenu.x
|
||||
y: -trayMenu.y
|
||||
width: Screen.width
|
||||
height: Screen.height
|
||||
color: "transparent"
|
||||
visible: trayMenu.visible
|
||||
z: -1
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: trayMenu.visible
|
||||
acceptedButtons: Qt.AllButtons
|
||||
onPressed: {
|
||||
trayMenu.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flatten hierarchical menu structure into single list
|
||||
function flattenMenuItems(menuHandle) {
|
||||
var result = [];
|
||||
if (!menuHandle || !menuHandle.children) {
|
||||
return result;
|
||||
}
|
||||
|
||||
var childrenArray = [];
|
||||
for (var i = 0; i < menuHandle.children.length; i++) {
|
||||
childrenArray.push(menuHandle.children[i]);
|
||||
}
|
||||
|
||||
for (var i = 0; i < childrenArray.length; i++) {
|
||||
var item = childrenArray[i];
|
||||
|
||||
if (item.isSeparator) {
|
||||
result.push(item);
|
||||
} else if (item.menu) {
|
||||
// Add parent item and its submenu items
|
||||
result.push(item);
|
||||
var submenuItems = flattenMenuItems(item.menu);
|
||||
result = result.concat(submenuItems);
|
||||
} else {
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Menu item list
|
||||
ListView {
|
||||
id: listView
|
||||
anchors.fill: parent
|
||||
anchors.margins: 6
|
||||
anchors.topMargin: 3
|
||||
anchors.bottomMargin: 9
|
||||
model: ScriptModel {
|
||||
values: flattenMenuItems(opener.menu)
|
||||
}
|
||||
interactive: false
|
||||
|
||||
delegate: Rectangle {
|
||||
id: entry
|
||||
required property var modelData
|
||||
|
||||
width: listView.width - 12
|
||||
height: modelData.isSeparator ? 10 : 28
|
||||
color: modelData.isSeparator ? Data.ThemeManager.bgColor : (mouseArea.containsMouse ? Data.ThemeManager.highlightBg : "transparent")
|
||||
radius: modelData.isSeparator ? 0 : 4
|
||||
|
||||
// Separator line rendering
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
visible: modelData.isSeparator
|
||||
|
||||
Rectangle {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: parent.width * 0.85
|
||||
height: 1
|
||||
color: Data.ThemeManager.accentColor
|
||||
opacity: 0.3
|
||||
}
|
||||
}
|
||||
|
||||
// Menu item content (text and icon)
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 8
|
||||
anchors.rightMargin: 8
|
||||
spacing: 6
|
||||
visible: !modelData.isSeparator
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
color: (modelData?.enabled ?? true) ? Data.ThemeManager.fgColor : Qt.darker(Data.ThemeManager.fgColor, 1.8)
|
||||
text: modelData?.text ?? ""
|
||||
font.pixelSize: 12
|
||||
font.family: "FiraCode Nerd Font"
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 1
|
||||
}
|
||||
|
||||
Image {
|
||||
Layout.preferredWidth: 14
|
||||
Layout.preferredHeight: 14
|
||||
source: modelData?.icon ?? ""
|
||||
visible: (modelData?.icon ?? "") !== ""
|
||||
fillMode: Image.PreserveAspectFit
|
||||
}
|
||||
}
|
||||
|
||||
// Click handling
|
||||
MouseArea {
|
||||
id: mouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
enabled: (modelData?.enabled ?? true) && trayMenu.visible && !modelData.isSeparator
|
||||
|
||||
onClicked: {
|
||||
if (modelData) {
|
||||
modelData.triggered()
|
||||
trayMenu.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,442 @@
|
|||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Shapes
|
||||
import Qt5Compat.GraphicalEffects
|
||||
import Quickshell.Io
|
||||
import "root:/Data" as Data
|
||||
import "root:/Core" as Core
|
||||
|
||||
// Niri workspace indicator
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property ListModel workspaces: ListModel {}
|
||||
property int currentWorkspace: -1
|
||||
property bool isDestroying: false
|
||||
|
||||
// Signal for workspace change bursts
|
||||
signal workspaceChanged(int workspaceId, color accentColor)
|
||||
|
||||
// MASTER ANIMATION CONTROLLER - drives Desktop overlay burst effect
|
||||
property real masterProgress: 0.0
|
||||
property bool effectsActive: false
|
||||
property color effectColor: Data.ThemeManager.accent
|
||||
|
||||
// Single master animation that controls Desktop overlay burst
|
||||
function triggerUnifiedWave() {
|
||||
effectColor = Data.ThemeManager.accent
|
||||
masterAnimation.restart()
|
||||
}
|
||||
|
||||
SequentialAnimation {
|
||||
id: masterAnimation
|
||||
|
||||
PropertyAction {
|
||||
target: root
|
||||
property: "effectsActive"
|
||||
value: true
|
||||
}
|
||||
|
||||
NumberAnimation {
|
||||
target: root
|
||||
property: "masterProgress"
|
||||
from: 0.0
|
||||
to: 1.0
|
||||
duration: 1000
|
||||
easing.type: Easing.OutQuint
|
||||
}
|
||||
|
||||
PropertyAction {
|
||||
target: root
|
||||
property: "effectsActive"
|
||||
value: false
|
||||
}
|
||||
|
||||
PropertyAction {
|
||||
target: root
|
||||
property: "masterProgress"
|
||||
value: 0.0
|
||||
}
|
||||
}
|
||||
|
||||
color: Data.ThemeManager.bgColor
|
||||
width: 32
|
||||
height: workspaceColumn.implicitHeight + 24
|
||||
|
||||
// Smooth height animation
|
||||
Behavior on height {
|
||||
NumberAnimation {
|
||||
duration: 300
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
|
||||
// Right-side rounded corners
|
||||
topRightRadius: width / 2
|
||||
bottomRightRadius: width / 2
|
||||
topLeftRadius: 0
|
||||
bottomLeftRadius: 0
|
||||
|
||||
// Wave effects overlay - unified animation system (DISABLED - using Desktop overlay)
|
||||
Item {
|
||||
id: waveEffects
|
||||
anchors.fill: parent
|
||||
visible: false // Disabled in favor of unified overlay
|
||||
z: 2
|
||||
}
|
||||
|
||||
// Niri event stream listener
|
||||
Process {
|
||||
id: niriProcess
|
||||
command: ["niri", "msg", "event-stream"]
|
||||
running: true
|
||||
|
||||
stdout: SplitParser {
|
||||
onRead: data => {
|
||||
const lines = data.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
parseNiriEvent(line.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onExited: {
|
||||
// Auto-restart on failure to maintain workspace sync (but not during destruction)
|
||||
if (exitCode !== 0 && !root.isDestroying) {
|
||||
Qt.callLater(() => running = true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse Niri event stream messages
|
||||
function parseNiriEvent(line) {
|
||||
try {
|
||||
// Handle workspace focus changes
|
||||
if (line.startsWith("Workspace focused: ")) {
|
||||
const workspaceId = parseInt(line.replace("Workspace focused: ", ""));
|
||||
if (!isNaN(workspaceId)) {
|
||||
const previousWorkspace = root.currentWorkspace;
|
||||
root.currentWorkspace = workspaceId;
|
||||
updateWorkspaceFocus(workspaceId);
|
||||
|
||||
// Trigger burst effect if workspace actually changed
|
||||
if (previousWorkspace !== workspaceId && previousWorkspace !== -1) {
|
||||
root.triggerUnifiedWave();
|
||||
root.workspaceChanged(workspaceId, Data.ThemeManager.accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle workspace list updates
|
||||
else if (line.startsWith("Workspaces changed: ")) {
|
||||
const workspaceData = line.replace("Workspaces changed: ", "");
|
||||
parseWorkspaceList(workspaceData);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Error parsing niri event:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Update workspace focus states
|
||||
function updateWorkspaceFocus(focusedWorkspaceId) {
|
||||
for (let i = 0; i < root.workspaces.count; i++) {
|
||||
const workspace = root.workspaces.get(i);
|
||||
const wasFocused = workspace.isFocused;
|
||||
const isFocused = workspace.id === focusedWorkspaceId;
|
||||
const isActive = workspace.id === focusedWorkspaceId;
|
||||
|
||||
// Only update changed properties to trigger animations
|
||||
if (wasFocused !== isFocused) {
|
||||
root.workspaces.setProperty(i, "isFocused", isFocused);
|
||||
root.workspaces.setProperty(i, "isActive", isActive);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse workspace data from Niri's Rust-style output format
|
||||
function parseWorkspaceList(data) {
|
||||
try {
|
||||
const workspaceMatches = data.match(/Workspace \{[^}]+\}/g);
|
||||
if (!workspaceMatches) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newWorkspaces = [];
|
||||
|
||||
for (const match of workspaceMatches) {
|
||||
const idMatch = match.match(/id: (\d+)/);
|
||||
const idxMatch = match.match(/idx: (\d+)/);
|
||||
const nameMatch = match.match(/name: Some\("([^"]+)"\)|name: None/);
|
||||
const outputMatch = match.match(/output: Some\("([^"]+)"\)/);
|
||||
const isActiveMatch = match.match(/is_active: (true|false)/);
|
||||
const isFocusedMatch = match.match(/is_focused: (true|false)/);
|
||||
const isUrgentMatch = match.match(/is_urgent: (true|false)/);
|
||||
|
||||
if (idMatch && idxMatch && outputMatch) {
|
||||
const workspace = {
|
||||
id: parseInt(idMatch[1]),
|
||||
idx: parseInt(idxMatch[1]),
|
||||
name: nameMatch && nameMatch[1] ? nameMatch[1] : "",
|
||||
output: outputMatch[1],
|
||||
isActive: isActiveMatch ? isActiveMatch[1] === "true" : false,
|
||||
isFocused: isFocusedMatch ? isFocusedMatch[1] === "true" : false,
|
||||
isUrgent: isUrgentMatch ? isUrgentMatch[1] === "true" : false
|
||||
};
|
||||
|
||||
newWorkspaces.push(workspace);
|
||||
|
||||
if (workspace.isFocused) {
|
||||
root.currentWorkspace = workspace.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by index and update model
|
||||
newWorkspaces.sort((a, b) => a.idx - b.idx);
|
||||
root.workspaces.clear();
|
||||
root.workspaces.append(newWorkspaces);
|
||||
} catch (e) {
|
||||
console.log("Error parsing workspace list:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Vertical workspace indicator pills
|
||||
Column {
|
||||
id: workspaceColumn
|
||||
anchors.centerIn: parent
|
||||
spacing: 6
|
||||
|
||||
Repeater {
|
||||
model: root.workspaces
|
||||
|
||||
Rectangle {
|
||||
id: workspacePill
|
||||
|
||||
// Dynamic sizing based on focus state
|
||||
width: model.isFocused ? 18 : 16
|
||||
height: model.isFocused ? 36 : 22
|
||||
radius: width / 2
|
||||
scale: model.isFocused ? 1.0 : 0.9
|
||||
|
||||
// Material Design 3 inspired colors
|
||||
color: {
|
||||
if (model.isFocused) {
|
||||
return Data.ThemeManager.accent;
|
||||
}
|
||||
if (model.isActive) {
|
||||
return Qt.rgba(Data.ThemeManager.accent.r, Data.ThemeManager.accent.g, Data.ThemeManager.accent.b, 0.5);
|
||||
}
|
||||
if (model.isUrgent) {
|
||||
return Data.ThemeManager.error;
|
||||
}
|
||||
return Qt.rgba(Data.ThemeManager.primaryText.r, Data.ThemeManager.primaryText.g, Data.ThemeManager.primaryText.b, 0.4);
|
||||
}
|
||||
|
||||
// Workspace pill burst overlay (DISABLED - using unified overlay)
|
||||
Rectangle {
|
||||
id: pillBurst
|
||||
anchors.centerIn: parent
|
||||
width: parent.width + 8
|
||||
height: parent.height + 8
|
||||
radius: width / 2
|
||||
color: Data.ThemeManager.accent
|
||||
opacity: 0 // Disabled in favor of unified overlay
|
||||
visible: false
|
||||
z: -1
|
||||
}
|
||||
|
||||
// Subtle pulse for inactive pills during workspace changes
|
||||
Rectangle {
|
||||
id: inactivePillPulse
|
||||
anchors.fill: parent
|
||||
radius: parent.radius
|
||||
color: Data.ThemeManager.accent
|
||||
opacity: {
|
||||
// Only pulse inactive pills during effects
|
||||
if (model.isFocused || !root.effectsActive) return 0
|
||||
|
||||
// More subtle pulse that peaks mid-animation
|
||||
if (root.masterProgress < 0.3) {
|
||||
return (root.masterProgress / 0.3) * 0.15
|
||||
} else if (root.masterProgress < 0.7) {
|
||||
return 0.15
|
||||
} else {
|
||||
return 0.15 * (1.0 - (root.masterProgress - 0.7) / 0.3)
|
||||
}
|
||||
}
|
||||
z: -0.5 // Behind the pill content but visible
|
||||
}
|
||||
|
||||
// Enhanced corner shadows for burst effect (DISABLED - using unified overlay)
|
||||
Rectangle {
|
||||
id: cornerBurst
|
||||
anchors.centerIn: parent
|
||||
width: parent.width + 4
|
||||
height: parent.height + 4
|
||||
radius: width / 2
|
||||
color: "transparent"
|
||||
border.color: Data.ThemeManager.accent
|
||||
border.width: 0 // Disabled
|
||||
opacity: 0 // Disabled in favor of unified overlay
|
||||
visible: false
|
||||
z: 1
|
||||
}
|
||||
|
||||
// Elevation shadow
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.topMargin: model.isFocused ? 1 : 0
|
||||
anchors.leftMargin: model.isFocused ? 0.5 : 0
|
||||
anchors.rightMargin: model.isFocused ? -0.5 : 0
|
||||
anchors.bottomMargin: model.isFocused ? -1 : 0
|
||||
radius: parent.radius
|
||||
color: Qt.rgba(0, 0, 0, model.isFocused ? 0.15 : 0)
|
||||
z: -1
|
||||
visible: model.isFocused
|
||||
|
||||
Behavior on color { ColorAnimation { duration: 200 } }
|
||||
}
|
||||
|
||||
// Smooth Material Design transitions
|
||||
Behavior on width {
|
||||
NumberAnimation {
|
||||
duration: 300
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
Behavior on height {
|
||||
NumberAnimation {
|
||||
duration: 300
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
Behavior on scale {
|
||||
NumberAnimation {
|
||||
duration: 250
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: 200
|
||||
}
|
||||
}
|
||||
|
||||
// Workspace number text
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: model.idx.toString()
|
||||
color: model.isFocused ? Data.ThemeManager.background : Data.ThemeManager.primaryText
|
||||
font.pixelSize: model.isFocused ? 10 : 8
|
||||
font.bold: model.isFocused
|
||||
font.family: "Roboto, sans-serif"
|
||||
visible: model.isFocused || model.isActive
|
||||
|
||||
Behavior on font.pixelSize { NumberAnimation { duration: 200 } }
|
||||
Behavior on color { ColorAnimation { duration: 200 } }
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onClicked: {
|
||||
// Switch workspace via Niri command
|
||||
switchProcess.command = ["niri", "msg", "action", "focus-workspace", model.idx.toString()];
|
||||
switchProcess.running = true;
|
||||
}
|
||||
|
||||
// Hover feedback
|
||||
onEntered: {
|
||||
if (!model.isFocused) {
|
||||
workspacePill.color = Qt.rgba(Data.ThemeManager.primaryText.r, Data.ThemeManager.primaryText.g, Data.ThemeManager.primaryText.b, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
onExited: {
|
||||
// Reset to normal color
|
||||
if (!model.isFocused) {
|
||||
if (model.isActive) {
|
||||
workspacePill.color = Qt.rgba(Data.ThemeManager.accent.r, Data.ThemeManager.accent.g, Data.ThemeManager.accent.b, 0.5);
|
||||
} else if (model.isUrgent) {
|
||||
workspacePill.color = Data.ThemeManager.error;
|
||||
} else {
|
||||
workspacePill.color = Qt.rgba(Data.ThemeManager.primaryText.r, Data.ThemeManager.primaryText.g, Data.ThemeManager.primaryText.b, 0.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Workspace switching command process
|
||||
Process {
|
||||
id: switchProcess
|
||||
running: false
|
||||
onExited: {
|
||||
running = false
|
||||
if (exitCode !== 0) {
|
||||
console.log("Failed to switch workspace:", exitCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Border integration corners
|
||||
Core.Corners {
|
||||
id: topLeftCorner
|
||||
position: "topleft"
|
||||
size: 1.3
|
||||
fillColor: Data.ThemeManager.bgColor
|
||||
offsetX: -41
|
||||
offsetY: -25
|
||||
}
|
||||
|
||||
// Top-left corner wave overlay (DISABLED - using unified overlay)
|
||||
Shape {
|
||||
id: topLeftWave
|
||||
width: topLeftCorner.width
|
||||
height: topLeftCorner.height
|
||||
x: topLeftCorner.x
|
||||
y: topLeftCorner.y
|
||||
visible: false // Disabled in favor of unified overlay
|
||||
preferredRendererType: Shape.CurveRenderer
|
||||
layer.enabled: true
|
||||
layer.samples: 4
|
||||
}
|
||||
|
||||
Core.Corners {
|
||||
id: bottomLeftCorner
|
||||
position: "bottomleft"
|
||||
size: 1.3
|
||||
fillColor: Data.ThemeManager.bgColor
|
||||
offsetX: -41
|
||||
offsetY: 78
|
||||
}
|
||||
|
||||
// Bottom-left corner wave overlay (DISABLED - using unified overlay)
|
||||
Shape {
|
||||
id: bottomLeftWave
|
||||
width: bottomLeftCorner.width
|
||||
height: bottomLeftCorner.height
|
||||
x: bottomLeftCorner.x
|
||||
y: bottomLeftCorner.y
|
||||
visible: false // Disabled in favor of unified overlay
|
||||
preferredRendererType: Shape.CurveRenderer
|
||||
layer.enabled: true
|
||||
layer.samples: 4
|
||||
}
|
||||
|
||||
// Clean up processes on destruction
|
||||
Component.onDestruction: {
|
||||
root.isDestroying = true
|
||||
|
||||
if (niriProcess.running) {
|
||||
niriProcess.running = false
|
||||
}
|
||||
if (switchProcess.running) {
|
||||
switchProcess.running = false
|
||||
}
|
||||
}
|
||||
}
|
||||
228
modules/home/services/quickshell/qml/Widgets/System/OSD.qml
Normal file
228
modules/home/services/quickshell/qml/Widgets/System/OSD.qml
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Shapes
|
||||
import "root:/Data/" as Data
|
||||
import "root:/Core" as Core
|
||||
|
||||
Item {
|
||||
id: osd
|
||||
property var shell
|
||||
|
||||
QtObject {
|
||||
id: modeEnum
|
||||
readonly property int volume: 0
|
||||
readonly property int brightness: 1
|
||||
}
|
||||
|
||||
property int mode: -1
|
||||
property int lastVolume: -1
|
||||
property int lastBrightness: -1
|
||||
|
||||
width: osdBackground.width
|
||||
height: osdBackground.height
|
||||
visible: false
|
||||
|
||||
Timer {
|
||||
id: hideTimer
|
||||
interval: 2500
|
||||
onTriggered: hideOsd()
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: brightnessFile
|
||||
path: "/tmp/brightness_osd_level"
|
||||
watchChanges: true
|
||||
blockLoading: true
|
||||
|
||||
onLoaded: updateBrightness()
|
||||
onFileChanged: {
|
||||
brightnessFile.reload();
|
||||
updateBrightness();
|
||||
}
|
||||
|
||||
function updateBrightness() {
|
||||
const val = parseInt(brightnessFile.text());
|
||||
if (!isNaN(val) && val !== lastBrightness) {
|
||||
lastBrightness = val;
|
||||
mode = modeEnum.brightness;
|
||||
showOsd();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: shell
|
||||
function onVolumeChanged() {
|
||||
if (shell.volume !== lastVolume && lastVolume !== -1) {
|
||||
lastVolume = shell.volume;
|
||||
mode = modeEnum.volume;
|
||||
showOsd();
|
||||
}
|
||||
lastVolume = shell.volume;
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (shell?.volume !== undefined)
|
||||
lastVolume = shell.volume;
|
||||
}
|
||||
|
||||
function showOsd() {
|
||||
if (!osd.visible) {
|
||||
osd.visible = true;
|
||||
slideInAnimation.start();
|
||||
}
|
||||
hideTimer.restart();
|
||||
}
|
||||
|
||||
function hideOsd() {
|
||||
slideOutAnimation.start();
|
||||
}
|
||||
|
||||
NumberAnimation {
|
||||
id: slideInAnimation
|
||||
target: osdBackground
|
||||
property: "x"
|
||||
from: osd.width
|
||||
to: 0
|
||||
duration: 300
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
|
||||
NumberAnimation {
|
||||
id: slideOutAnimation
|
||||
target: osdBackground
|
||||
property: "x"
|
||||
from: 0
|
||||
to: osd.width
|
||||
duration: 250
|
||||
easing.type: Easing.InCubic
|
||||
onFinished: {
|
||||
osd.visible = false;
|
||||
osdBackground.x = 0;
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: osdBackground
|
||||
width: 45
|
||||
height: 250
|
||||
color: Data.ThemeManager.bgColor
|
||||
topLeftRadius: 20
|
||||
bottomLeftRadius: 20
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 16
|
||||
spacing: 12
|
||||
|
||||
Text {
|
||||
id: osdIcon
|
||||
font.family: "Roboto"
|
||||
font.pixelSize: 16
|
||||
color: Data.ThemeManager.fgColor
|
||||
text: {
|
||||
if (mode === modeEnum.volume) {
|
||||
if (!shell || shell.volume === undefined)
|
||||
return "";
|
||||
const vol = shell.volume;
|
||||
return vol === 0 ? "" : vol < 33 ? "" : vol < 66 ? "" : "";
|
||||
} else if (mode === modeEnum.brightness) {
|
||||
const b = lastBrightness;
|
||||
return b < 0 ? "" : b < 33 ? "" : b < 66 ? "" : "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
|
||||
Behavior on text {
|
||||
SequentialAnimation {
|
||||
PropertyAnimation {
|
||||
target: osdIcon
|
||||
property: "scale"
|
||||
to: 1.2
|
||||
duration: 100
|
||||
}
|
||||
PropertyAnimation {
|
||||
target: osdIcon
|
||||
property: "scale"
|
||||
to: 1.0
|
||||
duration: 100
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: 10
|
||||
height: parent.height - osdIcon.height - osdLabel.height - 36
|
||||
radius: 5
|
||||
color: Qt.darker(Data.ThemeManager.accentColor, 1.5)
|
||||
border.color: Qt.darker(Data.ThemeManager.accentColor, 2.0)
|
||||
border.width: 1
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
|
||||
Rectangle {
|
||||
id: fillBar
|
||||
width: parent.width - 2
|
||||
radius: parent.radius - 1
|
||||
x: 1
|
||||
color: Data.ThemeManager.accentColor
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: 1
|
||||
height: {
|
||||
const val = mode === modeEnum.volume ? shell?.volume : lastBrightness;
|
||||
const maxHeight = parent.height - 2;
|
||||
return maxHeight * Math.max(0, Math.min(1, val / 100));
|
||||
}
|
||||
Behavior on height {
|
||||
NumberAnimation {
|
||||
duration: 250
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: osdLabel
|
||||
text: {
|
||||
const val = mode === modeEnum.volume ? shell?.volume : lastBrightness;
|
||||
return val >= 0 ? val + "%" : "0%";
|
||||
}
|
||||
font.pixelSize: 10
|
||||
font.weight: Font.Bold
|
||||
color: Data.ThemeManager.fgColor
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
|
||||
Behavior on text {
|
||||
PropertyAnimation {
|
||||
target: osdLabel
|
||||
property: "opacity"
|
||||
from: 0.7
|
||||
to: 1.0
|
||||
duration: 150
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Core.Corners {
|
||||
position: "bottomright"
|
||||
size: 1.3
|
||||
fillColor: Data.ThemeManager.bgColor
|
||||
offsetX: 39 + osdBackground.x
|
||||
offsetY: 78
|
||||
}
|
||||
|
||||
Core.Corners {
|
||||
position: "topright"
|
||||
size: 1.3
|
||||
fillColor: Data.ThemeManager.bgColor
|
||||
offsetX: 39 + osdBackground.x
|
||||
offsetY: -26
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Controls
|
||||
import Quickshell
|
||||
import Quickshell.Services.SystemTray
|
||||
import "root:/Data" as Data
|
||||
|
||||
// System tray with optimized icon caching
|
||||
Row {
|
||||
property var bar
|
||||
property var shell
|
||||
property var trayMenu
|
||||
spacing: 8
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
|
||||
property bool containsMouse: false
|
||||
property var systemTray: SystemTray
|
||||
|
||||
// Custom icon cache for memory optimization
|
||||
property var iconCache: ({})
|
||||
property var iconCacheCount: ({})
|
||||
|
||||
// Cache cleanup to prevent memory leaks
|
||||
Timer {
|
||||
interval: 120000
|
||||
repeat: true
|
||||
running: systemTray.items.length > 0
|
||||
onTriggered: {
|
||||
// Decrement counters and remove unused icons
|
||||
for (let icon in iconCacheCount) {
|
||||
iconCacheCount[icon]--
|
||||
if (iconCacheCount[icon] <= 0) {
|
||||
delete iconCache[icon]
|
||||
delete iconCacheCount[icon]
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce maximum cache size
|
||||
const maxCacheSize = 10;
|
||||
const cacheKeys = Object.keys(iconCache);
|
||||
if (cacheKeys.length > maxCacheSize) {
|
||||
const toRemove = cacheKeys.slice(0, cacheKeys.length - maxCacheSize);
|
||||
toRemove.forEach(key => {
|
||||
delete iconCache[key];
|
||||
delete iconCacheCount[key];
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: systemTray.items
|
||||
delegate: Item {
|
||||
width: 24
|
||||
height: 24
|
||||
property bool isHovered: trayMouseArea.containsMouse
|
||||
|
||||
onIsHoveredChanged: updateParentHoverState()
|
||||
Component.onCompleted: updateParentHoverState()
|
||||
|
||||
function updateParentHoverState() {
|
||||
let anyHovered = false
|
||||
for (let i = 0; i < parent.children.length; i++) {
|
||||
if (parent.children[i].isHovered) {
|
||||
anyHovered = true
|
||||
break
|
||||
}
|
||||
}
|
||||
parent.containsMouse = anyHovered
|
||||
}
|
||||
|
||||
// Hover animations
|
||||
scale: isHovered ? 1.15 : 1.0
|
||||
Behavior on scale {
|
||||
enabled: isHovered
|
||||
NumberAnimation {
|
||||
duration: 150
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
|
||||
rotation: isHovered ? 5 : 0
|
||||
Behavior on rotation {
|
||||
enabled: isHovered
|
||||
NumberAnimation {
|
||||
duration: 200
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
|
||||
Image {
|
||||
id: trayIcon
|
||||
anchors.centerIn: parent
|
||||
width: 18
|
||||
height: 18
|
||||
sourceSize.width: 18
|
||||
sourceSize.height: 18
|
||||
smooth: false // Memory savings
|
||||
asynchronous: true
|
||||
cache: false // Use custom cache instead
|
||||
source: {
|
||||
let icon = modelData?.icon || "";
|
||||
if (!icon) return "";
|
||||
|
||||
// Return cached icon if available
|
||||
if (iconCache[icon]) {
|
||||
iconCacheCount[icon] = 2
|
||||
return iconCache[icon];
|
||||
}
|
||||
|
||||
// Process icon path
|
||||
let finalPath = icon;
|
||||
if (icon.includes("?path=")) {
|
||||
const [name, path] = icon.split("?path=");
|
||||
const fileName = name.substring(name.lastIndexOf("/") + 1);
|
||||
finalPath = `file://${path}/${fileName}`;
|
||||
}
|
||||
|
||||
// Cache the processed path
|
||||
iconCache[icon] = finalPath;
|
||||
iconCacheCount[icon] = 2;
|
||||
return finalPath;
|
||||
}
|
||||
opacity: status === Image.Ready ? 1 : 0
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: 300
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component.onDestruction: {
|
||||
let icon = modelData?.icon || "";
|
||||
if (icon) {
|
||||
delete iconCache[icon];
|
||||
delete iconCacheCount[icon];
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: trayMouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
|
||||
onClicked: (mouse) => {
|
||||
if (!modelData) return;
|
||||
|
||||
if (mouse.button === Qt.LeftButton) {
|
||||
if (trayMenu && trayMenu.visible) {
|
||||
trayMenu.hide()
|
||||
}
|
||||
if (!modelData.onlyMenu) {
|
||||
modelData.activate()
|
||||
}
|
||||
} else if (mouse.button === Qt.MiddleButton) {
|
||||
if (trayMenu && trayMenu.visible) {
|
||||
trayMenu.hide()
|
||||
}
|
||||
modelData.secondaryActivate && modelData.secondaryActivate()
|
||||
} else if (mouse.button === Qt.RightButton) {
|
||||
if (trayMenu && trayMenu.visible) {
|
||||
trayMenu.hide()
|
||||
return
|
||||
}
|
||||
// Show context menu if available
|
||||
if (modelData.hasMenu && modelData.menu && trayMenu) {
|
||||
trayMenu.menu = modelData.menu
|
||||
const iconCenter = Qt.point(width / 2, height)
|
||||
const iconPos = mapToItem(trayMenu.parent, 0, 0)
|
||||
const menuX = iconPos.x - (trayMenu.width / 2) + (width / 2)
|
||||
const menuY = iconPos.y + height + 15
|
||||
trayMenu.show(Qt.point(menuX, menuY), trayMenu.parent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Shapes
|
||||
import "root:/Data/" as Data
|
||||
import "root:/Core" as Core
|
||||
|
||||
// Volume OSD with slide animation
|
||||
Item {
|
||||
id: volumeOsd
|
||||
property var shell
|
||||
|
||||
// Size and visibility
|
||||
width: osdBackground.width
|
||||
height: osdBackground.height
|
||||
visible: false
|
||||
|
||||
// Auto-hide timer (2.5 seconds of inactivity)
|
||||
Timer {
|
||||
id: hideTimer
|
||||
interval: 2500
|
||||
onTriggered: hideOsd()
|
||||
}
|
||||
|
||||
property int lastVolume: -1
|
||||
|
||||
// Monitor volume changes from shell and trigger OSD
|
||||
Connections {
|
||||
target: shell
|
||||
function onVolumeChanged() {
|
||||
if (shell.volume !== lastVolume && lastVolume !== -1) {
|
||||
showOsd()
|
||||
}
|
||||
lastVolume = shell.volume
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
// Initialize lastVolume on startup
|
||||
if (shell && shell.volume !== undefined) {
|
||||
lastVolume = shell.volume
|
||||
}
|
||||
}
|
||||
|
||||
// Show OSD
|
||||
function showOsd() {
|
||||
if (!volumeOsd.visible) {
|
||||
volumeOsd.visible = true
|
||||
slideInAnimation.start()
|
||||
}
|
||||
hideTimer.restart()
|
||||
}
|
||||
|
||||
// Start slide-out animation to hide OSD
|
||||
function hideOsd() {
|
||||
slideOutAnimation.start()
|
||||
}
|
||||
|
||||
// Slide in from right edge
|
||||
NumberAnimation {
|
||||
id: slideInAnimation
|
||||
target: osdBackground
|
||||
property: "x"
|
||||
from: volumeOsd.width
|
||||
to: 0
|
||||
duration: 300
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
|
||||
// Slide out to right edge
|
||||
NumberAnimation {
|
||||
id: slideOutAnimation
|
||||
target: osdBackground
|
||||
property: "x"
|
||||
from: 0
|
||||
to: volumeOsd.width
|
||||
duration: 250
|
||||
easing.type: Easing.InCubic
|
||||
onFinished: {
|
||||
volumeOsd.visible = false
|
||||
osdBackground.x = 0 // Reset position
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: osdBackground
|
||||
width: 45
|
||||
height: 250
|
||||
color: Data.ThemeManager.bgColor
|
||||
topLeftRadius: 20
|
||||
bottomLeftRadius: 20
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 16
|
||||
spacing: 12
|
||||
|
||||
// Dynamic volume icon
|
||||
Text {
|
||||
id: volumeIcon
|
||||
font.family: "Roboto"
|
||||
font.pixelSize: 16
|
||||
color: Data.ThemeManager.fgColor
|
||||
text: {
|
||||
if (!shell || shell.volume === undefined) return ""
|
||||
var vol = shell.volume
|
||||
if (vol === 0) return "" // Muted
|
||||
else if (vol < 33) return "" // Low
|
||||
else if (vol < 66) return "" // Medium
|
||||
else return "" // High
|
||||
}
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
|
||||
// Scale animation on volume change
|
||||
Behavior on text {
|
||||
SequentialAnimation {
|
||||
PropertyAnimation { target: volumeIcon; property: "scale"; to: 1.2; duration: 100 }
|
||||
PropertyAnimation { target: volumeIcon; property: "scale"; to: 1.0; duration: 100 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Vertical volume bar
|
||||
Rectangle {
|
||||
width: 10
|
||||
height: parent.height - volumeIcon.height - volumeLabel.height - 36
|
||||
radius: 5
|
||||
color: Qt.darker(Data.ThemeManager.accentColor, 1.5)
|
||||
border.color: Qt.darker(Data.ThemeManager.accentColor, 2.0)
|
||||
border.width: 1
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
|
||||
// Animated volume fill indicator
|
||||
Rectangle {
|
||||
id: volumeFill
|
||||
width: parent.width - 2
|
||||
radius: parent.radius - 1
|
||||
x: 1
|
||||
color: Data.ThemeManager.accentColor
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: 1
|
||||
height: {
|
||||
if (!shell || shell.volume === undefined) return 0
|
||||
var maxHeight = parent.height - 2
|
||||
return maxHeight * Math.max(0, Math.min(1, shell.volume / 100))
|
||||
}
|
||||
Behavior on height {
|
||||
NumberAnimation { duration: 250; easing.type: Easing.OutCubic }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Volume percentage text
|
||||
Text {
|
||||
id: volumeLabel
|
||||
text: (shell && shell.volume !== undefined ? shell.volume + "%" : "0%")
|
||||
font.pixelSize: 10
|
||||
font.weight: Font.Bold
|
||||
color: Data.ThemeManager.fgColor
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
|
||||
// Fade animation on volume change
|
||||
Behavior on text {
|
||||
PropertyAnimation { target: volumeLabel; property: "opacity"; from: 0.7; to: 1.0; duration: 150 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Core.Corners {
|
||||
id: bottomRightCorner
|
||||
position: "bottomright"
|
||||
size: 1.3
|
||||
fillColor: Data.ThemeManager.bgColor
|
||||
offsetX: 39 + osdBackground.x
|
||||
offsetY: 78
|
||||
}
|
||||
|
||||
Core.Corners {
|
||||
id: topRightCorner
|
||||
position: "topright"
|
||||
size: 1.3
|
||||
fillColor: Data.ThemeManager.bgColor
|
||||
offsetX: 39 + osdBackground.x
|
||||
offsetY: -26
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue