Skip to content
170 changes: 146 additions & 24 deletions lib/extension/tree.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ export const ORIENTATION_TYPES = Utils.createEnum(["NONE", "HORIZONTAL", "VERTIC

export const POSITION = Utils.createEnum(["BEFORE", "AFTER", "UNKNOWN"]);

// Smallest size (logical px, gaps excluded) a tiled window can be resized to
const MIN_WINDOW_SIZE = 50;

/**
* The Node data representation of the following elements in the user's display:
*
Expand Down Expand Up @@ -1389,7 +1392,9 @@ export class Tree extends Node {
}

let tiledChildren = this.getTiledChildren(node.childNodes);
let sizes = this.computeSizes(node, tiledChildren);
// Skip windows whose actors were destroyed mid-render
let validChildren = tiledChildren.filter((c) => c.isNodeValid());
let sizes = this.computeSizes(node, validChildren);

params.sizes = sizes;
let showTabs = this.settings.get_boolean("showtab-decoration-enabled");
Expand All @@ -1405,20 +1410,17 @@ export class Tree extends Node {
});
}

// Skip windows whose actors were destroyed mid-render
tiledChildren
.filter((c) => c.isNodeValid())
.forEach((child, index) => {
// A monitor can contain a window or container child
if (node.layout === LAYOUT_TYPES.HSPLIT || node.layout === LAYOUT_TYPES.VSPLIT) {
this.processSplit(node, child, params, index);
} else if (node.layout === LAYOUT_TYPES.STACKED) {
this.processStacked(node, child, params, index);
} else if (node.layout === LAYOUT_TYPES.TABBED) {
this.processTabbed(node, child, params, index);
}
this.processNode(child);
});
validChildren.forEach((child, index) => {
// A monitor can contain a window or container child
if (node.layout === LAYOUT_TYPES.HSPLIT || node.layout === LAYOUT_TYPES.VSPLIT) {
this.processSplit(node, child, params, index);
} else if (node.layout === LAYOUT_TYPES.STACKED) {
this.processStacked(node, child, params, index);
} else if (node.layout === LAYOUT_TYPES.TABBED) {
this.processTabbed(node, child, params, index);
}
this.processNode(child);
});
}

if (node.isWindow()) {
Expand Down Expand Up @@ -1602,20 +1604,140 @@ export class Tree extends Node {
}

computeSizes(node, childItems) {
let sizes = [];
let orientation = Utils.orientationFromLayout(node.layout);
let totalSize =
orientation === ORIENTATION_TYPES.HORIZONTAL ? node.rect.width : node.rect.height;
let grabTiled = node.getNodeByMode(Window.WINDOW_MODES.GRAB_TILE).length > 0;
childItems.forEach((childNode, index) => {
let percent =
childNode.percent && childNode.percent > 0.0 && !grabTiled
? childNode.percent
: 1.0 / childItems.length;
sizes[index] = Math.floor(percent * totalSize);
let percents = childItems.map((childNode) =>
childNode.percent && childNode.percent > 0.0 && !grabTiled
? childNode.percent
: 1.0 / childItems.length
);
// Only splits use the sizes (tabbed and stacked children share the whole container)
let split = node.isHSplit() || node.isVSplit();
let mins = childItems.map((childNode) => (split ? this.minSizeOf(childNode, orientation) : 0));
return this.fitSizes(percents, mins, totalSize);
}

/**
* Split `totalSize` px between children in proportion to `percents`, but give every child at
* least its minimum size: a child that would get less gets its minimum and the difference comes
* from the others. If the minimums don't all fit, they are scaled down together, so every
* child is short of its minimum by the same fraction. The sizes always add up to exactly
* `totalSize`, so the children fill their container without a gap or overlap, even if the
* percents do not sum to 1.
*/
fitSizes(percents, mins, totalSize) {
const count = percents.length;
const minTotal = mins.reduce((sum, min) => sum + min, 0);
if (minTotal > totalSize) mins = mins.map((min) => (min * totalSize) / minTotal);
const sizes = new Array(count).fill(0);
const atMin = new Array(count).fill(false);

let changed = true;
while (changed) {
changed = false;
let free = totalSize;
let share = 0;
for (let i = 0; i < count; i++) {
if (atMin[i]) free -= mins[i];
else share += percents[i];
}
for (let i = 0; i < count; i++) {
if (atMin[i]) {
sizes[i] = mins[i];
continue;
}
sizes[i] = share > 0 ? (free * percents[i]) / share : 0;
if (sizes[i] < mins[i]) {
atMin[i] = true;
changed = true;
}
}
}

const rounded = sizes.map((size) => Math.round(size));
const rest = totalSize - rounded.reduce((sum, size) => sum + size, 0);
if (count > 0 && rest !== 0) {
// The rounding remainder goes to the child with the most room above its minimum
let k = 0;
for (let i = 1; i < count; i++) if (rounded[i] - mins[i] > rounded[k] - mins[k]) k = i;
rounded[k] += rest;
}
return rounded;
}

/**
* The smallest size (px, gaps included) that `node` can take along `orientation`
* without any of its windows going below the minimum size its app allows.
* Windows without a minimum size, and GNOME versions that cannot report it (< 50),
* use a small floor so that a node can never shrink to nothing.
*/
minSizeOf(node, orientation, gap = null) {
if (!node) return 0;
const horizontal = orientation === ORIENTATION_TYPES.HORIZONTAL;
// The gap only depends on the monitor, so it is looked up once per call
if (gap === null) {
const firstWindow = node.isWindow() ? node : node.getNodeByType(NODE_TYPES.WINDOW)[0];
gap = firstWindow ? this.extWm.calculateGaps(firstWindow) : 0;
}

if (node.isWindow()) {
const metaWindow = node.nodeValue;
let min = 0;
if (metaWindow && typeof metaWindow.get_min_size === "function") {
try {
const [hasMin, minWidth, minHeight] = metaWindow.get_min_size();
if (hasMin) {
// The minimum is a client size; convert it to a frame size (CSD shadows, SSD title bar)
const rect = metaWindow.get_frame_rect();
rect.width = minWidth;
rect.height = minHeight;
const frame = metaWindow.client_rect_to_frame_rect(rect);
min = horizontal ? frame.width : frame.height;
}
} catch (e) {
// the window may be going away mid-render
}
}
return Math.max(min, MIN_WINDOW_SIZE) + gap * 2;
}

const children = this.getTiledChildren(node.childNodes).filter((c) => c.isNodeValid());
if (children.length === 0) return 0;
const mins = children.map((child) => this.minSizeOf(child, orientation, gap));
if ((node.isHSplit() && horizontal) || (node.isVSplit() && !horizontal)) {
return mins.reduce((sum, min) => sum + min, 0);
}

// Children share the node's full size; stacked and tabbed headers take some of the height
let min = Math.max(...mins);
if (!horizontal && node.isStacked()) {
min += this.defaultStackHeight * (children.length - 1);
} else if (!horizontal && node.isTabbed()) {
const showTabs = this.settings.get_boolean("showtab-decoration-enabled");
min += showTabs ? this.defaultStackHeight * Utils.dpi() : 0;
}
return min;
}

/**
* Set the shares of the split `node`'s tiled children from their current sizes (what is
* shown), so that a resize which changes some of them leaves the others exactly where they are.
* Stored shares can differ from what is shown: 0 means an equal split, and minimum sizes can
* override a share. Children that are not shown (minimized, floating) get no share. Returns
* false (and changes nothing) if a child has not been laid out yet.
*/
syncShares(node) {
if (!node.rect || !(node.isHSplit() || node.isVSplit())) return false;
const horizontal = node.isHSplit();
const total = horizontal ? node.rect.width : node.rect.height;
const shown = this.getTiledChildren(node.childNodes).filter((c) => c.isNodeValid());
if (!total || shown.some((c) => !c.rect)) return false;
node.childNodes.forEach((c) => {
c.percent = shown.includes(c) ? (horizontal ? c.rect.width : c.rect.height) / total : 0;
});
// TODO - make sure the totalSize = the sizes total
return sizes;
return true;
}

findFirstNodeWindowFrom(node) {
Expand Down
Loading