Skip to content

Commit 5e4efae

Browse files
committed
expo: Allow drag-and-drop reordering of workspaces.
1 parent bfc454e commit 5e4efae

2 files changed

Lines changed: 239 additions & 12 deletions

File tree

js/ui/expoThumbnail.js

Lines changed: 209 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const ICON_OPACITY = Math.round(255 * 0.9);
2929
const ICON_SIZE = 128;
3030
const ICON_OFFSET = -5;
3131

32+
const WORKSPACE_DRAG_ANIMATION_TIME = 200;
3233
const DRAGGING_WINDOW_OPACITY = Math.round(255 * 0.8);
3334
const WINDOW_DND_SIZE = 256;
3435

@@ -321,6 +322,34 @@ var ExpoWorkspaceThumbnail = GObject.registerClass({
321322

322323
this._lastButtonPressTimeStamp = 0;
323324

325+
// Connected before makeDraggable so this handler fires first. If the
326+
// click originated from a window clone, inhibit workspace drag so the
327+
// window clone's own DnD handles it instead.
328+
this.connect('button-press-event', (actor, event) => {
329+
if (!this.windows)
330+
return Clutter.EVENT_PROPAGATE;
331+
let source = event.get_source();
332+
this._draggable.inhibit = this.windows.some(
333+
w => w === source || w.contains(source)
334+
);
335+
return Clutter.EVENT_PROPAGATE;
336+
});
337+
338+
this._draggable = DND.makeDraggable(this);
339+
this._draggable.connect('drag-begin', () => {
340+
this.inWorkspaceDrag = true;
341+
});
342+
this._draggable.connect('drag-end', () => {
343+
this.inWorkspaceDrag = false;
344+
this.show();
345+
this.frame.show();
346+
this.title.show();
347+
this.box._endWorkspaceDrag();
348+
// Reset so the next motion-event after drop re-runs the hover logic
349+
// (button + highlight).
350+
this.hovering = false;
351+
});
352+
324353
this.title = new St.Entry({ style_class: 'expo-workspaces-name-entry',
325354
track_hover: true,
326355
can_focus: true });
@@ -938,8 +967,36 @@ var ExpoWorkspaceThumbnail = GObject.registerClass({
938967
return indexOne - 1;
939968
}
940969

941-
// Draggable target interface
970+
getDragActor() {
971+
let clone = new Clutter.Clone({ source: this });
972+
clone.set_size(this.width * this.scale_x,
973+
this.height * this.scale_y);
974+
975+
this.hide();
976+
this.frame.hide();
977+
this.title.hide();
978+
this.box._startWorkspaceDrag(this.box.thumbnails.indexOf(this));
979+
980+
return clone;
981+
}
982+
983+
getDragActorSource() {
984+
return this;
985+
}
986+
942987
handleDragOver(source, actor, x, y, time) {
988+
if (source instanceof ExpoWorkspaceThumbnail) {
989+
if (source === this)
990+
return DND.DragMotionResult.CONTINUE;
991+
992+
let alloc = this.get_allocation_box();
993+
let scale = this.get_scale()[0];
994+
let boxX = alloc.x1 + x * scale;
995+
let boxY = alloc.y1 + y * scale;
996+
this.box._updateWorkspaceDragFromPosition(boxX, boxY);
997+
return DND.DragMotionResult.MOVE_DROP;
998+
}
999+
9431000
this.emit('drag-over');
9441001
if (!this.overviewMode) {
9451002
this.overviewModeOn();
@@ -1011,6 +1068,16 @@ var ExpoWorkspaceThumbnail = GObject.registerClass({
10111068
}
10121069

10131070
acceptDrop(source, actor, x, y, time) {
1071+
if (source instanceof ExpoWorkspaceThumbnail) {
1072+
let sourceIndex = this.box._wsSourceIndex;
1073+
let dropIndex = this.box._wsDropIndex;
1074+
this.box._endWorkspaceDrag();
1075+
if (sourceIndex >= 0 && dropIndex >= 0 && sourceIndex !== dropIndex) {
1076+
this.box._reorderWorkspace(sourceIndex, dropIndex);
1077+
}
1078+
return true;
1079+
}
1080+
10141081
if (this.handleDragOverOrDrop(false, source, actor, x, y, time) != DND.DragMotionResult.CONTINUE) {
10151082
if (this.handleDragOverOrDrop(true, source, actor, x, y, time) != DND.DragMotionResult.CONTINUE) {
10161083
this.restack(true);
@@ -1056,12 +1123,27 @@ var ExpoThumbnailsBox = GObject.registerClass({
10561123
// for the border and padding of the background actor.
10571124
this.background = new St.Bin({reactive:true});
10581125
this.add_child(this.background);
1059-
this.background.handleDragOver = function(source, actor, x, y, time) {
1126+
this.background.handleDragOver = (source, actor, x, y, time) => {
1127+
if (source instanceof ExpoWorkspaceThumbnail) {
1128+
if (this._wsDropIndex < 0)
1129+
return DND.DragMotionResult.CONTINUE;
1130+
this._updateWorkspaceDragFromPosition(x, y);
1131+
return DND.DragMotionResult.MOVE_DROP;
1132+
}
10601133
return source.metaWindow && !source.metaWindow.is_on_all_workspaces() ?
10611134
DND.DragMotionResult.MOVE_DROP : DND.DragMotionResult.CONTINUE;
10621135
};
10631136
this.background.acceptDrop = (source, actor, x, y, time) => {
1064-
if (this.background.handleDragOver(source, actor, x, y, time) === DND.DragMotionResult.MOVE_DROP) {
1137+
if (source instanceof ExpoWorkspaceThumbnail) {
1138+
let sourceIndex = this._wsSourceIndex;
1139+
let dropIndex = this._wsDropIndex;
1140+
this._endWorkspaceDrag();
1141+
if (sourceIndex >= 0 && dropIndex >= 0 && sourceIndex !== dropIndex) {
1142+
this._reorderWorkspace(sourceIndex, dropIndex);
1143+
}
1144+
return true;
1145+
}
1146+
if (this.background.handleDragOver(source, actor, x, y, time) === DND.DragMotionResult.MOVE_DROP) {
10651147
let draggable = source._draggable;
10661148
actor.get_parent().remove_actor(actor);
10671149
draggable._dragOrigParent.add_actor(actor);
@@ -1084,6 +1166,9 @@ var ExpoThumbnailsBox = GObject.registerClass({
10841166
this.targetScale = 0;
10851167
this.pendingScaleUpdate = false;
10861168
this.stateUpdateQueued = false;
1169+
this._wsSourceIndex = -1;
1170+
this._wsDropIndex = -1;
1171+
this._slotCenters = [];
10871172

10881173
this.stateCounts = {};
10891174
for (let key in ThumbnailState)
@@ -1163,6 +1248,14 @@ var ExpoThumbnailsBox = GObject.registerClass({
11631248
this.addThumbnails(index, 1);
11641249
},
11651250
'workspace-removed', () => {
1251+
if (this._wsSourceIndex >= 0) {
1252+
let source = this.thumbnails[this._wsSourceIndex];
1253+
if (source && source._draggable && source._draggable._dragInProgress)
1254+
source._draggable._cancelDrag(null);
1255+
else
1256+
this._endWorkspaceDrag();
1257+
}
1258+
11661259
this.button.hide();
11671260

11681261
let removedCount = 0;
@@ -1227,6 +1320,70 @@ var ExpoThumbnailsBox = GObject.registerClass({
12271320
this.thumbnails[this.kbThumbnailIndex].removeWorkspace();
12281321
}
12291322

1323+
_startWorkspaceDrag(sourceIndex) {
1324+
this._wsSourceIndex = sourceIndex;
1325+
this._wsDropIndex = sourceIndex;
1326+
this.button.hide();
1327+
this.queue_relayout();
1328+
}
1329+
1330+
_updateWorkspaceDragFromPosition(boxX, boxY) {
1331+
if (!this._slotCenters || this._slotCenters.length === 0)
1332+
return;
1333+
1334+
let minDist = Infinity;
1335+
let dropIndex = this._wsDropIndex;
1336+
for (let i = 0; i < this._slotCenters.length; i++) {
1337+
let dx = boxX - this._slotCenters[i].x;
1338+
let dy = boxY - this._slotCenters[i].y;
1339+
let dist = dx * dx + dy * dy;
1340+
if (dist < minDist) {
1341+
minDist = dist;
1342+
dropIndex = i;
1343+
}
1344+
}
1345+
1346+
if (this._wsDropIndex !== dropIndex) {
1347+
this._wsDropIndex = dropIndex;
1348+
this.queue_relayout();
1349+
}
1350+
}
1351+
1352+
_endWorkspaceDrag() {
1353+
this._wsSourceIndex = -1;
1354+
this._wsDropIndex = -1;
1355+
this.queue_relayout();
1356+
}
1357+
1358+
_reorderWorkspace(oldIndex, newIndex) {
1359+
Main.reorderWorkspace(oldIndex, newIndex);
1360+
1361+
let thumbnail = this.thumbnails.splice(oldIndex, 1)[0];
1362+
this.thumbnails.splice(newIndex, 0, thumbnail);
1363+
1364+
if (this.kbThumbnailIndex === oldIndex) {
1365+
this.kbThumbnailIndex = newIndex;
1366+
} else if (oldIndex < this.kbThumbnailIndex && newIndex >= this.kbThumbnailIndex) {
1367+
this.kbThumbnailIndex--;
1368+
} else if (oldIndex > this.kbThumbnailIndex && newIndex <= this.kbThumbnailIndex) {
1369+
this.kbThumbnailIndex++;
1370+
}
1371+
1372+
for (let i = 0; i < this.thumbnails.length; i++) {
1373+
this.thumbnails[i].refreshTitle();
1374+
}
1375+
1376+
let activeWorkspace = global.workspace_manager.get_active_workspace();
1377+
for (let i = 0; i < this.thumbnails.length; i++) {
1378+
let isActive = this.thumbnails[i].metaWorkspace === activeWorkspace;
1379+
this.thumbnails[i].setActive(isActive);
1380+
if (isActive)
1381+
this.lastActiveWorkspace = this.thumbnails[i];
1382+
}
1383+
1384+
this.queue_relayout();
1385+
}
1386+
12301387
// returns true if symbol was understood, false otherwise
12311388
selectNextWorkspace(symbol) {
12321389
let prevIndex = this.kbThumbnailIndex;
@@ -1654,37 +1811,71 @@ var ExpoThumbnailsBox = GObject.registerClass({
16541811

16551812
this.background.allocate(childBox, flags);
16561813

1814+
// During a workspace drag, build a virtual display order:
1815+
// remove the source thumbnail and leave a gap at the drop position.
1816+
let displayOrder = [];
1817+
let isDragging = this._wsSourceIndex >= 0 && this._wsDropIndex >= 0;
1818+
if (isDragging) {
1819+
for (let i = 0; i < this.thumbnails.length; i++) {
1820+
if (i !== this._wsSourceIndex)
1821+
displayOrder.push(this.thumbnails[i]);
1822+
}
1823+
displayOrder.splice(this._wsDropIndex, 0, null);
1824+
this._slotCenters = [];
1825+
} else {
1826+
for (let i = 0; i < this.thumbnails.length; i++)
1827+
displayOrder.push(this.thumbnails[i]);
1828+
}
1829+
16571830
let x;
16581831
let y = spacing + Math.floor((availY - nRows * thumbnailHeight) / 2);
1659-
for (let i = 0; i < this.thumbnails.length; i++) {
1832+
for (let i = 0; i < displayOrder.length; i++) {
16601833
let column = i % nColumns;
16611834
let row = Math.floor(i / nColumns);
1662-
let cItemsInRow = Math.min(this.thumbnails.length - (row * nColumns), nColumns);
1835+
let cItemsInRow = Math.min(displayOrder.length - (row * nColumns), nColumns);
16631836
x = column > 0 ? x : calcPaddingX(cItemsInRow);
1664-
let rowMultiplier = row + 1;
16651837

1666-
let thumbnail = this.thumbnails[i];
1838+
if (isDragging) {
1839+
this._slotCenters.push({
1840+
x: x + thumbnailWidth / 2,
1841+
y: y + thumbnailHeight / 2
1842+
});
1843+
}
1844+
1845+
let thumbnail = displayOrder[i];
1846+
1847+
if (thumbnail === null) {
1848+
x += thumbnailWidth + spacing;
1849+
y += (i + 1) % nColumns > 0 ? 0 : thumbnailHeight + extraHeight + thTitleMargin;
1850+
continue;
1851+
}
16671852

16681853
// We might end up with thumbnailHeight being something like 99.33
16691854
// pixels. To make this work and not end up with a gap at the bottom,
16701855
// we need some thumbnails to be 99 pixels and some 100 pixels height;
16711856
// we compute an actual scale separately for each thumbnail.
16721857
let x1 = Math.round(x + (thumbnailWidth * thumbnail.slide_position / 2));
16731858
let x2 = Math.round(x + thumbnailWidth);
1859+
let y1 = y;
1860+
let y2 = y1 + thumbnailHeight;
16741861

1675-
let y1, y2;
1862+
let scale = this.thumbnail_scale * (1 - thumbnail.slide_position);
16761863

1677-
y1 = y;
1678-
y2 = y1 + thumbnailHeight;
1864+
let animate = isDragging && thumbnail.has_allocation();
1865+
if (animate) {
1866+
for (let actor of [thumbnail, thumbnail.frame, thumbnail.title]) {
1867+
actor.save_easing_state();
1868+
actor.set_easing_mode(Clutter.AnimationMode.EASE_OUT_QUAD);
1869+
actor.set_easing_duration(WORKSPACE_DRAG_ANIMATION_TIME);
1870+
}
1871+
}
16791872

16801873
// Allocating a scaled actor is funny - x1/y1 correspond to the origin
16811874
// of the actor, but x2/y2 are increased by the *unscaled* size.
16821875
childBox.x1 = x1;
16831876
childBox.x2 = x1 + portholeWidth;
16841877
childBox.y1 = y1;
16851878
childBox.y2 = y1 + portholeHeight;
1686-
1687-
let scale = this.thumbnail_scale * (1 - thumbnail.slide_position);
16881879
thumbnail.set_scale(scale, scale);
16891880
thumbnail.allocate(childBox, flags);
16901881

@@ -1704,6 +1895,12 @@ var ExpoThumbnailsBox = GObject.registerClass({
17041895
childBox.y2 = childBox.y1 + thumbnail.title.height;
17051896
thumbnail.title.allocate(childBox, flags);
17061897

1898+
if (animate) {
1899+
for (let actor of [thumbnail, thumbnail.frame, thumbnail.title]) {
1900+
actor.restore_easing_state();
1901+
}
1902+
}
1903+
17071904
x += thumbnailWidth + spacing;
17081905
y += (i + 1) % nColumns > 0 ? 0 : thumbnailHeight + extraHeight + thTitleMargin;
17091906
}

js/ui/main.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -767,6 +767,36 @@ function hasDefaultWorkspaceName(index) {
767767
return getWorkspaceName(index) == _makeDefaultWorkspaceName(index);
768768
}
769769

770+
function reorderWorkspace(oldIndex, newIndex) {
771+
let n = global.workspace_manager.n_workspaces;
772+
if (oldIndex === newIndex ||
773+
oldIndex < 0 || oldIndex >= n ||
774+
newIndex < 0 || newIndex >= n)
775+
return;
776+
777+
let workspace = global.workspace_manager.get_workspace_by_index(oldIndex);
778+
global.workspace_manager.reorder_workspace(workspace, newIndex);
779+
780+
// If every workspace has its default name, there's nothing to move -
781+
// default names regenerate from the index automatically.
782+
let hasCustomName = false;
783+
for (let i = 0; i < global.workspace_manager.n_workspaces; i++) {
784+
if (!hasDefaultWorkspaceName(i)) {
785+
hasCustomName = true;
786+
break;
787+
}
788+
}
789+
if (!hasCustomName)
790+
return;
791+
792+
_fillWorkspaceNames(Math.max(oldIndex, newIndex) + 1);
793+
let name = workspace_names[oldIndex] || '';
794+
workspace_names.splice(oldIndex, 1);
795+
workspace_names.splice(newIndex, 0, name);
796+
_trimWorkspaceNames();
797+
wmSettings.set_strv("workspace-names", workspace_names);
798+
}
799+
770800
function _addWorkspace() {
771801
global.workspace_manager.append_new_workspace(false, global.get_current_time());
772802
return true;

0 commit comments

Comments
 (0)