From 546ea14e26a803cf73db9dccc27140a439f313b7 Mon Sep 17 00:00:00 2001 From: mattchristenson Date: Sat, 26 Sep 2026 17:23:55 -0500 Subject: [PATCH 1/4] fix: remove tab bars of containers that are no longer tabbed A container's tab bar could stay on screen after the container stopped being tabbed or left the tree: - updateDecorationLayout() showed the tab bar of every container with tiled windows, tabbed or not, so a container switched back to a split (auto-exit-tabbed, the tabbed toggle) kept an empty strip at its old size and position; - removeChild() only destroyed the tab bar of a container that was still tabbed, so a container emptied after auto-exit-tabbed had switched it to a split (all of its windows closing at once, or moved out of it one by one) left its bar behind, with a tab for every former window. It also destroyed the tab bar of a tabbed container that was only being moved (appendChild/insertBefore), which then never got one again. Show tab bars only for tabbed containers. Tear down tab bars only when nodes really leave the tree (removeNode, removeWorkspace): destroy the tabs of the windows that leave and the tab bars of the containers that leave, whatever their layout, and detach any other tabs. Apply auto-exit-tabbed to the container that lost a child, also when the emptied parent in between was removed. Co-Authored-By: Claude Opus 5.5 --- lib/extension/tree.js | 57 +++++++++++++++++++++++++---------------- lib/extension/window.js | 2 +- 2 files changed, 36 insertions(+), 23 deletions(-) diff --git a/lib/extension/tree.js b/lib/extension/tree.js index ea957d29..c544bf10 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, @@ -709,6 +702,7 @@ export class Tree extends Node { global.window_group.remove_child(existingWsNode.actorBin); this.removeChild(existingWsNode); + this._forgetRemoved(existingWsNode); return true; } @@ -1239,26 +1233,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 +1263,27 @@ 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) => { + 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); diff --git a/lib/extension/window.js b/lib/extension/window.js index 61820e1e..bd8eaf1a 100644 --- a/lib/extension/window.js +++ b/lib/extension/window.js @@ -1838,7 +1838,7 @@ 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) { + if (con.decoration && con.isTabbed() && tiled.length > 0 && showTabs) { con.decoration.show(); if (global.window_group.contains(con.decoration) && this.focusMetaWindow) { global.window_group.remove_child(con.decoration); From 7f336b8bfb22856cd62d6eb37c4333028a445d8f Mon Sep 17 00:00:00 2001 From: mattchristenson Date: Mon, 28 Sep 2026 14:34:45 -0500 Subject: [PATCH 2/4] fix: show every window of a stack in a title list A stacked container placed each window one header height lower than the one before, relying on the windows' own title bars peeking out. Only the windows before the focused one showed: with focus on the first window of a stack, every other window was completely hidden, with nothing showing it was there. (A 2022 change meant to reorder the stack on focus never took effect: the focus handler called updateStackedFocus() without the window.) A stack now works like i3's: a title list at its top with one row per window (the tabbed title bar laid out vertically, with the same icon, title, close button and click-to-focus), and all the windows in the same place below it. The focused window is raised and its row highlighted; the order never changes, so Super+J/K walk the list. With title bars turned off (showtab-decoration-enabled), stacks keep the old cascade. Co-Authored-By: Claude Opus 5.5 --- lib/extension/tree.js | 189 +++++++++++++++++++++++----------------- lib/extension/window.js | 34 ++++++-- 2 files changed, 135 insertions(+), 88 deletions(-) diff --git a/lib/extension/tree.js b/lib/extension/tree.js index c544bf10..f4073731 100644 --- a/lib/extension/tree.js +++ b/lib/extension/tree.js @@ -1517,26 +1517,43 @@ 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 is a window with a title row (a container, or a window whose app is + * unknown, has none). + */ + 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.isWindow() && c.isNodeValid() && c.tab); } /** @@ -1544,73 +1561,87 @@ 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; - - let alwaysShowDecorationTab = true; - - 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) {} - - // 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; + 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, + }; + } - if (gap === 0) { - adjustY = renderRect.y; - } + /** + * 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.isWindow() || !child.isNodeValid()) return; + if (child.tab && !decoration.contains(child.tab)) { + try { + 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(); + } - 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 bd8eaf1a..9b1a1d04 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"); @@ -945,7 +949,7 @@ export class WindowManager extends GObject.Object { 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,7 +1273,7 @@ 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"); } @@ -1492,9 +1496,9 @@ 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.movePointerWith(focusNodeWindow); }, }); @@ -1579,10 +1583,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 mixed stack): 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()) @@ -1838,7 +1852,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 && con.isTabbed() && tiled.length > 0 && showTabs) { + const grouped = con.isTabbed() || con.isStacked(); + const hasRows = con.decoration?.get_n_children() > 0; // not for a group of containers only + 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); From c9ddb631494e684ec731457a5f3fba5ee8c65259 Mon Sep 17 00:00:00 2001 From: mattchristenson Date: Mon, 28 Sep 2026 14:35:24 -0500 Subject: [PATCH 3/4] fix: focusing a tabbed or stacked group returns to its last-used window Moving focus into a stacked group from outside it (Super+H/J/K/L) always focused the stack's last window, and into a tabbed group its first or last tab, whichever window had been used there before (#230). Tabbed and stacked groups now remember their last focused window (lastTabFocus, already used for tabs in some places, is now kept up to date on every focus change for both), and focus arriving from outside the group goes to it, if it is still a visible tile there. Moving within a group is unchanged. Co-Authored-By: Claude Opus 5.5 --- lib/extension/tree.js | 59 ++++++++++++++++++++++------------------- lib/extension/window.js | 12 +++++++++ 2 files changed, 43 insertions(+), 28 deletions(-) diff --git a/lib/extension/tree.js b/lib/extension/tree.js index f4073731..7c3e5e8b 100644 --- a/lib/extension/tree.js +++ b/lib/extension/tree.js @@ -779,6 +779,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); @@ -793,20 +822,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) { @@ -820,20 +836,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; } diff --git a/lib/extension/window.js b/lib/extension/window.js index 9b1a1d04..128fbd19 100644 --- a/lib/extension/window.js +++ b/lib/extension/window.js @@ -1499,6 +1499,7 @@ export class WindowManager extends GObject.Object { const focusNodeWindow = this.tree.findNode(this.focusMetaWindow); this.updateStackedFocus(focusNodeWindow, true); this.updateTabbedFocus(focusNodeWindow); + this._rememberInGroups(focusNodeWindow); this.movePointerWith(focusNodeWindow); }, }); @@ -1610,6 +1611,17 @@ 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; + } + } + updateTabbedFocus(focusNodeWindow) { if (!focusNodeWindow) return; if (focusNodeWindow.parentNode.layout === LAYOUT_TYPES.TABBED && !this._freezeRender) { From b469f7110f613c878400e146874840f8595df93d Mon Sep 17 00:00:00 2001 From: mattchristenson Date: Tue, 29 Sep 2026 17:38:54 -0500 Subject: [PATCH 4/4] fix: splits inside tabbed and stacked groups get a tab and show whole A container that is a member of a tabbed or stacked group had no tab or title row, made a stack fall back to the cascade (drawn over the other windows), and only the focused one of its windows was raised, so another tab showed through where its other windows were. Such a container now gets a tab (title of the window last used in it, "(+n)", a click goes back there), stacks holding one get the title list, and the focused window's whole member of every group is raised at once in the focus handler and after the layout toggles. Co-Authored-By: Claude Opus 5.5 --- lib/extension/tree.js | 103 +++++++++++++++++++++++++++++++++++++--- lib/extension/window.js | 50 ++++++++++++++++++- 2 files changed, 145 insertions(+), 8 deletions(-) diff --git a/lib/extension/tree.js b/lib/extension/tree.js index 7c3e5e8b..0c6ccfb6 100644 --- a/lib/extension/tree.js +++ b/lib/extension/tree.js @@ -509,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(); @@ -548,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(); } @@ -1280,6 +1300,8 @@ export class Tree extends Node { }); 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(); @@ -1549,14 +1571,17 @@ export class Tree extends Node { /** * 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 is a window with a title row (a container, or a window whose app is - * unknown, 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.isWindow() && c.isNodeValid() && c.tab); + return ( + tiled.length > 0 && + tiled.every((c) => c.isCon() || (c.isWindow() && c.isNodeValid() && c.tab)) + ); } /** @@ -1586,9 +1611,11 @@ export class Tree extends Node { const decoration = node.decoration; if (!decoration) return; if (index === 0) this._placeBar(node, child, decoration, height, vertical); - if (!child.isWindow() || !child.isNodeValid()) return; + 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) {} } @@ -1608,6 +1635,70 @@ export class Tree extends Node { child.render(); } + /** + * 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; + } + } + + /** + * 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); + } + + /** + * 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)); + } + /** 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); diff --git a/lib/extension/window.js b/lib/extension/window.js index 128fbd19..da2c4180 100644 --- a/lib/extension/window.js +++ b/lib/extension/window.js @@ -666,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; @@ -688,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) { @@ -944,6 +946,13 @@ 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) { @@ -1278,6 +1287,11 @@ export class WindowManager extends GObject.Object { 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 ( @@ -1505,6 +1519,12 @@ export class WindowManager extends GObject.Object { }); 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)) { @@ -1596,7 +1616,7 @@ export class WindowManager extends GObject.Object { // window to the front focusNodeWindow.nodeValue.raise(); } else if (!fromFocusChange) { - // No title list (title bars off, or a mixed stack): the cascade as before, the focused + // 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 @@ -1619,6 +1639,8 @@ export class WindowManager extends GObject.Object { 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; } } @@ -1628,6 +1650,30 @@ export class WindowManager extends GObject.Object { 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(); } /** @@ -1865,7 +1911,7 @@ export class WindowManager extends GObject.Object { let tiled = this.tree.getTiledChildren(con.childNodes); let showTabs = this.ext.settings.get_boolean("showtab-decoration-enabled"); const grouped = con.isTabbed() || con.isStacked(); - const hasRows = con.decoration?.get_n_children() > 0; // not for a group of containers only + 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) {