diff --git a/lib/extension/tree.js b/lib/extension/tree.js index ea957d29..0c6ccfb6 100644 --- a/lib/extension/tree.js +++ b/lib/extension/tree.js @@ -351,13 +351,6 @@ export class Node extends GObject.Object { } removeChild(node) { - if (node.isTabbed() && node.decoration) { - node.decoration.hide(); - node.decoration.destroy_all_children(); - node.decoration.destroy(); - node.decoration = null; - } - let refNode; if (this.contains(node)) { // Since contains() tries to find node on all descendants, @@ -516,6 +509,26 @@ export class Node extends GObject.Object { this.tab = tabContents; } + /** + * The tab (or stack title row) of a container that is a member of a tabbed or stacked group, + * e.g. a split among tabs. Tree._placeDecoration() sets its title and icon (from the window + * last used in it); a click goes back to that window. + */ + _createConTab(onClick) { + if (this.tab || !this.isCon()) return; + const tabContents = new St.BoxLayout({ + style_class: "window-tabbed-tab", + x_expand: true, + }); + const iconBin = new St.Button({ style_class: "window-tabbed-tab-icon" }); + const titleButton = new St.Button({ x_expand: true, label: "" }); + tabContents.add_child(iconBin); + tabContents.add_child(titleButton); + iconBin.connect("clicked", onClick); + titleButton.connect("clicked", onClick); + this.tab = tabContents; + } + _createDecoration() { if (this.decoration) return; let decoration = new St.BoxLayout(); @@ -555,8 +568,8 @@ export class Node extends GObject.Object { } render() { - // Always update the title for the tab - if (this.tab !== null && this.tab !== undefined) { + // Always update the title for the tab (a container's: see Tree._placeDecoration()) + if (this.isWindow() && this.tab !== null && this.tab !== undefined) { let titleLabel = this.tab.get_child_at_index(1); if (titleLabel) titleLabel.label = this._getTitle(); } @@ -709,6 +722,7 @@ export class Tree extends Node { global.window_group.remove_child(existingWsNode.actorBin); this.removeChild(existingWsNode); + this._forgetRemoved(existingWsNode); return true; } @@ -785,6 +799,35 @@ export class Tree extends Node { /** * Focuses on the next node, if metaWindow and tiled, raise it */ + /** + * The window to focus when focus enters container `con` from outside it (from the `previous` + * side): a tabbed or stacked group's last-used window; otherwise, as before, a stack's last + * window, or the first or last tiled window. + */ + _entryWindow(con, previous) { + const isGroup = (n) => n.isTabbed() || n.isStacked(); + let pick = isGroup(con) ? this._lastUsedIn(con) : null; + if (!pick) { + const tiled = con.getNodeByType(NODE_TYPES.WINDOW).filter((w) => w.isTile()); + if (con.isStacked()) pick = tiled[tiled.length - 1] ?? con.lastChild; + else pick = previous ? tiled[tiled.length - 1] : tiled[0]; + } + // A window inside a group further down (e.g. a split holding tabs): that group's last-used one + let outer = null; + for (let p = pick?.parentNode; p && p !== con; p = p.parentNode) if (isGroup(p)) outer = p; + return (outer && this._lastUsedIn(outer)) ?? pick; + } + + /** + * The window last focused in a tabbed or stacked group, if it is still a visible tile anywhere + * inside it (null once it was closed, moved out, minimized or floated). + */ + _lastUsedIn(group) { + const last = group.lastTabFocus; + const node = last ? group.getNodeByValue(last) : null; + return node && node.isWindow() && node.isTile() && !last.minimized ? node : null; + } + focus(node, direction) { if (!node) return null; let next = this.next(node, direction); @@ -799,20 +842,7 @@ export class Tree extends Node { case NODE_TYPES.WINDOW: break; case NODE_TYPES.CON: - const tiledConWindows = next.getNodeByType(NODE_TYPES.WINDOW).filter((w) => w.isTile()); - if (next.layout === LAYOUT_TYPES.STACKED) { - next = next.lastChild; - } else { - if (tiledConWindows.length > 1) { - if (previous) { - next = tiledConWindows[tiledConWindows.length - 1]; - } else { - next = tiledConWindows[0]; - } - } else { - next = tiledConWindows[0]; - } - } + next = this._entryWindow(next, previous); break; case NODE_TYPES.MONITOR: if (next.layout === LAYOUT_TYPES.STACKED) { @@ -826,20 +856,7 @@ export class Tree extends Node { } if (next && next.nodeType === NODE_TYPES.CON) { - const tiledConWindows = next.getNodeByType(NODE_TYPES.WINDOW).filter((w) => w.isTile()); - if (next.layout === LAYOUT_TYPES.STACKED) { - next = next.lastChild; - } else { - if (tiledConWindows.length > 1) { - if (previous) { - next = tiledConWindows[tiledConWindows.length - 1]; - } else { - next = tiledConWindows[0]; - } - } else { - next = tiledConWindows[0]; - } - } + next = this._entryWindow(next, previous); } break; } @@ -1239,26 +1256,24 @@ export class Tree extends Node { let parentNode = node.parentNode; // If parent has only this window, remove the parent instead - if (parentNode.childNodes.length === 1 && parentNode.nodeType !== NODE_TYPES.MONITOR) { - let existParent = parentNode.parentNode; - oldChild = existParent.removeChild(parentNode); - cleanUpParent(existParent); - } else { - let existParent = node.parentNode; - oldChild = existParent.removeChild(node); - if (!this.extWm.floatingWindow(node)) cleanUpParent(existParent); - } + const removeParent = + parentNode.childNodes.length === 1 && parentNode.nodeType !== NODE_TYPES.MONITOR; + const removed = removeParent ? parentNode : node; + const existParent = removed.parentNode; + oldChild = existParent.removeChild(removed); + if (removeParent || !this.extWm.floatingWindow(node)) cleanUpParent(existParent); + this._forgetRemoved(removed); // If only a single tab remains, exit tabbed layout if ( this.settings.get_boolean("auto-exit-tabbed") && - parentNode.nodeType === NODE_TYPES.CON && - parentNode.layout === LAYOUT_TYPES.TABBED && - parentNode.childNodes.length === 1 + existParent.nodeType === NODE_TYPES.CON && + existParent.layout === LAYOUT_TYPES.TABBED && + existParent.childNodes.length === 1 ) { - parentNode.layout = this.extWm.determineSplitLayout(); - this.resetSiblingPercent(parentNode); - parentNode.lastTabFocus = null; + existParent.layout = this.extWm.determineSplitLayout(); + this.resetSiblingPercent(existParent); + existParent.lastTabFocus = null; } if (node === this.attachNode) { @@ -1271,6 +1286,29 @@ export class Tree extends Node { return oldChild ? true : false; } + /** + * A subtree has left the tree for good: destroy the tabs of its windows and the tab bars of its + * containers (whatever their layout: a container can still show tabs after it stopped being + * tabbed, e.g. when all of its windows close at once). Tab bars of other containers keep their + * tabs; a tab bar being destroyed only detaches tabs that belong to windows still in the tree. + */ + _forgetRemoved(root) { + const windows = root.isWindow() ? [root] : root.getNodeByType(NODE_TYPES.WINDOW); + windows.forEach((w) => { + w.tab?.destroy(); + w.tab = null; + }); + const cons = new Set([...(root.isCon() ? [root] : []), ...root.getNodeByType(NODE_TYPES.CON)]); + cons.forEach((con) => { + con.tab?.destroy(); + con.tab = null; + if (!con.decoration) return; + con.decoration.remove_all_children(); + con.decoration.destroy(); + con.decoration = null; + }); + } + render(from) { Logger.debug(`render tree ${from ? "from " + from : ""}`); this.processNode(this); @@ -1504,26 +1542,46 @@ export class Tree extends Node { * */ processStacked(node, child, params, index) { - let layout = node.layout; - let nodeWidth = node.rect.width; - let nodeHeight = node.rect.height; - let nodeX = node.rect.x; - let nodeY = node.rect.y; - let stackHeight = this.defaultStackHeight; - - if (layout === LAYOUT_TYPES.STACKED) { - if (node.childNodes.length > 1) { - nodeY += stackHeight * index; - nodeHeight -= stackHeight * index; - } - + if (node.layout !== LAYOUT_TYPES.STACKED) return; + const rect = node.rect; + const rows = params.tiledChildren.length; + if (!this.hasTitleList(node)) { + // The cascade as before, where the top of each window before the focused one shows + const stackHeight = node.childNodes.length > 1 ? this.defaultStackHeight : 0; child.rect = { - x: nodeX, - y: nodeY, - width: nodeWidth, - height: nodeHeight, + x: rect.x, + y: rect.y + stackHeight * index, + width: rect.width, + height: rect.height - stackHeight * index, }; + return; } + // A list of every window's title at the top (like i3), and all the windows in the same place + // below it: the focused one is raised, and any other is one click (or Super+J/K) away + const listHeight = params.stackedHeight * rows; + this._placeDecoration(node, child, index, listHeight, params.stackedHeight); + child.rect = { + x: rect.x, + y: rect.y + listHeight, + width: rect.width, + height: rect.height - listHeight, + }; + } + + /** + * Whether a stacked container shows a title list (else the old cascade): title bars are on + * (showtab-decoration-enabled), it has a bar to draw them in (a workspace-level stack has none), + * and every tiled child has a title row: a container always gets one, a window has one unless + * its app is unknown. + */ + hasTitleList(node) { + if (!node.isStacked() || !node.decoration) return false; + if (!this.settings.get_boolean("showtab-decoration-enabled")) return false; + const tiled = this.getTiledChildren(node.childNodes); + return ( + tiled.length > 0 && + tiled.every((c) => c.isCon() || (c.isWindow() && c.isNodeValid() && c.tab)) + ); } /** @@ -1531,73 +1589,153 @@ export class Tree extends Node { * It will be moved to the Node class in the future as Node.render() * */ - processTabbed(node, child, params, _index) { - let layout = node.layout; - let nodeRect = node.rect; - let nodeWidth; - let nodeHeight; - let nodeX; - let nodeY; - - if (layout === LAYOUT_TYPES.TABBED) { - nodeWidth = nodeRect.width; - nodeX = nodeRect.x; - nodeY = nodeRect.y; - nodeHeight = nodeRect.height; + processTabbed(node, child, params, index) { + if (node.layout !== LAYOUT_TYPES.TABBED) return; + const rect = node.rect; + this._placeDecoration(node, child, index, params.stackedHeight); + child.rect = { + x: rect.x, + y: rect.y + params.stackedHeight, + width: rect.width, + height: rect.height - params.stackedHeight, + }; + } - let alwaysShowDecorationTab = true; + /** + * Put a tabbed or stacked container's title bar at its top, `height` px high, and `child`'s tab + * in it: side by side for tabs, or, with `rowHeight`, one row of that height per window (a stack). + * The bar itself is placed once per container, with its first child (`index` 0). + */ + _placeDecoration(node, child, index, height, rowHeight = 0) { + const vertical = rowHeight > 0; + const decoration = node.decoration; + if (!decoration) return; + if (index === 0) this._placeBar(node, child, decoration, height, vertical); + if (!child.isNodeValid()) return; + if (child.isCon()) this._updateConTab(child); + if (child.tab && !decoration.contains(child.tab)) { + try { + child.tab.get_parent()?.remove_child(child.tab); // still in the bar of a group it left + decoration.add_child(child.tab); + } catch (e) {} + } + // A stack's rows get their share of the list exactly, less their CSS margins, so that they + // neither overflow it nor take their natural height + let rowSize = -1; + if (vertical && child.tab) { + rowSize = rowHeight; + try { + const theme = child.tab.get_theme_node(); + rowSize -= theme.get_margin(St.Side.TOP) + theme.get_margin(St.Side.BOTTOM); + } catch (e) { + rowSize -= 2; // not styled yet: the default theme's margins + } + } + child.tab?.set_height(Math.max(rowSize, -1)); + child.render(); + } - if (node.childNodes.length > 1 || alwaysShowDecorationTab) { - nodeY = nodeRect.y + params.stackedHeight; - nodeHeight = nodeRect.height - params.stackedHeight; - if (node.decoration && child.isWindow() && child.isNodeValid()) { - let gap = this.extWm.calculateGaps(node); - let renderRect = this.processGap(node); - // Border actor may be gone if the window was destroyed mid-render - let borderWidth = 0; - try { - if (child.actor?.border) { - borderWidth = child.actor.border.get_theme_node().get_border_width(St.Side.TOP); - } - } catch (e) {} + /** + * Create or refresh the tab of `con`, a container in a tabbed or stacked group: the title and + * icon of the window it shows (the one last used in it), and how many more windows it holds. + */ + _updateConTab(con) { + con._createConTab(() => + this.memberWindow(con)?.nodeValue.activate(global.display.get_current_time()) + ); + const shown = this.memberWindow(con); + const count = con.getNodeByType(NODE_TYPES.WINDOW).filter((w) => w.isTile()).length; + if (!shown || !con.tab) return; + // (not _getTitle(): the app of a window can be unknown, and this runs in the middle of a render) + const title = shown.nodeValue?.title || shown.app?.get_name() || ""; + const titleButton = con.tab.get_child_at_index(1); + if (titleButton) titleButton.label = count > 1 ? `${title} (+${count - 1})` : title; + const iconBin = con.tab.get_child_at_index(0); + if (iconBin && con._tabApp !== shown.app) { + con._tabApp = shown.app; + iconBin.child = shown.app ? shown.app.create_icon_texture(24 * Utils.dpi()) : null; + } + } - // Make adjustments to the gaps - let adjust = 4 * Utils.dpi(); - let adjustWidth = renderRect.width + (borderWidth * 2 + gap) / adjust; - let adjustX = renderRect.x - (gap + borderWidth * 2) / (adjust * 2); - let adjustY = renderRect.y - adjust; + /** + * The window a group member shows and goes back to: the member itself if it is a window, + * otherwise the window last used in it (or its first). + */ + memberWindow(member) { + if (member.isWindow()) return member; + // the window last focused in it (see WindowManager._rememberInGroups), if still a visible tile + const last = member.lastMemberFocus; + const node = last ? member.getNodeByValue(last) : null; + if (node && node.isWindow() && node.isTile() && !last.minimized) return node; + // else one that is shown (a minimized window still counts as tiled) + const shown = member + .getNodeByType(NODE_TYPES.WINDOW) + .find((w) => w.isTile() && !w.nodeValue.minimized); + return shown ?? this._entryWindow(member, false); + } - if (gap === 0) { - adjustY = renderRect.y; - } + /** + * The windows that show when `node` is shown: all of a split's, and of a tabbed or stacked + * group only its current member's (the one holding `focus`, else the one last used). A stack + * without a title list is the old cascade, where every member shows: then all of them, the + * one holding `focus` last. + */ + shownWindows(node, focus = null) { + if (node.isWindow()) return node.isTile() && !node.nodeValue.minimized ? [node] : []; + const tiled = this.getTiledChildren(node.childNodes); + if (node.isStacked() && !this.hasTitleList(node)) { + const holds = (c) => focus && (c === focus || c.contains(focus)); + const ordered = [...tiled.filter((c) => !holds(c)), ...tiled.filter(holds)]; + return ordered.flatMap((c) => this.shownWindows(c, focus)); + } + if (node.isTabbed() || node.isStacked()) { + const holds = (c, w) => w && (c === w || c.contains(w)); + const member = + tiled.find((c) => holds(c, focus)) ?? + tiled.find((c) => holds(c, this._lastUsedIn(node))) ?? + tiled[0]; + return member ? this.shownWindows(member, focus) : []; + } + return tiled.flatMap((c) => this.shownWindows(c, focus)); + } - let decoration = node.decoration; + /** The container-level part of _placeDecoration(): the bar's place, size and direction. */ + _placeBar(node, child, decoration, height, vertical) { + let gap = this.extWm.calculateGaps(node); + let renderRect = this.processGap(node); + // The windows' border, from a window of the group (the first child may be a container). + // Border actor may be gone if the window was destroyed mid-render + const window = child.isWindow() ? child : node.childNodes.find((c) => c.isWindow()); + let borderWidth = 0; + try { + if (window?.actor?.border) { + borderWidth = window.actor.border.get_theme_node().get_border_width(St.Side.TOP); + } + } catch (e) {} - if (decoration !== null && decoration !== undefined) { - decoration.set_size(adjustWidth, params.stackedHeight); - decoration.set_position(adjustX, adjustY); - if (params.tiledChildren.length > 0 && params.stackedHeight !== 0) { - decoration.show(); - } else { - decoration.hide(); - } - if (child.tab && !decoration.contains(child.tab)) { - try { - decoration.add_child(child.tab); - } catch (e) {} - } - } + // Make adjustments to the gaps + let adjust = 4 * Utils.dpi(); + let adjustWidth = renderRect.width + (borderWidth * 2 + gap) / adjust; + let adjustX = renderRect.x - (gap + borderWidth * 2) / (adjust * 2); + let adjustY = renderRect.y - adjust; - child.render(); - } - } + if (gap === 0) { + adjustY = renderRect.y; + } - child.rect = { - x: nodeX, - y: nodeY, - width: nodeWidth, - height: nodeHeight, - }; + if ("orientation" in decoration) { + decoration.orientation = vertical + ? Clutter.Orientation.VERTICAL + : Clutter.Orientation.HORIZONTAL; + } else { + decoration.vertical = vertical; // GNOME < 48 + } + decoration.set_size(adjustWidth, height); + decoration.set_position(adjustX, adjustY); + if (height !== 0) { + decoration.show(); + } else { + decoration.hide(); } } diff --git a/lib/extension/window.js b/lib/extension/window.js index 61820e1e..da2c4180 100644 --- a/lib/extension/window.js +++ b/lib/extension/window.js @@ -518,7 +518,11 @@ export class WindowManager extends GObject.Object { if (this.eventQueue.length <= 0) { this.unfreezeRender(); if (focusNodeWindow.parentNode.layout === LAYOUT_TYPES.STACKED) { - focusNodeWindow.parentNode.appendChild(focusNodeWindow); + // With a title list the stack keeps the order the move gave it; the old cascade + // shows the focused window last + if (!this.tree.hasTitleList(focusNodeWindow.parentNode)) { + focusNodeWindow.parentNode.appendChild(focusNodeWindow); + } focusNodeWindow.nodeValue.raise(); focusNodeWindow.nodeValue.activate(global.display.get_current_time()); this.renderTree("move-stacked-queue"); @@ -662,6 +666,7 @@ export class WindowManager extends GObject.Object { this.unfreezeRender(); this.tree.attachNode = focusNodeWindow.parentNode; this.renderTree("layout-stacked-toggle"); + this._raiseGroupMembers(this.findNodeWindow(this.focusMetaWindow)); break; case "LayoutTabbedToggle": if (!focusNodeWindow) return; @@ -684,6 +689,7 @@ export class WindowManager extends GObject.Object { this.unfreezeRender(); this.tree.attachNode = focusNodeWindow.parentNode; this.renderTree("layout-tabbed-toggle"); + this._raiseGroupMembers(this.findNodeWindow(this.focusMetaWindow)); break; case "CancelOperation": if (focusNodeWindow.mode === WINDOW_MODES.GRAB_TILE) { @@ -940,12 +946,19 @@ export class WindowManager extends GObject.Object { } hideWindowBorders() { + this.tree.getNodeByType(NODE_TYPES.CON).forEach((con) => { + try { + con.tab?.remove_style_class_name("window-tabbed-tab-active"); + } catch (e) { + // the tab may have been destroyed + } + }); this.tree.nodeWindows.forEach((nodeWindow) => { let actor = nodeWindow.windowActor; if (actor) { this.hideActorBorder(actor); } - if (nodeWindow.parentNode.isTabbed()) { + if (nodeWindow.parentNode.isTabbed() || nodeWindow.parentNode.isStacked()) { if (nodeWindow.tab) { // TODO: review the cleanup of the tab:St.Widget variable try { @@ -1269,11 +1282,16 @@ export class WindowManager extends GObject.Object { const floatingWindow = nodeWindow.isFloat(); const tiledBorder = windowActor.border; - if (parentNode.isTabbed()) { + if (parentNode.isTabbed() || parentNode.isStacked()) { if (nodeWindow.tab) { nodeWindow.tab.add_style_class_name("window-tabbed-tab-active"); } } + // and the tab of each container holding it that is a member of a group + for (let m = parentNode; m?.parentNode && !m.isMonitor(); m = m.parentNode) { + if (m.tab && (m.parentNode.isTabbed() || m.parentNode.isStacked())) + m.tab.add_style_class_name("window-tabbed-tab-active"); + } if (tiledBorder && focusBorderEnabled) { if ( @@ -1492,14 +1510,21 @@ export class WindowManager extends GObject.Object { this.unfreezeRender(); this.updateBorderLayout(); this.updateDecorationLayout(); - this.updateStackedFocus(); - this.updateTabbedFocus(); - let focusNodeWindow = this.tree.findNode(this.focusMetaWindow); + const focusNodeWindow = this.tree.findNode(this.focusMetaWindow); + this.updateStackedFocus(focusNodeWindow, true); + this.updateTabbedFocus(focusNodeWindow); + this._rememberInGroups(focusNodeWindow); this.movePointerWith(focusNodeWindow); }, }); let focusNodeWindow = this.tree.findNode(this.focusMetaWindow); if (focusNodeWindow) { + // GNOME has just raised the focused window alone: bring the rest of its group + // member (e.g. the other windows of a split among tabs) with it now, not after + // the queued update, or another tab shows through meanwhile + this._raiseGroupMembers(focusNodeWindow); + // and remember it now, before the render below updates the groups' tabs + this._rememberInGroups(focusNodeWindow); // handle the attach node this.tree.attachNode = focusNodeWindow._parent; if (this.floatingWindow(focusNodeWindow)) { @@ -1579,10 +1604,20 @@ export class WindowManager extends GObject.Object { } } - updateStackedFocus(focusNodeWindow) { + /** + * `fromFocusChange`: called for every focus change (not only moves, swaps and the overview). + */ + updateStackedFocus(focusNodeWindow, fromFocusChange = false) { if (!focusNodeWindow) return; const parentNode = focusNodeWindow.parentNode; - if (parentNode.layout === LAYOUT_TYPES.STACKED && !this._freezeRender) { + if (parentNode.layout !== LAYOUT_TYPES.STACKED || this._freezeRender) return; + if (this.tree.hasTitleList(parentNode)) { + // The stack keeps its order (its title list shows every window): only bring the focused + // window to the front + focusNodeWindow.nodeValue.raise(); + } else if (!fromFocusChange) { + // No title list (title bars off, or a window whose app is unknown): the cascade as before, the focused + // window moved to the end so that the tops of the others show above it parentNode.appendChild(focusNodeWindow); parentNode.childNodes .filter((child) => child.isWindow()) @@ -1596,12 +1631,49 @@ export class WindowManager extends GObject.Object { } } + /** + * Remember a focused tiled window in every tabbed or stacked group it is in, so that focus + * coming back to a group returns to it (a floating window doesn't count). + */ + _rememberInGroups(nodeWindow) { + if (!nodeWindow?.isWindow() || !nodeWindow.isTile()) return; + for (let p = nodeWindow.parentNode; p && !p.isMonitor(); p = p.parentNode) { + if (p.isTabbed() || p.isStacked()) p.lastTabFocus = nodeWindow.nodeValue; + // and in each container, for its tab when it is a member of a group (Tree.memberWindow) + if (p.isCon()) p.lastMemberFocus = nodeWindow.nodeValue; + } + } + updateTabbedFocus(focusNodeWindow) { if (!focusNodeWindow) return; if (focusNodeWindow.parentNode.layout === LAYOUT_TYPES.TABBED && !this._freezeRender) { const metaWindow = focusNodeWindow.nodeValue; metaWindow.raise(); } + this._raiseGroupMembers(focusNodeWindow); + } + + /** + * Bring what the focused window belongs to in front of the other members of each tabbed or + * stacked group it is in: when that member is a container (e.g. a split among tabs), all of its + * windows, not only the focused one, or another tab shows through where the split's other + * windows are. The focused window ends on top. + */ + _raiseGroupMembers(focusNodeWindow) { + if (this._freezeRender || !focusNodeWindow?.isWindow() || !focusNodeWindow.isTile()) return; + const members = []; + let member = focusNodeWindow; + for (let p = member.parentNode; p && !p.isMonitor(); member = p, p = p.parentNode) { + if (p.isTabbed() || p.isStacked()) members.unshift(member); + } + if (!members.length) return; + // the outermost first, so that the members further in end up in front + members.forEach((m) => { + this.tree.shownWindows(m, focusNodeWindow).forEach((w) => { + if (w !== focusNodeWindow) w.nodeValue.raise(); + }); + }); + focusNodeWindow.nodeValue.raise(); } /** @@ -1838,7 +1910,9 @@ export class WindowManager extends GObject.Object { activeMonWsCons.forEach((con) => { let tiled = this.tree.getTiledChildren(con.childNodes); let showTabs = this.ext.settings.get_boolean("showtab-decoration-enabled"); - if (con.decoration && tiled.length > 0 && showTabs) { + const grouped = con.isTabbed() || con.isStacked(); + const hasRows = con.decoration?.get_n_children() > 0; // none yet, e.g. before a render + if (con.decoration && grouped && tiled.length > 0 && showTabs && hasRows) { con.decoration.show(); if (global.window_group.contains(con.decoration) && this.focusMetaWindow) { global.window_group.remove_child(con.decoration);