bar: shrink flexible widgets before moving anything into overflow

This commit is contained in:
bbedward
2026-10-07 11:05:08 -04:00
parent 8e59341c50
commit a8146d2ca5
10 changed files with 173 additions and 28 deletions
+14 -2
View File
@@ -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 {
+1 -1
View File
@@ -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
+105 -11
View File
@@ -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);
}
+5
View File
@@ -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
}
+19 -1
View File
@@ -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;
+9 -5
View File
@@ -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
@@ -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
@@ -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
@@ -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
+17
View File
@@ -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]);
});