plasma-taskmanager-zoom/contents/ui/Task.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
}
}
}
}