add noctalia and fuzzel
This commit is contained in:
@@ -0,0 +1,665 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
import qs.Services.Control
|
||||
import qs.Services.UI
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// Compositor detection
|
||||
property bool isHyprland: false
|
||||
property bool isNiri: false
|
||||
property bool isSway: false
|
||||
property bool isMango: false
|
||||
property bool isLabwc: false
|
||||
property bool isExtWorkspace: false
|
||||
property bool isScroll: false
|
||||
|
||||
// Generic workspace and window data
|
||||
property ListModel workspaces: ListModel {}
|
||||
property ListModel windows: ListModel {}
|
||||
property int focusedWindowIndex: -1
|
||||
|
||||
// Display scale data
|
||||
property var displayScales: ({})
|
||||
property bool displayScalesLoaded: false
|
||||
|
||||
// Overview state (Niri-specific, defaults to false for other compositors)
|
||||
property bool overviewActive: false
|
||||
|
||||
// Global workspaces flag (workspaces shared across all outputs)
|
||||
// True for LabWC (stacking compositor), false for tiling WMs with per-output workspaces
|
||||
property bool globalWorkspaces: false
|
||||
|
||||
// Generic events
|
||||
signal workspaceChanged
|
||||
signal activeWindowChanged
|
||||
signal windowListChanged
|
||||
|
||||
// Backend service loader
|
||||
property var backend: null
|
||||
|
||||
Component.onCompleted: {
|
||||
// Load display scales from ShellState
|
||||
Qt.callLater(() => {
|
||||
if (typeof ShellState !== 'undefined' && ShellState.isLoaded) {
|
||||
loadDisplayScalesFromState();
|
||||
}
|
||||
});
|
||||
|
||||
detectCompositor();
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: typeof ShellState !== 'undefined' ? ShellState : null
|
||||
function onIsLoadedChanged() {
|
||||
if (ShellState.isLoaded) {
|
||||
loadDisplayScalesFromState();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function detectCompositor() {
|
||||
const hyprlandSignature = Quickshell.env("HYPRLAND_INSTANCE_SIGNATURE");
|
||||
const niriSocket = Quickshell.env("NIRI_SOCKET");
|
||||
const swaySock = Quickshell.env("SWAYSOCK");
|
||||
const currentDesktop = Quickshell.env("XDG_CURRENT_DESKTOP");
|
||||
const labwcPid = Quickshell.env("LABWC_PID");
|
||||
|
||||
// Check for MangoWC using XDG_CURRENT_DESKTOP environment variable
|
||||
// MangoWC sets XDG_CURRENT_DESKTOP=mango
|
||||
if (currentDesktop && currentDesktop.toLowerCase().includes("mango")) {
|
||||
isHyprland = false;
|
||||
isNiri = false;
|
||||
isSway = false;
|
||||
isMango = true;
|
||||
isLabwc = false;
|
||||
isExtWorkspace = false;
|
||||
backendLoader.sourceComponent = mangoComponent;
|
||||
} else if (labwcPid && labwcPid.length > 0) {
|
||||
isHyprland = false;
|
||||
isNiri = false;
|
||||
isSway = false;
|
||||
isMango = false;
|
||||
isLabwc = true;
|
||||
isExtWorkspace = false;
|
||||
backendLoader.sourceComponent = labwcComponent;
|
||||
Logger.i("CompositorService", "Detected LabWC with PID: " + labwcPid);
|
||||
} else if (niriSocket && niriSocket.length > 0) {
|
||||
isHyprland = false;
|
||||
isNiri = true;
|
||||
isSway = false;
|
||||
isMango = false;
|
||||
isLabwc = false;
|
||||
isExtWorkspace = false;
|
||||
backendLoader.sourceComponent = niriComponent;
|
||||
} else if (hyprlandSignature && hyprlandSignature.length > 0) {
|
||||
isHyprland = true;
|
||||
isNiri = false;
|
||||
isSway = false;
|
||||
isMango = false;
|
||||
isLabwc = false;
|
||||
isExtWorkspace = false;
|
||||
backendLoader.sourceComponent = hyprlandComponent;
|
||||
} else if (swaySock && swaySock.length > 0) {
|
||||
isHyprland = false;
|
||||
isNiri = false;
|
||||
isSway = true;
|
||||
isMango = false;
|
||||
isLabwc = false;
|
||||
isExtWorkspace = false;
|
||||
isScroll = currentDesktop && currentDesktop.toLowerCase().includes("scroll");
|
||||
backendLoader.sourceComponent = swayComponent;
|
||||
} else {
|
||||
// Always fallback to ext-workspace-v1
|
||||
isHyprland = false;
|
||||
isNiri = false;
|
||||
isSway = false;
|
||||
isMango = false;
|
||||
isLabwc = false;
|
||||
isExtWorkspace = true;
|
||||
backendLoader.sourceComponent = extWorkspaceComponent;
|
||||
Logger.i("CompositorService", "Using generic ext-workspace backend (no recognized compositor env)");
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: backendLoader
|
||||
onLoaded: {
|
||||
if (item) {
|
||||
if (isScroll) {
|
||||
item.msgCommand = "scrollmsg";
|
||||
}
|
||||
root.backend = item;
|
||||
setupBackendConnections();
|
||||
backend.initialize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load display scales from ShellState
|
||||
function loadDisplayScalesFromState() {
|
||||
try {
|
||||
const cached = ShellState.getDisplay();
|
||||
if (cached && Object.keys(cached).length > 0) {
|
||||
displayScales = cached;
|
||||
displayScalesLoaded = true;
|
||||
Logger.d("CompositorService", "Loaded display scales from ShellState");
|
||||
} else {
|
||||
// Migration is now handled in Settings.qml
|
||||
displayScalesLoaded = true;
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.e("CompositorService", "Failed to load display scales:", error);
|
||||
displayScalesLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Hyprland backend component
|
||||
Component {
|
||||
id: hyprlandComponent
|
||||
HyprlandService {
|
||||
id: hyprlandBackend
|
||||
}
|
||||
}
|
||||
|
||||
// Niri backend component
|
||||
Component {
|
||||
id: niriComponent
|
||||
NiriService {
|
||||
id: niriBackend
|
||||
}
|
||||
}
|
||||
|
||||
// Sway backend component
|
||||
Component {
|
||||
id: swayComponent
|
||||
SwayService {
|
||||
id: swayBackend
|
||||
}
|
||||
}
|
||||
|
||||
// Mango backend component
|
||||
Component {
|
||||
id: mangoComponent
|
||||
MangoService {
|
||||
id: mangoBackend
|
||||
}
|
||||
}
|
||||
|
||||
// Labwc backend component
|
||||
Component {
|
||||
id: labwcComponent
|
||||
LabwcService {
|
||||
id: labwcBackend
|
||||
}
|
||||
}
|
||||
|
||||
// Generic ext-workspace (WindowManager) when compositor env is unknown
|
||||
Component {
|
||||
id: extWorkspaceComponent
|
||||
ExtWorkspaceService {
|
||||
id: extWorkspaceBackend
|
||||
}
|
||||
}
|
||||
|
||||
function setupBackendConnections() {
|
||||
if (!backend)
|
||||
return;
|
||||
|
||||
// Connect backend signals to facade signals
|
||||
backend.workspaceChanged.connect(() => {
|
||||
// Sync workspaces when they change
|
||||
syncWorkspaces();
|
||||
// Forward the signal
|
||||
workspaceChanged();
|
||||
});
|
||||
|
||||
backend.activeWindowChanged.connect(() => {
|
||||
// Only sync focus state, not entire window list
|
||||
syncFocusedWindow();
|
||||
// Forward the signal
|
||||
activeWindowChanged();
|
||||
});
|
||||
|
||||
backend.windowListChanged.connect(() => {
|
||||
syncWindows();
|
||||
});
|
||||
|
||||
// Property bindings - use automatic property change signal
|
||||
backend.focusedWindowIndexChanged.connect(() => {
|
||||
focusedWindowIndex = backend.focusedWindowIndex;
|
||||
});
|
||||
|
||||
// Overview state (Niri-specific)
|
||||
if (backend.overviewActiveChanged) {
|
||||
backend.overviewActiveChanged.connect(() => {
|
||||
overviewActive = backend.overviewActive;
|
||||
});
|
||||
}
|
||||
|
||||
// Initial sync
|
||||
syncWorkspaces();
|
||||
syncWindows();
|
||||
focusedWindowIndex = backend.focusedWindowIndex;
|
||||
if (backend.overviewActive !== undefined) {
|
||||
overviewActive = backend.overviewActive;
|
||||
}
|
||||
if (backend.globalWorkspaces !== undefined) {
|
||||
globalWorkspaces = backend.globalWorkspaces;
|
||||
}
|
||||
}
|
||||
|
||||
function syncWorkspaces() {
|
||||
workspaces.clear();
|
||||
const ws = backend.workspaces;
|
||||
for (var i = 0; i < ws.count; i++) {
|
||||
workspaces.append(ws.get(i));
|
||||
}
|
||||
// Emit signal to notify listeners that workspace list has been updated
|
||||
workspacesChanged();
|
||||
}
|
||||
|
||||
function syncWindows() {
|
||||
windows.clear();
|
||||
const ws = backend.windows;
|
||||
for (var i = 0; i < ws.length; i++) {
|
||||
windows.append(ws[i]);
|
||||
}
|
||||
// Emit signal to notify listeners that window list has been updated
|
||||
windowListChanged();
|
||||
}
|
||||
|
||||
// Sync only the focused window state, not the entire window list
|
||||
function syncFocusedWindow() {
|
||||
const newIndex = backend.focusedWindowIndex;
|
||||
|
||||
// Update isFocused flags by syncing from backend
|
||||
for (var i = 0; i < windows.count && i < backend.windows.length; i++) {
|
||||
const backendFocused = backend.windows[i].isFocused;
|
||||
if (windows.get(i).isFocused !== backendFocused) {
|
||||
windows.setProperty(i, "isFocused", backendFocused);
|
||||
}
|
||||
}
|
||||
|
||||
focusedWindowIndex = newIndex;
|
||||
}
|
||||
|
||||
// Update display scales from backend
|
||||
function updateDisplayScales() {
|
||||
if (!backend || !backend.queryDisplayScales) {
|
||||
Logger.w("CompositorService", "Backend does not support display scale queries");
|
||||
return;
|
||||
}
|
||||
|
||||
backend.queryDisplayScales();
|
||||
}
|
||||
|
||||
// Called by backend when display scales are ready
|
||||
function onDisplayScalesUpdated(scales) {
|
||||
displayScales = scales;
|
||||
saveDisplayScalesToCache();
|
||||
Logger.d("CompositorService", "Display scales updated");
|
||||
}
|
||||
|
||||
// Save display scales to cache
|
||||
function saveDisplayScalesToCache() {
|
||||
try {
|
||||
ShellState.setDisplay(displayScales);
|
||||
Logger.d("CompositorService", "Saved display scales to ShellState");
|
||||
} catch (error) {
|
||||
Logger.e("CompositorService", "Failed to save display scales:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Public function to get scale for a specific display
|
||||
function getDisplayScale(displayName) {
|
||||
if (!displayName || !displayScales[displayName]) {
|
||||
return 1.0;
|
||||
}
|
||||
return displayScales[displayName].scale || 1.0;
|
||||
}
|
||||
|
||||
// Public function to get all display info for a specific display
|
||||
function getDisplayInfo(displayName) {
|
||||
if (!displayName || !displayScales[displayName]) {
|
||||
return null;
|
||||
}
|
||||
return displayScales[displayName];
|
||||
}
|
||||
|
||||
// Get focused window
|
||||
function getFocusedWindow() {
|
||||
if (focusedWindowIndex >= 0 && focusedWindowIndex < windows.count) {
|
||||
return windows.get(focusedWindowIndex);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get focused screen from compositor
|
||||
function getFocusedScreen() {
|
||||
if (backend && backend.getFocusedScreen) {
|
||||
return backend.getFocusedScreen();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get focused window title
|
||||
function getFocusedWindowTitle() {
|
||||
if (focusedWindowIndex >= 0 && focusedWindowIndex < windows.count) {
|
||||
var title = windows.get(focusedWindowIndex).title;
|
||||
if (title !== undefined) {
|
||||
title = title.replace(/(\r\n|\n|\r)/g, "");
|
||||
}
|
||||
return title || "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// Get clean app name from appId
|
||||
// Extracts the last segment from reverse domain notation (e.g., "org.kde.dolphin" -> "Dolphin")
|
||||
// Falls back to title if appId is empty
|
||||
function getCleanAppName(appId, fallbackTitle) {
|
||||
var name = (appId || "").split(".").pop() || fallbackTitle || "Unknown";
|
||||
return name.charAt(0).toUpperCase() + name.slice(1);
|
||||
}
|
||||
|
||||
function getWindowsForWorkspace(workspaceId) {
|
||||
var windowsInWs = [];
|
||||
for (var i = 0; i < windows.count; i++) {
|
||||
var window = windows.get(i);
|
||||
if (window.workspaceId === workspaceId) {
|
||||
// Snapshot to plain JS object so callers never hold live ListModel
|
||||
// proxies that become invalid when syncWindows() clears the model.
|
||||
windowsInWs.push({
|
||||
id: window.id,
|
||||
title: window.title,
|
||||
appId: window.appId,
|
||||
isFocused: window.isFocused,
|
||||
workspaceId: window.workspaceId,
|
||||
handle: window.handle
|
||||
});
|
||||
}
|
||||
}
|
||||
return windowsInWs;
|
||||
}
|
||||
|
||||
// Generic workspace switching
|
||||
function switchToWorkspace(workspace) {
|
||||
if (backend && backend.switchToWorkspace) {
|
||||
backend.switchToWorkspace(workspace);
|
||||
} else {
|
||||
Logger.w("Compositor", "No backend available for workspace switching");
|
||||
}
|
||||
}
|
||||
|
||||
// Scrollable workspace content (Niri)
|
||||
function scrollWorkspaceContent(direction) {
|
||||
if (backend && backend.scrollWorkspaceContent) {
|
||||
backend.scrollWorkspaceContent(direction);
|
||||
}
|
||||
}
|
||||
|
||||
// Get current workspace
|
||||
function getCurrentWorkspace() {
|
||||
for (var i = 0; i < workspaces.count; i++) {
|
||||
const ws = workspaces.get(i);
|
||||
if (ws.isFocused) {
|
||||
return ws;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get active workspaces
|
||||
function getActiveWorkspaces() {
|
||||
const activeWorkspaces = [];
|
||||
for (var i = 0; i < workspaces.count; i++) {
|
||||
const ws = workspaces.get(i);
|
||||
if (ws.isActive) {
|
||||
activeWorkspaces.push(ws);
|
||||
}
|
||||
}
|
||||
return activeWorkspaces;
|
||||
}
|
||||
|
||||
// Set focused window
|
||||
function focusWindow(window) {
|
||||
if (backend && backend.focusWindow) {
|
||||
backend.focusWindow(window);
|
||||
} else {
|
||||
Logger.w("Compositor", "No backend available for window focus");
|
||||
}
|
||||
}
|
||||
|
||||
// Close window
|
||||
function closeWindow(window) {
|
||||
if (backend && backend.closeWindow) {
|
||||
backend.closeWindow(window);
|
||||
} else {
|
||||
Logger.w("Compositor", "No backend available for window closing");
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn command
|
||||
function spawn(command) {
|
||||
// Ensure command is a proper JS array (QML lists can behave unexpectedly in some contexts)
|
||||
const cmdArray = Array.isArray(command) ? command : (command && typeof command === "object" && command.length !== undefined) ? Array.from(command) : [command];
|
||||
|
||||
Logger.d("CompositorService", `Spawning: ${cmdArray.join(" ")}`);
|
||||
if (backend && backend.spawn) {
|
||||
backend.spawn(cmdArray);
|
||||
} else {
|
||||
try {
|
||||
Quickshell.execDetached(cmdArray);
|
||||
} catch (e) {
|
||||
Logger.e("CompositorService", "Failed to execute detached:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Session management helper for custom commands
|
||||
function getCustomCommand(action) {
|
||||
const powerOptions = Settings.data.sessionMenu.powerOptions || [];
|
||||
for (let i = 0; i < powerOptions.length; i++) {
|
||||
const option = powerOptions[i];
|
||||
if (option.action === action && option.enabled && option.command && option.command.trim() !== "") {
|
||||
return option.command.trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function executeSessionAction(action, defaultCommand) {
|
||||
const customCommand = getCustomCommand(action);
|
||||
if (customCommand) {
|
||||
Logger.i("Compositor", `Executing custom command for action: ${action} Command: ${customCommand}`);
|
||||
Quickshell.execDetached(["sh", "-c", customCommand]);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Session management
|
||||
function logout() {
|
||||
Logger.i("Compositor", "Logout requested");
|
||||
if (executeSessionAction("logout"))
|
||||
return;
|
||||
|
||||
if (backend && backend.logout) {
|
||||
backend.logout();
|
||||
} else {
|
||||
Logger.w("Compositor", "No backend available for logout");
|
||||
}
|
||||
}
|
||||
|
||||
function shutdown() {
|
||||
Logger.i("Compositor", "Shutdown requested");
|
||||
if (executeSessionAction("shutdown"))
|
||||
return;
|
||||
|
||||
HooksService.executeSessionHook("shutdown", () => {
|
||||
Quickshell.execDetached(["sh", "-c", "systemctl poweroff || loginctl poweroff"]);
|
||||
});
|
||||
}
|
||||
|
||||
function reboot() {
|
||||
Logger.i("Compositor", "Reboot requested");
|
||||
if (executeSessionAction("reboot"))
|
||||
return;
|
||||
|
||||
HooksService.executeSessionHook("reboot", () => {
|
||||
Quickshell.execDetached(["sh", "-c", "systemctl reboot || loginctl reboot"]);
|
||||
});
|
||||
}
|
||||
|
||||
function userspaceReboot() {
|
||||
Logger.i("Compositor", "Userspace reboot requested");
|
||||
if (executeSessionAction("userspaceReboot"))
|
||||
return;
|
||||
|
||||
HooksService.executeSessionHook("userspaceReboot", () => {
|
||||
Quickshell.execDetached(["sh", "-c", "systemctl soft-reboot"]);
|
||||
});
|
||||
}
|
||||
|
||||
function rebootToUefi() {
|
||||
Logger.i("Compositor", "Reboot to UEFI firmware requested requested");
|
||||
if (executeSessionAction("rebootToUefi"))
|
||||
return;
|
||||
|
||||
HooksService.executeSessionHook("rebootToUefi", () => {
|
||||
Quickshell.execDetached(["sh", "-c", "systemctl reboot --firmware-setup || loginctl reboot --firmware-setup"]);
|
||||
});
|
||||
}
|
||||
|
||||
function turnOffMonitors() {
|
||||
Logger.i("Compositor", "Turn off monitors requested");
|
||||
if (backend && backend.turnOffMonitors) {
|
||||
backend.turnOffMonitors();
|
||||
} else {
|
||||
Logger.w("Compositor", "No backend available for turnOffMonitors");
|
||||
}
|
||||
}
|
||||
|
||||
function turnOnMonitors() {
|
||||
Logger.i("Compositor", "Turn on monitors requested");
|
||||
if (backend && backend.turnOnMonitors) {
|
||||
backend.turnOnMonitors();
|
||||
} else {
|
||||
Logger.w("Compositor", "No backend available for turnOnMonitors");
|
||||
}
|
||||
}
|
||||
|
||||
function suspend() {
|
||||
Logger.i("Compositor", "Suspend requested");
|
||||
if (executeSessionAction("suspend"))
|
||||
return;
|
||||
|
||||
Quickshell.execDetached(["sh", "-c", "systemctl suspend || loginctl suspend"]);
|
||||
}
|
||||
|
||||
function lock() {
|
||||
Logger.i("Compositor", "LockScreen requested");
|
||||
if (executeSessionAction("lock"))
|
||||
return;
|
||||
|
||||
if (PanelService && PanelService.lockScreen) {
|
||||
PanelService.lockScreen.active = true;
|
||||
}
|
||||
}
|
||||
|
||||
function hibernate() {
|
||||
Logger.i("Compositor", "Hibernate requested");
|
||||
if (executeSessionAction("hibernate"))
|
||||
return;
|
||||
|
||||
Quickshell.execDetached(["sh", "-c", "systemctl hibernate || loginctl hibernate"]);
|
||||
}
|
||||
|
||||
function cycleKeyboardLayout() {
|
||||
if (backend && backend.cycleKeyboardLayout) {
|
||||
backend.cycleKeyboardLayout();
|
||||
}
|
||||
}
|
||||
|
||||
property int lockAndSuspendCheckCount: 0
|
||||
|
||||
function lockAndSuspend() {
|
||||
Logger.i("Compositor", "Lock and suspend requested");
|
||||
|
||||
// if a custom lock command exists, execute it and suspend without wait
|
||||
if (executeSessionAction("lock")) {
|
||||
suspend();
|
||||
return;
|
||||
}
|
||||
|
||||
// If already locked, suspend immediately
|
||||
if (PanelService && PanelService.lockScreen && PanelService.lockScreen.active) {
|
||||
Logger.i("Compositor", "Screen already locked, suspending");
|
||||
suspend();
|
||||
return;
|
||||
}
|
||||
|
||||
// Lock the screen first
|
||||
try {
|
||||
if (PanelService && PanelService.lockScreen) {
|
||||
PanelService.lockScreen.active = true;
|
||||
lockAndSuspendCheckCount = 0;
|
||||
|
||||
// Wait for lock screen to be confirmed active before suspending
|
||||
lockAndSuspendTimer.start();
|
||||
} else {
|
||||
Logger.w("Compositor", "Lock screen not available, suspending without lock");
|
||||
suspend();
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.w("Compositor", "Failed to activate lock screen before suspend: " + e);
|
||||
suspend();
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: lockAndSuspendTimer
|
||||
interval: 100
|
||||
repeat: true
|
||||
running: false
|
||||
|
||||
onTriggered: {
|
||||
lockAndSuspendCheckCount++;
|
||||
|
||||
// Check if lock screen is now active
|
||||
if (PanelService && PanelService.lockScreen && PanelService.lockScreen.active) {
|
||||
// Verify the lock screen component is loaded
|
||||
if (PanelService.lockScreen.item) {
|
||||
Logger.i("Compositor", "Lock screen confirmed active, suspending");
|
||||
stop();
|
||||
lockAndSuspendCheckCount = 0;
|
||||
suspend();
|
||||
} else {
|
||||
// Lock screen is active but component not loaded yet, wait a bit more
|
||||
if (lockAndSuspendCheckCount > 20) {
|
||||
// Max 2 seconds wait
|
||||
Logger.w("Compositor", "Lock screen active but component not loaded, suspending anyway");
|
||||
stop();
|
||||
lockAndSuspendCheckCount = 0;
|
||||
suspend();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Lock screen not active yet, keep checking
|
||||
if (lockAndSuspendCheckCount > 30) {
|
||||
// Max 3 seconds wait
|
||||
Logger.w("Compositor", "Lock screen failed to activate, suspending anyway");
|
||||
stop();
|
||||
lockAndSuspendCheckCount = 0;
|
||||
suspend();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.WindowManager
|
||||
import qs.Commons
|
||||
|
||||
// Generic ext-workspace-v1 + toplevel handling
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property ListModel workspaces: ListModel {}
|
||||
property var windows: []
|
||||
property int focusedWindowIndex: -1
|
||||
property var trackedToplevels: new Set()
|
||||
|
||||
property bool globalWorkspaces: false
|
||||
|
||||
property var nativeWorkspaceMap: ({})
|
||||
property var connectedWorkspaces: ({})
|
||||
|
||||
signal workspaceChanged
|
||||
signal activeWindowChanged
|
||||
signal windowListChanged
|
||||
signal displayScalesChanged
|
||||
|
||||
function initialize() {
|
||||
updateWindows();
|
||||
connectWorkspaceSignals();
|
||||
syncWorkspaces();
|
||||
Logger.i("ExtWorkspaceService", "Service started (generic ext-workspace-v1)");
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: WindowManager
|
||||
|
||||
function onWindowsetsChanged() {
|
||||
root.connectWorkspaceSignals();
|
||||
Qt.callLater(root.syncWorkspaces);
|
||||
}
|
||||
|
||||
function onWindowsetProjectionsChanged() {
|
||||
Qt.callLater(root.syncWorkspaces);
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 500
|
||||
running: true
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
if (WindowManager.windowsets.length > 0) {
|
||||
root.connectWorkspaceSignals();
|
||||
root.syncWorkspaces();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function connectWorkspaceSignals() {
|
||||
const nativeWs = WindowManager.windowsets;
|
||||
const newConnected = {};
|
||||
|
||||
for (const ws of nativeWs) {
|
||||
const key = ws.id || ws.toString();
|
||||
newConnected[key] = true;
|
||||
|
||||
if (connectedWorkspaces[key])
|
||||
continue;
|
||||
|
||||
ws.activeChanged.connect(() => {
|
||||
Qt.callLater(root.syncWorkspaces);
|
||||
});
|
||||
|
||||
ws.urgentChanged.connect(() => {
|
||||
Qt.callLater(root.syncWorkspaces);
|
||||
});
|
||||
|
||||
ws.shouldDisplayChanged.connect(() => {
|
||||
Qt.callLater(root.syncWorkspaces);
|
||||
});
|
||||
|
||||
ws.nameChanged.connect(() => {
|
||||
Qt.callLater(root.syncWorkspaces);
|
||||
});
|
||||
}
|
||||
|
||||
connectedWorkspaces = newConnected;
|
||||
}
|
||||
|
||||
function syncWorkspaces() {
|
||||
const nativeWs = WindowManager.windowsets;
|
||||
|
||||
workspaces.clear();
|
||||
nativeWorkspaceMap = {};
|
||||
|
||||
/* Per-output (projection) index: compositors expose one workspace group per
|
||||
* monitor with names "1"…"9". A single global idx produced 10–18 on the
|
||||
* second head because all windowsets were merged into one list. */
|
||||
const perOutputNextIdx = {};
|
||||
|
||||
for (const ws of nativeWs) {
|
||||
if (!ws.shouldDisplay) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let outputName = "";
|
||||
if (ws.projection) {
|
||||
const projScreens = ws.projection.screens;
|
||||
if (projScreens && projScreens.length > 0) {
|
||||
outputName = projScreens[0].name || "";
|
||||
}
|
||||
}
|
||||
|
||||
const groupKey = outputName || "_";
|
||||
let idx;
|
||||
const numericName = ws.name && /^\d+$/.test(String(ws.name)) ? parseInt(ws.name, 10) : NaN;
|
||||
if (!isNaN(numericName) && numericName >= 1) {
|
||||
idx = numericName;
|
||||
} else {
|
||||
if (perOutputNextIdx[groupKey] === undefined) {
|
||||
perOutputNextIdx[groupKey] = 1;
|
||||
}
|
||||
idx = perOutputNextIdx[groupKey]++;
|
||||
}
|
||||
|
||||
const wsEntry = {
|
||||
"id": ws.id || idx.toString(),
|
||||
"idx": idx,
|
||||
"name": ws.name || ("Workspace " + idx),
|
||||
"output": outputName,
|
||||
"isFocused": ws.active,
|
||||
"isActive": true,
|
||||
"isUrgent": ws.urgent,
|
||||
"isOccupied": false,
|
||||
"oid": ws.id || idx.toString()
|
||||
};
|
||||
|
||||
workspaces.append(wsEntry);
|
||||
nativeWorkspaceMap[wsEntry.id] = ws;
|
||||
}
|
||||
|
||||
updateWindowWorkspaces();
|
||||
workspaceChanged();
|
||||
}
|
||||
|
||||
function updateWindowWorkspaces() {
|
||||
let activeId = "";
|
||||
for (let i = 0; i < workspaces.count; i++) {
|
||||
const ws = workspaces.get(i);
|
||||
if (ws.isFocused) {
|
||||
activeId = ws.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < windows.length; i++) {
|
||||
if (activeId) {
|
||||
windows[i].workspaceId = activeId;
|
||||
}
|
||||
}
|
||||
windowListChanged();
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: ToplevelManager.toplevels
|
||||
function onValuesChanged() {
|
||||
updateWindows();
|
||||
}
|
||||
}
|
||||
|
||||
function connectToToplevel(toplevel) {
|
||||
if (!toplevel)
|
||||
return;
|
||||
|
||||
toplevel.activatedChanged.connect(() => {
|
||||
Qt.callLater(onToplevelActivationChanged);
|
||||
});
|
||||
|
||||
toplevel.titleChanged.connect(() => {
|
||||
Qt.callLater(updateWindows);
|
||||
});
|
||||
}
|
||||
|
||||
function onToplevelActivationChanged() {
|
||||
updateWindows();
|
||||
activeWindowChanged();
|
||||
}
|
||||
|
||||
function updateWindows() {
|
||||
const newWindows = [];
|
||||
const toplevels = ToplevelManager.toplevels?.values || [];
|
||||
|
||||
let focusedIdx = -1;
|
||||
let idx = 0;
|
||||
|
||||
let activeId = "";
|
||||
for (let i = 0; i < workspaces.count; i++) {
|
||||
const ws = workspaces.get(i);
|
||||
if (ws.isFocused) {
|
||||
activeId = ws.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (const toplevel of toplevels) {
|
||||
if (!toplevel)
|
||||
continue;
|
||||
|
||||
if (!trackedToplevels.has(toplevel)) {
|
||||
connectToToplevel(toplevel);
|
||||
trackedToplevels.add(toplevel);
|
||||
}
|
||||
|
||||
const output = (toplevel.screens && toplevel.screens.length > 0) ? (toplevel.screens[0].name || "") : "";
|
||||
|
||||
const windowId = (toplevel.appId || "") + ":" + idx;
|
||||
|
||||
newWindows.push({
|
||||
"id": windowId,
|
||||
"appId": toplevel.appId || "",
|
||||
"title": toplevel.title || "",
|
||||
"output": output,
|
||||
"workspaceId": activeId || "1",
|
||||
"isFocused": toplevel.activated || false,
|
||||
"toplevel": toplevel
|
||||
});
|
||||
|
||||
if (toplevel.activated) {
|
||||
focusedIdx = idx;
|
||||
}
|
||||
idx++;
|
||||
}
|
||||
windows = newWindows;
|
||||
focusedWindowIndex = focusedIdx;
|
||||
|
||||
windowListChanged();
|
||||
}
|
||||
|
||||
function focusWindow(window) {
|
||||
if (window.toplevel && typeof window.toplevel.activate === "function") {
|
||||
window.toplevel.activate();
|
||||
}
|
||||
}
|
||||
|
||||
function closeWindow(window) {
|
||||
if (window.toplevel && typeof window.toplevel.close === "function") {
|
||||
window.toplevel.close();
|
||||
}
|
||||
}
|
||||
|
||||
function switchToWorkspace(workspace) {
|
||||
const nativeWs = nativeWorkspaceMap[workspace.id] || nativeWorkspaceMap[workspace.oid];
|
||||
if (nativeWs && nativeWs.canActivate) {
|
||||
nativeWs.activate();
|
||||
} else {
|
||||
Logger.w("ExtWorkspaceService", "Cannot activate workspace: " + (workspace.name || workspace.id));
|
||||
}
|
||||
}
|
||||
|
||||
function turnOffMonitors() {
|
||||
try {
|
||||
Quickshell.execDetached(["wlr-randr", "--off"]);
|
||||
} catch (e) {
|
||||
Logger.e("ExtWorkspaceService", "Failed to turn off monitors:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function turnOnMonitors() {
|
||||
try {
|
||||
Quickshell.execDetached(["wlr-randr", "--on"]);
|
||||
} catch (e) {
|
||||
Logger.e("ExtWorkspaceService", "Failed to turn on monitors:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
const sid = Quickshell.env("XDG_SESSION_ID");
|
||||
try {
|
||||
if (sid && sid.length > 0) {
|
||||
Quickshell.execDetached(["loginctl", "terminate-session", sid]);
|
||||
} else {
|
||||
Logger.w("ExtWorkspaceService", "logout: XDG_SESSION_ID unset; use session menu custom command or compositor-specific backend");
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.e("ExtWorkspaceService", "Failed to logout:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function cycleKeyboardLayout() {
|
||||
Logger.w("ExtWorkspaceService", "Keyboard layout cycling not supported");
|
||||
}
|
||||
|
||||
function queryDisplayScales() {
|
||||
Logger.w("ExtWorkspaceService", "Display scale queries not supported via ToplevelManager");
|
||||
}
|
||||
|
||||
function getFocusedScreen() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
import qs.Services.Keyboard
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// Properties that match the facade interface
|
||||
property ListModel workspaces: ListModel {}
|
||||
property var windows: []
|
||||
property int focusedWindowIndex: -1
|
||||
|
||||
// Signals that match the facade interface
|
||||
signal workspaceChanged
|
||||
signal activeWindowChanged
|
||||
signal windowListChanged
|
||||
signal displayScalesChanged
|
||||
|
||||
// Hyprland-specific properties
|
||||
property bool initialized: false
|
||||
property var workspaceCache: ({})
|
||||
property var windowCache: ({})
|
||||
|
||||
// Debounce timer for window updates
|
||||
Timer {
|
||||
id: updateTimer
|
||||
interval: 50
|
||||
repeat: false
|
||||
onTriggered: safeUpdate()
|
||||
}
|
||||
|
||||
// Deferred via Qt.callLater to coalesce workspace updates: onRawEvent calls
|
||||
// refreshWorkspaces() which triggers onValuesChanged synchronously in the
|
||||
// same call stack — without deferral the ListModel gets cleared+repopulated
|
||||
// twice per event. Qt.callLater deduplicates by function identity.
|
||||
function _deferredWorkspaceUpdate() {
|
||||
safeUpdateWorkspaces();
|
||||
workspaceChanged();
|
||||
}
|
||||
|
||||
// Initialization
|
||||
function initialize() {
|
||||
if (initialized)
|
||||
return;
|
||||
try {
|
||||
Hyprland.refreshWorkspaces();
|
||||
Hyprland.refreshToplevels();
|
||||
Qt.callLater(() => {
|
||||
safeUpdateWorkspaces();
|
||||
safeUpdateWindows();
|
||||
queryDisplayScales();
|
||||
queryKeyboardLayout();
|
||||
});
|
||||
initialized = true;
|
||||
Logger.i("HyprlandService", "Service started");
|
||||
} catch (e) {
|
||||
Logger.e("HyprlandService", "Failed to initialize:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Query display scales
|
||||
function queryDisplayScales() {
|
||||
hyprlandMonitorsProcess.running = true;
|
||||
}
|
||||
|
||||
// Hyprland monitors process for display scale detection
|
||||
Process {
|
||||
id: hyprlandMonitorsProcess
|
||||
running: false
|
||||
command: ["hyprctl", "monitors", "-j"]
|
||||
|
||||
property string accumulatedOutput: ""
|
||||
|
||||
stdout: SplitParser {
|
||||
onRead: function (line) {
|
||||
// Accumulate lines instead of parsing each one
|
||||
hyprlandMonitorsProcess.accumulatedOutput += line;
|
||||
}
|
||||
}
|
||||
|
||||
onExited: function (exitCode) {
|
||||
if (exitCode !== 0 || !accumulatedOutput) {
|
||||
Logger.e("HyprlandService", "Failed to query monitors, exit code:", exitCode);
|
||||
accumulatedOutput = "";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const monitorsData = JSON.parse(accumulatedOutput);
|
||||
const scales = {};
|
||||
|
||||
for (const monitor of monitorsData) {
|
||||
if (monitor.name) {
|
||||
scales[monitor.name] = {
|
||||
"name": monitor.name,
|
||||
"scale": monitor.scale || 1.0,
|
||||
"width": monitor.width || 0,
|
||||
"height": monitor.height || 0,
|
||||
"refresh_rate": monitor.refreshRate || 0,
|
||||
"x": monitor.x || 0,
|
||||
"y": monitor.y || 0,
|
||||
"active_workspace": monitor.activeWorkspace ? monitor.activeWorkspace.id : -1,
|
||||
"vrr": monitor.vrr || false,
|
||||
"focused": monitor.focused || false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Notify CompositorService (it will emit displayScalesChanged)
|
||||
if (CompositorService && CompositorService.onDisplayScalesUpdated) {
|
||||
CompositorService.onDisplayScalesUpdated(scales);
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.e("HyprlandService", "Failed to parse monitors:", e);
|
||||
} finally {
|
||||
// Clear accumulated output for next query
|
||||
accumulatedOutput = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function queryKeyboardLayout() {
|
||||
hyprlandDevicesProcess.running = true;
|
||||
}
|
||||
// Hyprland devices process for keyboard layout detection
|
||||
Process {
|
||||
id: hyprlandDevicesProcess
|
||||
running: false
|
||||
command: ["hyprctl", "devices", "-j"]
|
||||
|
||||
property string accumulatedOutput: ""
|
||||
|
||||
stdout: SplitParser {
|
||||
onRead: function (line) {
|
||||
// Accumulate lines instead of parsing each one
|
||||
hyprlandDevicesProcess.accumulatedOutput += line;
|
||||
}
|
||||
}
|
||||
|
||||
onExited: function (exitCode) {
|
||||
if (exitCode !== 0 || !accumulatedOutput) {
|
||||
Logger.e("HyprlandService", "Failed to query devices, exit code:", exitCode);
|
||||
accumulatedOutput = "";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const devicesData = JSON.parse(accumulatedOutput);
|
||||
for (const keyboard of devicesData.keyboards) {
|
||||
if (keyboard.main) {
|
||||
const layoutName = keyboard.active_keymap;
|
||||
KeyboardLayoutService.setCurrentLayout(layoutName);
|
||||
Logger.d("HyprlandService", "Keyboard layout switched:", layoutName);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.e("HyprlandService", "Failed to parse devices:", e);
|
||||
} finally {
|
||||
// Clear accumulated output for next query
|
||||
accumulatedOutput = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Safe update wrapper
|
||||
function safeUpdate() {
|
||||
safeUpdateWindows();
|
||||
safeUpdateWorkspaces();
|
||||
workspaceChanged();
|
||||
windowListChanged();
|
||||
}
|
||||
|
||||
// Safe workspace update
|
||||
function safeUpdateWorkspaces() {
|
||||
try {
|
||||
workspaces.clear();
|
||||
workspaceCache = {};
|
||||
|
||||
if (!Hyprland.workspaces || !Hyprland.workspaces.values) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hlWorkspaces = Hyprland.workspaces.values;
|
||||
const occupiedIds = getOccupiedWorkspaceIds();
|
||||
|
||||
for (var i = 0; i < hlWorkspaces.length; i++) {
|
||||
const ws = hlWorkspaces[i];
|
||||
if (ws.name && ws.name.startsWith("special:"))
|
||||
continue;
|
||||
|
||||
const wsData = {
|
||||
"id": ws.id,
|
||||
"idx": ws.id,
|
||||
"name": ws.name || "",
|
||||
"output": (ws.monitor && ws.monitor.name) ? ws.monitor.name : "",
|
||||
"isActive": ws.active === true,
|
||||
"isFocused": ws.focused === true,
|
||||
"isUrgent": ws.urgent === true,
|
||||
"isOccupied": occupiedIds[ws.id] === true
|
||||
};
|
||||
|
||||
workspaceCache[ws.id] = wsData;
|
||||
workspaces.append(wsData);
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.e("HyprlandService", "Error updating workspaces:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Get occupied workspace IDs safely
|
||||
function getOccupiedWorkspaceIds() {
|
||||
const occupiedIds = {};
|
||||
|
||||
try {
|
||||
if (!Hyprland.toplevels || !Hyprland.toplevels.values) {
|
||||
return occupiedIds;
|
||||
}
|
||||
|
||||
const hlToplevels = Hyprland.toplevels.values;
|
||||
for (var i = 0; i < hlToplevels.length; i++) {
|
||||
const toplevel = hlToplevels[i];
|
||||
if (!toplevel)
|
||||
continue;
|
||||
try {
|
||||
const wsId = toplevel.workspace ? toplevel.workspace.id : null;
|
||||
if (wsId !== null && wsId !== undefined) {
|
||||
occupiedIds[wsId] = true;
|
||||
}
|
||||
} catch (e)
|
||||
|
||||
// Ignore individual toplevel errors
|
||||
{}
|
||||
}
|
||||
} catch (e)
|
||||
|
||||
// Return empty if we can't determine occupancy
|
||||
{}
|
||||
|
||||
return occupiedIds;
|
||||
}
|
||||
|
||||
// Safe window update
|
||||
function safeUpdateWindows() {
|
||||
try {
|
||||
const windowsList = [];
|
||||
windowCache = {};
|
||||
|
||||
if (!Hyprland.toplevels || !Hyprland.toplevels.values) {
|
||||
windows = [];
|
||||
focusedWindowIndex = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
const hlToplevels = Hyprland.toplevels.values;
|
||||
let focusedWindowId = null;
|
||||
|
||||
// Get active workspaces to filter focus
|
||||
const activeWorkspaceIds = {};
|
||||
if (Hyprland.workspaces && Hyprland.workspaces.values) {
|
||||
const hlWorkspaces = Hyprland.workspaces.values;
|
||||
for (var j = 0; j < hlWorkspaces.length; j++) {
|
||||
if (hlWorkspaces[j].active) {
|
||||
activeWorkspaceIds[hlWorkspaces[j].id] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < hlToplevels.length; i++) {
|
||||
const toplevel = hlToplevels[i];
|
||||
if (!toplevel)
|
||||
continue;
|
||||
const windowData = extractWindowData(toplevel);
|
||||
if (windowData) {
|
||||
// If the window claims to be focused, verify it's on an active workspace
|
||||
if (windowData.isFocused) {
|
||||
if (!activeWorkspaceIds[windowData.workspaceId]) {
|
||||
windowData.isFocused = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize to a plain, backend-independent window object
|
||||
const normalized = {
|
||||
"id": windowData.id ? String(windowData.id) : "",
|
||||
"title": windowData.title ? String(windowData.title) : "",
|
||||
"appId": windowData.appId ? String(windowData.appId) : "",
|
||||
"workspaceId": (typeof windowData.workspaceId === "number" && !isNaN(windowData.workspaceId)) ? windowData.workspaceId : -1,
|
||||
"isFocused": windowData.isFocused === true,
|
||||
"output": windowData.output ? String(windowData.output) : "",
|
||||
"x": (typeof windowData.x === "number" && !isNaN(windowData.x)) ? windowData.x : 0,
|
||||
"y": (typeof windowData.y === "number" && !isNaN(windowData.y)) ? windowData.y : 0
|
||||
};
|
||||
|
||||
windowsList.push(normalized);
|
||||
windowCache[normalized.id] = normalized;
|
||||
|
||||
if (normalized.isFocused) {
|
||||
focusedWindowId = normalized.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
windows = toSortedWindowList(windowsList);
|
||||
|
||||
// Resolve focused index from sorted list (order changes after sort)
|
||||
let newFocusedIndex = -1;
|
||||
if (focusedWindowId) {
|
||||
for (let k = 0; k < windows.length; k++) {
|
||||
if (windows[k].id === focusedWindowId) {
|
||||
newFocusedIndex = k;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newFocusedIndex !== focusedWindowIndex) {
|
||||
focusedWindowIndex = newFocusedIndex;
|
||||
activeWindowChanged();
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.e("HyprlandService", "Error updating windows:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract window data safely from a toplevel
|
||||
function extractWindowData(toplevel) {
|
||||
if (!toplevel)
|
||||
return null;
|
||||
|
||||
try {
|
||||
// Safely extract properties
|
||||
const windowId = safeGetProperty(toplevel, "address", "");
|
||||
if (!windowId)
|
||||
return null;
|
||||
|
||||
const appId = getAppId(toplevel);
|
||||
const title = getAppTitle(toplevel);
|
||||
const wsId = toplevel.workspace ? toplevel.workspace.id : null;
|
||||
const focused = toplevel.activated === true;
|
||||
const output = toplevel.monitor?.name || "";
|
||||
|
||||
// Extract position
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
try {
|
||||
const ipcData = toplevel.lastIpcObject;
|
||||
if (ipcData && ipcData.at) {
|
||||
x = ipcData.at[0];
|
||||
y = ipcData.at[1];
|
||||
} else if (typeof toplevel.x !== 'undefined') {
|
||||
x = toplevel.x;
|
||||
y = toplevel.y;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// Normalize coordinates to safe numeric values
|
||||
const safeX = (typeof x === "number" && !isNaN(x)) ? x : 0;
|
||||
const safeY = (typeof y === "number" && !isNaN(y)) ? y : 0;
|
||||
|
||||
return {
|
||||
"id": windowId,
|
||||
"title": title,
|
||||
"appId": appId,
|
||||
"workspaceId": wsId || -1,
|
||||
"isFocused": focused,
|
||||
"output": output,
|
||||
"x": safeX,
|
||||
"y": safeY
|
||||
};
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function toSortedWindowList(windowList) {
|
||||
return windowList.sort((a, b) => {
|
||||
// Sort by workspace first (just in case they are mixed)
|
||||
if (a.workspaceId !== b.workspaceId) {
|
||||
return a.workspaceId - b.workspaceId;
|
||||
}
|
||||
// Then sort by X position (left to right)
|
||||
if (a.x !== b.x) {
|
||||
return a.x - b.x;
|
||||
}
|
||||
// Then sort by Y position (top to bottom)
|
||||
if (a.y !== b.y) {
|
||||
return a.y - b.y;
|
||||
}
|
||||
// Fallback to Window ID mapping
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
}
|
||||
|
||||
function getAppTitle(toplevel) {
|
||||
try {
|
||||
var title = toplevel.wayland.title;
|
||||
if (title)
|
||||
return title;
|
||||
} catch (e) {}
|
||||
|
||||
return safeGetProperty(toplevel, "title", "");
|
||||
}
|
||||
|
||||
function getAppId(toplevel) {
|
||||
if (!toplevel)
|
||||
return "";
|
||||
|
||||
var appId = "";
|
||||
|
||||
// Try the wayland object first!
|
||||
// From my (Lemmy) testing it works fine so we could probably get rid of all the other attempts below.
|
||||
// Leaving them in for now, just in case...
|
||||
try {
|
||||
appId = toplevel.wayland.appId;
|
||||
if (appId)
|
||||
return appId;
|
||||
} catch (e) {}
|
||||
|
||||
// Try direct properties
|
||||
appId = safeGetProperty(toplevel, "class", "");
|
||||
if (appId)
|
||||
return appId;
|
||||
|
||||
appId = safeGetProperty(toplevel, "initialClass", "");
|
||||
if (appId)
|
||||
return appId;
|
||||
|
||||
appId = safeGetProperty(toplevel, "appId", "");
|
||||
if (appId)
|
||||
return appId;
|
||||
|
||||
// Try lastIpcObject
|
||||
try {
|
||||
const ipcData = toplevel.lastIpcObject;
|
||||
if (ipcData) {
|
||||
return String(ipcData.class || ipcData.initialClass || ipcData.appId || ipcData.wm_class || "");
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
// Safe property getter
|
||||
function safeGetProperty(obj, prop, defaultValue) {
|
||||
try {
|
||||
const value = obj[prop];
|
||||
if (value !== undefined && value !== null) {
|
||||
return String(value);
|
||||
}
|
||||
} catch (e)
|
||||
|
||||
// Property access failed
|
||||
{}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
function handleActiveLayoutEvent(ev) {
|
||||
try {
|
||||
let beforeParenthesis;
|
||||
const parenthesisPos = ev.lastIndexOf('(');
|
||||
|
||||
if (parenthesisPos === -1) {
|
||||
beforeParenthesis = ev;
|
||||
} else {
|
||||
beforeParenthesis = ev.substring(0, parenthesisPos);
|
||||
}
|
||||
|
||||
const layoutNameStart = beforeParenthesis.lastIndexOf(',') + 1;
|
||||
const layoutName = ev.substring(layoutNameStart);
|
||||
|
||||
// Ignore bogus "error" layout reported by virtual keyboards (e.g. wtype)
|
||||
if (layoutName.toLowerCase() === "error") {
|
||||
Logger.d("HyprlandService", "Ignoring bogus 'error' layout from activelayout event");
|
||||
return;
|
||||
}
|
||||
|
||||
KeyboardLayoutService.setCurrentLayout(layoutName);
|
||||
Logger.d("HyprlandService", "Keyboard layout switched:", layoutName);
|
||||
} catch (e) {
|
||||
Logger.e("HyprlandService", "Error handling activelayout:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Connections to Hyprland
|
||||
Connections {
|
||||
target: Hyprland.workspaces
|
||||
enabled: initialized
|
||||
function onValuesChanged() {
|
||||
Qt.callLater(_deferredWorkspaceUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: Hyprland.toplevels
|
||||
enabled: initialized
|
||||
function onValuesChanged() {
|
||||
updateTimer.restart();
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: Hyprland
|
||||
enabled: initialized
|
||||
function onRawEvent(event) {
|
||||
Hyprland.refreshWorkspaces();
|
||||
Hyprland.refreshToplevels();
|
||||
// Workspace and window updates are deferred — refreshWorkspaces()/
|
||||
// refreshToplevels() trigger onValuesChanged which also calls
|
||||
// Qt.callLater, so the deduplication coalesces into one update.
|
||||
Qt.callLater(_deferredWorkspaceUpdate);
|
||||
updateTimer.restart();
|
||||
|
||||
const monitorsEvents = ["configreloaded", "monitoradded", "monitorremoved", "monitoraddedv2", "monitorremovedv2"];
|
||||
|
||||
if (monitorsEvents.includes(event.name)) {
|
||||
Qt.callLater(queryDisplayScales);
|
||||
}
|
||||
|
||||
if (event.name == "activelayout") {
|
||||
handleActiveLayoutEvent(event.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Public functions
|
||||
function switchToWorkspace(workspace) {
|
||||
try {
|
||||
if (workspace.name) {
|
||||
Hyprland.dispatch(`workspace ${workspace.name}`);
|
||||
return;
|
||||
}
|
||||
Hyprland.dispatch(`workspace ${workspace.idx}`);
|
||||
} catch (e) {
|
||||
Logger.e("HyprlandService", "Failed to switch workspace:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function focusWindow(window) {
|
||||
try {
|
||||
if (!window || !window.id) {
|
||||
Logger.w("HyprlandService", "Invalid window object for focus");
|
||||
return;
|
||||
}
|
||||
|
||||
const windowId = window.id.toString();
|
||||
Hyprland.dispatch(`focuswindow address:0x${windowId}`);
|
||||
Hyprland.dispatch(`alterzorder top,address:0x${windowId}`); // Bring the focused window to the top (essential for Float Mode)
|
||||
} catch (e) {
|
||||
Logger.e("HyprlandService", "Failed to switch window:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function closeWindow(window) {
|
||||
try {
|
||||
Hyprland.dispatch(`killwindow address:0x${window.id}`);
|
||||
} catch (e) {
|
||||
Logger.e("HyprlandService", "Failed to close window:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function turnOffMonitors() {
|
||||
try {
|
||||
Quickshell.execDetached(["hyprctl", "dispatch", "dpms", "off"]);
|
||||
} catch (e) {
|
||||
Logger.e("HyprlandService", "Failed to turn off monitors:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function turnOnMonitors() {
|
||||
try {
|
||||
Quickshell.execDetached(["hyprctl", "dispatch", "dpms", "on"]);
|
||||
} catch (e) {
|
||||
Logger.e("HyprlandService", "Failed to turn on monitors:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
try {
|
||||
Quickshell.execDetached(["hyprctl", "dispatch", "exit"]);
|
||||
} catch (e) {
|
||||
Logger.e("HyprlandService", "Failed to logout:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function cycleKeyboardLayout() {
|
||||
try {
|
||||
Quickshell.execDetached(["hyprctl", "switchxkblayout", "all", "next"]);
|
||||
} catch (e) {
|
||||
Logger.e("HyprlandService", "Failed to cycle keyboard layout:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function getFocusedScreen() {
|
||||
const hyprMon = Hyprland.focusedMonitor;
|
||||
if (hyprMon) {
|
||||
const monitorName = hyprMon.name;
|
||||
for (let i = 0; i < Quickshell.screens.length; i++) {
|
||||
if (Quickshell.screens[i].name === monitorName) {
|
||||
return Quickshell.screens[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function spawn(command) {
|
||||
try {
|
||||
Quickshell.execDetached(["hyprctl", "dispatch", "--", "exec"].concat(command));
|
||||
} catch (e) {
|
||||
Logger.e("HyprlandService", "Failed to spawn command:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.WindowManager
|
||||
import qs.Commons
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property ListModel workspaces: ListModel {}
|
||||
property var windows: []
|
||||
property int focusedWindowIndex: -1
|
||||
property var trackedToplevels: new Set()
|
||||
|
||||
// LabWC typically has global workspaces (shared across all outputs)
|
||||
property bool globalWorkspaces: true
|
||||
|
||||
// Map from native workspace id to the native Workspace object for activation
|
||||
property var nativeWorkspaceMap: ({})
|
||||
|
||||
// Set of workspace objects we've already connected signals to
|
||||
property var connectedWorkspaces: ({})
|
||||
|
||||
signal workspaceChanged
|
||||
signal activeWindowChanged
|
||||
signal windowListChanged
|
||||
signal displayScalesChanged
|
||||
|
||||
function initialize() {
|
||||
updateWindows();
|
||||
connectWorkspaceSignals();
|
||||
syncWorkspaces();
|
||||
Logger.i("LabwcService", "Service started (ext-workspace-v1)");
|
||||
}
|
||||
|
||||
// Watch for windowsets being added/removed
|
||||
Connections {
|
||||
target: WindowManager
|
||||
|
||||
function onWindowsetsChanged() {
|
||||
root.connectWorkspaceSignals();
|
||||
Qt.callLater(root.syncWorkspaces);
|
||||
}
|
||||
|
||||
function onWindowsetProjectionsChanged() {
|
||||
Qt.callLater(root.syncWorkspaces);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-check windowsets after a short delay - the Wayland protocol data
|
||||
// may arrive after init and the changed signal can be missed
|
||||
Timer {
|
||||
interval: 500
|
||||
running: true
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
if (WindowManager.windowsets.length > 0) {
|
||||
root.connectWorkspaceSignals();
|
||||
root.syncWorkspaces();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Connect to property change signals on each native windowset object
|
||||
function connectWorkspaceSignals() {
|
||||
const nativeWs = WindowManager.windowsets;
|
||||
const newConnected = {};
|
||||
|
||||
for (const ws of nativeWs) {
|
||||
const key = ws.id || ws.toString();
|
||||
newConnected[key] = true;
|
||||
|
||||
if (connectedWorkspaces[key])
|
||||
continue;
|
||||
|
||||
ws.activeChanged.connect(() => {
|
||||
Qt.callLater(root.syncWorkspaces);
|
||||
});
|
||||
|
||||
ws.urgentChanged.connect(() => {
|
||||
Qt.callLater(root.syncWorkspaces);
|
||||
});
|
||||
|
||||
ws.shouldDisplayChanged.connect(() => {
|
||||
Qt.callLater(root.syncWorkspaces);
|
||||
});
|
||||
|
||||
ws.nameChanged.connect(() => {
|
||||
Qt.callLater(root.syncWorkspaces);
|
||||
});
|
||||
}
|
||||
|
||||
connectedWorkspaces = newConnected;
|
||||
}
|
||||
|
||||
function syncWorkspaces() {
|
||||
const nativeWs = WindowManager.windowsets;
|
||||
|
||||
workspaces.clear();
|
||||
nativeWorkspaceMap = {};
|
||||
|
||||
let idx = 1;
|
||||
|
||||
for (const ws of nativeWs) {
|
||||
// Skip hidden workspaces (shouldDisplay = false means hidden)
|
||||
if (!ws.shouldDisplay) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find which outputs this windowset's projection spans
|
||||
let outputName = "";
|
||||
if (ws.projection) {
|
||||
const projScreens = ws.projection.screens;
|
||||
if (projScreens && projScreens.length > 0) {
|
||||
outputName = projScreens[0].name || "";
|
||||
}
|
||||
}
|
||||
|
||||
const wsEntry = {
|
||||
"id": ws.id || idx.toString(),
|
||||
"idx": idx,
|
||||
"name": ws.name || ("Workspace " + idx),
|
||||
"output": outputName,
|
||||
"isFocused": ws.active,
|
||||
"isActive": true,
|
||||
"isUrgent": ws.urgent,
|
||||
"isOccupied": false,
|
||||
"oid": ws.id || idx.toString()
|
||||
};
|
||||
|
||||
workspaces.append(wsEntry);
|
||||
nativeWorkspaceMap[wsEntry.id] = ws;
|
||||
|
||||
idx++;
|
||||
}
|
||||
|
||||
// Update windows with workspace info
|
||||
updateWindowWorkspaces();
|
||||
workspaceChanged();
|
||||
}
|
||||
|
||||
function updateWindowWorkspaces() {
|
||||
// ext-workspace-v1 doesn't provide window-to-workspace mapping
|
||||
// Assign all windows to the active workspace
|
||||
let activeId = "";
|
||||
for (let i = 0; i < workspaces.count; i++) {
|
||||
const ws = workspaces.get(i);
|
||||
if (ws.isFocused) {
|
||||
activeId = ws.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < windows.length; i++) {
|
||||
if (activeId) {
|
||||
windows[i].workspaceId = activeId;
|
||||
}
|
||||
}
|
||||
windowListChanged();
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: ToplevelManager.toplevels
|
||||
function onValuesChanged() {
|
||||
updateWindows();
|
||||
}
|
||||
}
|
||||
|
||||
function connectToToplevel(toplevel) {
|
||||
if (!toplevel)
|
||||
return;
|
||||
|
||||
toplevel.activatedChanged.connect(() => {
|
||||
Qt.callLater(onToplevelActivationChanged);
|
||||
});
|
||||
|
||||
toplevel.titleChanged.connect(() => {
|
||||
Qt.callLater(updateWindows);
|
||||
});
|
||||
}
|
||||
|
||||
function onToplevelActivationChanged() {
|
||||
updateWindows();
|
||||
activeWindowChanged();
|
||||
}
|
||||
|
||||
function updateWindows() {
|
||||
const newWindows = [];
|
||||
const toplevels = ToplevelManager.toplevels?.values || [];
|
||||
|
||||
let focusedIdx = -1;
|
||||
let idx = 0;
|
||||
|
||||
// Find active workspace id
|
||||
let activeId = "";
|
||||
for (let i = 0; i < workspaces.count; i++) {
|
||||
const ws = workspaces.get(i);
|
||||
if (ws.isFocused) {
|
||||
activeId = ws.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (const toplevel of toplevels) {
|
||||
if (!toplevel)
|
||||
continue;
|
||||
|
||||
if (!trackedToplevels.has(toplevel)) {
|
||||
connectToToplevel(toplevel);
|
||||
trackedToplevels.add(toplevel);
|
||||
}
|
||||
|
||||
// Get output name from toplevel's screen list
|
||||
const output = (toplevel.screens && toplevel.screens.length > 0) ? (toplevel.screens[0].name || "") : "";
|
||||
|
||||
// Use appId + title as a stable id since Toplevel has no address property
|
||||
const windowId = (toplevel.appId || "") + ":" + idx;
|
||||
|
||||
newWindows.push({
|
||||
"id": windowId,
|
||||
"appId": toplevel.appId || "",
|
||||
"title": toplevel.title || "",
|
||||
"output": output,
|
||||
"workspaceId": activeId || "1",
|
||||
"isFocused": toplevel.activated || false,
|
||||
"toplevel": toplevel
|
||||
});
|
||||
|
||||
if (toplevel.activated) {
|
||||
focusedIdx = idx;
|
||||
}
|
||||
idx++;
|
||||
}
|
||||
windows = newWindows;
|
||||
focusedWindowIndex = focusedIdx;
|
||||
|
||||
windowListChanged();
|
||||
}
|
||||
|
||||
function focusWindow(window) {
|
||||
if (window.toplevel && typeof window.toplevel.activate === "function") {
|
||||
window.toplevel.activate();
|
||||
}
|
||||
}
|
||||
|
||||
function closeWindow(window) {
|
||||
if (window.toplevel && typeof window.toplevel.close === "function") {
|
||||
window.toplevel.close();
|
||||
}
|
||||
}
|
||||
|
||||
function switchToWorkspace(workspace) {
|
||||
// Find the native Workspace object and activate it directly
|
||||
const nativeWs = nativeWorkspaceMap[workspace.id] || nativeWorkspaceMap[workspace.oid];
|
||||
if (nativeWs && nativeWs.canActivate) {
|
||||
nativeWs.activate();
|
||||
} else {
|
||||
Logger.w("LabwcService", "Cannot activate workspace: " + (workspace.name || workspace.id));
|
||||
}
|
||||
}
|
||||
|
||||
function turnOffMonitors() {
|
||||
try {
|
||||
Quickshell.execDetached(["wlr-randr", "--off"]);
|
||||
} catch (e) {
|
||||
Logger.e("LabwcService", "Failed to turn off monitors:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function turnOnMonitors() {
|
||||
try {
|
||||
Quickshell.execDetached(["wlr-randr", "--on"]);
|
||||
} catch (e) {
|
||||
Logger.e("LabwcService", "Failed to turn on monitors:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
try {
|
||||
// Exit labwc by sending SIGTERM to $LABWC_PID or using --exit flag
|
||||
Quickshell.execDetached(["sh", "-c", "labwc --exit || kill -s SIGTERM $LABWC_PID"]);
|
||||
} catch (e) {
|
||||
Logger.e("LabwcService", "Failed to logout:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function cycleKeyboardLayout() {
|
||||
Logger.w("LabwcService", "Keyboard layout cycling not supported");
|
||||
}
|
||||
|
||||
function queryDisplayScales() {
|
||||
Logger.w("LabwcService", "Display scale queries not supported via ToplevelManager");
|
||||
}
|
||||
|
||||
function getFocusedScreen() {
|
||||
// de-activated until proper testing
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
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.consumed && (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;
|
||||
// Consume so a second toplevel with identical title+appId cannot also claim focus
|
||||
os.consumed = true;
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Niri
|
||||
import qs.Commons
|
||||
import qs.Services.Keyboard
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property int floatingWindowPosition: Number.MAX_SAFE_INTEGER
|
||||
|
||||
property ListModel workspaces: ListModel {}
|
||||
property var windows: []
|
||||
property int focusedWindowIndex: -1
|
||||
|
||||
property bool overviewActive: false
|
||||
|
||||
property var keyboardLayouts: []
|
||||
|
||||
signal workspaceChanged
|
||||
signal activeWindowChanged
|
||||
signal windowListChanged
|
||||
signal displayScalesChanged
|
||||
|
||||
property var outputCache: ({})
|
||||
property var workspaceCache: ({})
|
||||
|
||||
function initialize() {
|
||||
Niri.refreshOutputs();
|
||||
Niri.refreshWorkspaces();
|
||||
Niri.refreshWindows();
|
||||
|
||||
Qt.callLater(() => {
|
||||
safeUpdateOutputs();
|
||||
safeUpdateWorkspaces();
|
||||
safeUpdateWindows();
|
||||
queryDisplayScales();
|
||||
});
|
||||
|
||||
Logger.i("NiriService", "Service started");
|
||||
}
|
||||
|
||||
// Connections to the C++ Niri IPC module
|
||||
Connections {
|
||||
target: Niri
|
||||
function onWorkspacesUpdated() {
|
||||
safeUpdateWorkspaces();
|
||||
workspaceChanged();
|
||||
}
|
||||
function onWindowsUpdated() {
|
||||
safeUpdateWindows();
|
||||
windowListChanged();
|
||||
activeWindowChanged();
|
||||
}
|
||||
function onOutputsUpdated() {
|
||||
safeUpdateOutputs();
|
||||
queryDisplayScales();
|
||||
}
|
||||
function onOverviewActiveChanged() {
|
||||
overviewActive = Niri.overviewActive;
|
||||
}
|
||||
function onKeyboardLayoutsChanged() {
|
||||
keyboardLayouts = Niri.keyboardLayoutNames;
|
||||
const layoutName = Niri.currentKeyboardLayoutName;
|
||||
if (layoutName) {
|
||||
KeyboardLayoutService.setCurrentLayout(layoutName);
|
||||
}
|
||||
Logger.d("NiriService", "Keyboard layouts changed:", keyboardLayouts.toString());
|
||||
}
|
||||
function onKeyboardLayoutSwitched() {
|
||||
const layoutName = Niri.currentKeyboardLayoutName;
|
||||
if (layoutName) {
|
||||
KeyboardLayoutService.setCurrentLayout(layoutName);
|
||||
}
|
||||
Logger.d("NiriService", "Keyboard layout switched:", layoutName);
|
||||
}
|
||||
}
|
||||
|
||||
function safeUpdateOutputs() {
|
||||
const niriOutputs = Niri.outputs.values;
|
||||
outputCache = {};
|
||||
|
||||
for (var i = 0; i < niriOutputs.length; i++) {
|
||||
const output = niriOutputs[i];
|
||||
outputCache[output.name] = {
|
||||
"name": output.name,
|
||||
"connected": output.connected,
|
||||
"scale": output.scale,
|
||||
"width": output.width,
|
||||
"height": output.height,
|
||||
"x": output.x,
|
||||
"y": output.y,
|
||||
"physical_width": output.physicalWidth,
|
||||
"physical_height": output.physicalHeight,
|
||||
"refresh_rate": output.refreshRate,
|
||||
"vrr_supported": output.vrrSupported,
|
||||
"vrr_enabled": output.vrrEnabled,
|
||||
"transform": output.transform
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function safeUpdateWorkspaces() {
|
||||
const niriWorkspaces = Niri.workspaces.values;
|
||||
workspaceCache = {};
|
||||
|
||||
const workspacesList = [];
|
||||
for (var i = 0; i < niriWorkspaces.length; i++) {
|
||||
const ws = niriWorkspaces[i];
|
||||
const wsData = {
|
||||
"id": ws.id,
|
||||
"idx": ws.idx,
|
||||
"name": ws.name,
|
||||
"output": ws.output,
|
||||
"isFocused": ws.focused,
|
||||
"isActive": ws.active,
|
||||
"isUrgent": ws.urgent,
|
||||
"isOccupied": ws.occupied
|
||||
};
|
||||
workspacesList.push(wsData);
|
||||
workspaceCache[ws.id] = wsData;
|
||||
}
|
||||
|
||||
// Workspaces come pre-sorted from C++ (by output then idx)
|
||||
workspaces.clear();
|
||||
for (var j = 0; j < workspacesList.length; j++) {
|
||||
workspaces.append(workspacesList[j]);
|
||||
}
|
||||
}
|
||||
|
||||
function getWindowOutput(win) {
|
||||
for (var i = 0; i < workspaces.count; i++) {
|
||||
if (workspaces.get(i).id === win.workspaceId) {
|
||||
return workspaces.get(i).output;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toSortedWindowList(windowList) {
|
||||
return windowList.map(win => {
|
||||
const workspace = workspaceCache[win.workspaceId];
|
||||
const output = (workspace && workspace.output) ? outputCache[workspace.output] : null;
|
||||
|
||||
return {
|
||||
window: win,
|
||||
workspaceIdx: workspace ? workspace.idx : 0,
|
||||
outputX: output ? output.x : 0,
|
||||
outputY: output ? output.y : 0
|
||||
};
|
||||
}).sort((a, b) => {
|
||||
// Sort by output position first
|
||||
if (a.outputX !== b.outputX) {
|
||||
return a.outputX - b.outputX;
|
||||
}
|
||||
if (a.outputY !== b.outputY) {
|
||||
return a.outputY - b.outputY;
|
||||
}
|
||||
// Then by workspace index
|
||||
if (a.workspaceIdx !== b.workspaceIdx) {
|
||||
return a.workspaceIdx - b.workspaceIdx;
|
||||
}
|
||||
// Then by window position
|
||||
if (a.window.position.x !== b.window.position.x) {
|
||||
return a.window.position.x - b.window.position.x;
|
||||
}
|
||||
if (a.window.position.y !== b.window.position.y) {
|
||||
return a.window.position.y - b.window.position.y;
|
||||
}
|
||||
// Finally by window ID to ensure consistent ordering
|
||||
return a.window.id - b.window.id;
|
||||
}).map(info => info.window);
|
||||
}
|
||||
|
||||
function safeUpdateWindows() {
|
||||
const niriWindows = Niri.windows.values;
|
||||
const windowsList = [];
|
||||
|
||||
for (var i = 0; i < niriWindows.length; i++) {
|
||||
const win = niriWindows[i];
|
||||
windowsList.push({
|
||||
"id": win.id,
|
||||
"title": win.title || "",
|
||||
"appId": win.appId || "",
|
||||
"workspaceId": win.workspaceId || -1,
|
||||
"isFocused": win.focused,
|
||||
"output": win.output || getWindowOutput(win) || "",
|
||||
"position": {
|
||||
"x": win.isFloating ? floatingWindowPosition : win.positionX,
|
||||
"y": win.isFloating ? floatingWindowPosition : win.positionY
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
windows = toSortedWindowList(windowsList);
|
||||
safeUpdateFocusedWindow();
|
||||
}
|
||||
|
||||
function safeUpdateFocusedWindow() {
|
||||
focusedWindowIndex = -1;
|
||||
for (var i = 0; i < windows.length; i++) {
|
||||
if (windows[i].isFocused) {
|
||||
focusedWindowIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function queryDisplayScales() {
|
||||
if (CompositorService && CompositorService.onDisplayScalesUpdated) {
|
||||
CompositorService.onDisplayScalesUpdated(outputCache);
|
||||
}
|
||||
}
|
||||
|
||||
function switchToWorkspace(workspace) {
|
||||
try {
|
||||
Niri.dispatch(["focus-workspace", workspace.idx.toString()]);
|
||||
} catch (e) {
|
||||
Logger.e("NiriService", "Failed to switch workspace:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function scrollWorkspaceContent(direction) {
|
||||
try {
|
||||
var action = direction < 0 ? "focus-column-left" : "focus-column-right";
|
||||
Niri.dispatch([action]);
|
||||
} catch (e) {
|
||||
Logger.e("NiriService", "Failed to scroll workspace content:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function focusWindow(window) {
|
||||
try {
|
||||
Niri.dispatch(["focus-window", "--id", window.id.toString()]);
|
||||
} catch (e) {
|
||||
Logger.e("NiriService", "Failed to switch window:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function closeWindow(window) {
|
||||
try {
|
||||
Niri.dispatch(["close-window", "--id", window.id.toString()]);
|
||||
} catch (e) {
|
||||
Logger.e("NiriService", "Failed to close window:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function turnOffMonitors() {
|
||||
try {
|
||||
Niri.dispatch(["power-off-monitors"]);
|
||||
} catch (e) {
|
||||
Logger.e("NiriService", "Failed to turn off monitors:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function turnOnMonitors() {
|
||||
try {
|
||||
Niri.dispatch(["power-on-monitors"]);
|
||||
} catch (e) {
|
||||
Logger.e("NiriService", "Failed to turn on monitors:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
try {
|
||||
Niri.dispatch(["quit", "--skip-confirmation"]);
|
||||
} catch (e) {
|
||||
Logger.e("NiriService", "Failed to logout:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function cycleKeyboardLayout() {
|
||||
try {
|
||||
Niri.dispatch(["switch-layout", "next"]);
|
||||
} catch (e) {
|
||||
Logger.e("NiriService", "Failed to cycle keyboard layout:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function getFocusedScreen() {
|
||||
// On niri the code below only works when you have an actual app selected on that screen.
|
||||
return null;
|
||||
}
|
||||
|
||||
function spawn(command) {
|
||||
try {
|
||||
const niriArgs = ["spawn", "--"].concat(command);
|
||||
Logger.d("NiriService", "Calling niri spawn: niri msg action " + niriArgs.join(" "));
|
||||
Niri.dispatch(niriArgs);
|
||||
} catch (e) {
|
||||
Logger.e("NiriService", "Failed to spawn command:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.I3
|
||||
import Quickshell.Io
|
||||
import Quickshell.Wayland
|
||||
import qs.Commons
|
||||
import qs.Services.Keyboard
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// Configurable IPC command name (overridden to "scrollmsg" for Scroll)
|
||||
property string msgCommand: "swaymsg"
|
||||
|
||||
// Properties that match the facade interface
|
||||
property ListModel workspaces: ListModel {}
|
||||
property var windows: []
|
||||
property int focusedWindowIndex: -1
|
||||
|
||||
// Signals that match the facade interface
|
||||
signal workspaceChanged
|
||||
signal activeWindowChanged
|
||||
signal windowListChanged
|
||||
signal displayScalesChanged
|
||||
|
||||
// I3-specific properties
|
||||
property bool initialized: false
|
||||
|
||||
// Cache for window-to-workspace mapping
|
||||
property var windowWorkspaceMap: ({})
|
||||
|
||||
// Track window usage counts per workspace to handle duplicates
|
||||
property var windowUsageCountsPerWorkspace: ({})
|
||||
|
||||
// Debounce timer for updates
|
||||
Timer {
|
||||
id: updateTimer
|
||||
interval: 50
|
||||
repeat: false
|
||||
onTriggered: safeUpdate()
|
||||
}
|
||||
|
||||
// Initialization
|
||||
function initialize() {
|
||||
if (initialized)
|
||||
return;
|
||||
try {
|
||||
I3.refreshWorkspaces();
|
||||
Qt.callLater(() => {
|
||||
safeUpdateWorkspaces();
|
||||
queryWindowWorkspaces();
|
||||
queryDisplayScales();
|
||||
queryKeyboardLayout();
|
||||
});
|
||||
initialized = true;
|
||||
Logger.i("SwayService", "Service started");
|
||||
} catch (e) {
|
||||
Logger.e("SwayService", "Failed to initialize:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Query window-to-workspace mapping via IPC
|
||||
function queryWindowWorkspaces() {
|
||||
swayTreeProcess.running = true;
|
||||
}
|
||||
|
||||
// Sway tree process for getting window workspace information
|
||||
Process {
|
||||
id: swayTreeProcess
|
||||
running: false
|
||||
command: [msgCommand, "-t", "get_tree", "-r"]
|
||||
|
||||
property string accumulatedOutput: ""
|
||||
|
||||
stdout: SplitParser {
|
||||
onRead: function (line) {
|
||||
swayTreeProcess.accumulatedOutput += line;
|
||||
}
|
||||
}
|
||||
|
||||
onExited: function (exitCode) {
|
||||
if (exitCode !== 0 || !accumulatedOutput) {
|
||||
Logger.e("SwayService", "Failed to query tree, exit code:", exitCode);
|
||||
accumulatedOutput = "";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const treeData = JSON.parse(accumulatedOutput);
|
||||
const newMap = {};
|
||||
const workspaceWindows = {}; // Track windows per workspace
|
||||
|
||||
// Recursively find all windows and their workspaces
|
||||
function traverseTree(node, workspaceNum) {
|
||||
if (!node)
|
||||
return;
|
||||
|
||||
// If this is a workspace node, update the workspace number
|
||||
if (node.type === "workspace" && node.num !== undefined) {
|
||||
workspaceNum = node.num;
|
||||
if (!workspaceWindows[workspaceNum]) {
|
||||
workspaceWindows[workspaceNum] = [];
|
||||
}
|
||||
}
|
||||
|
||||
// If this is a regular or floating container with app_id/class (i.e., a window)
|
||||
if ((node.type === "con" || node.type === "floating_con") && (node.app_id || node.window_properties)) {
|
||||
const appId = node.app_id || (node.window_properties ? node.window_properties.class : null);
|
||||
const title = node.name || "";
|
||||
const id = node.id;
|
||||
|
||||
if (appId && workspaceNum !== undefined && workspaceNum >= 0) {
|
||||
// Store window info for this workspace
|
||||
workspaceWindows[workspaceNum].push({
|
||||
appId: appId,
|
||||
title: title,
|
||||
id: id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Traverse children
|
||||
if (node.nodes && node.nodes.length > 0) {
|
||||
for (const child of node.nodes) {
|
||||
traverseTree(child, workspaceNum);
|
||||
}
|
||||
}
|
||||
|
||||
// Traverse floating nodes
|
||||
if (node.floating_nodes && node.floating_nodes.length > 0) {
|
||||
for (const child of node.floating_nodes) {
|
||||
traverseTree(child, workspaceNum);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverseTree(treeData, -1);
|
||||
|
||||
// Now build the map with workspace-specific keys
|
||||
for (const wsNum in workspaceWindows) {
|
||||
const windows = workspaceWindows[wsNum];
|
||||
const appTitleCounts = {}; // Count occurrences of each appId:title in this workspace
|
||||
|
||||
for (const win of windows) {
|
||||
const baseKey = `${win.appId}:${win.title}`;
|
||||
|
||||
// Track how many times we've seen this appId:title combo in this workspace
|
||||
if (!appTitleCounts[baseKey]) {
|
||||
appTitleCounts[baseKey] = 0;
|
||||
}
|
||||
const occurrence = appTitleCounts[baseKey];
|
||||
appTitleCounts[baseKey]++;
|
||||
|
||||
// Create unique key with workspace and occurrence index
|
||||
const uniqueKey = `ws${wsNum}:${baseKey}[${occurrence}]`;
|
||||
newMap[uniqueKey] = parseInt(wsNum);
|
||||
|
||||
// Also store by ID if available (most reliable)
|
||||
if (win.id) {
|
||||
newMap[`id:${win.id}`] = parseInt(wsNum);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
windowWorkspaceMap = newMap;
|
||||
|
||||
// Update windows with new workspace information
|
||||
Qt.callLater(safeUpdateWindows);
|
||||
} catch (e) {
|
||||
Logger.e("SwayService", "Failed to parse tree:", e);
|
||||
} finally {
|
||||
accumulatedOutput = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Query display scales
|
||||
function queryDisplayScales() {
|
||||
swayOutputsProcess.running = true;
|
||||
}
|
||||
|
||||
// Sway outputs process for display scale detection
|
||||
Process {
|
||||
id: swayOutputsProcess
|
||||
running: false
|
||||
command: [msgCommand, "-t", "get_outputs", "-r"]
|
||||
|
||||
property string accumulatedOutput: ""
|
||||
|
||||
stdout: SplitParser {
|
||||
onRead: function (line) {
|
||||
swayOutputsProcess.accumulatedOutput += line;
|
||||
}
|
||||
}
|
||||
|
||||
onExited: function (exitCode) {
|
||||
if (exitCode !== 0 || !accumulatedOutput) {
|
||||
Logger.e("SwayService", "Failed to query outputs, exit code:", exitCode);
|
||||
accumulatedOutput = "";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const outputsData = JSON.parse(accumulatedOutput);
|
||||
const scales = {};
|
||||
|
||||
for (const output of outputsData) {
|
||||
if (output.name) {
|
||||
scales[output.name] = {
|
||||
"name": output.name,
|
||||
"scale": output.scale || 1.0,
|
||||
"width": output.current_mode ? output.current_mode.width : 0,
|
||||
"height": output.current_mode ? output.current_mode.height : 0,
|
||||
"refresh_rate": output.current_mode ? output.current_mode.refresh : 0,
|
||||
"x": output.rect ? output.rect.x : 0,
|
||||
"y": output.rect ? output.rect.y : 0,
|
||||
"active": output.active || false,
|
||||
"focused": output.focused || false,
|
||||
"current_workspace": output.current_workspace || ""
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Notify CompositorService (it will emit displayScalesChanged)
|
||||
if (CompositorService && CompositorService.onDisplayScalesUpdated) {
|
||||
CompositorService.onDisplayScalesUpdated(scales);
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.e("SwayService", "Failed to parse outputs:", e);
|
||||
} finally {
|
||||
// Clear accumulated output for next query
|
||||
accumulatedOutput = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function queryKeyboardLayout() {
|
||||
swayInputsProcess.running = true;
|
||||
}
|
||||
// Sway inputs process for keyboard layout detection
|
||||
Process {
|
||||
id: swayInputsProcess
|
||||
running: false
|
||||
command: [msgCommand, "-t", "get_inputs", "-r"]
|
||||
|
||||
property string accumulatedOutput: ""
|
||||
|
||||
stdout: SplitParser {
|
||||
onRead: function (line) {
|
||||
// Accumulate lines instead of parsing each one
|
||||
swayInputsProcess.accumulatedOutput += line;
|
||||
}
|
||||
}
|
||||
|
||||
onExited: function (exitCode) {
|
||||
if (exitCode !== 0 || !accumulatedOutput) {
|
||||
Logger.e("SwayService", "Failed to query inputs, exit code:", exitCode);
|
||||
accumulatedOutput = "";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const inputsData = JSON.parse(accumulatedOutput);
|
||||
for (const input of inputsData) {
|
||||
if (input.type == "keyboard") {
|
||||
const layoutName = input.xkb_active_layout_name;
|
||||
KeyboardLayoutService.setCurrentLayout(layoutName);
|
||||
Logger.d("SwayService", "Keyboard layout switched:", layoutName);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.e("SwayService", "Failed to parse inputs:", e);
|
||||
} finally {
|
||||
// Clear accumulated output for next query
|
||||
accumulatedOutput = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Safe update wrapper
|
||||
function safeUpdate() {
|
||||
queryWindowWorkspaces();
|
||||
safeUpdateWorkspaces();
|
||||
}
|
||||
|
||||
// Safe workspace update
|
||||
function safeUpdateWorkspaces() {
|
||||
try {
|
||||
workspaces.clear();
|
||||
|
||||
if (!I3.workspaces || !I3.workspaces.values) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hlWorkspaces = I3.workspaces.values;
|
||||
|
||||
for (var i = 0; i < hlWorkspaces.length; i++) {
|
||||
const ws = hlWorkspaces[i];
|
||||
if (!ws || ws.id < 1)
|
||||
continue;
|
||||
const wsData = {
|
||||
"id": i,
|
||||
"idx": ws.num,
|
||||
"name": ws.name || "",
|
||||
"output": (ws.monitor && ws.monitor.name) ? ws.monitor.name : "",
|
||||
"isActive": ws.active === true,
|
||||
"isFocused": ws.focused === true,
|
||||
"isUrgent": ws.urgent === true,
|
||||
"isOccupied": true,
|
||||
"handle": ws
|
||||
};
|
||||
|
||||
workspaces.append(wsData);
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.e("SwayService", "Error updating workspaces:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Safe window update
|
||||
function safeUpdateWindows() {
|
||||
try {
|
||||
const windowsList = [];
|
||||
|
||||
// Reset usage counts per workspace before processing windows
|
||||
windowUsageCountsPerWorkspace = {};
|
||||
|
||||
if (!ToplevelManager.toplevels || !ToplevelManager.toplevels.values) {
|
||||
windows = [];
|
||||
focusedWindowIndex = -1;
|
||||
windowListChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
const hlToplevels = ToplevelManager.toplevels.values;
|
||||
let newFocusedIndex = -1;
|
||||
|
||||
for (var i = 0; i < hlToplevels.length; i++) {
|
||||
const toplevel = hlToplevels[i];
|
||||
if (!toplevel)
|
||||
continue;
|
||||
const windowData = extractWindowData(toplevel);
|
||||
if (windowData) {
|
||||
windowsList.push(windowData);
|
||||
|
||||
if (windowData.isFocused) {
|
||||
newFocusedIndex = windowsList.length - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
windows = windowsList;
|
||||
|
||||
if (newFocusedIndex !== focusedWindowIndex) {
|
||||
focusedWindowIndex = newFocusedIndex;
|
||||
activeWindowChanged();
|
||||
}
|
||||
|
||||
windowListChanged();
|
||||
} catch (e) {
|
||||
Logger.e("SwayService", "Error updating windows:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract window data safely from a toplevel
|
||||
function extractWindowData(toplevel) {
|
||||
if (!toplevel)
|
||||
return null;
|
||||
|
||||
try {
|
||||
// Safely extract properties
|
||||
const appId = getAppId(toplevel);
|
||||
const title = safeGetProperty(toplevel, "title", "");
|
||||
const focused = toplevel.activated === true;
|
||||
|
||||
// Try to find workspace ID from our cached map by trying all workspaces
|
||||
let workspaceId = -1;
|
||||
let foundWorkspaceNum = -1;
|
||||
|
||||
// Build base key for this window
|
||||
const baseKey = `${appId}:${title}`;
|
||||
|
||||
// Try to find this window in any workspace
|
||||
for (var i = 0; i < workspaces.count; i++) {
|
||||
const ws = workspaces.get(i);
|
||||
if (!ws)
|
||||
continue;
|
||||
|
||||
const wsNum = ws.idx;
|
||||
|
||||
// Initialize usage count for this workspace if needed
|
||||
if (!windowUsageCountsPerWorkspace[wsNum]) {
|
||||
windowUsageCountsPerWorkspace[wsNum] = {};
|
||||
}
|
||||
|
||||
// Get current usage count for this appId:title in this workspace
|
||||
if (!windowUsageCountsPerWorkspace[wsNum][baseKey]) {
|
||||
windowUsageCountsPerWorkspace[wsNum][baseKey] = 0;
|
||||
}
|
||||
|
||||
const occurrence = windowUsageCountsPerWorkspace[wsNum][baseKey];
|
||||
const uniqueKey = `ws${wsNum}:${baseKey}[${occurrence}]`;
|
||||
|
||||
// Check if this key exists in our map
|
||||
if (windowWorkspaceMap[uniqueKey] !== undefined) {
|
||||
foundWorkspaceNum = windowWorkspaceMap[uniqueKey];
|
||||
workspaceId = ws.id;
|
||||
|
||||
// Increment the usage count for this workspace
|
||||
windowUsageCountsPerWorkspace[wsNum][baseKey]++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"title": title,
|
||||
"appId": appId,
|
||||
"isFocused": focused,
|
||||
"workspaceId": workspaceId,
|
||||
"handle": toplevel
|
||||
};
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getAppId(toplevel) {
|
||||
if (!toplevel)
|
||||
return "";
|
||||
|
||||
return toplevel.appId;
|
||||
}
|
||||
|
||||
// Safe property getter
|
||||
function safeGetProperty(obj, prop, defaultValue) {
|
||||
try {
|
||||
const value = obj[prop];
|
||||
if (value !== undefined && value !== null) {
|
||||
return String(value);
|
||||
}
|
||||
} catch (e)
|
||||
|
||||
// Property access failed
|
||||
{}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
function handleInputEvent(ev) {
|
||||
try {
|
||||
const eventData = JSON.parse(ev);
|
||||
if (eventData.change == "xkb_layout" && eventData.input != null) {
|
||||
const input = eventData.input;
|
||||
if (input.type == "keyboard" && input.xkb_active_layout_name != null) {
|
||||
const layoutName = input.xkb_active_layout_name;
|
||||
KeyboardLayoutService.setCurrentLayout(layoutName);
|
||||
Logger.d("SwayService", "Keyboard layout switched:", layoutName);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.e("SwayService", "Error handling input event:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Connections to I3
|
||||
Connections {
|
||||
target: I3.workspaces
|
||||
enabled: initialized
|
||||
function onValuesChanged() {
|
||||
safeUpdateWorkspaces();
|
||||
workspaceChanged();
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: ToplevelManager
|
||||
enabled: initialized
|
||||
function onActiveToplevelChanged() {
|
||||
updateTimer.restart();
|
||||
}
|
||||
}
|
||||
|
||||
// Some programs change title of window dependent on content
|
||||
Connections {
|
||||
target: ToplevelManager ? ToplevelManager.activeToplevel : null
|
||||
enabled: initialized
|
||||
function onTitleChanged() {
|
||||
updateTimer.restart();
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: I3
|
||||
enabled: initialized
|
||||
function onRawEvent(event) {
|
||||
safeUpdateWorkspaces();
|
||||
workspaceChanged();
|
||||
updateTimer.restart();
|
||||
|
||||
if (event.type === "output") {
|
||||
Qt.callLater(queryDisplayScales);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
I3IpcListener {
|
||||
subscriptions: ["input"]
|
||||
onIpcEvent: function (event) {
|
||||
handleInputEvent(event.data);
|
||||
}
|
||||
}
|
||||
|
||||
// Public functions
|
||||
function switchToWorkspace(workspace) {
|
||||
try {
|
||||
workspace.handle.activate();
|
||||
} catch (e) {
|
||||
Logger.e("SwayService", "Failed to switch workspace:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function focusWindow(window) {
|
||||
try {
|
||||
window.handle.activate();
|
||||
} catch (e) {
|
||||
Logger.e("SwayService", "Failed to switch window:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function closeWindow(window) {
|
||||
try {
|
||||
window.handle.close();
|
||||
} catch (e) {
|
||||
Logger.e("SwayService", "Failed to close window:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function turnOffMonitors() {
|
||||
try {
|
||||
Quickshell.execDetached([msgCommand, "output", "*", "dpms", "off"]);
|
||||
} catch (e) {
|
||||
Logger.e("SwayService", "Failed to turn off monitors:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function turnOnMonitors() {
|
||||
try {
|
||||
Quickshell.execDetached([msgCommand, "output", "*", "dpms", "on"]);
|
||||
} catch (e) {
|
||||
Logger.e("SwayService", "Failed to turn on monitors:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
try {
|
||||
Quickshell.execDetached([msgCommand, "exit"]);
|
||||
} catch (e) {
|
||||
Logger.e("SwayService", "Failed to logout:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function cycleKeyboardLayout() {
|
||||
try {
|
||||
Quickshell.execDetached([msgCommand, "input", "type:keyboard", "xkb_switch_layout", "next"]);
|
||||
} catch (e) {
|
||||
Logger.e("SwayService", "Failed to cycle keyboard layout:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function getFocusedScreen() {
|
||||
// de-activated until proper testing
|
||||
return null;
|
||||
|
||||
// const i3Mon = I3.focusedMonitor;
|
||||
// if (i3Mon) {
|
||||
// const monitorName = i3Mon.name;
|
||||
// for (let i = 0; i < Quickshell.screens.length; i++) {
|
||||
// if (Quickshell.screens[i].name === monitorName) {
|
||||
// return Quickshell.screens[i];
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// return null;
|
||||
}
|
||||
|
||||
function spawn(command) {
|
||||
try {
|
||||
Quickshell.execDetached([msgCommand, "exec", "--"].concat(command));
|
||||
} catch (e) {
|
||||
Logger.e("SwayService", "Failed to spawn command:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user