Files
noctalia-shell/Services/Compositor/MangoService.qml
T

587 lines
15 KiB
QML

import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services.UI
Item {
id: root
// Properties matching facade interface
property ListModel workspaces: ListModel {}
property var windows: []
property int focusedWindowIndex: -1
// Signals matching facade interface
signal workspaceChanged
signal activeWindowChanged
signal windowListChanged
signal displayScalesChanged
// Mango-specific properties
property bool initialized: false
property bool overviewActive: false
property var workspaceCache: ({})
property var windowCache: ({})
property var monitorCache: ({})
property string currentLayout: ""
property string currentLayoutSymbol: ""
property string currentKeyboardLayout: ""
// Constants
readonly property var mmsgCommands: ({
query: {
workspaces: ["mmsg", "-g", "-t"],
windows: ["mmsg", "-g", "-c"],
layout: ["mmsg", "-g", "-l"],
keyboard: ["mmsg", "-g", "-k"],
outputs: ["mmsg", "-g", "-A"],
eventStream: ["mmsg", "-w"]
},
action: {
view: ["mmsg", "-d", "view"],
tag: ["mmsg", "-t"],
focusMaster: ["mmsg", "-d", "focusmaster"],
killClient: ["mmsg", "-d", "killclient"],
toggleOverview: ["mmsg", "-d", "toggleoverview"],
setLayout: ["mmsg", "-d", "setlayout"],
quit: ["mmsg", "-d", "quit"]
}
})
readonly property string overviewLayoutSymbol: "󰃇"
readonly property int defaultWorkspaceId: 1
// Debounce timer for updates
Timer {
id: updateTimer
interval: 50
repeat: false
onTriggered: safeUpdate()
}
// Event stream for real-time updates
Process {
id: eventStream
running: false
command: mmsgCommands.query.eventStream
stdout: SplitParser {
onRead: function (line) {
try {
handleEvent(line.trim())
} catch (e) {
Logger.e("MangoService", "Event parsing error:", e, line)
}
}
}
onExited: function (exitCode) {
if (exitCode !== 0) {
Logger.e("MangoService", "Event stream exited, restarting...")
restartTimer.start()
}
}
}
Timer {
id: restartTimer
interval: 1000
onTriggered: {
if (initialized) {
eventStream.running = true
}
}
}
// Query processes
Process {
id: workspacesProcess
running: false
command: mmsgCommands.query.workspaces
property string accumulatedOutput: ""
stdout: SplitParser {
onRead: function (line) {
workspacesProcess.accumulatedOutput += line + "\n"
}
}
onExited: function (exitCode) {
if (exitCode === 0) {
parseWorkspaces(accumulatedOutput)
} else {
Logger.e("MangoService", "Workspaces query failed:", exitCode)
}
accumulatedOutput = ""
}
}
Process {
id: windowsProcess
running: false
command: mmsgCommands.query.windows
property string accumulatedOutput: ""
property var currentWindow: ({})
onRunningChanged: {
if (running) {
currentWindow = {}
}
}
stdout: SplitParser {
onRead: function (line) {
const trimmed = line.trim()
if (!trimmed) return
const parts = trimmed.split(' ')
if (parts.length >= 3) {
const outputName = parts[0]
const property = parts[1]
const value = parts.slice(2).join(' ')
if (!currentWindow[outputName]) {
currentWindow[outputName] = {
id: outputName,
output: outputName
}
}
switch (property) {
case "title":
currentWindow[outputName].title = value
break
case "appid":
currentWindow[outputName].appId = value
currentWindow[outputName].class = value
break
case "fullscreen":
currentWindow[outputName].fullscreen = (value === "1")
break
case "floating":
currentWindow[outputName].floating = (value === "1")
break
case "x":
currentWindow[outputName].x = parseInt(value)
break
case "y":
currentWindow[outputName].y = parseInt(value)
break
case "width":
currentWindow[outputName].width = parseInt(value)
break
case "height":
currentWindow[outputName].height = parseInt(value)
break
}
}
}
}
onExited: function (exitCode) {
if (exitCode === 0) {
parseWindows(currentWindow)
} else {
Logger.e("MangoService", "Windows query failed:", exitCode)
}
accumulatedOutput = ""
currentWindow = {}
}
}
Process {
id: layoutProcess
running: false
command: mmsgCommands.query.layout
stdout: SplitParser {
onRead: function (line) {
try {
const parts = line.trim().split(/\s+/)
if (parts.length >= 2) {
const layoutSymbol = parts.slice(1).join(' ')
handleLayoutChange(layoutSymbol)
}
} catch (e) {
Logger.e("MangoService", "Layout parsing error:", e, line)
}
}
}
onExited: function (exitCode) {
if (exitCode !== 0) {
Logger.e("MangoService", "Layout query failed:", exitCode)
}
}
}
Process {
id: keyboardProcess
running: false
command: mmsgCommands.query.keyboard
stdout: SplitParser {
onRead: function (line) {
try {
const parts = line.trim().split(/\s+/)
if (parts.length >= 2 && parts[1] === "kb_layout") {
const layoutName = parts.slice(2).join(' ')
if (layoutName && layoutName !== currentKeyboardLayout) {
currentKeyboardLayout = layoutName
KeyboardLayoutService.setCurrentLayout(layoutName)
}
}
} catch (e) {
Logger.e("MangoService", "Keyboard layout parsing error:", e, line)
}
}
}
onExited: function (exitCode) {
if (exitCode !== 0) {
Logger.e("MangoService", "Keyboard query failed:", exitCode)
}
}
}
Process {
id: outputsProcess
running: false
command: mmsgCommands.query.outputs
stdout: SplitParser {
onRead: function (line) {
try {
const parts = line.trim().split(/\s+/)
if (parts.length >= 3 && parts[1] === "scale_factor") {
const outputName = parts[0]
const scaleFactor = parseFloat(parts[2])
if (!monitorCache[outputName]) {
monitorCache[outputName] = {}
}
monitorCache[outputName].scale = scaleFactor
monitorCache[outputName].name = outputName
}
} catch (e) {
Logger.e("MangoService", "Output parsing error:", e, line)
}
}
}
onExited: function (exitCode) {
if (exitCode === 0) {
updateDisplayScales()
} else {
Logger.e("MangoService", "Outputs query failed:", exitCode)
}
}
}
// Initialization
function initialize() {
if (initialized) {
Logger.w("MangoService", "Already initialized")
return
}
try {
Logger.i("MangoService", "Initializing MangoWC service...")
eventStream.running = true
queryWorkspaces()
queryWindows()
queryLayout()
queryKeyboard()
queryOutputs()
initialized = true
Logger.i("MangoService", "Service initialized successfully")
} catch (e) {
Logger.e("MangoService", "Initialization failed:", e)
eventStream.running = true
}
}
// Workspace operations
function switchToWorkspace(workspace) {
try {
const tagId = workspace.idx || workspace.id || defaultWorkspaceId
const command = mmsgCommands.action.tag.concat([tagId.toString()])
Quickshell.execDetached(command)
Logger.d("MangoService", `Switching to workspace ${tagId}`)
} catch (e) {
Logger.e("MangoService", "Failed to switch workspace:", e)
}
}
// Window operations
function focusWindow(window) {
try {
if (window && window.workspaceId) {
const command = mmsgCommands.action.view.concat([window.workspaceId.toString()])
Quickshell.execDetached(command)
Qt.callLater(() => {
Quickshell.execDetached(mmsgCommands.action.focusMaster)
})
}
} catch (e) {
Logger.e("MangoService", "Failed to focus window:", e)
}
}
function closeWindow() {
try {
Quickshell.execDetached(mmsgCommands.action.killClient)
} catch (e) {
Logger.e("MangoService", "Failed to close window:", e)
}
}
// MangoWC-specific operations
function toggleOverview() {
try {
Quickshell.execDetached(mmsgCommands.action.toggleOverview)
} catch (e) {
Logger.e("MangoService", "Failed to toggle overview:", e)
}
}
function setLayout(layoutName) {
try {
const command = mmsgCommands.action.setLayout.concat([layoutName])
Quickshell.execDetached(command)
} catch (e) {
Logger.e("MangoService", "Failed to set layout:", e)
}
}
function logout() {
try {
Quickshell.execDetached(mmsgCommands.action.quit)
} catch (e) {
Logger.e("MangoService", "Failed to logout:", e)
}
}
// Data parsing
function parseWorkspaces(output) {
const lines = output.trim().split('\n')
const workspacesList = []
const newWorkspaceCache = {}
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) continue
const match = trimmed.match(/^(\S+)\s+tag\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)$/)
if (match) {
const [, outputName, tagNum, state, clients, focused] = match
const tagId = parseInt(tagNum)
const isActive = (parseInt(state) & 1) !== 0
const isUrgent = (parseInt(state) & 2) !== 0
const isOccupied = parseInt(clients) > 0
const isFocused = isActive && parseInt(focused) === 1
const workspaceData = {
id: tagId,
idx: tagId,
name: tagId.toString(),
output: outputName,
isActive: isActive,
isFocused: isFocused,
isUrgent: isUrgent,
isOccupied: isOccupied,
clients: parseInt(clients)
}
newWorkspaceCache[tagId] = workspaceData
workspacesList.push(workspaceData)
}
const layoutMatch = trimmed.match(/^(\S+)\s+layout\s+(\S+)$/)
if (layoutMatch) {
const [, , layoutSymbol] = layoutMatch
handleLayoutChange(layoutSymbol)
}
}
if (JSON.stringify(newWorkspaceCache) !== JSON.stringify(workspaceCache)) {
workspaceCache = newWorkspaceCache
workspacesList.sort((a, b) => {
if (a.id !== b.id) return a.id - b.id
return a.output.localeCompare(b.output)
})
workspaces.clear()
for (var i = 0; i < workspacesList.length; i++) {
workspaces.append(workspacesList[i])
}
workspaceChanged()
}
}
function parseWindows(windowData) {
const windowsList = []
const newWindowCache = {}
let newFocusedIndex = -1
for (const [outputName, data] of Object.entries(windowData)) {
if (data.title || data.appId) {
const windowInfo = {
id: outputName,
title: data.title || "",
appId: data.appId || "",
class: data.appId || "",
workspaceId: getCurrentActiveTagId(),
isFocused: false,
output: outputName,
fullscreen: data.fullscreen || false,
floating: data.floating || false,
x: data.x || 0,
y: data.y || 0,
width: data.width || 0,
height: data.height || 0,
geometry: {
x: data.x || 0,
y: data.y || 0,
width: data.width || 0,
height: data.height || 0
}
}
windowsList.push(windowInfo)
newWindowCache[outputName] = windowInfo
}
}
for (let i = 0; i < windowsList.length; i++) {
const window = windowsList[i]
const outputData = monitorCache[window.output]
if (outputData && outputData.focused) {
window.isFocused = true
newFocusedIndex = i
break
}
}
if (JSON.stringify(newWindowCache) !== JSON.stringify(windowCache)) {
windowCache = newWindowCache
windows = windowsList
if (newFocusedIndex !== focusedWindowIndex) {
focusedWindowIndex = newFocusedIndex
activeWindowChanged()
}
windowListChanged()
}
}
function handleLayoutChange(layoutSymbol) {
const wasOverview = overviewActive
const isOverview = (layoutSymbol === overviewLayoutSymbol)
if (wasOverview !== isOverview) {
overviewActive = isOverview
Logger.d("MangoService", `Overview mode: ${overviewActive}`)
}
if (layoutSymbol !== currentLayoutSymbol) {
currentLayoutSymbol = layoutSymbol
currentLayout = layoutSymbol
}
}
function updateDisplayScales() {
const scales = {}
for (const [outputName, data] of Object.entries(monitorCache)) {
scales[outputName] = {
name: data.name || outputName,
scale: data.scale || 1.0,
width: data.width || 0,
height: data.height || 0,
refresh_rate: data.refresh_rate || 0,
x: data.x || 0,
y: data.y || 0,
active: data.active || false,
focused: data.focused || false
}
}
if (CompositorService && CompositorService.onDisplayScalesUpdated) {
CompositorService.onDisplayScalesUpdated(scales)
}
displayScalesChanged()
}
// Event handling
function handleEvent(eventLine) {
const parts = eventLine.trim().split(/\s+/)
if (parts.length < 2) return
const eventType = parts[1]
switch (eventType) {
case "tag":
case "title":
case "appid":
case "fullscreen":
case "floating":
case "layout":
case "kb_layout":
case "scale_factor":
case "monitor":
case "client":
case "selmon":
updateTimer.restart()
break
}
}
// Queries
function queryWorkspaces() {
workspacesProcess.running = true
}
function queryWindows() {
windowsProcess.running = true
}
function queryLayout() {
layoutProcess.running = true
}
function queryKeyboard() {
keyboardProcess.running = true
}
function queryOutputs() {
outputsProcess.running = true
}
// Utilities
function safeUpdate() {
try {
queryWorkspaces()
queryWindows()
} catch (e) {
Logger.e("MangoService", "Safe update failed:", e)
}
}
function getCurrentActiveTagId() {
for (const [tagId, tagData] of Object.entries(workspaceCache)) {
if (tagData.isActive) {
return parseInt(tagId)
}
}
return defaultWorkspaceId
}
}