diff --git a/quickshell/Modules/DBar/BarSection.qml b/quickshell/Modules/DBar/BarSection.qml index 66998c972..654a06413 100644 --- a/quickshell/Modules/DBar/BarSection.qml +++ b/quickshell/Modules/DBar/BarSection.qml @@ -43,12 +43,23 @@ Item { for (let index = 0; index < (entryRepeater?.count ?? 0); index++) { const wrapper = entryRepeater.itemAt(index); entries.push({ - size: wrapper?.primarySize ?? 0, + size: wrapper?.preferredSize ?? 0, + min: wrapper?.minimumSize ?? 0, mode: wrapper?.overflowMode ?? "bar" }); } return entries; } + readonly property var inlineEntries: { + root.entryRevision; + const entries = []; + for (let index = 0; index < (entryRepeater?.count ?? 0); index++) + entries.push({ + size: entryRepeater.itemAt(index)?.primarySize ?? 0 + }); + return entries; + } + readonly property var allottedSizes: barContent?.overflowPlan?.sizes?.[section] ?? [] readonly property var hiddenEntries: { root.entryRevision; const entries = []; @@ -86,6 +97,7 @@ Item { } onLayoutEntriesChanged: requestOverflowLayout() + onInlineEntriesChanged: layoutRequested() onOverflowIndicesChanged: layoutRequested() onOverflowPositionChanged: requestOverflowLayout() onOverflowTriggerSizeChanged: requestOverflowLayout() @@ -97,7 +109,7 @@ Item { } function inlineLayout() { - return OverflowLayout.section(layoutEntries, hiddenEntries.map(entry => entry.occurrenceOrder), widgetSpacing, overflowTriggerSize, overflowPosition); + return OverflowLayout.section(inlineEntries, hiddenEntries.map(entry => entry.occurrenceOrder), widgetSpacing, overflowTriggerSize, overflowPosition); } Item { diff --git a/quickshell/Modules/DBar/DBarContent.qml b/quickshell/Modules/DBar/DBarContent.qml index 4f4fb714e..4aadb9de6 100644 --- a/quickshell/Modules/DBar/DBarContent.qml +++ b/quickshell/Modules/DBar/DBarContent.qml @@ -79,6 +79,7 @@ Item { }, overflowPlan.hidden); const next = { hidden: result.hidden, + sizes: result.sizes, fits: result.fits }; if (JSON.stringify(next) !== JSON.stringify(overflowPlan)) @@ -428,7 +429,6 @@ Item { surfaceContext: widgetContext spacingTight: topBarContent.spacingTight overlapping: topBarContent.overlapping - leadingSectionLimit: topBarContent.fitToWidgets || topBarContent._barIsVertical || hCenterSection.contentSize <= 0 ? Infinity : hCenterSection.x + hCenterSection.contentStart - topBarContent.sectionGap - hLeftSection.x } readonly property var allComponents: widgetFactory.componentMap readonly property var componentMap: widgetFactory.componentMap diff --git a/quickshell/Modules/DBar/OverflowLayout.js b/quickshell/Modules/DBar/OverflowLayout.js index 7149cbb14..9ee2b6ba0 100644 --- a/quickshell/Modules/DBar/OverflowLayout.js +++ b/quickshell/Modules/DBar/OverflowLayout.js @@ -132,8 +132,97 @@ function measure(sections, hidden, options) { }; } +const names = ["left", "center", "right"]; + +function minimumOf(entry) { + return typeof entry.min === "number" ? Math.min(entry.min, entry.size) : entry.size; +} + +function preferredSizes(sections) { + const sizes = {}; + for (const name of names) + sizes[name] = sections[name].map(entry => entry.size); + return sizes; +} + +function sized(sections, sizes) { + const result = {}; + for (const name of names) + result[name] = sections[name].map((entry, index) => Object.assign({}, entry, { + size: sizes[name][index] + })); + return result; +} + +function flexibleEntries(sections, hidden, scope) { + const entries = []; + for (const name of scope) { + for (let index = 0; index < sections[name].length; index++) { + const entry = sections[name][index]; + if (entry.size > 0 && minimumOf(entry) < entry.size && !hidden[name].includes(index)) + entries.push({ + name, + index + }); + } + } + return entries; +} + +// Flexible widgets give up width down to their minimum before anything leaves the bar; the colliding sections shrink first. +function shrinkToFit(sections, hidden, options) { + const preferred = preferredSizes(sections); + let measured = measure(sections, hidden, options); + if (measured.fits) + return { + sizes: preferred, + measured + }; + let floor = null; + for (const scope of [measured.collisions, names]) { + const candidates = flexibleEntries(sections, hidden, scope); + if (!candidates.length) + continue; + const at = t => { + const sizes = preferredSizes(sections); + for (const { + name, + index + } of candidates) { + const entry = sections[name][index]; + const minimum = minimumOf(entry); + sizes[name][index] = Math.floor(minimum + (entry.size - minimum) * t); + } + return { + sizes, + measured: measure(sized(sections, sizes), hidden, options) + }; + }; + floor = at(0); + if (!floor.measured.fits) + continue; + let low = 0; + let high = 1; + let best = floor; + for (let step = 0; step < 12; step++) { + const mid = (low + high) / 2; + const trial = at(mid); + if (trial.measured.fits) { + best = trial; + low = mid; + } else { + high = mid; + } + } + return best; + } + return floor ?? { + sizes: preferred, + measured + }; +} + function resolve(sections, options, previous = {}) { - const names = ["left", "center", "right"]; const hidden = {}; for (const name of names) { hidden[name] = []; @@ -144,8 +233,9 @@ function resolve(sections, options, previous = {}) { } } - let measured = measure(sections, hidden, options); - while (!measured.fits) { + let fit = shrinkToFit(sections, hidden, options); + while (!fit.measured.fits) { + const measured = fit.measured; // The center anchors the bar, so the sides yield first; between them the longer one gives up a widget. const colliding = measured.collisions.slice().sort((a, b) => (a === "center") - (b === "center") || measured.intervals[b].size - measured.intervals[a].size); let candidate = null; @@ -169,15 +259,15 @@ function resolve(sections, options, previous = {}) { if (!candidate) break; hidden[candidate.name].push(candidate.index); - measured = measure(sections, hidden, options); + fit = shrinkToFit(sections, hidden, options); } const fresh = {}; for (const name of names) fresh[name] = hidden[name].slice(); - const freshFits = measured.fits; + const freshFits = fit.measured.fits; - // Hysteresis: a restored widget must also fit the margin, or it would bounce in and out at the boundary. + // Hysteresis: a restored widget must also fit the margin at its minimum, or it would bounce in and out at the boundary. const restoring = []; for (const name of names) { for (const index of previous[name] ?? []) { @@ -197,18 +287,22 @@ function resolve(sections, options, previous = {}) { const trial = {}; for (const key of names) trial[key] = hidden[key].filter(value => key !== name || value !== index); + const floor = minimumOf(sections[name][index]) + (options.restoreMargin ?? 0); const padded = Object.assign({}, sections, { [name]: sections[name].map((entry, entryIndex) => entryIndex === index ? Object.assign({}, entry, { - size: entry.size + (options.restoreMargin ?? 0) + size: floor, + min: floor }) : entry) }); - if (measure(padded, trial, options).fits) + if (shrinkToFit(padded, trial, options).measured.fits) hidden[name] = trial[name]; } - const result = freshFits && !measure(sections, hidden, options).fits ? fresh : hidden; + const result = freshFits && !shrinkToFit(sections, hidden, options).measured.fits ? fresh : hidden; for (const name of names) result[name].sort((a, b) => a - b); + fit = shrinkToFit(sections, result, options); return Object.assign({ - hidden: result - }, measure(sections, result, options)); + hidden: result, + sizes: fit.sizes + }, fit.measured); } diff --git a/quickshell/Modules/DBar/SectionEntry.qml b/quickshell/Modules/DBar/SectionEntry.qml index 4c12eb668..bc2a3ab57 100644 --- a/quickshell/Modules/DBar/SectionEntry.qml +++ b/quickshell/Modules/DBar/SectionEntry.qml @@ -18,6 +18,10 @@ Item { readonly property real itemHeight: widgetItem?.height ?? 0 readonly property bool available: active && widgetLoader.widgetEnabled && itemVisible && effectiveVisible && itemWidth > 0 && itemHeight > 0 readonly property real primarySize: available ? (sectionContext.isVertical ? itemHeight : itemWidth) : 0 + // A flexible widget reports the width it wants and the width it can live with; the resolver allots between them. + readonly property real preferredSize: !available ? 0 : widgetItem && "naturalPrimarySize" in widgetItem ? widgetItem.naturalPrimarySize : primarySize + readonly property real minimumSize: available && widgetItem && "minimumPrimarySize" in widgetItem ? Math.min(preferredSize, widgetItem.minimumPrimarySize) : preferredSize + readonly property real allottedSize: sectionContext.allottedSizes[occurrenceOrder] ?? 0 readonly property string overflowMode: OverflowLayout.placement(itemData, sectionContext.overflowDefaultMode) readonly property bool inOverflow: overflowMode === "always" || sectionContext.overflowIndices.includes(occurrenceOrder) readonly property bool inPopup: inOverflow && sectionContext.overflowOpen && !!sectionContext.overflowParent @@ -61,6 +65,7 @@ Item { isTopBarEdge: !root.inOverflow && isInColumn && section === "left" && root.sectionContext.edgeIsScreenEdge isBottomBarEdge: !root.inOverflow && isInColumn && section === "right" && root.sectionContext.edgeIsScreenEdge crossEdgeExtension: !root.inOverflow && !isInColumn && section !== "center" ? root.sectionContext.crossEdgeExtension : 0 + allottedPrimarySize: root.inOverflow ? 0 : root.allottedSize overflowAnchor: root.inOverflow ? root.sectionContext.overflowButton : null overflowSurface: root.inOverflow ? root.sectionContext.overflowSurface : null } diff --git a/quickshell/Modules/DBar/Widgets/FocusedApp.qml b/quickshell/Modules/DBar/Widgets/FocusedApp.qml index 91f679525..49714381d 100644 --- a/quickshell/Modules/DBar/Widgets/FocusedApp.qml +++ b/quickshell/Modules/DBar/Widgets/FocusedApp.qml @@ -28,7 +28,10 @@ BasePill { return 456; } } - property int availableWidth: maxWidth + property real allottedPrimarySize: 0 + property int availableWidth: allottedPrimarySize > 0 ? allottedPrimarySize : maxWidth + readonly property real naturalPrimarySize: isVerticalOrientation ? height : !hasWindowsOnCurrentWorkspace ? 0 : Theme.snap(Math.min(contentItem?.naturalWidth ?? 0, maxWidth - horizontalPadding * 2) + horizontalPadding * 2, dpr) + readonly property real minimumPrimarySize: isVerticalOrientation ? height : Math.min(naturalPrimarySize, Theme.snap((contentItem?.minimumWidth ?? 0) + horizontalPadding * 2, dpr)) readonly property real effectiveHorizontalWidth: Math.max(0, Math.min(maxWidth, availableWidth)) readonly property real effectiveHorizontalInnerWidth: Math.max(0, effectiveHorizontalWidth - horizontalPadding * 2) property var activeWindow: null @@ -114,6 +117,21 @@ BasePill { content: Component { Item { + readonly property bool iconShown: horizontalAppIcon.visible || horizontalSteamIcon.visible + readonly property real appTextNatural: appText.visible ? Math.min(appText.implicitWidth, root.compactMode ? 80 : 180) : 0 + readonly property real naturalWidth: { + const parts = []; + if (iconShown) + parts.push(contentRow.iconSize); + if (appText.visible) + parts.push(appTextNatural); + if (appSeparator.visible) + parts.push(appSeparator.width); + if (titleText.visible) + parts.push(titleText.implicitWidth); + return parts.reduce((sum, part) => sum + part, 0) + Math.max(0, parts.length - 1) * contentRow.spacing; + } + readonly property real minimumWidth: (iconShown ? contentRow.iconSize + contentRow.spacing : 0) + Math.min(48, appText.visible ? appTextNatural : titleText.visible ? titleText.implicitWidth : 0) implicitWidth: { if (!root.hasWindowsOnCurrentWorkspace) return 0; diff --git a/quickshell/Modules/DBar/Widgets/Media.qml b/quickshell/Modules/DBar/Widgets/Media.qml index b6e72343b..b0a15fa6a 100644 --- a/quickshell/Modules/DBar/Widgets/Media.qml +++ b/quickshell/Modules/DBar/Widgets/Media.qml @@ -28,6 +28,9 @@ BasePill { property bool compactMode: false property var widgetData: null readonly property bool adaptiveWidthEnabled: SettingsData.widgetOption("music", widgetData, "mediaAdaptiveWidthEnabled") + property real allottedPrimarySize: 0 + readonly property real naturalPrimarySize: isVerticalOrientation ? height : !playerAvailable ? 0 : Theme.snap((contentItem?.naturalContentWidth ?? 0) + horizontalPadding * 2, dpr) + readonly property real minimumPrimarySize: isVerticalOrientation ? height : !playerAvailable ? 0 : Theme.snap((contentItem?.fixedContentWidth ?? 0) + horizontalPadding * 2, dpr) readonly property bool lyricsEnabled: SettingsData.widgetOption("music", widgetData, "mediaShowLyrics") && LyricsService.allowed readonly property bool coverArtEnabled: SettingsData.widgetOption("music", widgetData, "mediaShowCoverArt") readonly property string coverArtUrl: coverArtEnabled && activePlayer && TrackArtService.artReadyFor(activePlayer) ? TrackArtService.resolvedArtUrl : "" @@ -228,7 +231,7 @@ BasePill { content: Component { Item { id: contentRoot - readonly property real measuredTextWidth: { + readonly property real naturalTextWidth: { if (!root.playerAvailable || root.maxTextWidth <= 0) return 0; // Preserve the fixed-width text slot even if metadata is briefly empty. @@ -241,10 +244,11 @@ BasePill { return 0; return Math.min(root.maxTextWidth, Math.ceil(rawWidth)); } - readonly property int horizontalContentWidth: { - const controlsWidth = 64 * root.contentScale + Theme.spacingXS * 2; - return root.badgeExtent + controlsWidth + (measuredTextWidth > 0 ? measuredTextWidth + Theme.spacingXS : 0); - } + readonly property real fixedContentWidth: root.badgeExtent + 64 * root.contentScale + Theme.spacingXS * 2 + readonly property real textBudget: root.allottedPrimarySize > 0 ? Math.max(0, root.allottedPrimarySize - root.horizontalPadding * 2 - fixedContentWidth - Theme.spacingXS) : Infinity + readonly property real measuredTextWidth: Math.min(naturalTextWidth, textBudget) + readonly property real naturalContentWidth: fixedContentWidth + (naturalTextWidth > 0 ? naturalTextWidth + Theme.spacingXS : 0) + readonly property int horizontalContentWidth: fixedContentWidth + (measuredTextWidth > 0 ? measuredTextWidth + Theme.spacingXS : 0) implicitWidth: root.playerAvailable ? (root.isVerticalOrientation ? root.currentContentWidth : horizontalContentWidth) : 0 implicitHeight: root.playerAvailable ? root.currentContentHeight : 0 diff --git a/quickshell/Modules/SurfaceWidgets/BarPill.qml b/quickshell/Modules/SurfaceWidgets/BarPill.qml index bf8aeee66..ddf1e9f64 100644 --- a/quickshell/Modules/SurfaceWidgets/BarPill.qml +++ b/quickshell/Modules/SurfaceWidgets/BarPill.qml @@ -43,6 +43,7 @@ Item { readonly property real visualWidth: Theme.snap(isVerticalOrientation ? widgetThickness : (contentLoader.item ? (contentLoader.item.implicitWidth + horizontalPadding * 2) : 0), dpr) readonly property real visualHeight: Theme.snap(isVerticalOrientation ? (contentLoader.item ? (contentLoader.item.implicitHeight + horizontalPadding * 2) : 0) : widgetThickness, dpr) readonly property alias visualContent: visualContent + readonly property alias contentItem: contentLoader.item readonly property real barEdgeExtension: BarMetrics.fittsReach readonly property real gapExtension: sectionSpacing readonly property real leftMargin: !isVerticalOrientation ? (isLeftBarEdge && isFirst ? barEdgeExtension : (isFirst ? gapExtension : gapExtension / 2)) : 0 diff --git a/quickshell/Modules/SurfaceWidgets/SurfaceWidgetFactory.qml b/quickshell/Modules/SurfaceWidgets/SurfaceWidgetFactory.qml index ee8462f82..9caa660b2 100644 --- a/quickshell/Modules/SurfaceWidgets/SurfaceWidgetFactory.qml +++ b/quickshell/Modules/SurfaceWidgets/SurfaceWidgetFactory.qml @@ -14,7 +14,6 @@ Item { readonly property string _barScreenName: surfaceContext.screen?.name ?? "" property bool spacingTight: false property bool overlapping: false - property real leadingSectionLimit: Infinity function getWidgetSection(item) { for (let current = item; current; current = current.parent) { if (typeof current.section === "string") @@ -329,12 +328,6 @@ Item { FocusedApp { id: focusedWindowWidget axis: surfaceContext.axis - availableWidth: { - const container = focusedWindowWidget.parent?.parent; - if (!container || !isFinite(root.leadingSectionLimit) || root.getWidgetSection(focusedWindowWidget) !== "left") - return focusedWindowWidget.maxWidth; - return Math.max(0, root.leadingSectionLimit - container.x); - } widgetThickness: surfaceContext.widgetThickness barThickness: surfaceContext.thickness barSpacing: barConfig?.spacing ?? 4 diff --git a/quickshell/Modules/SurfaceWidgets/SurfaceWidgetHost.qml b/quickshell/Modules/SurfaceWidgets/SurfaceWidgetHost.qml index 9ec172d4d..c2337e0d5 100644 --- a/quickshell/Modules/SurfaceWidgets/SurfaceWidgetHost.qml +++ b/quickshell/Modules/SurfaceWidgets/SurfaceWidgetHost.qml @@ -10,6 +10,7 @@ Loader { property var overflowAnchor: null property var overflowSurface: null property bool live: true + property real allottedPrimarySize: 0 property string instanceId: widgetData?.id ?? widgetId readonly property string registrationId: (surfaceContext?.kind ?? "bar") + ":" + (surfaceContext?.configId ?? barConfig?.id ?? "") + ":" + section + ":" + instanceId property int occurrenceOrder: 0 @@ -83,7 +84,7 @@ Loader { } Instantiator { - model: ["parentScreen", "section", "widgetThickness", "barThickness", "barSpacing", "barConfig", "blurBarWindow", "axis", "widgetData", "isFirst", "isLast", "sectionSpacing", "sectionAvailablePrimarySize", "isLeftBarEdge", "isRightBarEdge", "isTopBarEdge", "isBottomBarEdge", "crossEdgeExtension", "segmentRole", "surfaceContext", "hostContext"] + model: ["parentScreen", "section", "widgetThickness", "barThickness", "barSpacing", "barConfig", "blurBarWindow", "axis", "widgetData", "isFirst", "isLast", "sectionSpacing", "sectionAvailablePrimarySize", "isLeftBarEdge", "isRightBarEdge", "isTopBarEdge", "isBottomBarEdge", "crossEdgeExtension", "segmentRole", "surfaceContext", "hostContext", "allottedPrimarySize"] delegate: Binding { required property string modelData target: root.item diff --git a/quickshell/tests/bar-overflow.test.mjs b/quickshell/tests/bar-overflow.test.mjs index f3ca22054..5cdb8e945 100644 --- a/quickshell/tests/bar-overflow.test.mjs +++ b/quickshell/tests/bar-overflow.test.mjs @@ -69,3 +69,20 @@ test("a center holder does not replace the configured middle-widget anchor", () assert.equal(clockStart + 40, 300); } }); + +test("flexible widgets shrink to their minimum before anything overflows", () => { + const result = solve([entry(100), { size: 200, min: 60, mode: "auto" }], [entry(80)], [], { length: 500 }); + assert.deepEqual(plain(result.hidden), { left: [], center: [], right: [] }); + assert.deepEqual(plain(result.sizes.left), [100, 102]); + assert.equal(result.fits, true); + const tight = solve([entry(100), { size: 200, min: 60, mode: "auto" }], [entry(80)], [], { length: 400 }); + assert.deepEqual(plain(tight.hidden.left), [1]); +}); + +test("a hidden flexible widget returns once its minimum fits the margin", () => { + const sections = [[entry(100), { size: 200, min: 60, mode: "auto" }], [entry(80)], []]; + const result = solve(...sections, { length: 432 }, { left: [1] }); + assert.deepEqual(plain(result.hidden.left), []); + assert.deepEqual(plain(result.sizes.left), [100, 68]); + assert.deepEqual(plain(solve(...sections, { length: 420 }, { left: [1] }).hidden.left), [1]); +});