Files
noctalia-shell/Modules/Bar/Widgets/Taskbar.qml
T

686 lines
25 KiB
QML

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import Quickshell.Widgets
import qs.Commons
import qs.Services.Compositor
import qs.Services.UI
import qs.Widgets
Rectangle {
id: root
property ShellScreen screen
// Widget properties passed from Bar.qml for per-instance settings
property string widgetId: ""
property string section: ""
property int sectionWidgetIndex: -1
property int sectionWidgetsCount: 0
readonly property string barPosition: Settings.data.bar.position
readonly property bool isVerticalBar: barPosition === "left" || barPosition === "right"
readonly property string density: Settings.data.bar.density
readonly property real itemSize: (density === "compact") ? Style.capsuleHeight * 1.0 : Style.capsuleHeight * 0.9
property var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId]
property var widgetSettings: {
if (section && sectionWidgetIndex >= 0) {
var widgets = Settings.data.bar.widgets[section];
if (widgets && sectionWidgetIndex < widgets.length) {
return widgets[sectionWidgetIndex];
}
}
return {};
}
property bool hasWindow: false
readonly property string hideMode: (widgetSettings.hideMode !== undefined) ? widgetSettings.hideMode : widgetMetadata.hideMode
readonly property bool onlySameOutput: (widgetSettings.onlySameOutput !== undefined) ? widgetSettings.onlySameOutput : widgetMetadata.onlySameOutput
readonly property bool onlyActiveWorkspaces: (widgetSettings.onlyActiveWorkspaces !== undefined) ? widgetSettings.onlyActiveWorkspaces : widgetMetadata.onlyActiveWorkspaces
readonly property bool showTitle: isVerticalBar ? false : (widgetSettings.showTitle !== undefined) ? widgetSettings.showTitle : widgetMetadata.showTitle
readonly property bool smartWidth: (widgetSettings.smartWidth !== undefined) ? widgetSettings.smartWidth : widgetMetadata.smartWidth
readonly property int maxTaskbarWidthPercent: (widgetSettings.maxTaskbarWidth !== undefined) ? widgetSettings.maxTaskbarWidth : widgetMetadata.maxTaskbarWidth
// Maximum width for the taskbar widget to prevent overlapping with other widgets
readonly property real maxTaskbarWidth: {
if (!screen || isVerticalBar || !smartWidth || maxTaskbarWidthPercent <= 0)
return 0;
var barFloating = Settings.data.bar.floating || false;
var barMarginH = barFloating ? Math.ceil(Settings.data.bar.marginHorizontal * Style.marginXL) : 0;
var availableWidth = screen.width - (barMarginH * 2);
return Math.round(availableWidth * (maxTaskbarWidthPercent / 100));
}
readonly property int titleWidth: {
if (smartWidth && showTitle && !isVerticalBar && combinedModel.length > 0) {
var entriesCount = combinedModel.length;
var baseWidth = 140;
var calculatedWidth = baseWidth / Math.sqrt(entriesCount);
if (maxTaskbarWidth > 0) {
var maxWidthPerEntry = (maxTaskbarWidth / entriesCount) - itemSize - Style.marginS - Style.marginM * 2;
calculatedWidth = Math.min(calculatedWidth, maxWidthPerEntry);
}
return Math.max(Math.round(calculatedWidth), 20);
}
return (widgetSettings.titleWidth !== undefined) ? widgetSettings.titleWidth : widgetMetadata.titleWidth;
}
readonly property bool showPinnedApps: (widgetSettings.showPinnedApps !== undefined) ? widgetSettings.showPinnedApps : widgetMetadata.showPinnedApps
// Context menu state
property var selectedWindow: null
property string selectedAppName: ""
property int modelUpdateTrigger: 0 // Dummy property to force model re-evaluation
// Hover state
property var hoveredWindowId: ""
// Combined model of running windows and pinned apps
property var combinedModel: []
// Helper function to normalize app IDs for case-insensitive matching
function normalizeAppId(appId) {
if (!appId || typeof appId !== 'string')
return "";
return appId.toLowerCase().trim();
}
// Helper function to check if an app ID matches a pinned app (case-insensitive)
function isAppIdPinned(appId, pinnedApps) {
if (!appId || !pinnedApps || pinnedApps.length === 0)
return false;
const normalizedId = normalizeAppId(appId);
return pinnedApps.some(pinnedId => normalizeAppId(pinnedId) === normalizedId);
}
// Helper function to get app name from desktop entry
function getAppNameFromDesktopEntry(appId) {
if (!appId)
return appId;
try {
if (typeof DesktopEntries !== 'undefined' && DesktopEntries.heuristicLookup) {
const entry = DesktopEntries.heuristicLookup(appId);
if (entry && entry.name) {
return entry.name;
}
}
if (typeof DesktopEntries !== 'undefined' && DesktopEntries.byId) {
const entry = DesktopEntries.byId(appId);
if (entry && entry.name) {
return entry.name;
}
}
} catch (e)
// Fall through to return original appId
{}
// Return original appId if we can't find a desktop entry
return appId;
}
// Helper function to get desktop entry ID from an app ID
function getDesktopEntryId(appId) {
if (!appId)
return appId;
// Try to find the desktop entry using heuristic lookup
if (typeof DesktopEntries !== 'undefined' && DesktopEntries.heuristicLookup) {
try {
const entry = DesktopEntries.heuristicLookup(appId);
if (entry && entry.id) {
return entry.id;
}
} catch (e)
// Fall through to return original appId
{}
}
// Try direct lookup
if (typeof DesktopEntries !== 'undefined' && DesktopEntries.byId) {
try {
const entry = DesktopEntries.byId(appId);
if (entry && entry.id) {
return entry.id;
}
} catch (e)
// Fall through to return original appId
{}
}
// Return original appId if we can't find a desktop entry
return appId;
}
// Helper function to check if an app is pinned
function isAppPinned(appId) {
if (!appId)
return false;
const pinnedApps = Settings.data.dock.pinnedApps || [];
const normalizedId = normalizeAppId(appId);
return pinnedApps.some(pinnedId => normalizeAppId(pinnedId) === normalizedId);
}
// Helper function to toggle app pin/unpin
function toggleAppPin(appId) {
if (!appId)
return;
// Get the desktop entry ID for consistent pinning
const desktopEntryId = getDesktopEntryId(appId);
const normalizedId = normalizeAppId(desktopEntryId);
let pinnedApps = (Settings.data.dock.pinnedApps || []).slice(); // Create a copy
// Find existing pinned app with case-insensitive matching
const existingIndex = pinnedApps.findIndex(pinnedId => normalizeAppId(pinnedId) === normalizedId);
const isPinned = existingIndex >= 0;
if (isPinned) {
// Unpin: remove from array
pinnedApps.splice(existingIndex, 1);
} else {
// Pin: add desktop entry ID to array
pinnedApps.push(desktopEntryId);
}
// Update the settings
Settings.data.dock.pinnedApps = pinnedApps;
}
// Function to update the combined model
function updateCombinedModel() {
const runningWindows = [];
const pinnedApps = Settings.data.dock.pinnedApps || [];
const processedAppIds = new Set();
// First pass: Add all running windows
try {
const total = CompositorService.windows.count || 0;
const activeIds = CompositorService.getActiveWorkspaces().map(function (ws) {
return ws.id;
});
for (var i = 0; i < total; i++) {
var w = CompositorService.windows.get(i);
if (!w)
continue;
var passOutput = (!onlySameOutput) || (w.output == screen?.name);
var passWorkspace = (!onlyActiveWorkspaces) || (activeIds.includes(w.workspaceId));
if (passOutput && passWorkspace) {
const isPinned = isAppIdPinned(w.appId, pinnedApps);
runningWindows.push({
"id": w.id,
"type": isPinned ? "pinned-running" : "running",
"window": w,
"appId": w.appId,
"title": w.title || getAppNameFromDesktopEntry(w.appId)
});
processedAppIds.add(normalizeAppId(w.appId));
}
}
} catch (e)
// Ignore errors
{}
// Second pass: Add non-running pinned apps (only if showPinnedApps is enabled)
if (showPinnedApps) {
pinnedApps.forEach(pinnedAppId => {
const normalizedPinnedId = normalizeAppId(pinnedAppId);
if (!processedAppIds.has(normalizedPinnedId)) {
const appName = getAppNameFromDesktopEntry(pinnedAppId);
runningWindows.push({
"id": pinnedAppId,
"type": "pinned",
"window": null,
"appId": pinnedAppId,
"title": appName
});
}
});
}
combinedModel = runningWindows;
updateHasWindow();
}
// Function to launch a pinned app
function launchPinnedApp(appId) {
if (!appId)
return;
try {
const app = DesktopEntries.byId(appId);
if (Settings.data.appLauncher.customLaunchPrefixEnabled && Settings.data.appLauncher.customLaunchPrefix) {
// Use custom launch prefix
const prefix = Settings.data.appLauncher.customLaunchPrefix.split(" ");
if (app.runInTerminal) {
const terminal = Settings.data.appLauncher.terminalCommand.split(" ");
const command = prefix.concat(terminal.concat(app.command));
Quickshell.execDetached(command);
} else {
const command = prefix.concat(app.command);
Quickshell.execDetached(command);
}
} else if (Settings.data.appLauncher.useApp2Unit && app.id) {
Logger.d("Taskbar", `Using app2unit for: ${app.id}`);
if (app.runInTerminal)
Quickshell.execDetached(["app2unit", "--", app.id + ".desktop"]);
else
Quickshell.execDetached(["app2unit", "--"].concat(app.command));
} else {
// Fallback logic when app2unit is not used
if (app.runInTerminal) {
Logger.d("Taskbar", "Executing terminal app manually: " + app.name);
const terminal = Settings.data.appLauncher.terminalCommand.split(" ");
const command = terminal.concat(app.command);
Quickshell.execDetached(command);
} else if (app.execute) {
// Default execution for GUI apps
app.execute();
} else {
Logger.w("Taskbar", `Could not launch: ${app.name}. No valid launch method.`);
}
}
} catch (e) {
Logger.e("Taskbar", "Failed to launch app: " + e);
}
}
NPopupContextMenu {
id: contextMenu
model: {
// Reference modelUpdateTrigger to make binding reactive
const _ = root.modelUpdateTrigger;
var items = [];
if (root.selectedWindow) {
// Focus item (for running apps)
items.push({
"label": I18n.tr("dock.menu.focus"),
"action": "focus",
"icon": "eye"
});
}
// Pin/Unpin item (always available when right-clicking an app)
if (root.selectedWindow) {
const appId = root.selectedWindow.appId;
const isPinned = root.isAppPinned(appId);
items.push({
"label": !isPinned ? I18n.tr("dock.menu.pin") : I18n.tr("dock.menu.unpin"),
"action": "pin",
"icon": !isPinned ? "pin" : "unpin"
});
}
if (root.selectedWindow) {
// Close item (for running apps)
items.push({
"label": I18n.tr("dock.menu.close"),
"action": "close",
"icon": "x"
});
// Add desktop entry actions (like "New Window", "Private Window", etc.)
if (typeof DesktopEntries !== 'undefined' && DesktopEntries.byId && root.selectedWindow?.appId) {
const appId = root.selectedWindow.appId;
const entry = (DesktopEntries.heuristicLookup) ? DesktopEntries.heuristicLookup(appId) : DesktopEntries.byId(appId);
if (entry != null && entry.actions) {
entry.actions.forEach(function (action) {
items.push({
"label": action.name,
"action": "desktop-action-" + action.name,
"icon": "chevron-right",
"desktopAction": action
});
});
}
}
}
items.push({
"label": I18n.tr("context-menu.widget-settings"),
"action": "widget-settings",
"icon": "settings"
});
return items;
}
onTriggered: (action, item) => {
var popupMenuWindow = PanelService.getPopupMenuWindow(screen);
if (popupMenuWindow) {
popupMenuWindow.close();
}
if (action === "focus" && selectedWindow) {
CompositorService.focusWindow(selectedWindow);
} else if (action === "pin" && selectedWindow) {
root.toggleAppPin(selectedWindow.appId);
} else if (action === "close" && selectedWindow) {
CompositorService.closeWindow(selectedWindow);
} else if (action === "widget-settings") {
BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings);
} else if (action.startsWith("desktop-action-") && item && item.desktopAction) {
// Execute desktop entry action
item.desktopAction.execute();
}
selectedWindow = null;
selectedAppName = "";
}
}
function updateHasWindow() {
// Check if we have any items in the combined model (windows or pinned apps)
hasWindow = combinedModel.length > 0;
}
Connections {
target: CompositorService
function onActiveWindowChanged() {
updateCombinedModel();
}
function onWindowListChanged() {
updateCombinedModel();
}
function onWorkspaceChanged() {
updateCombinedModel();
}
}
Connections {
target: Settings.data.dock
function onPinnedAppsChanged() {
updateCombinedModel();
}
}
Component.onCompleted: {
updateCombinedModel();
}
onScreenChanged: updateCombinedModel()
// "visible": Always Visible, "hidden": Hide When Empty, "transparent": Transparent When Empty
visible: hideMode !== "hidden" || hasWindow
opacity: ((hideMode !== "hidden" && hideMode !== "transparent") || hasWindow) ? 1.0 : 0.0
Behavior on opacity {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutCubic
}
}
implicitWidth: {
if (!visible)
return 0;
if (isVerticalBar)
return Style.capsuleHeight;
var calculatedWidth = showTitle ? taskbarLayout.implicitWidth : taskbarLayout.implicitWidth + Style.marginM * 2;
// Apply maximum width constraint when smartWidth is enabled
if (smartWidth && maxTaskbarWidth > 0) {
return Math.min(calculatedWidth, maxTaskbarWidth);
}
return Math.round(calculatedWidth);
}
implicitHeight: visible ? (isVerticalBar ? Math.round(taskbarLayout.implicitHeight + Style.marginM * 2) : Style.capsuleHeight) : 0
radius: Style.radiusM
color: Style.capsuleColor
border.color: Style.capsuleBorderColor
border.width: Style.capsuleBorderWidth
GridLayout {
id: taskbarLayout
anchors.fill: parent
anchors {
leftMargin: (root.showTitle || isVerticalBar) ? undefined : Style.marginM
rightMargin: (root.showTitle || isVerticalBar) ? undefined : Style.marginM
topMargin: (density === "compact") ? 0 : isVerticalBar ? Style.marginM : undefined
bottomMargin: (density === "compact") ? 0 : isVerticalBar ? Style.marginM : undefined
}
// Configure GridLayout to behave like RowLayout or ColumnLayout
rows: isVerticalBar ? -1 : 1 // -1 means unlimited
columns: isVerticalBar ? 1 : -1 // -1 means unlimited
rowSpacing: isVerticalBar ? Style.marginXXS : 0
columnSpacing: isVerticalBar ? 0 : Style.marginXXS
Repeater {
model: root.combinedModel
delegate: Item {
id: taskbarItem
required property var modelData
property ShellScreen screen: root.screen
readonly property bool isRunning: modelData.window !== null
readonly property bool isPinned: modelData.type === "pinned" || modelData.type === "pinned-running"
readonly property bool isFocused: isRunning && modelData.window && modelData.window.isFocused
readonly property bool isPinnedRunning: isPinned && isRunning && !isFocused
readonly property bool isHovered: root.hoveredWindowId === modelData.id
readonly property bool shouldShowTitle: root.showTitle && modelData.type !== "pinned"
readonly property real itemSpacing: Style.marginS
readonly property real contentWidth: shouldShowTitle ? root.itemSize + itemSpacing + root.titleWidth : root.itemSize
readonly property string title: modelData.title || modelData.appId || "Unknown application"
readonly property color titleBgColor: (isHovered || isFocused) ? Color.mHover : Style.capsuleColor
readonly property color titleFgColor: (isHovered || isFocused) ? Color.mOnHover : Color.mOnSurface
Layout.preferredWidth: root.showTitle ? Math.round(contentWidth + Style.marginM * 2) : Math.round(contentWidth) // Add margins for both pinned and running apps
Layout.preferredHeight: root.itemSize
Layout.alignment: Qt.AlignCenter
Rectangle {
id: titleBackground
visible: shouldShowTitle
anchors.centerIn: parent
width: parent.width
height: root.height
color: titleBgColor
radius: Style.radiusM
Behavior on color {
ColorAnimation {
duration: Style.animationFast
easing.type: Easing.InOutQuad
}
}
}
Rectangle {
anchors.centerIn: parent
width: taskbarItem.contentWidth
height: parent.height
color: "transparent"
RowLayout {
id: itemLayout
anchors.fill: parent
spacing: taskbarItem.itemSpacing
Item {
Layout.preferredWidth: root.itemSize
Layout.preferredHeight: root.itemSize
Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft
IconImage {
id: appIcon
anchors.fill: parent
source: ThemeIcons.iconForAppId(taskbarItem.modelData.appId)
smooth: true
asynchronous: true
// Apply dock shader to all taskbar icons
layer.enabled: widgetSettings.colorizeIcons !== false
layer.effect: ShaderEffect {
property color targetColor: Settings.data.colorSchemes.darkMode ? Color.mOnSurface : Color.mSurfaceVariant
property real colorizeMode: 0.0 // Dock mode (grayscale)
fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/appicon_colorize.frag.qsb")
}
}
Rectangle {
id: iconBackground
visible: !shouldShowTitle
anchors.bottomMargin: -2
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
width: 4
height: 4
color: taskbarItem.isFocused ? Color.mPrimary : Color.transparent
radius: Math.min(Style.radiusXXS, width / 2)
}
}
NText {
id: titleText
visible: shouldShowTitle
Layout.preferredWidth: root.titleWidth
Layout.preferredHeight: root.itemSize
Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft
Layout.fillWidth: false
text: taskbarItem.title
elide: Text.ElideRight
verticalAlignment: Text.AlignVCenter
horizontalAlignment: Text.AlignLeft
pointSize: root.itemSize * 0.5
color: titleFgColor
opacity: Style.opacityFull
}
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: function (mouse) {
if (!modelData)
return;
if (mouse.button === Qt.LeftButton) {
if (isRunning && modelData.window) {
// Running app - focus it
try {
CompositorService.focusWindow(modelData.window);
} catch (error) {
Logger.e("Taskbar", "Failed to activate toplevel: " + error);
}
} else if (isPinned) {
// Pinned app not running - launch it
root.launchPinnedApp(modelData.appId);
}
} else if (mouse.button === Qt.RightButton) {
TooltipService.hide();
// Only show context menu for running apps
if (isRunning && modelData.window) {
root.selectedWindow = modelData.window;
root.selectedAppName = CompositorService.getCleanAppName(modelData.appId, modelData.title);
root.openTaskbarContextMenu(taskbarItem);
}
}
}
onEntered: {
root.hoveredWindowId = taskbarItem.modelData.id;
TooltipService.show(taskbarItem, taskbarItem.title, BarService.getTooltipDirection());
}
onExited: {
root.hoveredWindowId = "";
TooltipService.hide();
}
}
}
}
}
function openTaskbarContextMenu(item) {
// Build menu model directly
var items = [];
if (root.selectedWindow) {
// Focus item (for running apps)
items.push({
"label": I18n.tr("dock.menu.focus"),
"action": "focus",
"icon": "eye"
});
// Pin/Unpin item
const appId = root.selectedWindow.appId;
const isPinned = root.isAppPinned(appId);
items.push({
"label": !isPinned ? I18n.tr("dock.menu.pin") : I18n.tr("dock.menu.unpin"),
"action": "pin",
"icon": !isPinned ? "pin" : "unpin"
});
// Close item
items.push({
"label": I18n.tr("dock.menu.close"),
"action": "close",
"icon": "x"
});
// Add desktop entry actions (like "New Window", "Private Window", etc.)
if (typeof DesktopEntries !== 'undefined' && DesktopEntries.byId && root.selectedWindow.appId) {
const entry = (DesktopEntries.heuristicLookup) ? DesktopEntries.heuristicLookup(appId) : DesktopEntries.byId(appId);
if (entry != null && entry.actions) {
entry.actions.forEach(function (action) {
items.push({
"label": action.name,
"action": "desktop-action-" + action.name,
"icon": "chevron-right",
"desktopAction": action
});
});
}
}
}
items.push({
"label": I18n.tr("context-menu.widget-settings"),
"action": "widget-settings",
"icon": "settings"
});
// Set the model directly
contextMenu.model = items;
var popupMenuWindow = PanelService.getPopupMenuWindow(screen);
if (popupMenuWindow) {
popupMenuWindow.open();
// Calculate menu position
const globalPos = item.mapToItem(root, 0, 0);
let menuX, menuY;
if (root.barPosition === "top") {
menuX = globalPos.x + (item.width / 2) - (contextMenu.implicitWidth / 2);
menuY = Style.barHeight + Style.marginS;
} else if (root.barPosition === "bottom") {
const menuHeight = 12 + contextMenu.model.length * contextMenu.itemHeight;
menuX = globalPos.x + (item.width / 2) - (contextMenu.implicitWidth / 2);
menuY = -menuHeight - Style.marginS;
} else if (root.barPosition === "left") {
menuX = Style.barHeight + Style.marginS;
menuY = globalPos.y + (item.height / 2) - (contextMenu.implicitHeight / 2);
} else {
// right
menuX = -contextMenu.implicitWidth - Style.marginS;
menuY = globalPos.y + (item.height / 2) - (contextMenu.implicitHeight / 2);
}
popupMenuWindow.showContextMenu(contextMenu);
contextMenu.openAtItem(root, menuX, menuY);
}
}
}