diff --git a/shell/Ui/WidgetButton.qml b/shell/Ui/WidgetButton.qml index 1641fcfd..02d843ab 100644 --- a/shell/Ui/WidgetButton.qml +++ b/shell/Ui/WidgetButton.qml @@ -59,6 +59,9 @@ Item { readonly property real scaledHorizontalMargin: Style.spaceReal(horizontalMargin) readonly property real scaledVerticalPadding: Style.spaceReal(verticalPadding) readonly property bool tooltipHovered: visible && interactive && !concealed && mouseArea.containsMouse + // Width of the painted label, for bar chrome that wants to line up with the + // text rather than with the slot it sits in. Zero on icon-only buttons. + readonly property real labelWidth: label.visible ? label.implicitWidth : 0 visible: hasVisualContent || keepSpace opacity: !hasVisualContent || concealed ? 0 : (dimmed ? 0.45 : 1) diff --git a/shell/plugins/bar/Bar.qml b/shell/plugins/bar/Bar.qml index af40f78b..4d7202cc 100644 --- a/shell/plugins/bar/Bar.qml +++ b/shell/plugins/bar/Bar.qml @@ -406,15 +406,19 @@ Item { function findPanelWidget(pluginId) { var id = String(pluginId || "") if (!id) return null + var candidates = [] for (var i = 0; i < moduleSlots.length; i++) { var slot = moduleSlots[i] if (!slot || !slot.activeItem) continue if (slot.moduleName !== id) continue var item = slot.activeItem if (typeof item.open !== "function" || typeof item.close !== "function" || item.opened === undefined) continue - return item + candidates.push(slot) } - return null + // Anchored center modules are mounted twice; only the drawn copy can + // anchor a popup or carry the open-panel mark. See BarModel.pickDrawnSlot. + var chosen = BarModel.pickDrawnSlot(candidates) + return chosen ? chosen.activeItem : null } function summonBarWidget(pluginId) { @@ -1386,6 +1390,16 @@ Item { readonly property bool hovered: moduleHover.hovered readonly property bool dragSource: root.barDragSource === slot readonly property bool panelOpen: root.activePopout === slot.activeItem + // Modules bigger than the mark they want (a text label in a padded slot, + // a multi-line stack on a vertical bar) can say how long the open-panel + // dot should be along the bar, so it tracks what the module paints + // instead of a fraction of whatever slot it happens to fill. + readonly property real panelIndicatorExtent: { + var key = root.vertical ? "openPanelIndicatorHeight" : "openPanelIndicatorWidth" + var hint = activeItem && key in activeItem ? activeItem[key] : undefined + if (hint !== undefined && hint !== null && hint > 0) return Math.round(hint) + return Math.max(Style.space(10), Math.round((root.vertical ? slot.height : slot.width) * 0.55)) + } implicitWidth: activeItem && activeItem.visible ? (root.vertical ? root.barSize : activeItem.implicitWidth) : 0 implicitHeight: activeItem && activeItem.visible ? activeItem.implicitHeight : 0 width: implicitWidth @@ -1455,8 +1469,12 @@ Item { opacity: slot.panelOpen && !slot.dragSource ? 0.9 : 0 color: Color.accent radius: Math.min(width, height) / 2 - width: root.vertical ? Style.space(2) : Math.max(Style.space(10), Math.round(parent.width * 0.55)) - height: root.vertical ? Math.max(Style.space(10), Math.round(parent.height * 0.55)) : Style.space(2) + width: root.vertical ? Style.space(2) : slot.panelIndicatorExtent + height: root.vertical ? slot.panelIndicatorExtent : Style.space(2) + // The mark sits on the module's inner edge — the one facing the + // desktop — so it underlines a top bar, overlines a bottom one, and + // points inward from a left or right one. It reads as pointing at the + // panel that opens on that side. x: root.vertical ? (root.position === "left" ? parent.width - width - inset : inset) : Math.round((parent.width - width) / 2) diff --git a/shell/plugins/bar/BarModel.js b/shell/plugins/bar/BarModel.js index e94d8a4f..89b3e3c2 100644 --- a/shell/plugins/bar/BarModel.js +++ b/shell/plugins/bar/BarModel.js @@ -96,8 +96,32 @@ function customModulePath(entry, home, configDir) { return source } +// A center module is mounted twice once an anchor is set: the copy that is +// actually drawn, and a zero-size placeholder holding its place in the flow +// beside the anchor. Panel routing has to pick the drawn one — it is the only +// one that can anchor a popup, carry the open-panel mark, or be found again +// by switchPanelFrom — and fall back to the placeholder only when nothing is +// on screen. The order the two are registered in is not stable across a live +// bar reconfiguration, so picking the first match is not good enough. +function isDrawnSlot(slot) { + return !!slot && slot.visible === true && slot.width > 0 && slot.height > 0 +} + +function pickDrawnSlot(slots) { + var placeholder = null + var list = slots || [] + for (var i = 0; i < list.length; i++) { + if (!list[i]) continue + if (isDrawnSlot(list[i])) return list[i] + if (!placeholder) placeholder = list[i] + } + return placeholder +} + if (typeof module !== "undefined") { module.exports = { + isDrawnSlot: isDrawnSlot, + pickDrawnSlot: pickDrawnSlot, normalizePosition: normalizePosition, entrySettings: entrySettings, entryId: entryId, diff --git a/test/shell.d/bar-test.sh b/test/shell.d/bar-test.sh index 99cb26cd..7a40ea9b 100644 --- a/test/shell.d/bar-test.sh +++ b/test/shell.d/bar-test.sh @@ -15,7 +15,47 @@ fi pass "bar move outline has no post-release settling state" run_node_test <<'JS' +const fs = require('fs') const bar = requireFromRoot('shell/plugins/bar/BarModel.js') +const barSource = fs.readFileSync(root + '/shell/plugins/bar/Bar.qml', 'utf8') + +// A center module is mounted twice — drawn copy plus zero-size placeholder — +// and the order they register in is not stable across a live reconfiguration, +// so panel routing has to pick the one that is actually on screen. +const drawn = { moduleName: 'omarchy.clock', visible: true, width: 28, height: 81 } +const placeholder = { moduleName: 'omarchy.clock', visible: false, width: 0, height: 0 } +assertEqual(bar.isDrawnSlot(drawn), true, 'bar recognises a drawn slot') +assertEqual(bar.isDrawnSlot(placeholder), false, 'bar recognises a layout placeholder') +assertEqual(bar.pickDrawnSlot([placeholder, drawn]), drawn, 'bar picks the drawn slot when the placeholder registers first') +assertEqual(bar.pickDrawnSlot([drawn, placeholder]), drawn, 'bar picks the drawn slot when it registers first') +assertEqual(bar.pickDrawnSlot([placeholder]), placeholder, 'bar falls back to the placeholder when nothing is drawn') +assertEqual(bar.pickDrawnSlot([]), null, 'bar reports no slot when there are none') +assertEqual(bar.pickDrawnSlot(null), null, 'bar tolerates a missing slot list') +assert( + /BarModel\.pickDrawnSlot\(candidates\)/.test(barSource), + 'bar routes panels through the drawn-slot picker' +) + +// The open-panel mark sits on the module's desktop-facing edge at every +// position: under a top bar, over a bottom one, inward from left and right. +const indicator = barSource.slice(barSource.indexOf('id: openPanelIndicator'), barSource.indexOf('id: openPanelIndicator') + 1600) +assert( + /x: root\.vertical\s*\n\s*\? \(root\.position === "left" \? parent\.width - width - inset : inset\)/.test(indicator), + 'bar pins the open-panel mark to the desktop-facing edge on vertical bars' +) +assert( + /root\.position === "top" \? parent\.height - height - inset : inset/.test(indicator), + 'bar pins the open-panel mark to the desktop-facing edge on horizontal bars' +) +assert( + /key in activeItem/.test(barSource), + 'bar asks whether a widget declares an indicator hint before reading it' +) +assert( + /width: root\.vertical \? Style\.space\(2\) : slot\.panelIndicatorExtent/.test(indicator) && + /height: root\.vertical \? slot\.panelIndicatorExtent : Style\.space\(2\)/.test(indicator), + 'bar sizes the open-panel mark from the same content hint on both axes' +) assertEqual(bar.normalizePosition('left'), 'left', 'bar accepts valid positions') assertEqual(bar.normalizePosition('sideways'), 'top', 'bar defaults invalid positions')