add crypto

This commit is contained in:
zack 2025-07-22 20:21:21 -04:00
parent 90cbe489f6
commit af6a3bce3e
Signed by: zoey
GPG key ID: 81FB9FECDD6A33E2
120 changed files with 24616 additions and 462 deletions

View 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
}
}

View file

@ -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()
}
}
}
}
}
}

View file

@ -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
}
}
}

View 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
}
}

View file

@ -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)
}
}
}
}
}
}
}

View file

@ -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
}
}