972 lines
38 KiB
QML
972 lines
38 KiB
QML
/*
|
|
SPDX-FileCopyrightText: 2012-2013 Eike Hein <hein@kde.org>
|
|
SPDX-FileCopyrightText: 2024 Nate Graham <nate@kde.org>
|
|
|
|
SPDX-License-Identifier: GPL-2.0-or-later
|
|
*/
|
|
|
|
pragma ComponentBehavior: Bound
|
|
|
|
import QtQuick
|
|
import QtQuick.Layouts
|
|
|
|
import org.kde.plasma.core as PlasmaCore
|
|
import org.kde.ksvg as KSvg
|
|
import org.kde.plasma.extras as PlasmaExtras
|
|
import org.kde.plasma.components as PlasmaComponents3
|
|
import org.kde.kirigami as Kirigami
|
|
import org.kde.plasma.private.taskmanager as TaskManagerApplet
|
|
import org.kde.plasma.plasmoid
|
|
|
|
import "code/layoutmetrics.js" as LayoutMetrics
|
|
import "code/tools.js" as TaskTools
|
|
|
|
PlasmaCore.ToolTipArea {
|
|
id: task
|
|
|
|
activeFocusOnTab: true
|
|
|
|
// To achieve a bottom-to-top layout on vertical panels, the task manager
|
|
// is rotated by 180 degrees(see main.qml). This makes the tasks rotated,
|
|
// so un-rotate them here to fix that.
|
|
rotation: Plasmoid.configuration.reverseMode && Plasmoid.formFactor === PlasmaCore.Types.Vertical ? 180 : 0
|
|
|
|
// Transform for movement animations and zoom effect - FIXED BINDING LOOP
|
|
transform: {
|
|
// Simple approach: always return appropriate transforms without modifying cache in binding
|
|
if (frame.zoomEnabled && (frame.isZoomed || frame.isAnimating)) {
|
|
return [translateTransform, zoomTransform];
|
|
} else {
|
|
return [translateTransform];
|
|
}
|
|
}
|
|
|
|
// Performance: Cache expensive layout calculations - REMOVED CACHING TO FIX BINDING LOOPS
|
|
implicitHeight: inPopup
|
|
? LayoutMetrics.preferredHeightInPopup()
|
|
: Math.max(tasksRoot.height / tasksRoot.plasmoid.configuration.maxStripes,
|
|
LayoutMetrics.preferredMinHeight())
|
|
|
|
implicitWidth: tasksRoot.vertical
|
|
? Math.max(LayoutMetrics.preferredMinWidth(), Math.min(LayoutMetrics.preferredMaxWidth(), tasksRoot.width / tasksRoot.plasmoid.configuration.maxStripes))
|
|
: 0
|
|
|
|
Layout.fillWidth: true
|
|
Layout.fillHeight: !inPopup
|
|
Layout.maximumWidth: tasksRoot.vertical
|
|
? -1
|
|
: ((model.IsLauncher && !tasks.iconsOnly) ? tasksRoot.height / taskList.rows : LayoutMetrics.preferredMaxWidth())
|
|
Layout.maximumHeight: tasksRoot.vertical ? LayoutMetrics.preferredMaxHeight() : -1
|
|
|
|
required property var model
|
|
required property int index
|
|
required property /*main.qml*/ Item tasksRoot
|
|
|
|
readonly property int pid: model.AppPid
|
|
readonly property string appName: model.AppName
|
|
readonly property string appId: model.AppId.replace(/\.desktop/, '')
|
|
readonly property bool isIcon: tasksRoot.iconsOnly || model.IsLauncher
|
|
property bool toolTipOpen: false
|
|
property bool inPopup: false
|
|
property bool isWindow: model.IsWindow
|
|
property int childCount: model.ChildCount
|
|
property int previousChildCount: 0
|
|
property alias labelText: label.text
|
|
property QtObject contextMenu: null
|
|
readonly property bool smartLauncherEnabled: !inPopup && !model.IsStartup
|
|
property QtObject smartLauncherItem: null
|
|
|
|
property Item audioStreamIcon: null
|
|
property var audioStreams: []
|
|
property bool delayAudioStreamIndicator: false
|
|
property bool completed: false
|
|
readonly property bool audioIndicatorsEnabled: Plasmoid.configuration.interactiveMute
|
|
readonly property bool hasAudioStream: audioStreams.length > 0
|
|
readonly property bool playingAudio: hasAudioStream && audioStreams.some(item => !item.corked)
|
|
readonly property bool muted: hasAudioStream && audioStreams.every(item => item.muted)
|
|
|
|
readonly property bool highlighted: (inPopup && activeFocus) || (!inPopup && containsMouse)
|
|
|| (task.contextMenu && task.contextMenu.status === PlasmaExtras.Menu.Open)
|
|
|| (!!tasksRoot.groupDialog && tasksRoot.groupDialog.visualParent === task)
|
|
|
|
active: !inPopup && !tasksRoot.groupDialog && task.contextMenu?.status !== PlasmaExtras.Menu.Open
|
|
interactive: model.IsWindow || mainItem.playerData
|
|
location: Plasmoid.location
|
|
mainItem: !Plasmoid.configuration.showToolTips || !model.IsWindow ? pinnedAppToolTipDelegate : openWindowToolTipDelegate
|
|
|
|
onXChanged: {
|
|
if (!completed) {
|
|
return;
|
|
}
|
|
if (oldX < 0) {
|
|
oldX = x;
|
|
return;
|
|
}
|
|
moveAnim.x = oldX - x + translateTransform.x;
|
|
moveAnim.y = translateTransform.y;
|
|
oldX = x;
|
|
moveAnim.restart();
|
|
}
|
|
onYChanged: {
|
|
if (!completed) {
|
|
return;
|
|
}
|
|
if (oldY < 0) {
|
|
oldY = y;
|
|
return;
|
|
}
|
|
moveAnim.y = oldY - y + translateTransform.y;
|
|
moveAnim.x = translateTransform.x;
|
|
oldY = y;
|
|
moveAnim.restart();
|
|
}
|
|
|
|
property real oldX: -1
|
|
property real oldY: -1
|
|
SequentialAnimation {
|
|
id: moveAnim
|
|
property real x
|
|
property real y
|
|
onRunningChanged: {
|
|
if (running) {
|
|
++task.parent.animationsRunning;
|
|
} else {
|
|
--task.parent.animationsRunning;
|
|
}
|
|
}
|
|
ParallelAnimation {
|
|
NumberAnimation {
|
|
target: translateTransform
|
|
properties: "x"
|
|
from: moveAnim.x
|
|
to: 0
|
|
easing.type: Easing.OutCubic
|
|
duration: Kirigami.Units.longDuration
|
|
}
|
|
NumberAnimation {
|
|
target: translateTransform
|
|
properties: "y"
|
|
from: moveAnim.y
|
|
to: 0
|
|
easing.type: Easing.OutCubic
|
|
duration: Kirigami.Units.longDuration
|
|
}
|
|
}
|
|
}
|
|
|
|
Accessible.name: model.display
|
|
Accessible.description: {
|
|
if (!model.display) {
|
|
return "";
|
|
}
|
|
|
|
if (model.IsLauncher) {
|
|
return i18nc("@info:usagetip %1 application name", "Launch %1", model.display)
|
|
}
|
|
|
|
let smartLauncherDescription = "";
|
|
if (iconBox.active) {
|
|
smartLauncherDescription += i18ncp("@info:tooltip", "There is %1 new message.", "There are %1 new messages.", task.smartLauncherItem.count);
|
|
}
|
|
|
|
if (model.IsGroupParent) {
|
|
switch (Plasmoid.configuration.groupedTaskVisualization) {
|
|
case 0:
|
|
break; // Use the default description
|
|
case 1: {
|
|
return `${i18nc("@info:usagetip %1 task name", "Show Task tooltip for %1", model.display)}; ${smartLauncherDescription}`;
|
|
}
|
|
case 2: {
|
|
if (effectWatcher.registered) {
|
|
return `${i18nc("@info:usagetip %1 task name", "Show windows side by side for %1", model.display)}; ${smartLauncherDescription}`;
|
|
}
|
|
// fallthrough
|
|
}
|
|
default:
|
|
return `${i18nc("@info:usagetip %1 task name", "Open textual list of windows for %1", model.display)}; ${smartLauncherDescription}`;
|
|
}
|
|
}
|
|
|
|
return `${i18n("Activate %1", model.display)}; ${smartLauncherDescription}`;
|
|
}
|
|
Accessible.role: Accessible.Button
|
|
Accessible.onPressAction: leftTapHandler.leftClick()
|
|
|
|
onToolTipVisibleChanged: toolTipVisible => {
|
|
task.toolTipOpen = toolTipVisible;
|
|
if (!toolTipVisible) {
|
|
tasksRoot.toolTipOpenedByClick = null;
|
|
} else {
|
|
tasksRoot.toolTipAreaItem = task;
|
|
}
|
|
}
|
|
|
|
onContainsMouseChanged: {
|
|
if (containsMouse) {
|
|
task.forceActiveFocus(Qt.MouseFocusReason);
|
|
task.updateMainItemBindings();
|
|
} else {
|
|
tasksRoot.toolTipOpenedByClick = null;
|
|
}
|
|
}
|
|
|
|
onPidChanged: updateAudioStreams({delay: false})
|
|
onAppNameChanged: updateAudioStreams({delay: false})
|
|
|
|
onIsWindowChanged: {
|
|
if (model.IsWindow) {
|
|
taskInitComponent.createObject(task);
|
|
updateAudioStreams({delay: false});
|
|
}
|
|
}
|
|
|
|
onChildCountChanged: {
|
|
if (TaskTools.taskManagerInstanceCount < 2 && childCount > previousChildCount) {
|
|
tasksModel.requestPublishDelegateGeometry(modelIndex(), backend.globalRect(task), task);
|
|
}
|
|
|
|
previousChildCount = childCount;
|
|
}
|
|
|
|
onIndexChanged: {
|
|
hideToolTip();
|
|
|
|
if (!inPopup && !tasksRoot.vertical
|
|
&& !Plasmoid.configuration.separateLaunchers) {
|
|
tasksRoot.requestLayout();
|
|
}
|
|
}
|
|
|
|
onSmartLauncherEnabledChanged: {
|
|
if (smartLauncherEnabled && !smartLauncherItem) {
|
|
const component = Qt.createComponent("org.kde.plasma.private.taskmanager", "SmartLauncherItem");
|
|
const smartLauncher = component.createObject(task);
|
|
component.destroy();
|
|
|
|
smartLauncher.launcherUrl = Qt.binding(() => model.LauncherUrlWithoutIcon);
|
|
|
|
smartLauncherItem = smartLauncher;
|
|
}
|
|
}
|
|
|
|
onHasAudioStreamChanged: {
|
|
const audioStreamIconActive = hasAudioStream;
|
|
if (!audioStreamIconActive) {
|
|
if (audioStreamIcon !== null) {
|
|
audioStreamIcon.destroy();
|
|
audioStreamIcon = null;
|
|
}
|
|
return;
|
|
}
|
|
// Create item on demand instead of using Loader to reduce memory consumption,
|
|
// because only a few applications have audio streams.
|
|
const component = Qt.createComponent("AudioStream.qml");
|
|
audioStreamIcon = component.createObject(task);
|
|
component.destroy();
|
|
}
|
|
onAudioIndicatorsEnabledChanged: task.hasAudioStreamChanged()
|
|
|
|
Keys.onMenuPressed: event => contextMenuTimer.start()
|
|
Keys.onReturnPressed: event => TaskTools.activateTask(modelIndex(), model, event.modifiers, task, Plasmoid, tasksRoot, effectWatcher.registered)
|
|
Keys.onEnterPressed: event => Keys.returnPressed(event);
|
|
Keys.onSpacePressed: event => Keys.returnPressed(event);
|
|
Keys.onUpPressed: event => Keys.leftPressed(event)
|
|
Keys.onDownPressed: event => Keys.rightPressed(event)
|
|
Keys.onLeftPressed: event => {
|
|
if (!inPopup && (event.modifiers & Qt.ControlModifier) && (event.modifiers & Qt.ShiftModifier)) {
|
|
tasksModel.move(task.index, task.index - 1);
|
|
} else {
|
|
event.accepted = false;
|
|
}
|
|
}
|
|
Keys.onRightPressed: event => {
|
|
if (!inPopup && (event.modifiers & Qt.ControlModifier) && (event.modifiers & Qt.ShiftModifier)) {
|
|
tasksModel.move(task.index, task.index + 1);
|
|
} else {
|
|
event.accepted = false;
|
|
}
|
|
}
|
|
|
|
function modelIndex(): /*QModelIndex*/ var {
|
|
return inPopup
|
|
? tasksModel.makeModelIndex(groupDialog.visualParent.index, index)
|
|
: tasksModel.makeModelIndex(index);
|
|
}
|
|
|
|
function showContextMenu(args: var): void {
|
|
task.hideImmediately();
|
|
contextMenu = tasksRoot.createContextMenu(task, modelIndex(), args);
|
|
contextMenu.show();
|
|
}
|
|
|
|
function updateAudioStreams(args: var): void {
|
|
if (args) {
|
|
// When the task just appeared (e.g. virtual desktop switch), show the audio indicator
|
|
// right away. Only when audio streams change during the lifetime of this task, delay
|
|
// showing that to avoid distraction.
|
|
delayAudioStreamIndicator = !!args.delay;
|
|
}
|
|
|
|
var pa = pulseAudio.item;
|
|
if (!pa || !task.isWindow) {
|
|
task.audioStreams = [];
|
|
return;
|
|
}
|
|
|
|
// Check appid first for app using portal
|
|
// https://docs.pipewire.org/page_portal.html
|
|
var streams = pa.streamsForAppId(task.appId);
|
|
if (!streams.length) {
|
|
streams = pa.streamsForPid(model.AppPid);
|
|
if (streams.length) {
|
|
pa.registerPidMatch(model.AppName);
|
|
} else {
|
|
// We only want to fall back to appName matching if we never managed to map
|
|
// a PID to an audio stream window. Otherwise if you have two instances of
|
|
// an application, one playing and the other not, it will look up appName
|
|
// for the non-playing instance and erroneously show an indicator on both.
|
|
if (!pa.hasPidMatch(model.AppName)) {
|
|
streams = pa.streamsForAppName(model.AppName);
|
|
}
|
|
}
|
|
}
|
|
|
|
task.audioStreams = streams;
|
|
}
|
|
|
|
function toggleMuted(): void {
|
|
if (muted) {
|
|
task.audioStreams.forEach(item => item.unmute());
|
|
} else {
|
|
task.audioStreams.forEach(item => item.mute());
|
|
}
|
|
}
|
|
|
|
// Will also be called in activateTaskAtIndex(index)
|
|
function updateMainItemBindings(): void {
|
|
if ((mainItem.parentTask === this && mainItem.rootIndex.row === index)
|
|
|| (tasksRoot.toolTipOpenedByClick === null && !active)
|
|
|| (tasksRoot.toolTipOpenedByClick !== null && tasksRoot.toolTipOpenedByClick !== this)) {
|
|
return;
|
|
}
|
|
|
|
mainItem.blockingUpdates = (mainItem.isGroup !== model.IsGroupParent); // BUG 464597 Force unload the previous component
|
|
|
|
mainItem.parentTask = this;
|
|
mainItem.rootIndex = tasksModel.makeModelIndex(index, -1);
|
|
|
|
mainItem.appName = Qt.binding(() => model.AppName);
|
|
mainItem.pidParent = Qt.binding(() => model.AppPid);
|
|
mainItem.windows = Qt.binding(() => model.WinIdList);
|
|
mainItem.isGroup = Qt.binding(() => model.IsGroupParent);
|
|
mainItem.icon = Qt.binding(() => model.decoration);
|
|
mainItem.launcherUrl = Qt.binding(() => model.LauncherUrlWithoutIcon);
|
|
mainItem.isLauncher = Qt.binding(() => model.IsLauncher);
|
|
mainItem.isMinimized = Qt.binding(() => model.IsMinimized);
|
|
mainItem.display = Qt.binding(() => model.display);
|
|
mainItem.genericName = Qt.binding(() => model.GenericName);
|
|
mainItem.virtualDesktops = Qt.binding(() => model.VirtualDesktops);
|
|
mainItem.isOnAllVirtualDesktops = Qt.binding(() => model.IsOnAllVirtualDesktops);
|
|
mainItem.activities = Qt.binding(() => model.Activities);
|
|
|
|
mainItem.smartLauncherCountVisible = Qt.binding(() => smartLauncherItem?.countVisible ?? false);
|
|
mainItem.smartLauncherCount = Qt.binding(() => mainItem.smartLauncherCountVisible ? smartLauncherItem.count : 0);
|
|
|
|
mainItem.blockingUpdates = false;
|
|
tasksRoot.toolTipAreaItem = this;
|
|
}
|
|
|
|
Connections {
|
|
target: pulseAudio.item
|
|
ignoreUnknownSignals: true // Plasma-PA might not be available
|
|
function onStreamsChanged(): void {
|
|
task.updateAudioStreams({delay: true})
|
|
}
|
|
}
|
|
|
|
TapHandler {
|
|
id: menuTapHandler
|
|
acceptedButtons: Qt.LeftButton
|
|
acceptedDevices: PointerDevice.TouchScreen | PointerDevice.Stylus
|
|
gesturePolicy: TapHandler.ReleaseWithinBounds
|
|
onLongPressed: {
|
|
// When we're a launcher, there's no window controls, so we can show all
|
|
// places without the menu getting super huge.
|
|
if (model.IsLauncher) {
|
|
showContextMenu({showAllPlaces: true})
|
|
} else {
|
|
showContextMenu();
|
|
}
|
|
}
|
|
}
|
|
|
|
TapHandler {
|
|
acceptedButtons: Qt.RightButton
|
|
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad | PointerDevice.Stylus
|
|
gesturePolicy: TapHandler.WithinBounds // Release grab when menu appears
|
|
onPressedChanged: if (pressed) contextMenuTimer.start()
|
|
}
|
|
|
|
Timer {
|
|
id: contextMenuTimer
|
|
interval: 0
|
|
onTriggered: menuTapHandler.longPressed()
|
|
}
|
|
|
|
TapHandler {
|
|
id: leftTapHandler
|
|
acceptedButtons: Qt.LeftButton
|
|
onTapped: (eventPoint, button) => leftClick()
|
|
|
|
function leftClick(): void {
|
|
if (task.active) {
|
|
hideToolTip();
|
|
}
|
|
TaskTools.activateTask(modelIndex(), model, point.modifiers, task, Plasmoid, tasksRoot, effectWatcher.registered);
|
|
}
|
|
}
|
|
|
|
TapHandler {
|
|
acceptedButtons: Qt.MiddleButton | Qt.BackButton | Qt.ForwardButton
|
|
onTapped: (eventPoint, button) => {
|
|
if (button === Qt.MiddleButton) {
|
|
if (Plasmoid.configuration.middleClickAction === TaskManagerApplet.Backend.NewInstance) {
|
|
tasksModel.requestNewInstance(modelIndex());
|
|
} else if (Plasmoid.configuration.middleClickAction === TaskManagerApplet.Backend.Close) {
|
|
tasksRoot.taskClosedWithMouseMiddleButton = model.WinIdList.slice()
|
|
tasksModel.requestClose(modelIndex());
|
|
} else if (Plasmoid.configuration.middleClickAction === TaskManagerApplet.Backend.ToggleMinimized) {
|
|
tasksModel.requestToggleMinimized(modelIndex());
|
|
} else if (Plasmoid.configuration.middleClickAction === TaskManagerApplet.Backend.ToggleGrouping) {
|
|
tasksModel.requestToggleGrouping(modelIndex());
|
|
} else if (Plasmoid.configuration.middleClickAction === TaskManagerApplet.Backend.BringToCurrentDesktop) {
|
|
tasksModel.requestVirtualDesktops(modelIndex(), [virtualDesktopInfo.currentDesktop]);
|
|
}
|
|
} else if (button === Qt.BackButton || button === Qt.ForwardButton) {
|
|
const playerData = mpris2Source.playerForLauncherUrl(model.LauncherUrlWithoutIcon, model.AppPid);
|
|
if (playerData) {
|
|
if (button === Qt.BackButton) {
|
|
playerData.Previous();
|
|
} else {
|
|
playerData.Next();
|
|
}
|
|
} else {
|
|
eventPoint.accepted = false;
|
|
}
|
|
}
|
|
|
|
backend.cancelHighlightWindows();
|
|
}
|
|
}
|
|
|
|
KSvg.FrameSvgItem {
|
|
id: frame
|
|
|
|
anchors {
|
|
fill: parent
|
|
|
|
topMargin: (!tasksRoot.vertical && taskList.rows > 1) ? LayoutMetrics.iconMargin : 0
|
|
bottomMargin: (!tasksRoot.vertical && taskList.rows > 1) ? LayoutMetrics.iconMargin : 0
|
|
leftMargin: ((inPopup || tasksRoot.vertical) && taskList.columns > 1) ? LayoutMetrics.iconMargin : 0
|
|
rightMargin: ((inPopup || tasksRoot.vertical) && taskList.columns > 1) ? LayoutMetrics.iconMargin : 0
|
|
}
|
|
|
|
imagePath: "widgets/tasks"
|
|
property bool isHovered: task.highlighted && Plasmoid.configuration.taskHoverEffect
|
|
property string basePrefix: "normal"
|
|
prefix: isHovered ? TaskTools.taskPrefixHovered(basePrefix, Plasmoid.location) : TaskTools.taskPrefix(basePrefix, Plasmoid.location)
|
|
|
|
// ALL zoom effect properties moved to frame for complete integration - PERFORMANCE OPTIMIZED
|
|
property real zoomScale: 1.0
|
|
property bool zoomEnabled: tasksRoot.iconsOnly && Plasmoid.configuration.magnifyFactor > 0
|
|
property real magnifyFactor: Plasmoid.configuration.magnifyFactor
|
|
property bool isZoomed: zoomScale > 1.0
|
|
property bool hoverDelayActive: false
|
|
property int zoomDuration: Plasmoid.configuration.zoomDuration
|
|
property int zoomEasing: Plasmoid.configuration.zoomEasing
|
|
property int hoverDelay: Plasmoid.configuration.hoverDelay
|
|
property int resetDelay: Plasmoid.configuration.resetDelay
|
|
property int zoomAnchor: Plasmoid.configuration.zoomAnchor
|
|
|
|
// Performance: Track animation state efficiently
|
|
property bool isAnimating: zoomScale !== 1.0 || zoomScaleAnimation.running || zoomScaleYAnimation.running
|
|
|
|
// Ensure zoomed frames appear above others
|
|
z: isZoomed ? 100 : 0
|
|
|
|
// Performance: Throttle hover detection using existing highlight system
|
|
property bool shouldZoom: (task.highlighted || model.IsActive || model.IsDemandingAttention) && zoomEnabled && !model.IsStartup
|
|
|
|
// Performance: Debounce zoom state changes (but keep it very responsive)
|
|
property bool _zoomPending: false
|
|
|
|
onShouldZoomChanged: {
|
|
// Much more aggressive response for rapid mouse movements
|
|
if (_zoomPending) return;
|
|
_zoomPending = true;
|
|
zoomDebounceTimer.restart();
|
|
}
|
|
|
|
// Highly responsive debounce timer for smooth rapid movements
|
|
Timer {
|
|
id: zoomDebounceTimer
|
|
interval: 4 // Ultra-responsive ~250fps for rapid mouse movements
|
|
onTriggered: {
|
|
frame._zoomPending = false;
|
|
if (frame.shouldZoom) {
|
|
resetDelayTimer.stop();
|
|
// For rapid movements, reduce or eliminate hover delay
|
|
if (frame.hoverDelay <= 50) {
|
|
// Very fast or immediate zoom for low hover delays
|
|
hoverDelayTimer.interval = Math.max(1, frame.hoverDelay);
|
|
} else {
|
|
hoverDelayTimer.interval = frame.hoverDelay;
|
|
}
|
|
hoverDelayTimer.restart();
|
|
|
|
// IMMEDIATE ZOOM: For ultra-low hover delays, apply zoom immediately
|
|
if (frame.hoverDelay <= 10) {
|
|
frame.hoverDelayActive = true;
|
|
|
|
let zoomIntensity;
|
|
if (model.IsDemandingAttention || (task.smartLauncherItem && task.smartLauncherItem.urgent)) {
|
|
zoomIntensity = frame.magnifyFactor * 0.6;
|
|
} else if (model.IsActive) {
|
|
zoomIntensity = frame.magnifyFactor * 0.4;
|
|
} else {
|
|
zoomIntensity = frame.magnifyFactor * 0.5;
|
|
}
|
|
|
|
const baseZoom = 1.0 + zoomIntensity;
|
|
frame.zoomScale = Math.max(1.0, baseZoom);
|
|
}
|
|
} else {
|
|
hoverDelayTimer.stop();
|
|
// Faster reset for rapid movements
|
|
resetDelayTimer.interval = Math.max(10, frame.resetDelay);
|
|
resetDelayTimer.restart();
|
|
|
|
// IMMEDIATE RESET: For ultra-low reset delays, reset immediately
|
|
if (frame.resetDelay <= 10) {
|
|
frame.hoverDelayActive = false;
|
|
frame.zoomScale = 1.0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Hover delay timer for smoother experience - HIGHLY OPTIMIZED for rapid movements
|
|
Timer {
|
|
id: hoverDelayTimer
|
|
interval: frame.hoverDelay // Dynamic interval set by debounce timer
|
|
onTriggered: {
|
|
if (frame.shouldZoom) {
|
|
frame.hoverDelayActive = true;
|
|
|
|
// Performance: Pre-calculate zoom intensity only when needed
|
|
let zoomIntensity;
|
|
if (model.IsDemandingAttention || (task.smartLauncherItem && task.smartLauncherItem.urgent)) {
|
|
zoomIntensity = frame.magnifyFactor * 0.6;
|
|
} else if (model.IsActive) {
|
|
zoomIntensity = frame.magnifyFactor * 0.4;
|
|
} else {
|
|
zoomIntensity = frame.magnifyFactor * 0.5;
|
|
}
|
|
|
|
const baseZoom = 1.0 + zoomIntensity;
|
|
frame.zoomScale = Math.max(1.0, baseZoom);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reset timer for smooth zoom out - OPTIMIZED for rapid movements
|
|
Timer {
|
|
id: resetDelayTimer
|
|
interval: frame.resetDelay // Dynamic interval set by debounce timer
|
|
onTriggered: {
|
|
if (!frame.shouldZoom) {
|
|
frame.hoverDelayActive = false;
|
|
frame.zoomScale = 1.0;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Performance: Static easing type lookup
|
|
function getEasingType(easingIndex) {
|
|
const easingTypes = [
|
|
Easing.Linear, Easing.OutQuad, Easing.OutCubic, Easing.OutQuart,
|
|
Easing.OutBack, Easing.OutElastic, Easing.OutBounce
|
|
];
|
|
return easingTypes[easingIndex] || Easing.OutCubic;
|
|
}
|
|
|
|
// Avoid repositioning delegate item after dragFinished
|
|
DragHandler {
|
|
id: dragHandler
|
|
grabPermissions: PointerHandler.CanTakeOverFromHandlersOfDifferentType
|
|
|
|
function setRequestedInhibitDnd(value: bool): void {
|
|
// This is modifying the value in the panel containment that
|
|
// inhibits accepting drag and drop, so that we don't accidentally
|
|
// drop the task on this panel.
|
|
let item = this;
|
|
while (item.parent) {
|
|
item = item.parent;
|
|
if (item.appletRequestsInhibitDnD !== undefined) {
|
|
item.appletRequestsInhibitDnD = value
|
|
}
|
|
}
|
|
}
|
|
|
|
onActiveChanged: {
|
|
if (active) {
|
|
icon.grabToImage(result => {
|
|
if (!dragHandler.active) {
|
|
// BUG 466675 grabToImage is async, so avoid updating dragSource when active is false
|
|
return;
|
|
}
|
|
setRequestedInhibitDnd(true);
|
|
tasksRoot.dragSource = task;
|
|
dragHelper.Drag.imageSource = result.url;
|
|
dragHelper.Drag.mimeData = {
|
|
"text/x-orgkdeplasmataskmanager_taskurl": backend.tryDecodeApplicationsUrl(model.LauncherUrlWithoutIcon).toString(),
|
|
[model.MimeType]: model.MimeData,
|
|
"application/x-orgkdeplasmataskmanager_taskbuttonitem": model.MimeData,
|
|
};
|
|
dragHelper.Drag.active = dragHandler.active;
|
|
});
|
|
} else {
|
|
setRequestedInhibitDnd(false);
|
|
dragHelper.Drag.active = false;
|
|
dragHelper.Drag.imageSource = "";
|
|
}
|
|
}
|
|
}
|
|
|
|
// Move iconBox and label to be children of frame so they scale with zoom
|
|
Loader {
|
|
id: iconBox
|
|
|
|
anchors {
|
|
left: parent.left
|
|
leftMargin: adjustMargin(true, parent.width, taskFrame.margins.left)
|
|
top: parent.top
|
|
topMargin: adjustMargin(false, parent.height, taskFrame.margins.top)
|
|
}
|
|
|
|
width: task.inPopup ? Math.max(Kirigami.Units.iconSizes.sizeForLabels, Kirigami.Units.iconSizes.medium) : Math.min(task.parent?.minimumWidth ?? 0, task.height)
|
|
height: task.inPopup ? width : (parent.height - adjustMargin(false, parent.height, taskFrame.margins.top)
|
|
- adjustMargin(false, parent.height, taskFrame.margins.bottom))
|
|
|
|
asynchronous: true
|
|
active: height >= Kirigami.Units.iconSizes.small
|
|
&& task.smartLauncherItem && task.smartLauncherItem.countVisible
|
|
source: "TaskBadgeOverlay.qml"
|
|
|
|
function adjustMargin(isVertical: bool, size: real, margin: real): real {
|
|
if (!size) {
|
|
return margin;
|
|
}
|
|
|
|
var margins = isVertical ? LayoutMetrics.horizontalMargins() : LayoutMetrics.verticalMargins();
|
|
|
|
if ((size - margins) < Kirigami.Units.iconSizes.small) {
|
|
return Math.ceil((margin * (Kirigami.Units.iconSizes.small / size)) / 2);
|
|
}
|
|
|
|
return margin;
|
|
}
|
|
|
|
Kirigami.Icon {
|
|
id: icon
|
|
|
|
anchors.fill: parent
|
|
|
|
active: task.highlighted
|
|
enabled: true
|
|
|
|
source: model.decoration
|
|
|
|
// Use Transform objects for all animations (this is what works for bouncing)
|
|
transform: [
|
|
Translate {
|
|
y: model.IsStartup && launchAnimationLoader.item ? launchAnimationLoader.item.iconOffsetY : 0
|
|
},
|
|
Scale {
|
|
xScale: model.IsStartup && launchAnimationLoader.item ? launchAnimationLoader.item.iconScale : 1.0
|
|
yScale: model.IsStartup && launchAnimationLoader.item ? launchAnimationLoader.item.iconScale : 1.0
|
|
origin.x: width / 2
|
|
origin.y: height / 2
|
|
},
|
|
Rotation {
|
|
angle: model.IsStartup && launchAnimationLoader.item ? launchAnimationLoader.item.iconRotation : 0
|
|
origin.x: width / 2
|
|
origin.y: height / 2
|
|
}
|
|
]
|
|
|
|
// Opacity still needs to be a direct property
|
|
opacity: model.IsStartup && launchAnimationLoader.item ? launchAnimationLoader.item.iconOpacity : 1.0
|
|
}
|
|
|
|
states: [
|
|
// Using a state transition avoids a binding loop between label.visible and
|
|
// the text label margin, which derives from the icon width.
|
|
State {
|
|
name: "standalone"
|
|
when: !label.visible && task.parent
|
|
|
|
AnchorChanges {
|
|
target: iconBox
|
|
anchors.left: undefined
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
}
|
|
|
|
PropertyChanges {
|
|
target: iconBox
|
|
anchors.leftMargin: 0
|
|
width: Math.min(task.parent.minimumWidth, tasks.height)
|
|
- adjustMargin(true, task.width, taskFrame.margins.left)
|
|
- adjustMargin(true, task.width, taskFrame.margins.right)
|
|
}
|
|
}
|
|
]
|
|
|
|
Loader {
|
|
id: launchAnimationLoader
|
|
anchors.centerIn: parent
|
|
width: Math.min(parent.width, parent.height)
|
|
height: width
|
|
active: model.IsStartup
|
|
sourceComponent: launchAnimationComponent
|
|
|
|
onLoaded: {
|
|
if (item) {
|
|
item.active = Qt.binding(() => model.IsStartup);
|
|
item.iconSource = Qt.binding(() => model.decoration);
|
|
item.animationType = Qt.binding(() => plasmoid.configuration.launchAnimationType);
|
|
item.animationDuration = Qt.binding(() => plasmoid.configuration.launchAnimationDuration);
|
|
item.animationIntensity = Qt.binding(() => plasmoid.configuration.launchAnimationIntensity);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Busy indicator overlay - shows on top of icon when animationType is 0
|
|
PlasmaComponents3.BusyIndicator {
|
|
anchors.centerIn: parent
|
|
width: Math.min(parent.width, parent.height) * 0.8
|
|
height: width
|
|
visible: model.IsStartup && launchAnimationLoader.item && launchAnimationLoader.item.animationType === 0
|
|
running: visible
|
|
z: 20
|
|
}
|
|
}
|
|
|
|
PlasmaComponents3.Label {
|
|
id: label
|
|
|
|
visible: (inPopup || !iconsOnly && !model.IsLauncher
|
|
&& (parent.width - iconBox.height - Kirigami.Units.smallSpacing) >= LayoutMetrics.spaceRequiredToShowText())
|
|
|
|
anchors {
|
|
fill: parent
|
|
leftMargin: taskFrame.margins.left + iconBox.width + LayoutMetrics.labelMargin
|
|
topMargin: taskFrame.margins.top
|
|
rightMargin: taskFrame.margins.right + (audioStreamIcon !== null && audioStreamIcon.visible ? (audioStreamIcon.width + LayoutMetrics.labelMargin) : 0)
|
|
bottomMargin: taskFrame.margins.bottom
|
|
}
|
|
|
|
wrapMode: (maximumLineCount === 1) ? Text.NoWrap : Text.Wrap
|
|
elide: Text.ElideRight
|
|
textFormat: Text.PlainText
|
|
verticalAlignment: Text.AlignVCenter
|
|
maximumLineCount: Plasmoid.configuration.maxTextLines || undefined
|
|
|
|
Accessible.ignored: true
|
|
|
|
// use State to avoid unnecessary re-evaluation when the label is invisible
|
|
states: State {
|
|
name: "labelVisible"
|
|
when: label.visible
|
|
|
|
PropertyChanges {
|
|
target: label
|
|
text: model.display
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Loader {
|
|
id: taskProgressOverlayLoader
|
|
|
|
anchors.fill: frame
|
|
asynchronous: true
|
|
active: model.IsWindow && task.smartLauncherItem && task.smartLauncherItem.progressVisible
|
|
|
|
source: "TaskProgressOverlay.qml"
|
|
}
|
|
|
|
states: [
|
|
State {
|
|
name: "launcher"
|
|
when: model.IsLauncher
|
|
|
|
PropertyChanges {
|
|
target: frame
|
|
basePrefix: ""
|
|
}
|
|
},
|
|
State {
|
|
name: "attention"
|
|
when: model.IsDemandingAttention || (task.smartLauncherItem && task.smartLauncherItem.urgent)
|
|
|
|
PropertyChanges {
|
|
target: frame
|
|
basePrefix: "attention"
|
|
}
|
|
},
|
|
State {
|
|
name: "minimized"
|
|
when: model.IsMinimized
|
|
|
|
PropertyChanges {
|
|
target: frame
|
|
basePrefix: "minimized"
|
|
}
|
|
},
|
|
State {
|
|
name: "active"
|
|
when: model.IsActive
|
|
|
|
PropertyChanges {
|
|
target: frame
|
|
basePrefix: "focus"
|
|
}
|
|
}
|
|
]
|
|
|
|
Component.onCompleted: {
|
|
if (!inPopup && model.IsWindow) {
|
|
const component = Qt.createComponent("GroupExpanderOverlay.qml");
|
|
component.createObject(task);
|
|
component.destroy();
|
|
updateAudioStreams({delay: false});
|
|
}
|
|
|
|
if (!inPopup && !model.IsWindow) {
|
|
taskInitComponent.createObject(task);
|
|
}
|
|
completed = true;
|
|
}
|
|
Component.onDestruction: {
|
|
if (moveAnim.running) {
|
|
(task.parent as TaskList).animationsRunning -= 1;
|
|
}
|
|
}
|
|
|
|
onHighlightedChanged: {
|
|
// ensure it doesn't get stuck with a window highlighted
|
|
backend.cancelHighlightWindows();
|
|
}
|
|
|
|
// Performance: Invalidate caches when relevant properties change
|
|
Connections {
|
|
target: tasksRoot
|
|
function onHeightChanged() { }
|
|
function onWidthChanged() { }
|
|
}
|
|
|
|
Connections {
|
|
target: Plasmoid.configuration
|
|
function onMaxStripesChanged() { }
|
|
function onZoomAnchorChanged() { }
|
|
// Add invalidation for filter changes that affect task visibility
|
|
function onShowOnlyCurrentDesktopChanged() { }
|
|
function onShowOnlyCurrentActivityChanged() { }
|
|
function onShowOnlyCurrentScreenChanged() { }
|
|
function onShowOnlyMinimizedChanged() { }
|
|
}
|
|
|
|
// Enhanced: Monitor task model changes that affect individual task layout
|
|
Connections {
|
|
target: tasksModel
|
|
function onCountChanged() { }
|
|
}
|
|
|
|
// Movement transform - always present
|
|
Translate {
|
|
id: translateTransform
|
|
}
|
|
|
|
// Zoom transform - only applied when actively zooming - REMOVED CACHING TO FIX BINDING LOOPS
|
|
Scale {
|
|
id: zoomTransform
|
|
|
|
// Simple, direct origin calculation without caching to avoid binding loops
|
|
origin.x: {
|
|
const effectiveWidth = frame.width;
|
|
const effectiveHeight = frame.height;
|
|
|
|
switch(frame.zoomAnchor) {
|
|
case 0: return effectiveWidth / 2; // Center
|
|
case 1: return effectiveWidth / 2; // Bottom
|
|
case 2: return effectiveWidth / 2; // Top
|
|
case 3: return 0; // Left
|
|
case 4: return effectiveWidth; // Right
|
|
case 5: return 0; // BottomLeft
|
|
case 6: return effectiveWidth; // BottomRight
|
|
case 7: return 0; // TopLeft
|
|
case 8: return effectiveWidth; // TopRight
|
|
default: return effectiveWidth / 2; // Default to Bottom
|
|
}
|
|
}
|
|
|
|
origin.y: {
|
|
const effectiveWidth = frame.width;
|
|
const effectiveHeight = frame.height;
|
|
|
|
switch(frame.zoomAnchor) {
|
|
case 0: return effectiveHeight / 2; // Center
|
|
case 1: return effectiveHeight; // Bottom
|
|
case 2: return 0; // Top
|
|
case 3: return effectiveHeight / 2; // Left
|
|
case 4: return effectiveHeight / 2; // Right
|
|
case 5: return effectiveHeight; // BottomLeft
|
|
case 6: return effectiveHeight; // BottomRight
|
|
case 7: return 0; // TopLeft
|
|
case 8: return 0; // TopRight
|
|
default: return effectiveHeight; // Default to Bottom
|
|
}
|
|
}
|
|
|
|
xScale: frame.zoomScale
|
|
yScale: frame.zoomScale
|
|
|
|
// Performance: Optimize animation behavior
|
|
Behavior on xScale {
|
|
enabled: frame.zoomEnabled
|
|
NumberAnimation {
|
|
id: zoomScaleAnimation
|
|
duration: frame.zoomDuration
|
|
easing.type: frame.getEasingType(frame.zoomEasing)
|
|
easing.overshoot: frame.zoomEasing === 4 ? 1.2 : 1.0
|
|
// Performance: Prevent unnecessary intermediate updates
|
|
easing.period: frame.zoomEasing === 5 ? 0.3 : 1.0
|
|
}
|
|
}
|
|
|
|
Behavior on yScale {
|
|
enabled: frame.zoomEnabled
|
|
NumberAnimation {
|
|
id: zoomScaleYAnimation
|
|
duration: frame.zoomDuration
|
|
easing.type: frame.getEasingType(frame.zoomEasing)
|
|
easing.overshoot: frame.zoomEasing === 4 ? 1.2 : 1.0
|
|
// Performance: Prevent unnecessary intermediate updates
|
|
easing.period: frame.zoomEasing === 5 ? 0.3 : 1.0
|
|
}
|
|
}
|
|
}
|
|
}
|