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 function closeFocusedDelegate() {
112 if (priv.focusedAppDelegate && priv.focusedAppDelegate.closeable) {
113 priv.focusedAppDelegate.closed();
117 mainApp: priv.focusedAppDelegate ? priv.focusedAppDelegate.application : null
119 orientationChangesEnabled: priv.focusedAppOrientationChangesEnabled
120 && !priv.focusedAppDelegateIsDislocated
121 && !(priv.focusedAppDelegate && priv.focusedAppDelegate.xBehavior.running)
122 && spreadView.phase === 0
124 supportedOrientations: mainApp ? mainApp.supportedOrientations
125 : (Qt.PortraitOrientation | Qt.LandscapeOrientation
126 | Qt.InvertedPortraitOrientation | Qt.InvertedLandscapeOrientation)
128 // How far left the stage has been dragged, used externally by tutorial code
129 dragProgress: spreadRepeater.count > 0 ? spreadRepeater.itemAt(0).animatedProgress : 0
131 readonly property alias dragging: spreadDragArea.dragging
135 function select(appId) {
136 spreadView.snapTo(priv.indexOf(appId));
139 onInverseProgressChanged: {
140 // This can't be a simple binding because that would be triggered after this handler
141 // while we need it active before doing the anition left/right
142 priv.animateX = (inverseProgress == 0)
143 if (inverseProgress == 0 && priv.oldInverseProgress > 0) {
144 // left edge drag released. Minimum distance is given by design.
145 if (priv.oldInverseProgress > units.gu(22)) {
146 applicationManager.requestFocusApplication("unity8-dash");
149 priv.oldInverseProgress = inverseProgress;
152 // <FIXME-contentX> See rationale in the next comment with this tag
154 if (!root.beingResized) {
155 // we're being resized without a warning (ie, the corresponding property wasn't set
156 root.beingResized = true;
157 beingResizedTimer.start();
161 id: beingResizedTimer
163 onTriggered: { root.beingResized = false; }
169 property bool focusedAppOrientationChangesEnabled: false
170 readonly property int firstSpreadIndex: root.focusFirstApp ? 1 : 0
171 property var focusedAppDelegate
172 // NB! This may differ from applicationManager.focusedApplicationId if focusedAppDelegate
173 // contains a screenshot instead of a surface.
174 property string focusedAppId: focusedAppDelegate ? focusedAppDelegate.application.appId : ""
176 property real oldInverseProgress: 0
177 property bool animateX: false
178 property int highlightIndex: 0
180 property bool focusedAppDelegateIsDislocated: focusedAppDelegate ?
181 (focusedAppDelegate.x !== 0 || focusedAppDelegate.xBehavior.running)
184 function indexOf(appId) {
185 for (var i = 0; i < spreadRepeater.count; i++) {
186 if (spreadRepeater.itemAt(i).application.appId == appId) {
193 // Is more stable than "spreadView.shiftedContentX === 0" as it filters out noise caused by
194 // Flickable.contentX changing due to resizes.
195 property bool fullyShowingFocusedApp: true
198 // The app that's about to go to foreground has to be focused, otherwise
199 // it would leave us in an inconsistent state.
200 if (!MirFocusController.focusedSurface && spreadRepeater.count > 0) {
201 spreadRepeater.itemAt(0).focus = true;
204 spreadView.selectedIndex = -1;
205 spreadView.phase = 0;
206 spreadView.contentX = -spreadView.shift;
209 onHighlightIndexChanged: {
210 spreadView.contentX = highlightIndex * spreadView.contentWidth / (spreadRepeater.count + 2)
214 id: fullyShowingFocusedAppUpdateTimer
217 priv.fullyShowingFocusedApp = spreadView.shiftedContentX === 0;
222 model: root.applicationManager
224 property var stateBinding: Binding {
225 readonly property bool isDash: model.application ? model.application.appId == "unity8-dash" : false
226 target: model.application
227 property: "requestedState"
228 value: (isDash && root.keepDashRunning)
229 || (!root.suspended && model.application && priv.focusedAppId === model.application.appId)
230 ? ApplicationInfoInterface.RequestedRunning
231 : ApplicationInfoInterface.RequestedSuspended
234 property var lifecycleBinding: Binding {
235 target: model.application
236 property: "exemptFromLifecycle"
237 value: model.application
238 ? (!model.application.isTouchApp || isExemptFromLifecycle(model.application.appId))
245 target: MirFocusController
246 property: "focusedSurface"
247 value: priv.focusedAppDelegate ? priv.focusedAppDelegate.focusedSurface : null
248 when: root.parent && !spreadRepeater.startingUp
253 objectName: "spreadView"
255 interactive: (spreadDragArea.dragging || phase > 1) && draggedDelegateCount === 0
256 contentWidth: spreadRow.width - shift
259 // This indicates when the spreadView is active. That means, all the animations
260 // are activated and tiles need to line up for the spread.
261 readonly property bool active: shiftedContentX > 0 || spreadDragArea.dragging || !root.focusFirstApp
263 // The flickable needs to fill the screen in order to get touch events all over.
264 // However, we don't want to the user to be able to scroll back all the way. For
265 // that, the beginning of the gesture starts with a negative value for contentX
266 // so the flickable wants to pull it into the view already. "shift" tunes the
267 // distance where to "lock" the content.
268 readonly property real shift: width / 2
269 readonly property real shiftedContentX: contentX + shift
271 property int tileDistance: width / 4
273 // Those markers mark the various positions in the spread (ratio to screen width from right to left):
274 // 0 - 1: following finger, snap back to the beginning on release
275 property real positionMarker1: 0.2
276 // 1 - 2: curved snapping movement, snap to app 1 on release
277 property real positionMarker2: 0.3
278 // 2 - 3: movement follows finger, snaps back to app 1 on release
279 property real positionMarker3: 0.35
280 // passing 3, we detach movement from the finger and snap to 4
281 property real positionMarker4: 0.9
283 // This is where the first app snaps to when bringing it in from the right edge.
284 property real snapPosition: 0.7
286 // Phase of the animation:
287 // 0: Starting from right edge, a new app (index 1) comes in from the right
288 // 1: The app has reached the first snap position.
289 // 2: The list is dragged further and snaps into the spread view when entering phase 2
290 property int phase: 0
292 property int selectedIndex: -1
293 property int draggedDelegateCount: 0
294 property int closingIndex: -1
296 // <FIXME-contentX> Workaround Flickable's behavior of bringing contentX back between valid boundaries
297 // when resized. The proper way to fix this is refactoring PhoneStage so that it doesn't
298 // rely on having Flickable.contentX keeping an out-of-bounds value when it's set programatically
299 // (as opposed to having contentX reaching an out-of-bounds value through dragging, which will trigger
300 // the Flickable.boundsBehavior upon release).
302 if (!undoContentXReset()) {
303 forceItToRemainStillIfBeingResized();
306 onShiftChanged: { forceItToRemainStillIfBeingResized(); }
307 function forceItToRemainStillIfBeingResized() {
308 if (root.beingResized && contentX != -spreadView.shift) {
309 contentX = -spreadView.shift;
312 function undoContentXReset() {
313 if (contentWidth <= 0) {
314 contentWidthOnLastContentXChange = contentWidth;
315 lastContentX = contentX;
319 if (contentWidth !== contentWidthOnLastContentXChange
320 && lastContentX === -shift && contentX === 0) {
321 // Flickable is resetting contentX because contentWidth has changed. Undo it.
326 contentWidthOnLastContentXChange = contentWidth;
327 lastContentX = contentX;
330 property real contentWidthOnLastContentXChange: -1
331 property real lastContentX: 0
334 Behavior on contentX {
335 enabled: root.altTabPressed
336 UbuntuNumberAnimation {}
339 onShiftedContentXChanged: {
340 if (root.beingResized) {
341 // Flickabe.contentX wiggles during resizes. Don't react to it.
347 // the "spreadEnabled" part is because when code does "phase = 0; contentX = -shift" to
348 // dismiss the spread because spreadEnabled went to false, for some reason, during tests,
349 // Flickable might jump in and change contentX value back, causing the code below to do
350 // "phase = 1" which will make the spread stay.
351 // It sucks that we have no control whatsoever over whether or when Flickable animates its
353 if (root.spreadEnabled && shiftedContentX > width * positionMarker2) {
358 if (shiftedContentX < width * positionMarker2) {
360 } else if (shiftedContentX >= width * positionMarker4 && !spreadDragArea.dragging) {
365 fullyShowingFocusedAppUpdateTimer.restart();
369 if (shiftedContentX < positionMarker1 * width) {
370 snapAnimation.targetContentX = -shift;
371 snapAnimation.start();
372 } else if (shiftedContentX < positionMarker2 * width) {
374 } else if (shiftedContentX < positionMarker3 * width) {
376 } else if (phase < 2){
382 function snapToSpread() {
383 // Add 1 pixel to make sure we definitely hit positionMarker4 even with rounding errors of the animation.
384 snapAnimation.targetContentX = (root.width * spreadView.positionMarker4) + 1 - spreadView.shift;
385 snapAnimation.start();
388 function snapTo(index) {
389 if (!root.altTabEnabled) {
390 // Reset to start instead
391 snapAnimation.targetContentX = -shift;
392 snapAnimation.start();
395 if (topLevelSurfaceList.count <= index) {
396 // In case we're trying to snap to some non existing app, lets snap back to the first one
399 spreadView.selectedIndex = index;
400 // If we're not in full spread mode yet, always unwind to start pos
401 // otherwise unwind up to progress 0 of the selected index
402 if (spreadView.phase < 2) {
403 snapAnimation.targetContentX = -shift;
405 snapAnimation.targetContentX = -shift + index * spreadView.tileDistance;
407 snapAnimation.start();
410 // In case the applicationManager already holds an app when starting up we're missing animations
411 // Make sure we end up in the same state
412 Component.onCompleted: {
413 spreadView.contentX = -spreadView.shift
414 priv.animateX = true;
415 snapAnimation.complete();
418 SequentialAnimation {
420 property int targetContentX: -spreadView.shift
422 UbuntuNumberAnimation {
425 to: snapAnimation.targetContentX
426 duration: UbuntuAnimation.FastDuration
431 if (spreadView.selectedIndex >= 0) {
432 var delegate = spreadRepeater.itemAt(spreadView.selectedIndex)
433 delegate.focus = true;
435 spreadView.selectedIndex = -1;
436 spreadView.phase = 0;
437 spreadView.contentX = -spreadView.shift;
445 // This width controls how much the spread can be flicked left/right. It's composed of:
446 // tileDistance * app count (with a minimum of 3 apps, in order to also allow moving 1 and 2 apps a bit)
447 // + some constant value (still scales with the screen width) which looks good and somewhat fills the screen
448 width: Math.max(3, topLevelSurfaceList.count) * spreadView.tileDistance + (spreadView.width - spreadView.tileDistance) * 1.5
449 height: parent.height
451 enabled: spreadView.closingIndex >= 0
452 UbuntuNumberAnimation {}
455 if (spreadView.closingIndex >= 0) {
456 spreadView.contentX = Math.min(spreadView.contentX, width - spreadView.width - spreadView.shift);
460 x: spreadView.contentX
463 if (root.altTabEnabled) {
464 spreadView.snapTo(0);
468 TopLevelSurfaceRepeater {
470 objectName: "spreadRepeater"
471 model: topLevelSurfaceList
474 // Unless we're closing the app ourselves,
475 // lets make sure the spread doesn't mess up by the changing app list.
476 if (spreadView.closingIndex == -1) {
477 spreadView.phase = 0;
478 spreadView.contentX = -spreadView.shift;
482 function focusTopMostApp() {
483 if (spreadRepeater.count > 0) {
484 var topmostDelegate = spreadRepeater.itemAt(0);
485 topmostDelegate.focus = true;
489 delegate: TransformedSpreadDelegate {
491 objectName: "spreadDelegate_" + model.id
494 startScale: root.startScale
495 endScale: root.endScale
496 startDistance: spreadView.tileDistance
497 endDistance: units.gu(.5)
498 width: spreadView.width
499 height: spreadView.height
500 selected: spreadView.selectedIndex == index
501 otherSelected: spreadView.selectedIndex >= 0 && !selected
502 interactive: !spreadView.interactive && spreadView.phase === 0
503 && priv.fullyShowingFocusedApp && root.interactive && focus
504 swipeToCloseEnabled: spreadView.interactive && root.interactive && !snapAnimation.running
505 maximizedAppTopMargin: root.maximizedAppTopMargin
506 dropShadow: spreadView.active || priv.focusedAppDelegateIsDislocated
507 focusFirstApp: root.focusFirstApp
508 highlightShown: root.altTabPressed && index === priv.highlightIndex
510 readonly property bool isDash: model.application.appId == "unity8-dash"
512 Component.onCompleted: {
513 // NB: We're differentiating if this delegate was created in response to a new entry in the model
514 // or if the Repeater is just populating itself with delegates to match the model it received.
515 if (!spreadRepeater.startingUp) {
516 // a top level window is always the focused one when it first appears, unfocusing
517 // any preexisting one
519 // new items are appended and must be manually brought to front.
520 // that's how it *must* be in order to get the animation for new
527 if (focus && !spreadRepeater.startingUp) {
528 priv.focusedAppDelegate = appDelegate;
529 // If we're orphan (!parent) it means this stage is no longer the current one
530 // and will be deleted shortly. So we should no longer have a say over the model
532 topLevelSurfaceList.raiseId(model.id);
536 function claimFocus() {
537 if (spreadView.phase > 0) {
538 spreadView.snapTo(model.index);
540 appDelegate.focus = true;
544 target: model.surface
545 onFocusRequested: claimFocus()
548 target: model.application
550 if (!model.surface) {
551 // when an app has no surfaces, we assume there's only one entry representing it:
555 // if the application has surfaces, focus request should be at surface-level.
560 z: isDash && !spreadView.active ? -1 : behavioredIndex
563 // focused app is always positioned at 0 except when following left edge drag
565 if (!isDash && root.inverseProgress > 0 && spreadView.phase === 0) {
566 return root.inverseProgress;
570 if (isDash && !spreadView.active && !spreadDragArea.dragging) {
574 // Otherwise line up for the spread
575 return spreadView.width + spreadIndex * spreadView.tileDistance;
578 application: model.application
579 surface: model.surface
582 property real behavioredIndex: index
583 Behavior on behavioredIndex {
584 enabled: spreadView.closingIndex >= 0
585 UbuntuNumberAnimation {
589 spreadView.closingIndex = -1;
595 property var xBehavior: xBehavior
597 enabled: root.spreadEnabled &&
598 !spreadView.active &&
599 !snapAnimation.running &&
600 !spreadDragArea.pressed &&
603 UbuntuNumberAnimation {
605 duration: UbuntuAnimation.BriskDuration
609 // Each tile has a different progress value running from 0 to 1.
610 // 0: means the tile is at the right edge.
611 // 1: means the tile has finished the main animation towards the left edge.
612 // >1: after the main animation has finished, tiles will continue to move very slowly to the left
614 var tileProgress = (spreadView.shiftedContentX - behavioredIndex * spreadView.tileDistance) / spreadView.width;
615 // Tile 1 needs to move directly from the beginning...
616 if (root.focusFirstApp && behavioredIndex == 1 && spreadView.phase < 2) {
617 tileProgress += spreadView.tileDistance / spreadView.width;
619 // Limiting progress to ~0 and 1.7 to avoid binding calculations when tiles are not
621 // < 0 : The tile is outside the screen on the right
622 // > 1.7: The tile is *very* close to the left edge and covered by other tiles now.
623 // Using 0.0001 to differentiate when a tile should still be visible (==0)
624 // or we can hide it (< 0)
625 tileProgress = Math.max(-0.0001, Math.min(1.7, tileProgress));
629 // This mostly is the same as progress, just adds the snapping to phase 1 for tiles 0 and 1
631 if (spreadView.phase == 0 && index <= priv.firstSpreadIndex) {
632 if (progress < spreadView.positionMarker1) {
634 } else if (progress < spreadView.positionMarker1 + 0.05){
635 // p : 0.05 = x : pm2
636 return spreadView.positionMarker1 + (progress - spreadView.positionMarker1) * (spreadView.positionMarker2 - spreadView.positionMarker1) / 0.05
638 return spreadView.positionMarker2;
644 // Hide tile when progress is such that it will be off screen.
645 property bool occluded: {
646 if (spreadView.active && (progress >= 0 && progress < 1.7)) return false;
647 else if (!spreadView.active && isFocused) return false;
648 else if (xBehavior.running) return false;
649 else if (z <= 1 && priv.focusedAppDelegateIsDislocated) return false;
653 visible: Powerd.status == Powerd.On &&
654 !greeter.fullyShown &&
657 shellOrientationAngle: root.shellOrientationAngle
658 shellOrientation: root.shellOrientation
659 orientations: root.orientations
662 if (root.altTabEnabled && spreadView.phase == 2) {
663 spreadView.snapTo(index);
669 spreadView.draggedDelegateCount++;
671 spreadView.draggedDelegateCount--;
676 spreadView.closingIndex = index;
677 if (appDelegate.surface) {
678 appDelegate.surface.close();
679 } else if (appDelegate.application) {
680 root.applicationManager.stopApplication(appDelegate.application.appId);
682 // should never happen
683 console.warn("Can't close topLevelSurfaceList entry as it has neither"
684 + " a surface nor an application");
691 property: "mainAppWindowOrientationAngle"
692 value: appWindowOrientationAngle
697 property: "focusedAppOrientationChangesEnabled"
698 value: orientationChangesEnabled
701 StagedFullscreenPolicy {
703 surface: model.surface
708 onStageAboutToBeUnloaded: fullscreenPolicy.active = false
715 //eat touch events during the right edge gesture
717 objectName: "eventEaterArea"
719 enabled: spreadDragArea.dragging
724 objectName: "spreadDragArea"
725 direction: Direction.Leftwards
726 enabled: (spreadView.phase != 2 && root.spreadEnabled) || dragging
728 anchors { top: parent.top; right: parent.right; bottom: parent.bottom; }
729 width: root.dragAreaWidth
731 property var gesturePoints: new Array()
733 onTouchPositionChanged: {
735 // Gesture recognized. Let's move the spreadView with the finger
736 var dragX = Math.min(touchPosition.x + width, width); // Prevent dragging rightwards
737 dragX = -dragX + spreadDragArea.width - spreadView.shift;
738 // Don't allow dragging further than the animation crossing with phase2's animation
739 var maxMovement = spreadView.width * spreadView.positionMarker4 - spreadView.shift;
741 spreadView.contentX = Math.min(dragX, maxMovement);
743 // Initial touch. Let's reset the spreadView to the starting position.
744 spreadView.phase = 0;
745 spreadView.contentX = -spreadView.shift;
748 gesturePoints.push(touchPosition.x);
753 // A potential edge-drag gesture has started. Start recording it
756 // Ok. The user released. Find out if it was a one-way movement.
757 var oneWayFlick = true;
758 var smallestX = spreadDragArea.width;
759 for (var i = 0; i < gesturePoints.length; i++) {
760 if (gesturePoints[i] >= smallestX) {
764 smallestX = gesturePoints[i];
768 if (oneWayFlick && spreadView.shiftedContentX > units.gu(2) &&
769 spreadView.shiftedContentX < spreadView.positionMarker1 * spreadView.width) {
770 // If it was a short one-way movement, do the Alt+Tab switch
771 // no matter if we didn't cross positionMarker1 yet.
772 spreadView.snapTo(1);
773 } else if (!dragging) {
774 // otherwise snap to the closest snap position we can find
775 // (might be back to start, to app 1 or to spread)
785 // NB: it does its own positioning according to the specified edge
789 spreadView.snapToSpread();
791 material: Component {
797 anchors.centerIn: parent
799 GradientStop { position: 0.0; color: Qt.rgba(0.16,0.16,0.16,0.7)}
800 GradientStop { position: 1.0; color: Qt.rgba(0.16,0.16,0.16,0)}