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

490 lines
15 KiB
QML

import QtQuick
import Quickshell
import Quickshell.DWL
import Quickshell.Io
import Quickshell.Wayland
import qs.Commons
import qs.Services.Keyboard
Item {
id: root
// ===== PUBLIC INTERFACE (CompositorService compatibility) =====
property ListModel workspaces: ListModel {}
property var windows: []
property int focusedWindowIndex: -1
property bool initialized: false
signal workspaceChanged
signal activeWindowChanged
signal windowListChanged
signal displayScalesChanged
// ===== MANGOSERVICE-SPECIFIC PROPERTIES =====
property string selectedMonitor: ""
property string currentLayoutSymbol: ""
// ===== INTERNAL STATE =====
QtObject {
id: internal
// Window-to-tag persistence: Map<UniqueID, TagID>
property var windowTagMap: ({})
// Window-to-output persistence: Map<UniqueID, OutputName>
property var windowOutputMap: ({})
// Toplevel-to-ID mapping: Map<ToplevelObject, UniqueID>
property var toplevelIdMap: new Map()
property int windowIdCounter: 0
// Output name to index mapping for unique workspace IDs
property var outputIndices: ({})
property int outputCounter: 0
// Monitor scales: Map<OutputName, scale>
property var monitorScales: ({})
// Window signature for change detection
property string lastWindowSignature: ""
// Scale regex
readonly property var scalePattern: /^(\S+)\s+scale_factor\s+(\d+(?:\.\d+)?)$/
// Get all screen names mapped to their DwlIpcOutput
function getOutputMap() {
const map = {};
const screens = Quickshell.screens;
for (let i = 0; i < screens.length; i++) {
const name = screens[i].name;
const dwlOutput = DwlIpc.outputForName(name);
if (dwlOutput) {
map[name] = dwlOutput;
}
}
return map;
}
// ===== REBUILD WORKSPACES FROM DWL =====
function rebuildWorkspaces() {
if (!DwlIpc.available) {
return;
}
const outputMap = getOutputMap();
const workspaceList = [];
for (const outputName in outputMap) {
const output = outputMap[outputName];
// Assign stable index to output
if (internal.outputIndices[outputName] === undefined) {
internal.outputIndices[outputName] = internal.outputCounter++;
}
const outputIdx = internal.outputIndices[outputName];
// Track selected monitor and layout
if (output.active) {
root.selectedMonitor = outputName;
root.currentLayoutSymbol = output.layoutSymbol;
}
const tags = output.tags;
for (let ti = 0; ti < tags.length; ti++) {
const tag = tags[ti];
const tagId = tag.index + 1; // DwlTag.index is zero-based, our IDs are 1-based
const uniqueId = outputIdx * 100 + tagId;
workspaceList.push({
id: uniqueId,
idx: tagId,
name: tagId.toString(),
output: outputName,
isActive: tag.active,
isFocused: tag.active && output.active,
isUrgent: tag.urgent,
isOccupied: tag.clientCount > 0
});
}
}
// Sort by unique ID
workspaceList.sort((a, b) => a.id - b.id);
root.workspaces.clear();
for (let k = 0; k < workspaceList.length; k++) {
root.workspaces.append(workspaceList[k]);
}
root.workspaceChanged();
}
// ===== UPDATE WINDOWS =====
function updateWindows() {
if (!ToplevelManager.toplevels || !DwlIpc.available) {
return;
}
const outputMap = getOutputMap();
const toplevels = ToplevelManager.toplevels.values;
const windowList = [];
let newFocusedIdx = -1;
const currentWindows = new Set();
// Build per-output state from DWL
// Map<outputName, { title, appId, activeTagId }>
// Always populated (activeTagId needed for visible-window inference),
// title/appId may be empty if no window is focused.
const outputState = {};
for (const outputName in outputMap) {
const output = outputMap[outputName];
// Find active tag for this output
let activeTagId = 1;
const tags = output.tags;
for (let ti = 0; ti < tags.length; ti++) {
if (tags[ti].active) {
activeTagId = tags[ti].index + 1;
break;
}
}
outputState[outputName] = {
title: output.title || "",
appId: output.appId || "",
activeTagId: activeTagId
};
// Ensure output index exists
if (internal.outputIndices[outputName] === undefined) {
internal.outputIndices[outputName] = internal.outputCounter++;
}
}
for (let i = 0; i < toplevels.length; i++) {
const toplevel = toplevels[i];
if (!toplevel || toplevel.outliers) {
continue;
}
const appId = toplevel.appId || toplevel.wayland?.appId || "";
const title = toplevel.title || toplevel.wayland?.title || "";
const isFocused = toplevel.activated;
// Get or assign a stable ID
let windowId;
if (internal.toplevelIdMap.has(toplevel)) {
windowId = internal.toplevelIdMap.get(toplevel);
} else {
windowId = `win-${internal.windowIdCounter++}`;
internal.toplevelIdMap.set(toplevel, windowId);
}
currentWindows.add(windowId);
// Determine output
let outputName;
// Priority 1: Focused window matched to DWL output metadata
if (isFocused && (title || appId)) {
for (const oName in outputState) {
const os = outputState[oName];
if ((os.title || os.appId) && title === os.title && appId === os.appId) {
outputName = oName;
internal.windowOutputMap[windowId] = oName;
break;
}
}
}
// Priority 2: Remembered output
if (!outputName && internal.windowOutputMap[windowId]) {
outputName = internal.windowOutputMap[windowId];
}
// Priority 3: toplevel.screens (wlr-foreign-toplevel visible screens)
if (!outputName && toplevel.screens && toplevel.screens.length > 0) {
outputName = toplevel.screens[0].name;
}
// Fallback: selected monitor
if (!outputName) {
outputName = root.selectedMonitor || "DP-1";
}
// Determine tag
let tagId = null;
const os = outputState[outputName];
if (isFocused && os && (os.title || os.appId) && title === os.title && appId === os.appId) {
// Focused window: assign to the active tag from DWL metadata
tagId = os.activeTagId;
internal.windowTagMap[windowId] = tagId;
} else if (internal.windowTagMap[windowId] !== undefined) {
// Previously seen window: use remembered tag
tagId = internal.windowTagMap[windowId];
}
if (tagId === null) {
// DWL only reports the focused window per output, so we can't
// determine the tag for unfocused windows until they gain focus.
continue;
}
// Convert to unique workspace ID
const outputIdx = internal.outputIndices[outputName];
if (outputIdx === undefined) {
Logger.e("MangoService", "No output index for", outputName);
continue;
}
const workspaceId = outputIdx * 100 + tagId;
windowList.push({
id: `${outputName}:${appId}:${title}:${i}`,
title: title,
appId: appId,
class: appId,
workspaceId: workspaceId,
isFocused: isFocused,
output: outputName,
handle: toplevel,
fullscreen: toplevel.fullscreen || false,
floating: toplevel.maximized === false && toplevel.fullscreen === false
});
if (isFocused) {
newFocusedIdx = windowList.length - 1;
}
}
// Clean up stale window tracking
if (Object.keys(internal.windowTagMap).length > toplevels.length + 20) {
const newTagMap = {};
const newOutputMap = {};
for (const windowId of currentWindows) {
if (internal.windowTagMap[windowId] !== undefined) {
newTagMap[windowId] = internal.windowTagMap[windowId];
}
if (internal.windowOutputMap[windowId] !== undefined) {
newOutputMap[windowId] = internal.windowOutputMap[windowId];
}
}
internal.windowTagMap = newTagMap;
internal.windowOutputMap = newOutputMap;
}
// Check if window list changed
const signature = JSON.stringify(windowList.map(w => w.id + w.workspaceId + w.isFocused));
if (signature !== internal.lastWindowSignature) {
internal.lastWindowSignature = signature;
root.windows = windowList;
root.windowListChanged();
}
if (newFocusedIdx !== root.focusedWindowIndex) {
root.focusedWindowIndex = newFocusedIdx;
root.activeWindowChanged();
}
}
// ===== PROCESS SCALES =====
function processScales(output) {
const lines = output.trim().split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
const match = line.match(scalePattern);
if (match) {
const outputName = match[1];
const scale = parseFloat(match[2]);
internal.monitorScales[outputName] = scale;
}
}
const scalesMap = {};
for (const name in internal.monitorScales) {
scalesMap[name] = {
name: name,
scale: internal.monitorScales[name] || 1.0,
width: 0,
height: 0,
x: 0,
y: 0
};
}
if (CompositorService && CompositorService.onDisplayScalesUpdated) {
CompositorService.onDisplayScalesUpdated(scalesMap);
}
root.displayScalesChanged();
}
}
// ===== DWL CONNECTIONS =====
// React to DWL frame events on each output (atomic state updates)
Instantiator {
model: DwlIpc.outputs
delegate: Connections {
required property DwlIpcOutput modelData
target: modelData
function onFrame() {
internal.rebuildWorkspaces();
internal.updateWindows();
}
function onKbLayoutChanged() {
if (KeyboardLayoutService) {
KeyboardLayoutService.setCurrentLayout(modelData.kbLayout);
}
}
}
}
// ===== PROCESSES =====
// Scale query (mmsg -g -A) - DWL doesn't provide scale info
property QtObject _scaleQuery: Process {
id: scaleQuery
command: ["mmsg", "-g", "-A"]
property string buffer: ""
stdout: SplitParser {
onRead: line => {
scaleQuery.buffer += line + "\n";
}
}
onExited: code => {
if (code === 0) {
internal.processScales(scaleQuery.buffer);
scaleQuery.buffer = "";
}
}
}
// ===== TOPLEVEL MANAGER CONNECTION =====
property QtObject _toplevelConnection: Connections {
target: ToplevelManager.toplevels
function onValuesChanged() {
internal.updateWindows();
}
}
// ===== PUBLIC FUNCTIONS =====
function initialize() {
if (initialized) {
return;
}
Logger.i("MangoService", "Initializing MangoWC/DWL compositor integration (DWL protocol)");
// Query display scales (only thing still needing mmsg)
scaleQuery.running = true;
// Initial build from DWL state
if (DwlIpc.available && DwlIpc.outputs.length > 0) {
internal.rebuildWorkspaces();
internal.updateWindows();
}
initialized = true;
}
function queryDisplayScales() {
scaleQuery.running = true;
}
function switchToWorkspace(workspace) {
const tagId = workspace.idx || workspace.id || 1;
const outputName = workspace.output || root.selectedMonitor || "";
// Use DWL protocol to switch tags
const dwlOutput = DwlIpc.outputForName(outputName);
if (dwlOutput) {
dwlOutput.setTags(1 << (tagId - 1)); // tagId is 1-based, bitmask is 0-based
} else {
// Fallback to mmsg
const cmd = ["mmsg", "-s", "-t", tagId.toString()];
if (outputName && Object.keys(internal.monitorScales).length > 1) {
cmd.push("-o", outputName);
}
Quickshell.execDetached(cmd);
}
}
function focusWindow(window) {
if (window && window.handle) {
window.handle.activate();
} else if (window.workspaceId) {
switchToWorkspace({
id: window.workspaceId,
output: window.output
});
}
}
function closeWindow(window) {
if (window && window.handle) {
window.handle.close();
} else {
Quickshell.execDetached(["mmsg", "-s", "-d", "killclient"]);
}
}
function turnOffMonitors() {
const screens = Quickshell.screens;
const cmds = [];
for (let i = 0; i < screens.length; i++) {
cmds.push("mmsg -s -d disable_monitor," + screens[i].name);
}
if (cmds.length > 0) {
Quickshell.execDetached(["sh", "-c", cmds.join(" && ")]);
}
}
function turnOnMonitors() {
const screens = Quickshell.screens;
const cmds = [];
for (let i = 0; i < screens.length; i++) {
cmds.push("mmsg -s -d enable_monitor," + screens[i].name);
}
if (cmds.length > 0) {
Quickshell.execDetached(["sh", "-c", cmds.join(" && ")]);
}
}
function logout() {
Quickshell.execDetached(["mmsg", "-s", "-q"]);
}
function cycleKeyboardLayout() {
Logger.w("MangoService", "Keyboard layout cycling not supported");
}
function getFocusedScreen() {
return null;
}
function spawn(command) {
try {
Quickshell.execDetached(["mmsg", "-s", "-d", "spawn_shell," + command.join(" ")]);
} catch (e) {
Logger.e("MangoService", "Failed to spawn command:", e);
}
}
}