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

1027 lines
38 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.System
import qs.Services.UI
import qs.Widgets
Item {
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
// Explicit screenName property ensures reactive binding when screen changes
readonly property string screenName: screen ? screen.name : ""
readonly property string barPosition: Settings.getBarPositionForScreen(screenName)
readonly property bool isVerticalBar: barPosition === "left" || barPosition === "right"
readonly property real barHeight: Style.getBarHeightForScreen(screenName)
readonly property real capsuleHeight: Style.getCapsuleHeightForScreen(screenName)
readonly property real barFontSize: Style.getBarFontSizeForScreen(screenName)
property var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId] ?? {}
property var widgetSettings: {
if (section && sectionWidgetIndex >= 0 && screenName) {
var widgets = Settings.getBarWidgetsForScreen(screenName)[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
readonly property real iconScale: (widgetSettings.iconScale !== undefined) ? widgetSettings.iconScale : widgetMetadata.iconScale
readonly property int itemSize: Style.toOdd(capsuleHeight * Math.max(0.1, iconScale))
// 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.barType === "floating";
var barMarginH = barFloating ? Math.ceil(Settings.data.bar.marginHorizontal) : 0;
var availableWidth = screen.width - (barMarginH * 2);
return Math.round(availableWidth * (maxTaskbarWidthPercent / 100));
}
readonly property int titleWidth: {
// First, use user-defined title width if set
var calculatedWidth = (widgetSettings.titleWidth !== undefined) ? widgetSettings.titleWidth : widgetMetadata.titleWidth;
// Second, shrink title width if it exceeds maxTaskbarWidth when smartWidth is enabled
if (smartWidth && combinedModel.length > 0) {
if (maxTaskbarWidth > 0) {
var entriesCount = combinedModel.length;
var maxWidthPerEntry = (maxTaskbarWidth / entriesCount) - itemSize - Style.marginS - Style.margin2M;
calculatedWidth = Math.min(calculatedWidth, maxWidthPerEntry);
}
calculatedWidth = Math.max(Math.round(calculatedWidth), 20);
}
return calculatedWidth;
}
readonly property bool showPinnedApps: (widgetSettings.showPinnedApps !== undefined) ? widgetSettings.showPinnedApps : widgetMetadata.showPinnedApps
// Context menu state - store ID instead of object reference to avoid stale references
property string selectedWindowId: ""
property string selectedAppId: ""
// Helper to get the current window object from ID
function getSelectedWindow() {
if (!selectedWindowId)
return null;
for (var i = 0; i < combinedModel.length; i++) {
// Using loose equality on purpose (==)
if (combinedModel[i].id == selectedWindowId && combinedModel[i].window) {
return combinedModel[i].window;
}
}
return null;
}
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: []
// Wheel scroll handling
property int wheelAccumulatedDelta: 0
property bool wheelCooldown: false
// Drag and Drop state for visual feedback
property int dragSourceIndex: -1
property int dragTargetIndex: -1
// Track the session order of apps (transient reordering)
property var sessionAppOrder: []
function getAppKey(appData) {
if (!appData)
return null;
// prefer window object identity for running apps to distinguish instances
if (appData.window)
return appData.window;
// fallback to appId for pinned-only apps
return appData.appId;
}
function sortApps(apps) {
if (!sessionAppOrder || sessionAppOrder.length === 0) {
return apps;
}
const sorted = [];
const remaining = [...apps];
// 1. Pick apps that are in the session order
for (let i = 0; i < sessionAppOrder.length; i++) {
const key = sessionAppOrder[i];
const idx = remaining.findIndex(app => getAppKey(app) === key);
if (idx !== -1) {
sorted.push(remaining[idx]);
remaining.splice(idx, 1);
}
}
// 2. Append any new/remaining apps
remaining.forEach(app => sorted.push(app));
return sorted;
}
function reorderApps(fromIndex, toIndex) {
Logger.d("Taskbar", "Reordering apps from " + fromIndex + " to " + toIndex);
if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0 || fromIndex >= combinedModel.length || toIndex >= combinedModel.length)
return;
const list = [...combinedModel];
const item = list.splice(fromIndex, 1)[0];
list.splice(toIndex, 0, item);
combinedModel = list;
sessionAppOrder = combinedModel.map(getAppKey);
savePinnedOrder();
}
function savePinnedOrder() {
const currentPinned = Settings.data.dock.pinnedApps || [];
const newPinned = [];
const seen = new Set();
// Extract pinned apps in their current visual order
combinedModel.forEach(app => {
if (app.appId && !seen.has(app.appId)) {
const isPinned = currentPinned.some(p => normalizeAppId(p) === normalizeAppId(app.appId));
if (isPinned) {
newPinned.push(app.appId);
seen.add(app.appId);
}
}
});
// Check if any pinned apps were missed (e.g. filtered out by workspace)
currentPinned.forEach(p => {
if (!seen.has(p)) {
newPinned.push(p);
seen.add(p);
}
});
if (JSON.stringify(currentPinned) !== JSON.stringify(newPinned)) {
Settings.data.dock.pinnedApps = newPinned;
}
}
// 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);
// Direct match
if (pinnedApps.some(pinnedId => normalizeAppId(pinnedId) === normalizedId))
return true;
// Resolve via desktop entry lookup (handles StartupWMClass != .desktop filename)
const resolved = resolveToDesktopEntryId(appId);
if (resolved !== appId) {
const normalizedResolved = normalizeAppId(resolved);
return pinnedApps.some(pinnedId => normalizeAppId(pinnedId) === normalizedResolved);
}
return false;
}
// Desktop entry ID resolution cache (cleared when DesktopEntries change)
property var _desktopEntryIdCache: ({})
// Resolve a toplevel appId to its canonical .desktop entry ID via heuristic lookup.
function resolveToDesktopEntryId(appId) {
if (!appId)
return appId;
if (_desktopEntryIdCache.hasOwnProperty(appId))
return _desktopEntryIdCache[appId];
try {
if (typeof DesktopEntries !== 'undefined' && DesktopEntries.heuristicLookup) {
const entry = DesktopEntries.heuristicLookup(appId);
if (entry && entry.id) {
_desktopEntryIdCache[appId] = entry.id;
return entry.id;
}
}
} catch (e) {}
_desktopEntryIdCache[appId] = appId;
return appId;
}
// 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);
if (pinnedApps.some(pinnedId => normalizeAppId(pinnedId) === normalizedId))
return true;
const resolved = resolveToDesktopEntryId(appId);
if (resolved !== appId) {
const normalizedResolved = normalizeAppId(resolved);
return pinnedApps.some(pinnedId => normalizeAppId(pinnedId) === normalizedResolved);
}
return false;
}
// 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));
// Also track the resolved desktop entry ID so pinned app matching works
const resolvedId = resolveToDesktopEntryId(w.appId);
if (resolvedId !== w.appId)
processedAppIds.add(normalizeAppId(resolvedId));
}
}
} 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 = sortApps(runningWindows);
// Sync session order if needed (e.g. first run or new apps added)
if (!sessionAppOrder || sessionAppOrder.length === 0 || sessionAppOrder.length !== combinedModel.length) {
sessionAppOrder = combinedModel.map(getAppKey);
}
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.trim() !== "") {
// Use custom launch prefix
const prefix = Settings.data.appLauncher.customLaunchPrefix.trim().split(" ");
if (app.runInTerminal && Settings.data.appLauncher.terminalCommand.trim() !== "") {
const terminal = Settings.data.appLauncher.terminalCommand.trim().split(" ");
const command = prefix.concat(terminal.concat(app.command));
Quickshell.execDetached(command);
} else {
const command = prefix.concat(app.command);
Quickshell.execDetached(command);
}
} else {
if (app.runInTerminal && Settings.data.appLauncher.terminalCommand.trim() !== "") {
Logger.d("Taskbar", "Executing terminal app manually: " + app.name);
const terminal = Settings.data.appLauncher.terminalCommand.trim().split(" ");
const command = terminal.concat(app.command);
CompositorService.spawn(command);
} else if (app.command && app.command.length > 0) {
CompositorService.spawn(app.command);
} else if (app.execute) {
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.selectedWindowId) {
// Focus item (for running apps)
items.push({
"label": I18n.tr("common.focus"),
"action": "focus",
"icon": "eye"
});
// Pin/Unpin item (always available when right-clicking an app)
const isPinned = root.isAppPinned(root.selectedAppId);
items.push({
"label": !isPinned ? I18n.tr("common.pin") : I18n.tr("common.unpin"),
"action": "pin",
"icon": !isPinned ? "pin" : "unpin"
});
// Close item (for running apps)
items.push({
"label": I18n.tr("common.close"),
"action": "close",
"icon": "x"
});
// Add desktop entry actions (like "New Window", "Private Window", etc.)
if (typeof DesktopEntries !== 'undefined' && DesktopEntries.byId && root.selectedAppId) {
const entry = (DesktopEntries.heuristicLookup) ? DesktopEntries.heuristicLookup(root.selectedAppId) : DesktopEntries.byId(root.selectedAppId);
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("actions.widget-settings"),
"action": "widget-settings",
"icon": "settings"
});
return items;
}
onTriggered: (action, item) => {
contextMenu.close();
PanelService.closeContextMenu(root.screen);
// Look up the window fresh each time to avoid stale references
const selectedWindow = root.getSelectedWindow();
if (action === "focus" && selectedWindow) {
CompositorService.focusWindow(selectedWindow);
} else if (action === "pin" && root.selectedAppId) {
root.toggleAppPin(root.selectedAppId);
} else if (action === "close" && selectedWindow) {
CompositorService.closeWindow(selectedWindow);
} else if (action === "widget-settings") {
BarService.openWidgetSettings(root.screen, root.section, root.sectionWidgetIndex, root.widgetId, root.widgetSettings);
} else if (action.startsWith("desktop-action-") && item && item.desktopAction) {
if (item.desktopAction.command && item.desktopAction.command.length > 0) {
Quickshell.execDetached(item.desktopAction.command);
} else if (item.desktopAction.execute) {
item.desktopAction.execute();
}
}
root.selectedWindowId = "";
root.selectedAppId = "";
}
}
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()
// Debounce timer for wheel interactions
Timer {
id: wheelDebounce
interval: 150
repeat: false
onTriggered: {
root.wheelCooldown = false;
root.wheelAccumulatedDelta = 0;
}
}
// Scroll to switch between windows
WheelHandler {
id: wheelHandler
target: root
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
onWheel: function (event) {
if (root.wheelCooldown || root.combinedModel.length === 0)
return;
var dy = event.angleDelta.y;
var dx = event.angleDelta.x;
var useDy = Math.abs(dy) >= Math.abs(dx);
var delta = useDy ? dy : dx;
root.wheelAccumulatedDelta += delta;
var step = 120;
if (Math.abs(root.wheelAccumulatedDelta) >= step) {
var direction = root.wheelAccumulatedDelta > 0 ? -1 : 1;
// Find the focused window or first running window
var currentIndex = -1;
for (var i = 0; i < root.combinedModel.length; i++) {
if (root.combinedModel[i].window && root.combinedModel[i].window.isFocused) {
currentIndex = i;
break;
}
}
if (currentIndex < 0) {
// No focused window, find first running window
for (var j = 0; j < root.combinedModel.length; j++) {
if (root.combinedModel[j].window) {
currentIndex = j;
break;
}
}
}
if (currentIndex >= 0) {
var nextIndex = (currentIndex + direction + root.combinedModel.length) % root.combinedModel.length;
var nextItem = root.combinedModel[nextIndex];
if (nextItem && nextItem.window) {
try {
CompositorService.focusWindow(nextItem.window);
} catch (error) {
Logger.e("Taskbar", "Failed to focus window: " + error);
}
}
}
root.wheelCooldown = true;
wheelDebounce.restart();
root.wheelAccumulatedDelta = 0;
event.accepted = true;
}
}
}
// "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
}
}
// Content dimensions for implicit sizing
readonly property real contentWidth: {
if (!visible)
return 0;
if (isVerticalBar)
return barHeight;
var calculatedWidth = showTitle ? taskbarLayout.implicitWidth : taskbarLayout.implicitWidth + Style.margin2M;
// Apply maximum width constraint when smartWidth is enabled
if (smartWidth && maxTaskbarWidth > 0) {
return Math.min(calculatedWidth, maxTaskbarWidth);
}
return Math.round(calculatedWidth);
}
readonly property real contentHeight: visible ? (isVerticalBar ? Math.round(taskbarLayout.implicitHeight + Style.margin2S) : capsuleHeight) : 0
implicitWidth: contentWidth
implicitHeight: contentHeight
// Visual capsule centered in parent
Rectangle {
id: visualCapsule
width: root.contentWidth
height: root.contentHeight
anchors.centerIn: parent
radius: Style.radiusM
color: Style.capsuleColor
border.color: Style.capsuleBorderColor
border.width: Style.capsuleBorderWidth
GridLayout {
id: taskbarLayout
// Pixel-perfect centering
x: isVerticalBar ? Style.pixelAlignCenter(parent.width, width) : ((root.showTitle) ? Style.pixelAlignCenter(parent.width, width) : Style.marginM)
y: Style.pixelAlignCenter(parent.height, height)
// 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
required property int index
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.isVerticalBar ? root.barHeight : (root.showTitle ? Math.round(contentWidth + Style.margin2M) : Math.round(contentWidth)) // Add margins for both pinned and running apps
Layout.preferredHeight: root.isVerticalBar ? root.itemSize : root.barHeight
Layout.alignment: Qt.AlignCenter
// Ensure dragged item is on top
z: (root.dragSourceIndex === index) ? 1000 : 1
property int modelIndex: index
objectName: "taskbarAppItem"
DropArea {
anchors.fill: parent
keys: ["taskbar-app"]
onEntered: function (drag) {
if (drag.source && drag.source.objectName === "taskbarAppItem") {
root.dragTargetIndex = taskbarItem.modelIndex;
}
}
onExited: function () {
if (root.dragTargetIndex === taskbarItem.modelIndex) {
root.dragTargetIndex = -1;
}
}
onDropped: function (drop) {
root.dragSourceIndex = -1;
root.dragTargetIndex = -1;
Logger.d("Taskbar", "Dropped! Source: " + (drop.source ? drop.source.objectName : "null") + " Index: " + (drop.source ? drop.source.modelIndex : "?") + " -> Target Index: " + taskbarItem.modelIndex);
if (drop.source && drop.source.objectName === "taskbarAppItem" && drop.source !== taskbarItem) {
root.reorderApps(drop.source.modelIndex, taskbarItem.modelIndex);
} else {
Logger.d("Taskbar", "Drop ignored. Source objectName: " + (drop.source ? drop.source.objectName : "null"));
}
}
}
Item {
id: draggableContent
width: parent.width
height: parent.height
anchors.centerIn: dragging ? undefined : parent
// Visual shifting logic
readonly property bool isDragged: root.dragSourceIndex === index
property real shiftOffset: 0
// Calculate shift based on drag state
// If I am NOT the dragged item, but I am in the path of the drag
Binding on shiftOffset {
value: {
if (root.dragSourceIndex !== -1 && root.dragTargetIndex !== -1 && !draggableContent.isDragged) {
if (root.dragSourceIndex < root.dragTargetIndex) {
// Dragging Right: Items between source and target shift Left
if (index > root.dragSourceIndex && index <= root.dragTargetIndex) {
return -1 * (root.isVerticalBar ? root.itemSize : draggableContent.width); // Simple approximation, could be refined
}
} else if (root.dragSourceIndex > root.dragTargetIndex) {
// Dragging Left: Items between target and source shift Right
if (index >= root.dragTargetIndex && index < root.dragSourceIndex) {
return (root.isVerticalBar ? root.itemSize : draggableContent.width);
}
}
}
return 0;
}
}
transform: Translate {
x: !root.isVerticalBar ? draggableContent.shiftOffset : 0
y: root.isVerticalBar ? draggableContent.shiftOffset : 0
Behavior on x {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.OutQuad
}
}
Behavior on y {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.OutQuad
}
}
}
property bool dragging: taskbarMouseArea.drag.active
onDraggingChanged: {
if (dragging) {
root.dragSourceIndex = index;
} else {
// Don't reset immediately on release to allow drop to handle it,
// or use a timer if needed, but drop handler usually fires.
// However, if dropped outside, we need to reset.
// Let's reset if not handled by drop area quickly?
// Actually, drag.active becomes false on release.
// We might want to clear it if no drop happened.
if (root.dragSourceIndex === index) {
// Slight delay/check? For now, let DropArea handle reset on success.
// If cancelled (dropped nowhere), we should reset.
Qt.callLater(() => {
if (!taskbarMouseArea.drag.active && root.dragSourceIndex === index) {
root.dragSourceIndex = -1;
root.dragTargetIndex = -1;
}
});
}
}
}
Drag.active: dragging
Drag.source: taskbarItem
Drag.hotSpot.x: width / 2
Drag.hotSpot.y: height / 2
Drag.keys: ["taskbar-app"]
z: dragging ? 1000 : 0
scale: dragging ? 1.05 : 1.0
Behavior on scale {
NumberAnimation {
duration: Style.animationFast
}
}
Rectangle {
id: titleBackground
visible: shouldShowTitle
anchors.centerIn: parent
width: parent.width
height: root.capsuleHeight
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: Style.toOdd(root.itemSize * 0.25)
height: 4
color: taskbarItem.isFocused ? Color.mPrimary : (taskbarItem.isHovered ? Color.mHover : "transparent")
radius: Math.min(Style.radiusXXS, width / 2)
Behavior on color {
ColorAnimation {
duration: Style.animationFast
easing.type: Easing.OutCubic
}
}
}
}
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: barFontSize
color: titleFgColor
opacity: Style.opacityFull
}
}
}
}
MouseArea {
id: taskbarMouseArea
objectName: "taskbarMouseArea"
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
drag.target: draggableContent
drag.axis: root.isVerticalBar ? Drag.YAxis : Drag.XAxis
preventStealing: true
onPressed: {
// Constrain drag to roughly the taskbar area but allow some freedom
// Or just let it be free since we only care about drops
}
onReleased: {
if (draggableContent.Drag.active) {
draggableContent.Drag.drop();
}
}
onClicked: 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.selectedWindowId = modelData.id;
root.selectedAppId = modelData.appId;
root.openTaskbarContextMenu(taskbarItem);
}
}
}
onEntered: {
root.hoveredWindowId = taskbarItem.modelData.id;
TooltipService.show(taskbarItem, taskbarItem.title, BarService.getTooltipDirection(root.screen?.name));
}
onExited: {
root.hoveredWindowId = "";
TooltipService.hide();
}
}
}
}
}
}
function openTaskbarContextMenu(item) {
// Build menu model directly
var items = [];
if (root.selectedWindowId) {
// Focus item (for running apps)
items.push({
"label": I18n.tr("common.focus"),
"action": "focus",
"icon": "eye"
});
// Pin/Unpin item
const isPinned = root.isAppPinned(root.selectedAppId);
items.push({
"label": !isPinned ? I18n.tr("common.pin") : I18n.tr("common.unpin"),
"action": "pin",
"icon": !isPinned ? "pin" : "unpin"
});
// Close item
items.push({
"label": I18n.tr("common.close"),
"action": "close",
"icon": "x"
});
// Add desktop entry actions (like "New Window", "Private Window", etc.)
if (typeof DesktopEntries !== 'undefined' && DesktopEntries.byId && root.selectedAppId) {
const entry = (DesktopEntries.heuristicLookup) ? DesktopEntries.heuristicLookup(root.selectedAppId) : DesktopEntries.byId(root.selectedAppId);
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("actions.widget-settings"),
"action": "widget-settings",
"icon": "settings"
});
// Set the model directly
contextMenu.model = items;
// Anchor to root (stable) but center horizontally on the clicked item
PanelService.showContextMenu(contextMenu, root, screen, item);
}
}