Files
noctalia-shell/Services/UI/PanelService.qml
T
2026-01-31 21:33:57 -05:00

274 lines
8.1 KiB
QML

pragma Singleton
import QtQuick
import Quickshell
import qs.Commons
import qs.Services.Compositor
Singleton {
id: root
// A ref. to the lockScreen, so it's accessible from anywhere.
property var lockScreen: null
// Panels
property var registeredPanels: ({})
property var openedPanel: null
property var closingPanel: null
property bool closedImmediately: false
// Brief window after panel opens where Exclusive keyboard is allowed on Hyprland
// This allows text inputs to receive focus, then switches to OnDemand for click-to-close
property bool isInitializingKeyboard: false
signal willOpen
signal didClose
// Background slot assignments for dynamic panel background rendering
// Slot 0: currently opening/open panel, Slot 1: closing panel
property var backgroundSlotAssignments: [null, null]
signal slotAssignmentChanged(int slotIndex, var panel)
function assignToSlot(slotIndex, panel) {
if (backgroundSlotAssignments[slotIndex] !== panel) {
var newAssignments = backgroundSlotAssignments.slice();
newAssignments[slotIndex] = panel;
backgroundSlotAssignments = newAssignments;
slotAssignmentChanged(slotIndex, panel);
}
}
// Popup menu windows (one per screen) - used for both tray menus and context menus
property var popupMenuWindows: ({})
signal popupMenuWindowRegistered(var screen)
// Register this panel (called after panel is loaded)
function registerPanel(panel) {
registeredPanels[panel.objectName] = panel;
Logger.d("PanelService", "Registered panel:", panel.objectName);
}
// Register popup menu window for a screen
function registerPopupMenuWindow(screen, window) {
if (!screen || !window)
return;
var key = screen.name;
popupMenuWindows[key] = window;
Logger.d("PanelService", "Registered popup menu window for screen:", key);
popupMenuWindowRegistered(screen);
}
// Get popup menu window for a screen
function getPopupMenuWindow(screen) {
if (!screen)
return null;
return popupMenuWindows[screen.name] || null;
}
// Show a context menu with proper handling for all compositors
// Optional targetItem: if provided, menu will be horizontally centered on this item instead of anchorItem
function showContextMenu(contextMenu, anchorItem, screen, targetItem) {
if (!contextMenu || !anchorItem)
return;
// Close any previously opened context menu first
closeContextMenu(screen);
var popupMenuWindow = getPopupMenuWindow(screen);
if (popupMenuWindow) {
popupMenuWindow.showContextMenu(contextMenu);
contextMenu.openAtItem(anchorItem, screen, targetItem);
}
}
// Close any open context menu or popup menu window
function closeContextMenu(screen) {
var popupMenuWindow = getPopupMenuWindow(screen);
if (popupMenuWindow && popupMenuWindow.visible) {
popupMenuWindow.close();
}
}
// Show a tray menu with proper handling for all compositors
// Returns true if menu was shown successfully
function showTrayMenu(screen, trayItem, trayMenu, anchorItem, menuX, menuY, widgetSection, widgetIndex) {
if (!trayItem || !trayMenu || !anchorItem)
return false;
// Close any previously opened menu first
closeContextMenu(screen);
trayMenu.trayItem = trayItem;
trayMenu.widgetSection = widgetSection;
trayMenu.widgetIndex = widgetIndex;
var popupMenuWindow = getPopupMenuWindow(screen);
if (popupMenuWindow) {
popupMenuWindow.open();
trayMenu.showAt(anchorItem, menuX, menuY);
} else {
return false;
}
return true;
}
// Close tray menu
function closeTrayMenu(screen) {
var popupMenuWindow = getPopupMenuWindow(screen);
if (popupMenuWindow) {
// This closes both the window and calls hideMenu on the tray menu
popupMenuWindow.close();
}
}
// Find a fallback screen, prioritizing 0x0 position (primary)
function findFallbackScreen() {
let primaryCandidate = null;
let firstScreen = null;
for (let i = 0; i < Quickshell.screens.length; i++) {
const s = Quickshell.screens[i];
if (s.x === 0 && s.y === 0) {
primaryCandidate = s;
}
if (!firstScreen) {
firstScreen = s;
}
}
return primaryCandidate || firstScreen || null;
}
// Returns a panel (loads it on-demand if not yet loaded)
// By default, if panel not found on screen, tries other screens (favoring 0x0)
// Pass fallback=false to disable this behavior
function getPanel(name, screen, fallback = true) {
if (!screen) {
Logger.d("PanelService", "missing screen for getPanel:", name);
// If no screen specified, return the first matching panel
for (var key in registeredPanels) {
if (key.startsWith(name + "-")) {
return registeredPanels[key];
}
}
return null;
}
var panelKey = `${name}-${screen.name}`;
// Check if panel is already loaded
if (registeredPanels[panelKey]) {
return registeredPanels[panelKey];
}
// If fallback enabled, try to find panel on another screen
if (fallback) {
// First try the primary screen (0x0)
var fallbackScreen = findFallbackScreen();
if (fallbackScreen && fallbackScreen.name !== screen.name) {
var fallbackKey = `${name}-${fallbackScreen.name}`;
if (registeredPanels[fallbackKey]) {
Logger.d("PanelService", "Panel fallback from", screen.name, "to", fallbackScreen.name);
return registeredPanels[fallbackKey];
}
}
// Try any other screen
for (var key in registeredPanels) {
if (key.startsWith(name + "-")) {
Logger.d("PanelService", "Panel fallback to first available:", key);
return registeredPanels[key];
}
}
}
Logger.w("PanelService", "Panel not found:", panelKey);
return null;
}
// Check if a panel exists
function hasPanel(name) {
return name in registeredPanels;
}
// Check if panels can be shown on a given screen (has bar enabled or allowPanelsOnScreenWithoutBar)
function canShowPanelsOnScreen(screen) {
const name = screen?.name || "";
const monitors = Settings.data.bar.monitors || [];
const allowPanelsOnScreenWithoutBar = Settings.data.general.allowPanelsOnScreenWithoutBar;
return allowPanelsOnScreenWithoutBar || monitors.length === 0 || monitors.includes(name);
}
// Find a screen that can show panels
function findScreenForPanels() {
for (let i = 0; i < Quickshell.screens.length; i++) {
if (canShowPanelsOnScreen(Quickshell.screens[i])) {
return Quickshell.screens[i];
}
}
return null;
}
// Timer to switch from Exclusive to OnDemand keyboard focus on Hyprland
Timer {
id: keyboardInitTimer
interval: 100
repeat: false
onTriggered: {
root.isInitializingKeyboard = false;
}
}
// Helper to keep only one panel open at any time
function willOpenPanel(panel) {
if (openedPanel && openedPanel !== panel) {
// Move current panel to closing slot before closing it
closingPanel = openedPanel;
assignToSlot(1, closingPanel);
openedPanel.close();
}
// Assign new panel to open slot
openedPanel = panel;
assignToSlot(0, panel);
// Start keyboard initialization period (for Hyprland workaround)
if (panel.exclusiveKeyboard) {
isInitializingKeyboard = true;
keyboardInitTimer.restart();
}
// emit signal
willOpen();
}
function closedPanel(panel) {
if (openedPanel && openedPanel === panel) {
openedPanel = null;
assignToSlot(0, null);
}
if (closingPanel && closingPanel === panel) {
closingPanel = null;
assignToSlot(1, null);
}
// Reset keyboard init state
isInitializingKeyboard = false;
keyboardInitTimer.stop();
// emit signal
didClose();
}
// Close panels when compositor overview opens (if setting is enabled)
Connections {
target: CompositorService
enabled: Settings.data.bar.hideOnOverview
function onOverviewActiveChanged() {
if (CompositorService.overviewActive && root.openedPanel) {
root.openedPanel.close();
}
}
}
}