442 lines
16 KiB
QML
442 lines
16 KiB
QML
|
|
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
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|