2 * Copyright (C) 2014-2016 Canonical, Ltd.
4 * This program is free software; you can redistribute it and/or modify
5 * it under the terms of the GNU General Public License as published by
6 * the Free Software Foundation; version 3.
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
13 * You should have received a copy of the GNU General Public License
14 * along with this program. If not, see <http://www.gnu.org/licenses/>.
18 import Ubuntu.Components 1.3
19 import Ubuntu.Gestures 0.1
20 import Unity.Application 0.1
21 import Unity.Session 0.1
24 import "../Components"
29 property bool focusFirstApp: true // If false, focused app will appear on right edge like other apps
30 property bool altTabEnabled: true
31 property real startScale: 1.1
32 property real endScale: 0.7
34 paintBackground: spreadView.shiftedContentX !== 0
36 onBeingResizedChanged: {
38 // Brace yourselves for impact!
42 onSpreadEnabledChanged: {
48 onAltTabPressedChanged: {
49 if (!spreadEnabled || !altTabEnabled) {
53 spreadView.snapToSpread();
54 priv.highlightIndex = Math.min(spreadRepeater.count - 1, 1);
56 spreadView.snapTo(priv.highlightIndex)
61 focus: root.altTabPressed
66 priv.highlightIndex = (priv.highlightIndex + 1) % spreadRepeater.count
69 priv.highlightIndex = (priv.highlightIndex + spreadRepeater.count - 1) % spreadRepeater.count
75 // Functions to be called from outside
76 function updateFocusedAppOrientation() {
77 if (spreadRepeater.count > 0) {
78 spreadRepeater.itemAt(0).matchShellOrientation();
81 for (var i = 1; i < spreadRepeater.count; ++i) {
83 var spreadDelegate = spreadRepeater.itemAt(i);
85 var delta = spreadDelegate.appWindowOrientationAngle - root.shellOrientationAngle;
86 if (delta < 0) { delta += 360; }
89 var supportedOrientations = spreadDelegate.application.supportedOrientations;
90 if (supportedOrientations === Qt.PrimaryOrientation) {
91 supportedOrientations = root.orientations.primary;
94 if (delta === 180 && (supportedOrientations & spreadDelegate.shellOrientation)) {
95 spreadDelegate.matchShellOrientation();
99 function updateFocusedAppOrientationAnimated() {
100 if (spreadRepeater.count > 0) {
101 spreadRepeater.itemAt(0).animateToShellOrientation();
105 function pushRightEdge(amount) {
106 if (spreadView.contentX == -spreadView.shift) {
107 edgeBarrier.push(amount);
111 mainApp: priv.focusedAppDelegate ? priv.focusedAppDelegate.application : null
113 orientationChangesEnabled: priv.focusedAppOrientationChangesEnabled
114 && !priv.focusedAppDelegateIsDislocated
115 && !(priv.focusedAppDelegate && priv.focusedAppDelegate.xBehavior.running)
116 && spreadView.phase === 0
118 supportedOrientations: mainApp ? mainApp.supportedOrientations
119 : (Qt.PortraitOrientation | Qt.LandscapeOrientation
120 | Qt.InvertedPortraitOrientation | Qt.InvertedLandscapeOrientation)
122 // How far left the stage has been dragged, used externally by tutorial code
123 dragProgress: spreadRepeater.count > 0 ? spreadRepeater.itemAt(0).animatedProgress : 0
125 readonly property alias dragging: spreadDragArea.dragging
129 function select(appId) {
130 spreadView.snapTo(priv.indexOf(appId));
133 onInverseProgressChanged: {
134 // This can't be a simple binding because that would be triggered after this handler
135 // while we need it active before doing the anition left/right
136 priv.animateX = (inverseProgress == 0)
137 if (inverseProgress == 0 && priv.oldInverseProgress > 0) {
138 // left edge drag released. Minimum distance is given by design.
139 if (priv.oldInverseProgress > units.gu(22)) {
140 applicationManager.requestFocusApplication("unity8-dash");
143 priv.oldInverseProgress = inverseProgress;
146 // <FIXME-contentX> See rationale in the next comment with this tag
148 if (!root.beingResized) {
149 // we're being resized without a warning (ie, the corresponding property wasn't set
150 root.beingResized = true;
151 beingResizedTimer.start();
155 id: beingResizedTimer
157 onTriggered: { root.beingResized = false; }
163 property bool focusedAppOrientationChangesEnabled: false
164 readonly property int firstSpreadIndex: root.focusFirstApp ? 1 : 0
165 property var focusedAppDelegate
166 // NB! This may differ from applicationManager.focusedApplicationId if focusedAppDelegate
167 // contains a screenshot instead of a surface.
168 property string focusedAppId: focusedAppDelegate ? focusedAppDelegate.application.appId : ""
170 property real oldInverseProgress: 0
171 property bool animateX: false
172 property int highlightIndex: 0
174 property bool focusedAppDelegateIsDislocated: focusedAppDelegate ?
175 (focusedAppDelegate.x !== 0 || focusedAppDelegate.xBehavior.running)
178 function indexOf(appId) {
179 for (var i = 0; i < spreadRepeater.count; i++) {
180 if (spreadRepeater.itemAt(i).application.appId == appId) {
187 // Is more stable than "spreadView.shiftedContentX === 0" as it filters out noise caused by
188 // Flickable.contentX changing due to resizes.
189 property bool fullyShowingFocusedApp: true
192 // The app that's about to go to foreground has to be focused, otherwise
193 // it would leave us in an inconsistent state.
194 if (!MirFocusController.focusedSurface && spreadRepeater.count > 0) {
195 spreadRepeater.itemAt(0).focus = true;
198 spreadView.selectedIndex = -1;
199 spreadView.phase = 0;
200 spreadView.contentX = -spreadView.shift;
203 onHighlightIndexChanged: {
204 spreadView.contentX = highlightIndex * spreadView.contentWidth / (spreadRepeater.count + 2)
208 id: fullyShowingFocusedAppUpdateTimer
211 priv.fullyShowingFocusedApp = spreadView.shiftedContentX === 0;
216 model: root.applicationManager
218 property var stateBinding: Binding {
219 readonly property bool isDash: model.application ? model.application.appId == "unity8-dash" : false
220 target: model.application
221 property: "requestedState"
222 value: (isDash && root.keepDashRunning)
223 || (!root.suspended && model.application && priv.focusedAppId === model.application.appId)
224 ? ApplicationInfoInterface.RequestedRunning
225 : ApplicationInfoInterface.RequestedSuspended
228 property var lifecycleBinding: Binding {
229 target: model.application
230 property: "exemptFromLifecycle"
231 value: model.application
232 ? (!model.application.isTouchApp || isExemptFromLifecycle(model.application.appId))
239 target: MirFocusController
240 property: "focusedSurface"
241 value: priv.focusedAppDelegate ? priv.focusedAppDelegate.surface : null
242 when: root.parent && !spreadRepeater.startingUp
247 objectName: "spreadView"
249 interactive: (spreadDragArea.dragging || phase > 1) && draggedDelegateCount === 0
250 contentWidth: spreadRow.width - shift
253 // This indicates when the spreadView is active. That means, all the animations
254 // are activated and tiles need to line up for the spread.
255 readonly property bool active: shiftedContentX > 0 || spreadDragArea.dragging || !root.focusFirstApp
257 // The flickable needs to fill the screen in order to get touch events all over.
258 // However, we don't want to the user to be able to scroll back all the way. For
259 // that, the beginning of the gesture starts with a negative value for contentX
260 // so the flickable wants to pull it into the view already. "shift" tunes the
261 // distance where to "lock" the content.
262 readonly property real shift: width / 2
263 readonly property real shiftedContentX: contentX + shift
265 property int tileDistance: width / 4
267 // Those markers mark the various positions in the spread (ratio to screen width from right to left):
268 // 0 - 1: following finger, snap back to the beginning on release
269 property real positionMarker1: 0.2
270 // 1 - 2: curved snapping movement, snap to app 1 on release
271 property real positionMarker2: 0.3
272 // 2 - 3: movement follows finger, snaps back to app 1 on release
273 property real positionMarker3: 0.35
274 // passing 3, we detach movement from the finger and snap to 4
275 property real positionMarker4: 0.9
277 // This is where the first app snaps to when bringing it in from the right edge.
278 property real snapPosition: 0.7
280 // Phase of the animation:
281 // 0: Starting from right edge, a new app (index 1) comes in from the right
282 // 1: The app has reached the first snap position.
283 // 2: The list is dragged further and snaps into the spread view when entering phase 2
284 property int phase: 0
286 property int selectedIndex: -1
287 property int draggedDelegateCount: 0
288 property int closingIndex: -1
290 // <FIXME-contentX> Workaround Flickable's behavior of bringing contentX back between valid boundaries
291 // when resized. The proper way to fix this is refactoring PhoneStage so that it doesn't
292 // rely on having Flickable.contentX keeping an out-of-bounds value when it's set programatically
293 // (as opposed to having contentX reaching an out-of-bounds value through dragging, which will trigger
294 // the Flickable.boundsBehavior upon release).
296 if (!undoContentXReset()) {
297 forceItToRemainStillIfBeingResized();
300 onShiftChanged: { forceItToRemainStillIfBeingResized(); }
301 function forceItToRemainStillIfBeingResized() {
302 if (root.beingResized && contentX != -spreadView.shift) {
303 contentX = -spreadView.shift;
306 function undoContentXReset() {
307 if (contentWidth <= 0) {
308 contentWidthOnLastContentXChange = contentWidth;
309 lastContentX = contentX;
313 if (contentWidth !== contentWidthOnLastContentXChange
314 && lastContentX === -shift && contentX === 0) {
315 // Flickable is resetting contentX because contentWidth has changed. Undo it.
320 contentWidthOnLastContentXChange = contentWidth;
321 lastContentX = contentX;
324 property real contentWidthOnLastContentXChange: -1
325 property real lastContentX: 0
328 Behavior on contentX {
329 enabled: root.altTabPressed
330 UbuntuNumberAnimation {}
333 onShiftedContentXChanged: {
334 if (root.beingResized) {
335 // Flickabe.contentX wiggles during resizes. Don't react to it.
341 // the "spreadEnabled" part is because when code does "phase = 0; contentX = -shift" to
342 // dismiss the spread because spreadEnabled went to false, for some reason, during tests,
343 // Flickable might jump in and change contentX value back, causing the code below to do
344 // "phase = 1" which will make the spread stay.
345 // It sucks that we have no control whatsoever over whether or when Flickable animates its
347 if (root.spreadEnabled && shiftedContentX > width * positionMarker2) {
352 if (shiftedContentX < width * positionMarker2) {
354 } else if (shiftedContentX >= width * positionMarker4 && !spreadDragArea.dragging) {
359 fullyShowingFocusedAppUpdateTimer.restart();
363 if (shiftedContentX < positionMarker1 * width) {
364 snapAnimation.targetContentX = -shift;
365 snapAnimation.start();
366 } else if (shiftedContentX < positionMarker2 * width) {
368 } else if (shiftedContentX < positionMarker3 * width) {
370 } else if (phase < 2){
376 function snapToSpread() {
377 // Add 1 pixel to make sure we definitely hit positionMarker4 even with rounding errors of the animation.
378 snapAnimation.targetContentX = (root.width * spreadView.positionMarker4) + 1 - spreadView.shift;
379 snapAnimation.start();
382 function snapTo(index) {
383 if (!root.altTabEnabled) {
384 // Reset to start instead
385 snapAnimation.targetContentX = -shift;
386 snapAnimation.start();
389 if (topLevelSurfaceList.count <= index) {
390 // In case we're trying to snap to some non existing app, lets snap back to the first one
393 spreadView.selectedIndex = index;
394 // If we're not in full spread mode yet, always unwind to start pos
395 // otherwise unwind up to progress 0 of the selected index
396 if (spreadView.phase < 2) {
397 snapAnimation.targetContentX = -shift;
399 snapAnimation.targetContentX = -shift + index * spreadView.tileDistance;
401 snapAnimation.start();
404 // In case the applicationManager already holds an app when starting up we're missing animations
405 // Make sure we end up in the same state
406 Component.onCompleted: {
407 spreadView.contentX = -spreadView.shift
408 priv.animateX = true;
409 snapAnimation.complete();
412 SequentialAnimation {
414 property int targetContentX: -spreadView.shift
416 UbuntuNumberAnimation {
419 to: snapAnimation.targetContentX
420 duration: UbuntuAnimation.FastDuration
425 if (spreadView.selectedIndex >= 0) {
426 var delegate = spreadRepeater.itemAt(spreadView.selectedIndex)
427 delegate.focus = true;
429 spreadView.selectedIndex = -1;
430 spreadView.phase = 0;
431 spreadView.contentX = -spreadView.shift;
439 // This width controls how much the spread can be flicked left/right. It's composed of:
440 // tileDistance * app count (with a minimum of 3 apps, in order to also allow moving 1 and 2 apps a bit)
441 // + some constant value (still scales with the screen width) which looks good and somewhat fills the screen
442 width: Math.max(3, topLevelSurfaceList.count) * spreadView.tileDistance + (spreadView.width - spreadView.tileDistance) * 1.5
443 height: parent.height
445 enabled: spreadView.closingIndex >= 0
446 UbuntuNumberAnimation {}
449 if (spreadView.closingIndex >= 0) {
450 spreadView.contentX = Math.min(spreadView.contentX, width - spreadView.width - spreadView.shift);
454 x: spreadView.contentX
457 if (root.altTabEnabled) {
458 spreadView.snapTo(0);
462 TopLevelSurfaceRepeater {
464 objectName: "spreadRepeater"
465 model: topLevelSurfaceList
468 // Unless we're closing the app ourselves in the spread,
469 // lets make sure the spread doesn't mess up by the changing app list.
470 if (spreadView.closingIndex == -1) {
471 spreadView.phase = 0;
472 spreadView.contentX = -spreadView.shift;
476 function focusTopMostApp() {
477 if (spreadRepeater.count > 0) {
478 var topmostDelegate = spreadRepeater.itemAt(0);
479 topmostDelegate.focus = true;
483 delegate: TransformedSpreadDelegate {
485 objectName: "spreadDelegate_" + model.id
488 startScale: root.startScale
489 endScale: root.endScale
490 startDistance: spreadView.tileDistance
491 endDistance: units.gu(.5)
492 width: spreadView.width
493 height: spreadView.height
494 selected: spreadView.selectedIndex == index
495 otherSelected: spreadView.selectedIndex >= 0 && !selected
496 interactive: !spreadView.interactive && spreadView.phase === 0
497 && priv.fullyShowingFocusedApp && root.interactive && focus
498 swipeToCloseEnabled: spreadView.interactive && root.interactive && !snapAnimation.running
499 maximizedAppTopMargin: root.maximizedAppTopMargin
500 dropShadow: spreadView.active || priv.focusedAppDelegateIsDislocated
501 focusFirstApp: root.focusFirstApp
502 highlightShown: root.altTabPressed && index === priv.highlightIndex
504 readonly property bool isDash: model.application.appId == "unity8-dash"
506 Component.onCompleted: {
507 // NB: We're differentiating if this delegate was created in response to a new entry in the model
508 // or if the Repeater is just populating itself with delegates to match the model it received.
509 if (!spreadRepeater.startingUp) {
510 // a top level window is always the focused one when it first appears, unfocusing
511 // any preexisting one
513 // new items are appended and must be manually brought to front.
514 // that's how it *must* be in order to get the animation for new
521 if (focus && !spreadRepeater.startingUp) {
522 priv.focusedAppDelegate = appDelegate;
523 // If we're orphan (!parent) it means this stage is no longer the current one
524 // and will be deleted shortly. So we should no longer have a say over the model
526 topLevelSurfaceList.raiseId(model.id);
530 function claimFocus() {
531 if (spreadView.phase > 0) {
532 spreadView.snapTo(model.index);
534 appDelegate.focus = true;
538 target: model.surface
539 onFocusRequested: claimFocus()
542 target: model.application
544 if (!model.surface) {
545 // when an app has no surfaces, we assume there's only one entry representing it:
549 // if the application has surfaces, focus request should be at surface-level.
554 z: isDash && !spreadView.active ? -1 : behavioredIndex
557 // focused app is always positioned at 0 except when following left edge drag
559 if (!isDash && root.inverseProgress > 0 && spreadView.phase === 0) {
560 return root.inverseProgress;
564 if (isDash && !spreadView.active && !spreadDragArea.dragging) {
568 // Otherwise line up for the spread
569 return spreadView.width + spreadIndex * spreadView.tileDistance;
572 application: model.application
573 surface: model.surface
576 property real behavioredIndex: index
577 Behavior on behavioredIndex {
578 enabled: spreadView.closingIndex >= 0
579 UbuntuNumberAnimation {
583 spreadView.closingIndex = -1;
589 property var xBehavior: xBehavior
591 enabled: root.spreadEnabled &&
592 !spreadView.active &&
593 !snapAnimation.running &&
594 !spreadDragArea.pressed &&
597 UbuntuNumberAnimation {
599 duration: UbuntuAnimation.BriskDuration
603 // Each tile has a different progress value running from 0 to 1.
604 // 0: means the tile is at the right edge.
605 // 1: means the tile has finished the main animation towards the left edge.
606 // >1: after the main animation has finished, tiles will continue to move very slowly to the left
608 var tileProgress = (spreadView.shiftedContentX - behavioredIndex * spreadView.tileDistance) / spreadView.width;
609 // Tile 1 needs to move directly from the beginning...
610 if (root.focusFirstApp && behavioredIndex == 1 && spreadView.phase < 2) {
611 tileProgress += spreadView.tileDistance / spreadView.width;
613 // Limiting progress to ~0 and 1.7 to avoid binding calculations when tiles are not
615 // < 0 : The tile is outside the screen on the right
616 // > 1.7: The tile is *very* close to the left edge and covered by other tiles now.
617 // Using 0.0001 to differentiate when a tile should still be visible (==0)
618 // or we can hide it (< 0)
619 tileProgress = Math.max(-0.0001, Math.min(1.7, tileProgress));
623 // This mostly is the same as progress, just adds the snapping to phase 1 for tiles 0 and 1
625 if (spreadView.phase == 0 && index <= priv.firstSpreadIndex) {
626 if (progress < spreadView.positionMarker1) {
628 } else if (progress < spreadView.positionMarker1 + 0.05){
629 // p : 0.05 = x : pm2
630 return spreadView.positionMarker1 + (progress - spreadView.positionMarker1) * (spreadView.positionMarker2 - spreadView.positionMarker1) / 0.05
632 return spreadView.positionMarker2;
638 // Hide tile when progress is such that it will be off screen.
639 property bool occluded: {
640 if (spreadView.active && (progress >= 0 && progress < 1.7)) return false;
641 else if (!spreadView.active && isFocused) return false;
642 else if (xBehavior.running) return false;
643 else if (z <= 1 && priv.focusedAppDelegateIsDislocated) return false;
647 visible: Powerd.status == Powerd.On &&
648 !greeter.fullyShown &&
651 shellOrientationAngle: root.shellOrientationAngle
652 shellOrientation: root.shellOrientation
653 orientations: root.orientations
656 if (root.altTabEnabled && spreadView.phase == 2) {
657 spreadView.snapTo(index);
663 spreadView.draggedDelegateCount++;
665 spreadView.draggedDelegateCount--;
670 spreadView.closingIndex = index;
671 if (appDelegate.surface) {
672 appDelegate.surface.close();
673 } else if (appDelegate.application) {
674 root.applicationManager.stopApplication(appDelegate.application.appId);
676 // should never happen
677 console.warn("Can't close topLevelSurfaceList entry as it has neither"
678 + " a surface nor an application");
685 property: "mainAppWindowOrientationAngle"
686 value: appWindowOrientationAngle
691 property: "focusedAppOrientationChangesEnabled"
692 value: orientationChangesEnabled
695 StagedFullscreenPolicy {
697 surface: model.surface
702 onStageAboutToBeUnloaded: fullscreenPolicy.active = false
709 //eat touch events during the right edge gesture
711 objectName: "eventEaterArea"
713 enabled: spreadDragArea.dragging
718 objectName: "spreadDragArea"
719 direction: Direction.Leftwards
720 enabled: (spreadView.phase != 2 && root.spreadEnabled) || dragging
722 anchors { top: parent.top; right: parent.right; bottom: parent.bottom; }
723 width: root.dragAreaWidth
725 property var gesturePoints: new Array()
727 onTouchPositionChanged: {
729 // Gesture recognized. Let's move the spreadView with the finger
730 var dragX = Math.min(touchPosition.x + width, width); // Prevent dragging rightwards
731 dragX = -dragX + spreadDragArea.width - spreadView.shift;
732 // Don't allow dragging further than the animation crossing with phase2's animation
733 var maxMovement = spreadView.width * spreadView.positionMarker4 - spreadView.shift;
735 spreadView.contentX = Math.min(dragX, maxMovement);
737 // Initial touch. Let's reset the spreadView to the starting position.
738 spreadView.phase = 0;
739 spreadView.contentX = -spreadView.shift;
742 gesturePoints.push(touchPosition.x);
747 // A potential edge-drag gesture has started. Start recording it
750 // Ok. The user released. Find out if it was a one-way movement.
751 var oneWayFlick = true;
752 var smallestX = spreadDragArea.width;
753 for (var i = 0; i < gesturePoints.length; i++) {
754 if (gesturePoints[i] >= smallestX) {
758 smallestX = gesturePoints[i];
762 if (oneWayFlick && spreadView.shiftedContentX > units.gu(2) &&
763 spreadView.shiftedContentX < spreadView.positionMarker1 * spreadView.width) {
764 // If it was a short one-way movement, do the Alt+Tab switch
765 // no matter if we didn't cross positionMarker1 yet.
766 spreadView.snapTo(1);
767 } else if (!dragging) {
768 // otherwise snap to the closest snap position we can find
769 // (might be back to start, to app 1 or to spread)
779 // NB: it does its own positioning according to the specified edge
783 spreadView.snapToSpread();
785 material: Component {
791 anchors.centerIn: parent
793 GradientStop { position: 0.0; color: Qt.rgba(0.16,0.16,0.16,0.7)}
794 GradientStop { position: 1.0; color: Qt.rgba(0.16,0.16,0.16,0)}