// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @fileoverview Behavior for handling display layout, specifically
* edge snapping and collisions.
*/
import {assert} from 'chrome://resources/js/assert.js';
import {dedupingMixin, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {Constructor} from '../common/types.js';
import {getDisplayApi} from './device_page_browser_proxy.js';
import {DragMixin, DragMixinInterface, Position} from './drag_mixin.js';
import Bounds = chrome.system.display.Bounds;
import DisplayLayout = chrome.system.display.DisplayLayout;
import DisplayUnitInfo = chrome.system.display.DisplayUnitInfo;
import LayoutPosition = chrome.system.display.LayoutPosition;
export {Position};
export interface LayoutMixinInterface extends DragMixinInterface {
/**
* Array of display layouts.
*/
layouts: DisplayLayout[];
/**
* Whether or not mirroring is enabled.
*/
mirroring: boolean;
initializeDisplayLayout(
displays: DisplayUnitInfo[], layouts: DisplayLayout[]): void;
/**
* Called when a drag event occurs. Checks collisions and updates the layout.
*/
updateDisplayBounds(id: string, newBounds: Bounds): Bounds;
/**
* Called when dragging ends. Sends the updated layout to chrome.
*/
finishUpdateDisplayBounds(id: string): void;
/**
* Overloaded method for better typechecking depending on existence and
* value of |notest| argument
* @param notest Set to true if bounds may not be set.
*/
getCalculatedDisplayBounds<T extends boolean>(displayId: string, notest: T):
T extends false? Bounds: (Bounds|undefined);
getCalculatedDisplayBounds(displayId: string): Bounds;
getDisplayLayoutMapForTesting(): Map<string, DisplayLayout>;
}
export const LayoutMixin = dedupingMixin(
<T extends Constructor<PolymerElement>>(superClass: T): T&
Constructor<LayoutMixinInterface> => {
const superClassBase = DragMixin(superClass);
class LayoutMixinInternal extends superClassBase implements
LayoutMixinInterface {
static get properties() {
return {
layouts: Array,
mirroring: {
type: Boolean,
value: false,
},
};
}
layouts: DisplayLayout[];
mirroring: boolean;
/**
* The calculated bounds used for generating the div bounds.
*/
private calculatedBoundsMap_: Map<string, Bounds> = new Map();
private displayBoundsMap_: Map<string, Bounds> = new Map();
private displayLayoutMap_: Map<string, DisplayLayout> = new Map();
private dragBounds_: Bounds|undefined = undefined;
private dragLayoutId_: string = '';
private dragLayoutPosition_: LayoutPosition|undefined = undefined;
private dragParentId_: string = '';
getDisplayLayoutMapForTesting(): Map<string, DisplayLayout> {
return this.displayLayoutMap_;
}
initializeDisplayLayout(
displays: DisplayUnitInfo[], layouts: DisplayLayout[]): void {
this.dragLayoutId_ = '';
this.dragParentId_ = '';
this.mirroring =
displays.length > 0 && !!displays[0].mirroringSourceId;
this.displayBoundsMap_.clear();
for (const display of displays) {
this.displayBoundsMap_.set(display.id, display.bounds);
}
this.displayLayoutMap_.clear();
for (const layout of layouts) {
this.displayLayoutMap_.set(layout.id, layout);
}
this.calculatedBoundsMap_.clear();
for (const display of displays) {
if (!this.calculatedBoundsMap_.has(display.id)) {
const bounds = display.bounds;
this.calculateBounds_(display.id, bounds.width, bounds.height);
}
}
}
updateDisplayBounds(id: string, newBounds: Bounds): Bounds {
this.dragLayoutId_ = id;
// Find the closest parent.
const closestId = this.findClosest_(id, newBounds);
assert(closestId);
// Find the closest edge.
const closestBounds = this.getCalculatedDisplayBounds(closestId);
const layoutPosition =
this.getLayoutPositionForBounds_(newBounds, closestBounds);
// Snap to the closest edge.
const snapPos =
this.snapBounds_(newBounds, closestId, layoutPosition);
newBounds.left = snapPos.x;
newBounds.top = snapPos.y;
// Calculate the new bounds and delta.
const oldBounds =
this.dragBounds_ || this.getCalculatedDisplayBounds(id);
const deltaPos = {
x: newBounds.left - oldBounds.left,
y: newBounds.top - oldBounds.top,
};
// Check for collisions after snapping. This should not collide with
// the closest parent.
this.collideAndModifyDelta_(id, oldBounds, deltaPos);
// If the edge changed, update and highlight it.
if (layoutPosition !== this.dragLayoutPosition_ ||
closestId !== this.dragParentId_) {
this.dragLayoutPosition_ = layoutPosition;
this.dragParentId_ = closestId;
this.highlightEdge_(closestId, layoutPosition);
}
newBounds.left = oldBounds.left + deltaPos.x;
newBounds.top = oldBounds.top + deltaPos.y;
this.dragBounds_ = newBounds;
return newBounds;
}
finishUpdateDisplayBounds(id: string): void {
this.highlightEdge_('', undefined); // Remove any highlights.
if (id !== this.dragLayoutId_ || !this.dragBounds_ ||
!this.dragLayoutPosition_) {
return;
}
const layout = this.displayLayoutMap_.get(id);
let orphanIds: string[];
if (!layout || layout.parentId === '') {
// Primary display. Set the calculated position to |dragBounds_|.
this.setCalculatedDisplayBounds_(id, this.dragBounds_);
// We cannot re-parent the primary display, so instead make all
// other displays orphans and clear their calculated bounds.
orphanIds = this.findChildren_(id, /* recurse= */ true);
// Re-parent |dragParentId_|. It will be forced to parent to the
// dragged display since it is the only non-orphan.
this.reparentOrphan_(this.dragParentId_, orphanIds);
orphanIds.splice(orphanIds.indexOf(this.dragParentId_), 1);
} else {
// All immediate children of |layout| will need to be re-parented.
orphanIds = this.findChildren_(id, false /* do not recurse */);
// When re-parenting to a descendant, also parent any immediate
// child to drag display's current parent.
let topLayout = this.displayLayoutMap_.get(this.dragParentId_);
while (topLayout && topLayout.parentId !== '') {
if (topLayout.parentId === id) {
topLayout.parentId = layout.parentId;
break;
}
topLayout = this.displayLayoutMap_.get(topLayout.parentId);
}
// Re-parent the dragged display.
layout.parentId = this.dragParentId_;
this.updateOffsetAndPosition_(
this.dragBounds_, this.dragLayoutPosition_, layout);
}
// Update any orphaned children. This may cause the dragged display to
// be re-attached if it was attached to a child.
this.updateOrphans_(orphanIds);
// Send the updated layouts.
getDisplayApi().setDisplayLayout(this.layouts).then(() => {
if (chrome.runtime.lastError) {
console.error(
'setDisplayLayout Error: ' +
chrome.runtime.lastError.message);
}
});
}
/**
* Overloaded method for better typechecking depending on existence and
* value of |notest| argument
* @param notest Set to true if bounds may not be set.
*/
getCalculatedDisplayBounds<T extends boolean>(
displayId: string, notest: T): T extends true?
(Bounds|undefined): Bounds;
getCalculatedDisplayBounds(displayId: string): Bounds;
getCalculatedDisplayBounds(displayId: string, notest?: boolean): Bounds
|undefined {
const bounds = this.calculatedBoundsMap_.get(displayId);
assert(notest || bounds);
return bounds;
}
private setCalculatedDisplayBounds_(
displayId: string, bounds: Bounds|undefined): void {
assert(bounds);
this.calculatedBoundsMap_.set(displayId, {...bounds});
}
/**
* Re-parents all entries in |orphanIds| and any children.
* @param orphanIds The list of ids affected by the move.
*/
private updateOrphans_(orphanIds: string[]): void {
const orphans = orphanIds.slice();
for (let i = 0; i < orphanIds.length; ++i) {
const orphan = orphanIds[i];
const newOrphans = this.findChildren_(orphan, true /* recurse */);
// If the dragged display was re-parented to one of its children,
// there may be duplicates so merge the lists.
for (let j = 0; j < newOrphans.length; ++j) {
const o = newOrphans[j];
if (!orphans.includes(o)) {
orphans.push(o);
}
}
}
// Remove each orphan from the list as it is re-parented so that
// subsequent orphans can be parented to it.
while (orphans.length) {
const orphanId = orphans.shift()!;
this.reparentOrphan_(orphanId, orphans);
}
}
/**
* Re-parents the orphan to a layout that is not a member of
* |otherOrphanIds|.
* @param orphanId The id of the orphan to re-parent.
* @param otherOrphanIds The list of ids of other orphans
* to ignore when re-parenting.
*/
private reparentOrphan_(orphanId: string, otherOrphanIds: string[]):
void {
const layout = this.displayLayoutMap_.get(orphanId);
assert(layout);
if (orphanId === this.dragId && layout.parentId !== '') {
this.setCalculatedDisplayBounds_(orphanId, this.dragBounds_);
return;
}
const bounds = this.getCalculatedDisplayBounds(orphanId);
// Find the closest parent.
const newParentId =
this.findClosest_(orphanId, bounds, otherOrphanIds);
assert(newParentId !== '');
layout.parentId = newParentId;
// Find the closest edge.
const parentBounds = this.getCalculatedDisplayBounds(newParentId);
const layoutPosition =
this.getLayoutPositionForBounds_(bounds, parentBounds);
// Move from the nearest corner to the desired location and get the
// delta.
const cornerBounds = this.getCornerBounds_(bounds, parentBounds);
const desiredPos =
this.snapBounds_(bounds, newParentId, layoutPosition);
const deltaPos = {
x: desiredPos.x - cornerBounds.left,
y: desiredPos.y - cornerBounds.top,
};
// Check for collisions.
this.collideAndModifyDelta_(orphanId, cornerBounds, deltaPos);
const desiredBounds = {
left: cornerBounds.left + deltaPos.x,
top: cornerBounds.top + deltaPos.y,
width: bounds.width,
height: bounds.height,
};
this.updateOffsetAndPosition_(desiredBounds, layoutPosition, layout);
}
/**
* @param recurse Whether or not to include descendants of children.
*/
private findChildren_(parentId: string, recurse: boolean): string[] {
let children: string[] = [];
this.displayLayoutMap_.forEach((value, key) => {
const childId = key;
if (childId !== parentId && value.parentId === parentId) {
// Insert immediate children at the front of the array.
children.unshift(childId);
if (recurse) {
// Descendants get added to the end of the list.
children = children.concat(this.findChildren_(childId, true));
}
}
});
return children;
}
/**
* Recursively calculates the absolute bounds of a display.
* Caches the display bounds so that parent bounds are only calculated
* once.
*/
private calculateBounds_(id: string, width: number, height: number):
void {
let left: number;
let top: number;
const layout = this.displayLayoutMap_.get(id);
if (this.mirroring || !layout || !layout.parentId) {
left = -width / 2;
top = -height / 2;
} else {
if (!this.calculatedBoundsMap_.has(layout.parentId)) {
const pbounds = this.displayBoundsMap_.get(layout.parentId)!;
this.calculateBounds_(
layout.parentId, pbounds.width, pbounds.height);
}
const parentBounds =
this.getCalculatedDisplayBounds(layout.parentId);
left = parentBounds.left;
top = parentBounds.top;
switch (layout.position) {
case LayoutPosition.TOP:
left += layout.offset;
top -= height;
break;
case LayoutPosition.RIGHT:
left += parentBounds.width;
top += layout.offset;
break;
case LayoutPosition.BOTTOM:
left += layout.offset;
top += parentBounds.height;
break;
case LayoutPosition.LEFT:
left -= width;
top += layout.offset;
break;
}
}
const result = {
left,
top,
width,
height,
};
this.setCalculatedDisplayBounds_(id, result);
}
/**
* Finds the display closest to |bounds| ignoring |ignoreIds|.
*/
private findClosest_(
displayId: string, bounds: Bounds, ignoreIds?: string[]): string {
const x = bounds.left + bounds.width / 2;
const y = bounds.top + bounds.height / 2;
let closestId = '';
let closestDelta2 = 0;
const keys = this.calculatedBoundsMap_.keys();
for (let iter = keys.next(); !iter.done; iter = keys.next()) {
const otherId = iter.value;
if (otherId === displayId) {
continue;
}
if (ignoreIds && ignoreIds.includes(otherId)) {
continue;
}
const {left, top, width, height} =
this.getCalculatedDisplayBounds(otherId);
if (x >= left && x < left + width && y >= top && y < top + height) {
return otherId;
} // point is inside rect
let dx: number;
let dy: number;
if (x < left) {
dx = left - x;
} else if (x > left + width) {
dx = x - (left + width);
} else {
dx = 0;
}
if (y < top) {
dy = top - y;
} else if (y > top + height) {
dy = y - (top + height);
} else {
dy = 0;
}
const delta2 = dx * dx + dy * dy;
if (closestId === '' || delta2 < closestDelta2) {
closestId = otherId;
closestDelta2 = delta2;
}
}
return closestId;
}
/**
* Calculates the LayoutPosition for |bounds| relative to |parentId|.
*/
private getLayoutPositionForBounds_(
bounds: Bounds, parentBounds: Bounds): LayoutPosition {
// Translate bounds from top-left to center.
const x = bounds.left + bounds.width / 2;
const y = bounds.top + bounds.height / 2;
// Determine the distance from the new bounds to both of the near
// edges.
const {left, top, width, height} = parentBounds;
// Signed deltas to the center.
const dx = x - (left + width / 2);
const dy = y - (top + height / 2);
// Unsigned distance to each edge.
const distx = Math.abs(dx) - width / 2;
const disty = Math.abs(dy) - height / 2;
if (distx > disty) {
if (dx < 0) {
return LayoutPosition.LEFT;
}
return LayoutPosition.RIGHT;
} else {
if (dy < 0) {
return LayoutPosition.TOP;
}
return LayoutPosition.BOTTOM;
}
}
/**
* Modifies |bounds| to the position closest to it along the edge of
* |parentId| specified by |layoutPosition|.
*/
private snapBounds_(
bounds: Bounds, parentId: string,
layoutPosition: LayoutPosition): Position {
const parentBounds = this.getCalculatedDisplayBounds(parentId);
let x: number;
if (layoutPosition === LayoutPosition.LEFT) {
x = parentBounds.left - bounds.width;
} else if (layoutPosition === LayoutPosition.RIGHT) {
x = parentBounds.left + parentBounds.width;
} else {
x = this.snapToX_(bounds, parentBounds);
}
let y: number;
if (layoutPosition === LayoutPosition.TOP) {
y = parentBounds.top - bounds.height;
} else if (layoutPosition === LayoutPosition.BOTTOM) {
y = parentBounds.top + parentBounds.height;
} else {
y = this.snapToY_(bounds, parentBounds);
}
return {x, y};
}
/**
* Snaps a horizontal value, see snapToEdge.
* @param snapDistance Optionally provide to override the snap distance.
* 0 means snap from any distance.
*/
private snapToX_(
newBounds: Bounds, parentBounds: Bounds,
snapDistance?: number): number {
return this.snapToEdge_(
newBounds.left, newBounds.width, parentBounds.left,
parentBounds.width, snapDistance);
}
/**
* Snaps a vertical value, see snapToEdge.
* @param snapDistance Optionally provide to override the snap distance.
* 0 means snap from any distance.
*/
private snapToY_(
newBounds: Bounds, parentBounds: Bounds,
snapDistance?: number): number {
return this.snapToEdge_(
newBounds.top, newBounds.height, parentBounds.top,
parentBounds.height, snapDistance);
}
/**
* Snaps the region [point, width] to [basePoint, baseWidth] if
* the [point, width] is close enough to the base's edge.
* @param snapDistance Provide to override the snap distance.
* 0 means snap at any distance.
* @return The moved point. Returns the point itself if it doesn't
* need to snap to the edge.
*/
private snapToEdge_(
point: number, width: number, basePoint: number, baseWidth: number,
snapDistance?: number): number {
// If the edge of the region is smaller than this, it will snap to the
// base's edge.
const SNAP_DISTANCE_PX = 16;
const snapDist =
(snapDistance !== undefined) ? snapDistance : SNAP_DISTANCE_PX;
const startDiff = Math.abs(point - basePoint);
const endDiff = Math.abs(point + width - (basePoint + baseWidth));
// Prefer the closer one if both edges are close enough.
if ((!snapDist || startDiff < snapDist) && startDiff < endDiff) {
return basePoint;
} else if (!snapDist || endDiff < snapDist) {
return basePoint + baseWidth - width;
}
return point;
}
/**
* Intersects |layout| with each other layout and reduces |deltaPos| to
* avoid any collisions (or sets it to [0,0] if the display can not be
* moved in the direction of |deltaPos|). Note: this assumes that
* deltaPos is already 'snapped' to the parent edge, and therefore will
* not collide with the parent, i.e. this is to prevent overlapping with
* displays other than the parent.
*/
private collideAndModifyDelta_(
id: string, bounds: Bounds, deltaPos: Position): void {
const keys = this.calculatedBoundsMap_.keys();
const others = new Set(keys);
others.delete(id);
let checkCollisions = true;
while (checkCollisions) {
checkCollisions = false;
const othersValues = others.values();
for (let iter = othersValues.next(); !iter.done;
iter = othersValues.next()) {
const otherId = iter.value;
const otherBounds = this.getCalculatedDisplayBounds(otherId);
if (this.collideWithBoundsAndModifyDelta_(
bounds, otherBounds, deltaPos)) {
if (deltaPos.x === 0 && deltaPos.y === 0) {
return;
}
others.delete(otherId);
checkCollisions = true;
break;
}
}
}
}
/**
* Intersects |bounds| with |otherBounds|. If there is a collision,
* modifies |deltaPos| to limit movement to a single axis and avoid the
* collision and returns true. See note for |collideAndModifyDelta_|.
*/
private collideWithBoundsAndModifyDelta_(
bounds: Bounds, otherBounds: Bounds, deltaPos: Position): boolean {
const newX = bounds.left + deltaPos.x;
const newY = bounds.top + deltaPos.y;
if ((newX + bounds.width <= otherBounds.left) ||
(newX >= otherBounds.left + otherBounds.width) ||
(newY + bounds.height <= otherBounds.top) ||
(newY >= otherBounds.top + otherBounds.height)) {
return false;
}
// |deltaPos| should already be restricted to X or Y. This shortens
// the delta to stay outside the bounds, however it does not change
// the sign of the delta, i.e. it does not "push" the point outside
// the bounds if the point is already inside.
if (Math.abs(deltaPos.x) > Math.abs(deltaPos.y)) {
deltaPos.y = 0;
let snapDeltaX: number;
if (deltaPos.x > 0) {
snapDeltaX =
Math.max(0, (otherBounds.left - bounds.width) - bounds.left);
} else {
snapDeltaX = Math.min(
0, (otherBounds.left + otherBounds.width) - bounds.left);
}
deltaPos.x = snapDeltaX;
} else {
deltaPos.x = 0;
let snapDeltaY: number;
if (deltaPos.y > 0) {
snapDeltaY =
Math.min(0, (otherBounds.top - bounds.height) - bounds.top);
} else if (deltaPos.y < 0) {
snapDeltaY = Math.max(
0, (otherBounds.top + otherBounds.height) - bounds.top);
} else {
snapDeltaY = 0;
}
deltaPos.y = snapDeltaY;
}
return true;
}
/**
* Updates the offset for |layout| from |bounds|.
*/
private updateOffsetAndPosition_(
bounds: Bounds, position: LayoutPosition,
layout: DisplayLayout): void {
layout.position = position;
if (!layout.parentId) {
layout.offset = 0;
return;
}
// Offset is calculated from top or left edge.
const parentBounds = this.getCalculatedDisplayBounds(layout.parentId);
let offset: number;
let minOffset: number;
let maxOffset: number;
if (position === LayoutPosition.LEFT ||
position === LayoutPosition.RIGHT) {
offset = bounds.top - parentBounds.top;
minOffset = -bounds.height;
maxOffset = parentBounds.height;
} else {
offset = bounds.left - parentBounds.left;
minOffset = -bounds.width;
maxOffset = parentBounds.width;
}
const MIN_OFFSET_OVERLAP = 50;
minOffset += MIN_OFFSET_OVERLAP;
maxOffset -= MIN_OFFSET_OVERLAP;
layout.offset = Math.max(minOffset, Math.min(offset, maxOffset));
// Update the calculated bounds to match the new offset.
this.calculateBounds_(layout.id, bounds.width, bounds.height);
}
/**
* Returns |bounds| translated to touch the closest corner of
* |parentBounds|.
*/
private getCornerBounds_(bounds: Bounds, parentBounds: Bounds): Bounds {
let x: number;
if (bounds.left > parentBounds.left + parentBounds.width / 2) {
x = parentBounds.left + parentBounds.width;
} else {
x = parentBounds.left - bounds.width;
}
let y: number;
if (bounds.top > parentBounds.top + parentBounds.height / 2) {
y = parentBounds.top + parentBounds.height;
} else {
y = parentBounds.top - bounds.height;
}
return {
left: x,
top: y,
width: bounds.width,
height: bounds.height,
};
}
/**
* Highlights the edge of the div associated with |id| based on
* |layoutPosition| and removes any other highlights. If
* |layoutPosition| is undefined, removes all highlights.
*/
private highlightEdge_(
id: string, layoutPosition: LayoutPosition|undefined): void {
for (let i = 0; i < this.layouts.length; ++i) {
const layout = this.layouts[i];
const highlight = (layout.id === id || layout.parentId === id) ?
layoutPosition :
undefined;
const div = id ? this.shadowRoot!.getElementById(`_${id}`) :
this.shadowRoot!.getElementById(`_${layout.id}`);
assert(div);
div.classList.toggle(
'highlight-right', highlight === LayoutPosition.RIGHT);
div.classList.toggle(
'highlight-left', highlight === LayoutPosition.LEFT);
div.classList.toggle(
'highlight-top', highlight === LayoutPosition.TOP);
div.classList.toggle(
'highlight-bottom', highlight === LayoutPosition.BOTTOM);
}
}
}
return LayoutMixinInternal;
});