Files
noctalia-shell/Modules/Bar/Widgets/Taskbar.qml
T
2025-12-07 17:37:51 -05:00

566 lines
21 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 * 0.9 : Style.capsuleHeight * 0.8
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 int titleWidth: (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;
}
// 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({
"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({
"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) {
items.push({
"label": I18n.tr("context-menu.activate-app", {
"app": root.selectedAppName
}),
"action": "activate",
"icon": "focus"
});
items.push({
"label": I18n.tr("context-menu.close-app", {
"app": root.selectedAppName
}),
"action": "close",
"icon": "x"
});
}
items.push({
"label": I18n.tr("context-menu.widget-settings"),
"action": "widget-settings",
"icon": "settings"
});
return items;
}
onTriggered: action => {
if (action === "activate" && selectedWindow) {
CompositorService.focusWindow(selectedWindow);
} else if (action === "close" && selectedWindow) {
CompositorService.closeWindow(selectedWindow);
} else if (action === "widget-settings") {
BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings);
}
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 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 !== "transparent" || hasWindow) ? 1.0 : 0
Behavior on opacity {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutCubic
}
}
implicitWidth: visible ? (isVerticalBar ? Style.capsuleHeight : showTitle ? Math.round(taskbarLayout.implicitWidth) : Math.round(taskbarLayout.implicitWidth + Style.marginM * 2)) : 0
implicitHeight: visible ? (isVerticalBar ? Math.round(taskbarLayout.implicitHeight + Style.marginM * 2) : Style.capsuleHeight) : 0
radius: Style.radiusM
color: Style.capsuleColor
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 real itemSpacing: Style.marginS
readonly property real contentWidth: root.showTitle ? root.itemSize + itemSpacing + root.titleWidth : root.itemSize
readonly property string title: modelData.title || modelData.appId || "Unknown application"
readonly property color titleBgColor: (isHovered || modelData.isFocused) ? Color.mHover : Style.capsuleColor
readonly property color titleFgColor: (isHovered || modelData.isFocused) ? Color.mOnHover : Color.mOnSurface
Layout.preferredWidth: root.showTitle ? contentWidth + Style.marginM * 2 : contentWidth
Layout.preferredHeight: root.itemSize
Layout.alignment: Qt.AlignCenter
Rectangle {
id: titleBackground
visible: root.showTitle
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
}
height: parent.height
source: ThemeIcons.iconForAppId(modelData.appId)
smooth: true
asynchronous: true
// Opacity: Focused (1.0) > Pinned Running (0.7) > Regular Running (0.6) > Just Pinned (0.4)
opacity: isFocused ? Style.opacityFull : (isPinnedRunning ? 0.7 : (isRunning ? 0.6 : 0.4))
// For pinned apps that aren't running: use a muted color to indicate not running
// For other apps: use standard colorization if enabled
layer.enabled: (isPinned && !isRunning) || (root.widgetSettings.colorizeIcons !== false && !isFocused)
layer.effect: ShaderEffect {
property color targetColor: {
// Pinned but not running: use a muted/desaturated color to indicate not running
if (isPinned && !isRunning) {
// Use a muted secondary or outline color
return Settings.data.colorSchemes.darkMode ? Qt.darker(Color.mSecondary, 1.3) : Qt.lighter(Color.mSecondary, 1.5);
}
// Standard colorization for other cases
return 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 {
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
opacity: (root.showTitle || modelData.isFocused) ? Style.opacityFull : 0.6
// 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: !root.showTitle
anchors.bottomMargin: -2
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
width: 4
height: 4
color: modelData.isFocused ? Color.mPrimary : Color.transparent
radius: Math.min(Style.radiusXXS, width / 2)
}
}
NText {
id: titleText
visible: root.showTitle
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
}
// Active indicator (focused window)
Rectangle {
id: iconBackground
anchors.bottomMargin: -2
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
width: 4
height: 4
color: isFocused ? Color.mPrimary : Color.transparent
radius: Math.min(Style.radiusXXS, width / 2)
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
preventStealing: true
onPressed: 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);
}
}
}
onReleased: function (mouse) {
if (!modelData)
return;
if (mouse.button === Qt.RightButton) {
mouse.accepted = true;
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);
// Store position and size for timer callback
const globalPos = taskbarItem.mapToItem(root, 0, 0);
contextMenuOpenTimer.globalX = globalPos.x;
contextMenuOpenTimer.globalY = globalPos.y;
contextMenuOpenTimer.itemWidth = taskbarItem.width;
contextMenuOpenTimer.itemHeight = taskbarItem.height;
contextMenuOpenTimer.restart();
}
}
}
onEntered: {
root.hoveredWindowId = taskbarItem.modelData.id;
TooltipService.show(taskbarItem, taskbarItem.title, BarService.getTooltipDirection());
}
onExited: {
root.hoveredWindowId = "";
TooltipService.hide();
}
}
}
}
}
Timer {
id: contextMenuOpenTimer
interval: 10
repeat: false
property real globalX: 0
property real globalY: 0
property real itemWidth: 0
property real itemHeight: 0
onTriggered: {
// Directly build and set model as a new array (bypass binding issues)
var items = [];
if (root.selectedWindow) {
items.push({
"label": I18n.tr("context-menu.activate-app", {
"app": root.selectedAppName
}),
"action": "activate",
"icon": "focus"
});
items.push({
"label": I18n.tr("context-menu.close-app", {
"app": root.selectedAppName
}),
"action": "close",
"icon": "x"
});
}
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
let menuX, menuY;
if (root.barPosition === "top") {
menuX = globalX + (itemWidth / 2) - (contextMenu.implicitWidth / 2);
menuY = Style.barHeight + Style.marginS;
} else if (root.barPosition === "bottom") {
const menuHeight = 12 + contextMenu.model.length * contextMenu.itemHeight;
menuX = globalX + (itemWidth / 2) - (contextMenu.implicitWidth / 2);
menuY = -menuHeight - Style.marginS;
} else if (root.barPosition === "left") {
menuX = Style.barHeight + Style.marginS;
menuY = globalY + (itemHeight / 2) - (contextMenu.implicitHeight / 2);
} else {
// right
menuX = -contextMenu.implicitWidth - Style.marginS;
menuY = globalY + (itemHeight / 2) - (contextMenu.implicitHeight / 2);
}
contextMenu.openAtItem(root, menuX, menuY);
popupMenuWindow.contentItem = contextMenu;
}
}
}
}